Metrics & leaderboard
How OCF measures the skill of its forecasts and compares forecasting approaches.
Status legend โ โ Implemented ยท ๐ง Planned ยท ๐ฌ Research. The
Metricsschema, themetricsDagster asset, the deterministic metrics (MAE, NMAE, RMSE, MBE), and the probabilistic metrics (CRPS, spread-skill ratio, pinball loss, PICP, interval width โ see the evaluation-metrics reference) are โ implemented, as are themanual_heuristicandclimatologybaseline forecasters. The interactive leaderboard visualisation is ๐ง planned. The implemented cross-validation protocol has moved out of the roadmap. See the roadmap index for status conventions. The ๐ง items are tracked under the v0.3 epic #6: baseline forecasters #147 ยท probabilistic evaluation #225 ยท tail & exceedance metrics #254 ยท tricky-days filter #255 ยท fold design #960 and scorer enforcement #958 ยท calibrated manual heuristic #715.
See also A staged probabilistic forecast, which proposes how calibrated quantiles and coherent ensemble members would be produced.
The leaderboard concept ๐ง
Issue: #4
A key deliverable is a leaderboard comparing many forecasting approaches. We plan one
leaderboard per time_series_type, e.g. primary substations, GSPs, BSPs, solar PV sites, wind
farms, BESS, etc.
Each leaderboard will have tens (maybe hundreds) of rows. Each row is one CV run: a particular forecasting model, trained with a particular set of features, processed a particular way. Entrants must be compared apples-to-apples โ same test dataset, same metrics, same assumptions.
The configuration, trained weights, and metrics of each CV run are stored in the project's MLflow database. The leaderboard will be displayed as an interactive table showing multiple metrics at a glance, inspired by the WeirdML leaderboard:

Three commitments guard the comparison, and each links to the reasoning behind it. Every baseline is run from its authors' own repository at its authors' recommended defaults, with no domain-specific tuning, because a team that runs every entry on its own leaderboard risks reporting its own implementation quality as a methodological result โ the rule TS-Arena applies, set out with its evidence under Leaderboards of machine learning results. The leaderboard is published with the material needed to check it โ the evaluation protocol, the metric definitions, the code that computes them, and the telemetry where NGED's data policy allows โ so someone outside the project can reproduce a row rather than take it on trust. And negative results get a row, because a leaderboard carrying only the approaches that worked hides how much of the search space was tried; both commitments are argued under Publishing results that others can compare against. A metered generator's time series and results are never published with the generator's name or ID.
Baseline forecasters ๐ง
Issue: #147
ManualHeuristicForecaster is the first baseline in the codebase. Its leaderboard rows answer the
question this project exists to answer: do we beat the manual heuristic? The climatology
baseline gives XGBoost's numbers a further point of reference. The persistence baseline
is deferred to the v0.9 nice-to-haves epic
(#361).
Every comparison against a baseline publishes the fraction of series that beat it alongside the average error, never the average alone โ see Publishing results that others can compare against for why an average can hide a model getting worse at a substantial minority of series.
The headline baseline โ manual_heuristic
manual_heuristic reproduces the manual heuristic
forecast โ the analogue-ensemble method that, until
recently, was the normal approach to substation forecasting among distribution network operators,
with no weather model and no ML. In brief (full description and the operator's-eye view are in the
background page): for each target half-hour it takes the observed power at the same weekday &
time-of-day from the last 6 weeks and from 49โ55 weeks back โ 13 analogues. The
analogue lags are fixed numbers of UTC hours, so across a clock change an analogue sits an hour off
local clock time (the baseline_forecasters package README gives the share of affected
analogues). An operator reads the plotted analogues by eye. If a single number is needed, the
operator picks the percentile that matches the company's risk appetite. We will score the
conservative 95th percentile, once the metrics
collapse lands.
Reproducing the manual heuristic matters because the manual heuristic is the bar we have to clear
to justify the project. "XGBoost beats persistence" is the least we must do; "XGBoost beats the
manual heuristic" is the deliverable. manual_heuristic is the first baseline we implement, and the
one we would keep if we could implement only one.
manual_heuristic fits our existing machinery, because every one of its 13 members is just a
power lag:
- Weekly group (last 6 weeks, same weekday & time):
power_lag_168h, 336h, 504h, 672h, 840h, 1008h - Annual group (49โ55 weeks ago, same weekday & time):
power_lag_8232h, 8400h, 8568h, 8736h, 8904h, 9072h, 9240h
So it rides the same audited, no-lookahead pipeline as PersistenceForecaster (below) with zero new
time-series logic. _nullify_leaky_lags already sheds the shortest members as lead time grows (past
7 days the 168 h member nullifies, past 14 days the 336 h, and so on). That shedding leaves the
annual members to carry the full 14-day horizon. Because the shortest member is a week old, the
manual heuristic has no short-horizon skill from recent power. That gap is faithful to the
analogue method, and a reason to keep the pure PersistenceForecaster as a contrast rather than to
sneak a recent-power member in.
manual_heuristic is also our first probabilistic baseline โ and this is the faithful
representation, not a bonus. The plotted spread is the manual heuristic's output โ an operator
reads it by eye. We emit the 13 analogues as 13 ensemble_member rows and let the probabilistic
metrics score them with no extra
implementation work โ scoring the spread is the closest automatable proxy for the plot a human
actually reads. Two consequences follow.
ensemble_member is overloaded here. For NWP models that column indexes an NWP ensemble member;
for manual_heuristic it indexes a historical analogue. Same column, different meaning. We
document this on the PowerForecast / AllFeatures schema so nobody assumes ensemble_member โ
NWP. The manual heuristic synthesises its ensemble inside predict() (by unpivoting its
analogue-lag columns into member rows) rather than consuming an NWP ensemble; it runs with
weather_source: "none".
Deterministic collapse is a property of the metrics layer, not the manual heuristic. The manual
heuristic emits its 13 members and nothing else; the metric-matched collapse
decision then scores its MAE on
the members' median (apples-to-apples with every other model's central forecast) and reports the
operator's conservative operating point โ the 95th percentile โ as a labelled secondary number
(mae/mbe at metric_param="p95"). Being deliberately conservative, the P95 carries a large
positive MBE by design (a peak-safety choice, not a forecasting error), so it belongs beside
the central metric. Either way the analogue method weights the analogues equally, with no further
processing, so equiprobable members โ and the probabilistic metrics (CRPS etc.) computed over them โ
are faithful, not an approximation.
A faithful replica and a "simple upgrades" variant
Most of the benefit may come from a few simple upgrades to the analogue method, not from heavy ML โ the message the pair of baselines is built to test. We implement two closely-related baselines built on the manual heuristic:
manual_heuristicโ the faithful replica above, treating bank holidays as ordinary days. Pure lag features.manual_heuristic_holiday_alignedโ the same skeleton, but analogue selection becomes calendar-aware: a bank-holiday target draws from prior bank holidays / the matching day-type (a bank-holiday Monday behaves like a Sunday). Moveable feasts align holiday-to-holiday (EasterโEaster) rather than by fixed week offset. This no longer rides the pure lag machinery โ the analogue offset is conditional on the calendar โ so it needs a bespoke picker plus a GB bank-holiday calendar (the pure-Pythonholidayspackage). It is deferred to the v0.9 nice-to-haves epic and tracked in #1088.
A third variant, manual_heuristic_calibrated, corrects the analogues after selection rather than
changing which analogues are selected, and has its own section
below.
Calibrating the manual heuristic aims at the 95th percentile
Issue: #715
A third variant, manual_heuristic_calibrated, keeps the same analogues and fits a statistical
correction to them on past errors. One standard form of that correction is Ensemble Model Output
Statistics (EMOS), which Gneiting et al. (2005) introduced for
weather ensembles: fit a predictive distribution whose mean is an affine function of the member
forecasts โ one coefficient each, collapsing to a single coefficient on the ensemble mean where the
members are exchangeable, as the manual heuristic's equally-weighted analogues are. Its variance is
an affine function of the ensemble variance, with the coefficients chosen by minimising CRPS over a
training window. Nothing in EMOS requires the ensemble to come from a weather model, so the
analogues manual_heuristic already synthesises are a valid input.
The faithful replica stays the headline bar, because the correction is our work rather than the
manual heuristic's. The manual heuristic forecast itself does not adjust for holidays, switching
events, load growth, or any statistical correction of the analogues. The question the leaderboard
exists to answer is whether we beat that unadjusted baseline. So manual_heuristic_calibrated is a
row of our own, sitting beside manual_heuristic_holiday_aligned as a second cheap upgrade to the
manual heuristic, and testing the same claim that most of the benefit comes from simple upgrades
rather than from heavy ML.
The 95th percentile of 13 analogues is exceeded far more than 5% of the time even when the
analogues are perfectly calibrated. Empirical quantiles from a finite ensemble sit inside the true
quantiles, and at 13 members the effect is large. For uniform draws the empirical p95 of 13
equiprobable members is exceeded about 11.4% of the time, by the same arithmetic behind PICP's
calibrated reference
table. The floor
tightens as members shed: _nullify_leaky_lags drops the 168-hour analogue past 7 days of lead and
the 336-hour analogue past 14, giving 11.9% at 12 members and 12.5% at 11.
No choice of analogues removes that penalty. An operator reading the percentile as "the level demand should stay under, 19 times out of 20" would, if the analogues were calibrated, be reading a level crossed more than twice as often as they think. Selecting the analogues differently cannot remove the penalty while the analogues stay calibrated, because the penalty comes from the number of members rather than from which analogues are chosen โ a wider-than-truthful ensemble can only mask it.
A fitted predictive distribution has no member-count floor. Its members need only sit at
equiprobable quantile levels (i โ 0.5)/m as the climatology baseline's do, and be numerous enough
that the empirical p95 the metrics layer reads still tracks the fitted percentile. The exceedance
rate of the upper delivery
quantiles is
the calibration check for the fitted percentile.
Fitting the mean also picks up the load growth the manual heuristic has no scaling for. The affine term on the analogue mean absorbs a systematic offset between the analogues and the target half-hour โ load growth over the year separating the annual group from today and the residual offset on a day type that the analogue selection matches only imperfectly. Neither offset needs weather to correct. The calibrated variant therefore stays naive in the sense that matters for a baseline, knowing nothing the substation's own history does not contain.
The two halves of the fit show up in different metrics. The mean correction improves NMAE wherever the analogues carry a stale level, and the variance correction moves CRPS and the exceedance rate.
An analogue ensemble may be miscalibrated in the opposite direction to a weather ensemble, so measure before building. The manual heuristic's members are observed powers rather than perturbed model runs. The analogue spread does not narrow at short lead the way an NWP ensemble's spread does, because the freshest analogue is already a week old. Week-to-week variation at the same weekday and time of day could easily exceed the real forecast uncertainty, leaving the manual heuristic over-dispersed where a weather ensemble is under-dispersed. The 95th percentile would then be crossed less often than the finite-ensemble arithmetic above implies. The correction would narrow the band rather than widen it.
Two cheap diagnostics point to the direction once manual_heuristic ships. The first diagnostic
is the rank histogram of the observation among the analogues per horizon slice, the same instrument
Phase C uses on the weather
ensemble. The second diagnostic is the correlation between the analogue spread and the absolute
error of the analogue mean. A flat histogram with no spread-error correlation would leave the
variance half of EMOS nothing to fit, reducing the work to a mean correction with a fixed-width
band. One published analogy worth reading alongside the diagnostics is the analogue-ensemble
literature โ Delle Monache et al. (2013) โ which builds
its members the same way, though it selects analogues on a weather forecast rather than on the
calendar.
A Gaussian predictive distribution puts the modelling assumption where the product is most sensitive, so measure a non-parametric fit beside EMOS. Minimum-CRPS estimation is dominated by the bulk of the distribution, while flexibility procurement and curtailment read the tails. The manual heuristic's residuals have no particular reason to be Gaussian out there. Henzi et al. (2021)'s isotonic distributional regression fits a conditional distribution non-parametrically and needs no tuning beyond the choice of a partial order on the covariates. A baseline with almost nothing to tune is also harder to accuse of having been tuned into a bar we can clear.
The correction is fitted, so it rides the cross-validation protocol like any other model. Coefficients are fitted on each fold's training window and applied to that fold's validation window, exactly as Phase C specifies for the weather ensemble. The fit also runs per horizon slice, because the surviving analogue mix changes as members shed with lead time. Fitting on the training window alone is the line between a baseline and a model that has seen the data it is scored on. A handful of coefficients crosses that line as easily as a large model does. The calibration itself belongs in the shared wrapper forecaster Phase C specifies, rather than inside the manual heuristic.
Persistence and climatology โ diagnostic bookends
The manual heuristic is really a hybrid โ its weekly group is persistence-like recency, its annual group is climatology-like seasonality โ so the two pure forms are still worth having: they isolate short-horizon from long-horizon naive skill. Persistence is famously hard to beat at 0 to 6 hours.
The climatology row measures where the weather ensemble stops adding skill over a plain seasonal average โ a cross-over point with no published figure for substation load, so far as we have found. The climatology row asks the same question at the far end of the horizon: once the weather ensemble has run out of lead time to be informative, does the forecast still beat a plain seasonal average? We have found no published figure for where that cross-over falls for substation load. Buizza and Leutbecher (2015) put the lead time beyond which a weather ensemble stops beating a climatological distribution at 16 to 23 days. But that figure was measured on upper-air variables rather than on a load forecast against a load climatology (see Horizon, ensembles, and tails). The climatology row is therefore how we measure the cross-over, not a number we can already assert. A model could "win" the leaderboard while adding no skill over either bookend, and without these rows nobody would know.
Side benefit: several more BaseForecaster implementations pressure-test the abstraction (the docs
promise the interface is model-agnostic; today XGBoostForecaster, ManualHeuristicForecaster, and
ClimatologyForecaster exercise it).
How much the choice of reference moves the answer has been measured, and it argues for reading a win over either bookend as the optimistic end of the range. Nguyen and Mรผsgens (2026) include the reference model as a regressor across 4,687 skill scores drawn from 188 solar forecasting papers. They find that a forecast scored against plain persistence reports a skill score 10.7 percentage points higher at horizons beyond 6 hours than the same forecast scored against a convex combination of smart persistence and climatology, with smart persistence alone 9.0 points higher. They recommend the combination as the more demanding benchmark. Two differences before carrying the number across: their smart persistence is persistence corrected by the clearness index and their climatology is a constant equal to the sample mean, both narrower than the rows described here, and their sample is deterministic solar forecasting rather than probabilistic substation net demand. The direction survives both โ a margin over persistence alone, or over climatology alone, flatters a forecast that a combined reference would judge more harshly.
This sensitivity to the choice of reference is an argument for keeping manual_heuristic as the
headline bar, not for building a fourth baseline. The manual heuristic already blends recency with
seasonality โ the last 6 weeks of same-weekday, same-time-of-day analogues alongside the
49-to-55-weeks-back group โ so it plays on substation load the role the combined reference plays on
irradiance. The manual heuristic is also the bar the project must clear. Persistence and climatology
stay as diagnostic bookends, read as the loose end of the range rather than as the benchmark a win
should be claimed against.
Carrying a loose bookend and a tight bookend is what the published guidance recommends โ see
Leaderboards of machine learning
results for
Doubleday et al. (2020)'s case for carrying a
yardstick benchmark and a point on the yardstick together. Persistence and climatology are the
yardstick here; manual_heuristic is the point on it.
Implementation details โ baselines (deleted when they ship)
The persistence
baseline (PR B) is deferred to the v0.9 nice-to-haves epic
(#361) and tracked in
#1087. The metrics
collapse below is its own issue under the v0.3 epic, and no baseline waits on the metrics collapse.
The manual_heuristic_holiday_aligned variant (described under A faithful replica and a "simple
upgrades" variant) is deferred to the v0.9 epic
and tracked in #1088. This
section is deleted when the metrics collapse ships. Before then, copy the PR B item into the body of
#1087, because the
persistence design text exists only here.
manual_heuristic_calibrated (described under Calibrating the manual
heuristic, and tracked in
#715) is a further PR, on
the same footing: out of scope here, tracked separately, and buildable now that manual_heuristic
has shipped.
Guiding principle โ no special path. New workspace package packages/baseline_forecasters/
mirroring the xgboost_forecaster layout (pyproject.toml, src/baseline_forecasters/, tests/),
added to the root pyproject.toml [tool.uv.sources] and dependencies. Every baseline subclasses
BaseForecaster and rides the identical asset chain (register_experiment_job โ
trained_cv_model โ cv_power_forecasts โ metrics) that XGBoostForecaster uses โ a train()
that only records trained_time_series_ids and a meta.json-only save() need no extra
engineering effort, and the uniform provenance is exactly what makes re-running routine. Re-running
cross-validation (CV) for any algo is then a native Dagster operation: select
trained_cv_model++ in the asset graph (two + โ metrics is two hops downstream, so a single
+ would silently stop at cv_power_forecasts and skip scoring), choose the CV run's
partitions, and launch a backfill. The unpartitioned metrics asset materialises once afterwards
with its default config (no filter, leaderboard scope). Verify this drill end-to-end on a
smoke-test fold before documenting it โ the Dagster version supports mixed partitioned/unpartitioned
backfill selections, but confirm the UI behaviour rather than assuming it. To re-score a single
CV run without touching the rest, use the metrics asset's PopulationFilter config instead.
Data check before interpreting the manual heuristic's results. For each eligible series, count
the observations in the cleaned power table that lie at least 49 weeks before val_start.
Eligibility requires only min_training_months of history, so a series can qualify yet have no
annual analogue, and its ensemble then holds only the weekly members.
Metrics collapse โ deterministic-collapse rework in compute_metrics, its own issue under the
v0.3 epic. The metrics collapse can land before or after any baseline. Implements the
metric-matched collapse
decision. Forecasters emit
members only; every collapse lives in the metrics layer, so there is no per-CV-run
collapse config and no designated point-forecast columns on PowerForecast. In
packages/ml_core/src/ml_core/metrics.py:
- In the per-run aggregation, emit both
power_fcst_mean(.mean()) andpower_fcst_median(reuse the already-computedq_p50empirical-quantile column โ Polars.quantile(0.5, "linear")is exactly.median()), and keepq_p95. - Score
mae/nmaeon the median error;rmse/mbeon the mean error; the spread-skill denominator stays the mean-error RMSE (its Fortin "1.0 = calibrated" target is defined against the mean). Add extra labelled rows:mae/mbeatmetric_param="p95"(the conservative operating point) andmbeatmetric_param="p50"(bias of the delivered median).METRIC_PARAMSalready contains"p95"and"p50"(both are inDELIVERY_QUANTILES), so noMetricsschema change โ and the primary key includesmetric_param, so the new rows do not collide with themetric_param="all"headline rows. - Extend the MLflow allowlist
_MLFLOW_LOGGED_PARAMETRICwith("mbe", "p95")and("mbe", "p50")so the operating-point bias and the median's bias appear on the leaderboard (aggregate keys come out distinct, e.g.mbe_p95__allvsmbe__all). - Update the docstrings that currently say deterministic metrics are "scored on the per-run ensemble
mean" (
METRIC_NAMESincontracts/ml_schemas.py), theMetrics.metric_paramfield description (no longer pinball-only), and the_MLFLOW_LOGGED_PARAMETRICkey-count claims. The promoted evaluation-metrics reference section must state explicitly that MAE/NMAE and RMSE/MBE score different point forecasts and why (otherwise the first person to recompute RMSE from the stored median members files a bug), and note the identitymae โก 2 ร pinball_loss@p50as a deliberate internal consistency check. - Tests: member sets where mean โ median โ p95, with hand-computed expected values per metric; the single-member ensemble (all collapses coincide; CRPS still reduces to MAE); the pinball-p50 identity. Recompute existing expected values โ never relax a test to absorb the shift.
- After the metrics collapse lands, run one
trained_cv_model++backfill over every existing CV run partition โ retrain, re-predict, and re-score everything under the new collapse. The backfill is deliberately the exact "re-run everything after a pipeline fix" drill, and it doubles as the empirical verification of the backfill mechanics before the recipe is written intodocs/cross-validation/dagster-workflow.md. Treat the change as a leaderboard epoch event, because the new collapse shifts existing numbers.
PR B โ PersistenceForecaster (seasonal-naive). This PR is deferred to the v0.9 epic and
tracked in #1087.
PersistenceForecaster reuses NwpRunRowsWithoutWeatherFeatureEngineer and the shared meta.json
helper, which the climatology PR extracted into baseline_forecasters. No StatelessForecaster
base class until a third stateless model exists.
MODEL_NAME = "persistence". Config defaultselected_features = {"power_lag_24h", "power_lag_48h", "power_lag_168h", "power_lag_336h"}.train()records the sortedtrained_time_series_ids(the requested ids present in the data, mirroring XGBoost's "no usable rows โ not trained" semantics).predict()=pl.coalesceof the lag columns in ascending-lag order โ_nullify_leaky_lagsnulls any lag withlead โฅ lag_hours(so the 24 h lag survives all intraday leads), and coalesce then selects the shortest non-leaky lag per row: same-time-yesterday for day-1, last-week for day 2โ7, two-weeks-ago beyond. Zero lookahead risk because it rides the audited pipeline. Output is one member, index 0, built as anInt8expression (Nwp.ensemble_memberandAllFeatures.ensemble_memberare bothInt8), withnwp_init_timenull. The identity columns followXGBoostForecaster._build_part. Rows where all lags are null are dropped, the count logged, and the per-series dropped/coverage counts recorded in asset metadata.conf/model/persistence.yaml:weather_source: "none",training_strategy: "none".- Tests: unit (shortest-non-leaky selection on a hand-built
AllFeaturesfixture; all-null-row dropping;save/loadround-trip freezing ids) plus an integration smoke fold via thetests/test_trained_cv_model.pyfixture pattern. - Register (
smoke_testโfull_cv), materialise the chain, sanity-check: NMAE worse than XGBoost overall but plausibly competitive intraday; single-member CRPS = MAE; spread-skill 0; PICP / interval width degenerate (expected and ignorable for a deterministic baseline). - Coverage caveat: persistence's longest lag is 336 h, so leads in
[336 h, ~360 h]drop out and itsall/extended_rangeaggregates cover a shorter lead population than other models'. Fair CRPS is size-comparable so nothing is wrong, but state the caveat where the sanity numbers are read, backed by the per-series coverage counts in asset metadata.
The recipe. No open questions remain. Full write-up in the manual heuristic forecast; the implementation spec:
- Weekly analogues: the last 6 weeks, same weekday & time-of-day.
- Annual analogues: the 7 weeks spanning 49โ55 weeks back, same weekday & time.
- Deterministic value: the operator picks the percentile that matches the company's risk appetite; we score the conservative 95th percentile of all 13 analogue values, reported alongside the metric-matched median headline (the metrics-collapse issue).
- No further processing: no weighting, no holiday handling, no anomaly rejection, no load-growth scaling. (So the holiday-aligned variant measures how much calendar awareness adds, rather than reimplementing a step the analogue method already takes.)
Cross-cutting. (1) Issue tracking: PR B is tracked in
#1087, and
manual_heuristic_holiday_aligned in
#1088, so the two deferred
items survive #147 closing. (2)
Re-run recipe: add a short "Re-running a CV run" subsection to
docs/cross-validation/dagster-workflow.md describing the trained_cv_model++ backfill, written
only after the drill is verified end-to-end, and mentioning PopulationFilter for single-CV-run
scoring. (3) MLflow re-logging on a re-run is idempotent in effect (latest value wins; history
accumulates harmlessly).
Cross-fold validation
The cross-validation protocol is implemented, so it has moved to its permanent home: Cross-validation folds. That page covers the expanding-window protocol, the current single fold (and why the available weather data constrains us to it), the target multiple-yearly-fold protocol, and the fold-design alternatives we considered.
Fold hygiene: selection bias and a final-test window ๐ง
Design and interim selection-bias guards: #960. The original issue and its design-update comment are retained in #226, closed as consolidated.
The single leaderboard fold (mid_2025_to_mid_2026 in conf/cv/default.yaml) serves as both the
model-selection set and the reported skill number, which the review already names as a source of
optimistic bias โ see
Leaderboards of machine learning results
for Hyndman (2020)'s M3/M4 warning and
Pinheiro et al. (2023)'s one-year minimum, both of
which our own fold already meets in length but not in independence. The winner's reported skill will
grow more optimistically biased over time, because hundreds of CV runs are planned. Each study
that posts a score to the leaderboard as a study/ MLflow experiment adds to the bias, because the
study chose its arms on the leaderboard fold. In phase 2 of the search that Experiments run by an
LLM agent describes, a large language model (LLM) agent screens ideas on folds
that end before the leaderboard fold's validation window, but each finalist the maintainer scores on
the leaderboard fold still adds to the bias. Our own fold is small in effective sample size rather
than in row count, because consecutive half-hours are strongly correlated. The epoch mechanism
handles data changes but not adaptive selection on a fixed fold.
Whether to reserve a genuinely untouched final-test year remains a design decision in #960. Jack has questioned whether to reserve that year rather than train on more history and report performance across multiple folds. Any alternative must address adaptive selection bias. If adopted, a separate final-test year needs a second, independent year of ECMWF ENS history. Dynamical.org's own Zarr backfill was estimated at ~November 2027 as of 2026-05, after v1.0, but a staged-GRIB route may deliver about three of the missing years sooner โ see a staged-GRIB route fills three of the missing years. The interim guards below are planned work in #960.
The planned Ladder guard publishes a new best only when it beats the standing best by more than a margin. The published score is then reported rounded to that margin. Blum and Hardt (2015) designed the Ladder for machine-learning competitions that publish a leaderboard and accept repeated submissions โ the same shape of risk hundreds of CV runs create when every one of them is adjudicated on one fold. Every query against a held-out set leaks a little information about it back to the experimenter. The margin-plus-rounding rule caps how much a single query can leak.
The plan also requires rerunning persistence and climatology baselines unchanged on every leaderboard epoch's evaluation window, so growth in data is not mistaken for method improvement. The precedent is CAMEO, a structure-prediction benchmark that keeps its baseline pipelines frozen while the protein-structure databases behind them keep updating (Robin et al. (2021)).
While evaluation data is reused for selection: leaderboard metrics are selection metrics; differences smaller than fold-level noise should not drive decisions; and the number of CV runs per epoch is itself a relevant statistic (visible as the MLflow experiment count).
The mid_2025_to_mid_2026 fold, at its full 12 validation months, is what decides promotion โ not
a separate, untouched final-test set. A promotion decision has to be made in minutes rather than
months, so it has to be judged against a window of history that already exists rather than a window
still arriving. It also cannot be judged on less than a year, because a shorter window cannot show
whether a model handles both ends of the annual cycle โ the one-year minimum Pinheiro et al. set out
above, and the same minimum the cross-validation
protocol
already builds the single fold around. The caveats above apply today; the planned Ladder guard
will constrain publication once implemented.
Measuring a promoted model's performance on live data is a separate question from deciding which model to promote. Every model running in production is also scored against live data as it runs (production monitoring), which answers "is the promoted model still performing", continuously and after the fact. The fold above answers "which candidate should we promote", once and ahead of the fact, and the two answers must not be blurred into a single answer. TS-Arena avoids reusing any fixed evaluation window at all (Meyer et al. (2026)); Flexpectation's promotion decision cannot work that way, for the reason just given, and it is the live-monitoring check where our practice matches the TS-Arena pattern instead.
Implementation details โ final-test window (deleted when it ships)
1. Document the caveat (immediately). A short "Selection bias" subsection in
docs/cross-validation/cross-validation-folds.md restating the paragraphs above.
2. If adopted in #960, reserve a final-test window once a second, independent year of data exists
โ not by shrinking the fold that decides promotion. (Jack's note: I'm not convinced we should do
this yet. Even when we have several years of data, may still want to train on as much data as
possible, and not to hold
out a separate "test" year. When we have multiple folds, I think a better test of "honest
performance" is average performance across all folds.). If adopted, the reservation waits on the
additional history tracked in #959,
which is also what turns the single fold into a genuine multi-fold epoch, so the final_test fold
and the further leaderboard folds are founded in one new epoch in conf/cv/default.yaml rather than
over two. The final_test fold needs a per-fold flag that keeps it out of every run mode, so no
CV run trains or scores on it in the normal flow: extend CvConfig / the fold schema in
packages/contracts/src/contracts/config_schemas.py, and make sure _fold_ids_for_run_mode in
defs/jobs.py includes the fold in no run mode at all. Scoring against it is then a deliberate,
rare act โ champion candidates immediately before promotion only โ through the metrics asset's
existing ad_hoc evaluation scope, so no new asset is needed and the discipline is procedural.
Whether the leaderboard-fold model can be reused as-is, or needs its own training run, depends on
where the disjoint year sits relative to mid_2025_to_mid_2026 โ decide that once the backfill
lands. Two rules go in alongside it: final-test results are never used to choose between
candidates, because that re-creates the problem the window exists to solve; they exist to report
honest skill for the chosen champion and to detect gross overfitting (final-test NMAE โซ validation
NMAE).
A multi-fold gotcha to handle when folds proliferate. The parent-MLflow-run aggregation in the
metrics asset averages each metric key over only the folds in which that key appears
(exp_metrics.setdefault(key, []).append(value) then sum/len in defs/cv_assets.py). Today every
fold emits the same key set, so this is invisible. Folds with different horizon coverage โ one
fold's forecasts stopping at 36 h, another's reaching day 14 โ would emit different
per-horizon-slice keys. rmse__all__extended_range would then silently average over a different
fold subset than rmse__all, with nothing marking the smaller denominator. Per-time_series_type
keys have the same property if fold populations differ. When adding any fold, either guarantee every
leaderboard fold emits an identical key set, or make the parent-run aggregation record its per-key
denominator.
Verification. If the full reservation is adopted: register_experiment_job must never create a
partition for the final_test fold in any run mode (extend
tests/test_register_experiment_job.py); and once the disjoint year lands, score one existing
CV run against the reserved window end-to-end and confirm the rows reach
forecast_metrics.delta with the window label while nothing is logged to the leaderboard MLflow
runs.
Evaluation metrics
| Metric | Type | Status | Purpose |
|---|---|---|---|
| Mean absolute error (MAE) | Deterministic | โ | Typical error magnitude (MW). |
| Normalised MAE (NMAE) | Deterministic | โ | MAE normalised by the series' effective capacity (full-history P99) โ comparable across substations of different sizes. |
| Root mean squared error (RMSE) | Deterministic | โ | Heavily penalises large misses (one 100 MW error costs more than two 50 MW errors). |
| Mean bias error (MBE) | Deterministic | โ | Systematic over/under-prediction. |
| Histogram of errors | Deterministic | ๐ง | Visual check that errors are ~Normal. |
| Pinball loss (quantile loss) | Quantile | โ | Penalises asymmetrically by target quantile, at the 13 NGED delivery quantiles. Averaged across quantiles for a single quantile-skill score. |
| PICP (Prediction Interval Coverage Probability) | Quantile | โ | Coverage of six symmetric bands (p1โp99 โฆ p35โp65). Judge against the finite-ensemble calibrated reference (โ 0.769 for p10โp90 at 51 members), not the nominal rate. |
| Interval width | Quantile | โ | Mean band width (MW) โ the sharpness companion that stops PICP being gamed by over-widening. |
| CRPS (Continuous Ranked Probability Score) | Ensemble | โ | Probabilistic equivalent of MAE; rewards both accuracy and sharpness. Fair (finite-ensemble-unbiased) form โ the one metric comparable across ensemble sizes. |
| Spread-Skill Ratio | Ensemble | โ | Fortin-corrected RMS ensemble spread รท RMSE of the ensemble mean. 1.0 = well-calibrated; < 1 under-dispersed (overconfident); > 1 over-dispersed (underconfident). |
| Threshold-weighted CRPS (twCRPS) | Ensemble | ๐ง | CRPS confined to behaviour above a per-series threshold โ the ranking-grade metric for skill near the network limit. See Tail & exceedance metrics. |
| Exceedance rate of upper quantiles | Quantile | ๐ง | "When we said p95, was it exceeded ~5% of the time?" โ one-sided calibration check for the delivered tail quantiles. |
| Brier score for threshold exceedance | Ensemble | ๐ง | Grades the ensemble's "chance load exceeds the threshold" probability โ the score for the yes/no warning NGED acts on. |
The
Metricsschema (contracts.ml_schemas.Metrics) stores results as(time_series_id, power_fcst_model_name, fold_id, horizon_slice, metric_name, metric_param, metric_value).metric_paramcarries, e.g., the quantile for Pinball Loss (p10) or the band for PICP (p10_p90). ThemetricsDagster asset computes every โ metric above and writes per-series rows toforecast_metricsDelta (partitioned byexperiment_name, fold_id), with per-fold and mean-across-folds aggregates logged to MLflow โ see Running the cross-validation pipeline end-to-end.
Every metric above is also broken out by named population-filter slices, which appear as extra leaderboard columns. Most are legitimate ranking columns, because the filter is fixed by information known before the outcome: the horizon slice a forecast falls in, the Tricky days calendar, and logged switching events. Two are diagnostic only and must never drive ranking, because they select hours by what actually happened and so reward a model that simply over-forecasts (the forecaster's-dilemma trap): the peak-events slice (the top 5% highest observed demand) and NGED's hand-picked hard examples. Both are described under Tail & exceedance metrics.
A prediction clamped to a generator's export cap is diagnostic only too, and for the same reason about what was known when. The clamp uses the setpoint the network operator issued for the hour being scored, which a forecast issued hours earlier could not have held. When the clamp is fair and when the clamp is lookahead bias are set out under drop curtailed hours from the training target.
Every ratio between a model and a reference is corrected for the ensemble-size bias Weigel et al. (2007) describe, and carries its reference forecast, population, and ensemble-member count โ see Publishing results that others can compare against for the general commitment. The fair, finite-ensemble-unbiased CRPS in the table above is the form that carries this correction. PICP is judged against the finite-ensemble calibrated reference rather than the nominal rate for the same reason.
Which ensemble collapse defines the deterministic point forecast? ๐ง
Decided: metric-matched collapse, uniform across every model. Implemented as part of the baseline work (the metrics-collapse item in the baseline implementation details); the reasoning below is the durable design rationale, promoted to the evaluation-metrics reference when the metrics collapse ships.
The problem
The deterministic metrics (MAE, NMAE, RMSE, MBE) score a single point forecast, but every model
on the leaderboard is really an ensemble (51 NWP members for the ML models; 13 historical
analogues for manual_heuristic; a quantile sample for climatology). The metrics layer has to
collapse each ensemble to one number. compute_metrics today collapses every ensemble to its
mean (packages/ml_core/src/ml_core/metrics.py), and the risk we were guarding against was that
different models would be scored on different collapses โ e.g. the ML models on their mean and
manual_heuristic on the median that an operator effectively reads off its analogue spread. Mean
and median diverge for skewed or underdispersed ensembles, so scoring some models on one and some on
the other is not apples-to-apples โ a silent trap that quietly mis-ranks models.
Mean versus median โ the trade-off
The instinct is to "pick one central statistic and apply it everywhere". But mean and median are not interchangeable, and the difference between them decides which one to use where.
The mean is the point forecast that minimises squared error โ so RMSE (and MBE, which is a
mean-of-errors and inherits the mean's clean energy-balance / expectations-aggregate reading) is
consistent with the mean. Our squared-error XGBoost models literally learn a conditional mean, and
the ensemble mean of 51 such members is a coherent estimate of E[power]. Scoring the mean on RMSE
rewards a model for reporting its honest conditional mean. The mean also averages out member noise,
so it is the more stable statistic on a small ensemble, and it matches standard NWP verification
practice.
The median is the point forecast that minimises absolute error โ so MAE and NMAE are
consistent with the median. It is robust to the skew that is real in this problem (holiday weeks,
solar clipping) and to the ensemble underdispersion the probabilistic
section documents, it is the faithful reading of the manual
heuristic's equally-weighted analogue spread, and it is coherent with the quantile columns already
on the leaderboard: median MAE is exactly 2 ร pinball_loss@p50, so a median headline makes the
deterministic and probabilistic columns tell one story.
The key realisation is that MAE and RMSE elicit different functionals, so no single collapse is fair on both columns. A uniform median is inconsistent for RMSE (it penalises squared-error models for forecasting their honest mean); a uniform mean is inconsistent for MAE (a model could improve its MAE ranking by warping its forecast away from its honest mean โ Gneiting 2011, Making and Evaluating Point Forecasts). The apples-to-apples requirement is only that the collapse be uniform across models, not across metrics.
The decision
Score each deterministic metric on the ensemble collapse it is consistent with, the same way for every model:
- MAE / NMAE โ ensemble median (NMAE is the headline cross-series metric, so the headline is effectively median-based).
- RMSE / MBE โ ensemble mean.
- Spread-skill ratio โ ensemble mean, internally, unchanged โ its Fortin calibration target
(RMSE of the ensemble mean =
โ((m+1)/m) รRMS spread, so "1.0 = calibrated") is defined against the mean; switching its internal collapse would silently break that reading.
Everything else is an extra, labelled row, never a headline: the manual heuristic's conservative
P95 operating point (mae/mbe at metric_param="p95" โ conservative by design, so a large
positive MBE that belongs beside the central number), and the median's own bias (mbe at
metric_param="p50", so the delivered central forecast has an honest bias number distinct from the
mean's energy-balance bias). No model is structurally disadvantaged on any column โ which a single
uniform statistic cannot achieve โ and the trap is closed because the collapse is uniform across
models. Because the collapse lives entirely downstream of the stored forecasts, switching it
re-scores the whole leaderboard from a single metrics re-materialisation with no retraining or
re-prediction. Doing it now, before the leaderboard adjudicates anything, is the easiest moment to
shift every existing deterministic number.
Effective-capacity normalisation, and the v0.7 upgrade to time-varying ๐ง
NMAE's denominator is each series' full-history effective capacity โ why a capacity-like
denominator is used rather than the mean, and why NMAE rather than mae__all is the headline
cross-series metric, is durable metric-design rationale that lives in Normalised MAE
(NMAE). The effective_capacity Delta
table itself, and why v0.1 stores one scalar row per series, is described in Table 4 โ
effective_capacity.
v0.7 upgrade: time-varying, and the join changes. The differentiable-physics capacity model produces a value that changes over time (panel degradation, inverter trips, seasonal derating). At that point the asset body and the metrics join both change, and nothing else:
- the
effective_capacityasset body emits one row per(time_series_id, time); and compute_metricschanges its capacity join fromtime_series_id-only to a temporal as-of join on(time_series_id, valid_time)โ matching each forecast'svalid_timeto the capacity in effect at that time.
The Metrics schema and the rest of the metrics pipeline are untouched. Note the table is
backward-looking only (it holds no future valid_times): fine for historical CV folds (whose
validation windows lie inside the observed history), but live-forecast scoring (production
monitoring) must choose which reference time's capacity to
apply rather than expecting a row at a future valid_time.
Each metered generator's series is also normalised by its estimated effective capacity before training โ unless the comparison described under effective-capacity estimation shows the normalisation is not needed โ and that estimate is tracked as it changes.
One related distinction to keep straight: the metric denominator may use the full-history smoothed capacity estimate, but any capacity used to normalise model inputs at forecast init time (the two-pass training scheme) must be the causal estimate available at that init time, or backtests gain lookahead โ see Capacity estimation.
Tail & exceedance metrics โ scoring the question NGED actually asks ๐ง
Issue: #254
This section is the delivery plan for the three ranking-grade metrics that answer NGED's actual question โ "will load cross the limit?" Why mean pinball loss and PICP under-serve that question, and why a model can only be ranked on hours selected by a criterion fixed in advance, other than the observed load, is durable reasoning that lives in Why the tails need their own metrics and The trap: scoring only the hours when the worst case actually happened. Definitions, equations, and intuitive explanations for each metric are in the evaluation-metrics reference.
Threshold-weighted CRPS
(twCRPS) โ CRPS confined to
behaviour above a per-series threshold, which is Gneiting and Ranjan
(2011)'s way of putting the emphasis inside the score
while it stays a proper scoring rule; the headline ranking metric for tail skill. A GB
distribution network has already been scored this way: Maia et al.
(2026) compare fault-count forecasts for SP Energy Networks
against a quantile-regression baseline on the threshold-weighted score, because an unweighted one
"would place substantial emphasis on parts of the predictive distribution where the two models are
identical". Implementation needs almost no extra work: replace members and observation by
max(value, threshold) and reuse the existing fair-CRPS aggregation unchanged, inheriting its
comparability across ensemble sizes.
-
Exceedance rate of the upper delivery quantiles (p80โp99) โ "when we said p95, was it exceeded ~5% of the time?"; the plain-language calibration check for the tail quantiles NGED reads, one-sided where PICP's bands are symmetric.
-
Brier score for threshold exceedance โ grades the ensemble's "chance that load exceeds the threshold" the way one would grade a "70% chance of rain" forecast; the most decision-legible number, scoring exactly the warning NGED acts on.
All three fit the existing Metrics shape โ metric_param carries the threshold or quantile label
โ but they need a contract change to get there: METRIC_NAMES has no twcrps or brier entry, and
METRIC_PARAMS no historical_p99, and both fields are pl.Enum.
Thresholds: static, per-series, quantile-derived. Each series gets one static threshold โ the P99 of its full observation history, in the series type's constraint-side direction (high load for demand; reverse power flow for generation) โ a percentile-of-history convention of the kind commonly used in capacity setting. The cost-savings metrics discuss limits of their own. Physical firm/flex ratings, where NGED supplies them, feed ad-hoc case studies and dashboard overlays instead. The full rationale โ why a full-history quantile threshold beats the physical rating for scoring โ and the explicit "this is a proxy" caveat live in the threshold-choice section of the reference.
Diagnostic slices โ never ranking columns. Two observed-outcome views stay available for understanding a model's errors, clearly labelled as ineligible for ranking (the design rule above): a peak-events slice (the top 5% highest-observed-demand half-hours, via the same population-filter mechanism as the Tricky-days filter) and hand-picked "hard examples" โ historically difficult periods NGED supplies, scored as case studies.
These metrics also serve the probabilistic-calibration work: an underdispersed ensemble pushes its exceedance probabilities to 0 or 1 too early, so the Brier score and exceedance rates are the sharpest before/after instruments for Phases C and D.
Implementation details โ tail & exceedance metrics (deleted when this ships)
- Thresholds: compute a per-series
historical_p99scalar from the full observation history alongside (or within) theeffective_capacityasset โ same full-history stability rationale, same join shape (time_series_id-only). Constraint-side direction resolved pertime_series_type; confirm the mapping with NGED for ambiguous types (BESS charges and discharges). - Threshold leak: decide whether
historical_p99is computed from the full observation history or from the training window only.effective_capacity, whichhistorical_p99piggybacks on, is a full-history P99 that includes the validation window, so a threshold built on that full-history value can see the same outcomes a model is scored against. A training-window-only threshold avoids that leak, at the cost of a threshold that shifts between folds rather than staying one fixed number per series. - twCRPS: transform members and observation with
pl.max_horizontal(col, threshold)and reuse the existing fair-CRPS expression (sorted-member identity, Float64 accumulation) verbatim. - Exceedance rates: compare
yagainst the already-computed empirical quantile columns for p80, p90, p95, p98, p99 โ one boolean mean per level. - Brier score: exceedance probability = member fraction above the threshold; outcome indicator
from
y; squared difference, averaged. - Event counts: print the number of exceedance days each tail metric is computed over, and refuse a comparison between two candidates when either side's event count falls below a minimum (an implementation-time choice) โ a metric computed over a handful of exceedances is not a reliable ranking signal.
- MLflow allowlist: extend
_MLFLOW_LOGGED_PARAMETRICwith a small headline subset (e.g.twcrps@historical_p99, exceedance rate at p95,brier@historical_p99); decide the exact set at implementation time and keep it small โ everything is in Delta regardless. - Peak-events diagnostic slice: one more named population filter on the shared mechanism (with the Tricky-days filter), flagged in the leaderboard UI as diagnostic-only.
- Verification: hand-computed toy-ensemble values for all three metrics; cross-check twCRPS
against the
scoringrulesreference implementation; Monte-Carlo the finite-ensemble one-sided exceedance references (mirroring the PICP reference-table verification); on a smoke fold, confirmmanual_heuristic's P95 operating point scores well on the p95 exceedance rate while its conservatism reduces its sharpness on Brier/twCRPS.
Tricky days โ a calendar-deterministic metric filter ๐ง
Issue: #255
Alongside the tail & exceedance metrics, we add a "Tricky days" filter: score models separately on the handful of calendar dates whose demand shape departs sharply from the usual weekly rhythm. We scope it deliberately to the calendar-deterministic set โ fixed and moveable public holidays (Christmas, Easter, and the rest of the GB bank holidays) plus the two annual daylight-saving transitions โ because these dates are exactly the days our weekday/seasonal analogues are built to mishandle. Days that are hard for data-dependent reasons stay in their own already-planned filters: switching-event days and NGED's hand-picked "hard examples" (above). One shared filter mechanism, several named filters โ folding genuinely different failure modes into one bucket would make the number impossible to act on ("bad on tricky days" โ is that Christmas, or a switching event?).
Mechanically it is another population filter (the same mechanism the peak-events diagnostic slice
uses): a boolean flag per timestep, derived from valid_time alone. Unlike that observed-peak
slice, this filter is a legitimate ranking column: the flag depends only on the calendar, which
every forecaster knew in advance, so it does not fall into the forecaster's-dilemma
trap.
Because it is purely calendar-driven it shares its calendar module with
manual_heuristic_holiday_aligned โ the same GB bank-holiday calendar (the pure-Python holidays
package) plus the two DST dates feed both the holiday-aligned baseline and this metric filter. The
two reinforce each other: manual_heuristic (no holiday logic) should score worse on tricky days
than overall, and manual_heuristic_holiday_aligned should recover most of that gap. The size of
the recovered gap measures how much calendar awareness adds.
Flag the day and its analogue-relevant neighbours, not just the day itself. The disruption spills onto surrounding timesteps:
- DST transitions: the hard part is not only the 23/25-hour day but that lag and analogue features are misaligned by an hour on the days either side.
- The Christmas run-up: demand in the days before Christmas is already atypical, so the window must cover the run-up, not just the 25th.
So the flag covers a small window around each date rather than a single day; the exact per-event widths are an implementation-time choice.
A subtlety to document now but not model yet. The shape of the Christmas run-up depends not just on the number of days before Christmas but also on which weekday Christmas falls on โ the run-up demand pattern shifts year to year with that day-of-week alignment. We record it here as a known effect; the v0.3 tricky-days flag ignores it (the flag simply marks the window), and we defer any explicit day-of-week-aware modelling of the run-up until there is evidence it moves the leaderboard.
Implementation details โ tricky days (deleted when this ships)
- A small calendar module (shared with baseline 2,
manual_heuristic_holiday_aligned) answers, for anyvalid_time, whether it falls inside a tricky-days window. Back it with theholidaysGB calendar plus the two annual DST dates; expose the per-event window widths as config. - Represent the tricky-days slice the same way the peak-events diagnostic slice is represented โ one
more named population filter, resolved by the same mechanism, not a new schema axis โ so the
leaderboard gains a Tricky days column with no
Metricsschema change. - Verification: unit-test the flag on known dates (a Christmas week, an Easter, both DST
switchovers, and a plain week that must be excluded); on a smoke-test fold, confirm
manual_heuristicscores worse on the tricky-days slice than overall.
Scoring under failure scenarios ๐ง
Status: ๐ง Planned (v0.3). Nothing scores a model under degraded inputs today, which means a v0.5 champion would be picked on clean-data skill alone.
The inherent-stability principle claims that the service keeps beating the manual heuristic as its inputs degrade. That claim is only worth anything if it is scored, so degradation becomes a dimension of the leaderboard rather than an aspiration in a design document.
A canonical failure-scenario suite. A named, versioned set of degradation transforms over an
AllFeatures frame โ NWP {fresh, n runs missed, absent} ร telemetry {present, partial, absent} ร
metadata โ on the order of 10 to 20 realistic regimes rather than a combinatorial explosion. Only
the episodic class needs enumerating; the chronic nulls the de-accumulated ECMWF variables carry
are present in every run we ingest and so are already in-distribution (see Inherent Stability โ
Missingness in learned
models). The vocabulary
is a contract: it is stamped onto every metrics row, so changing it later invalidates historical
comparisons.
How each scenario is scored. Train once, then predict once per scenario โ the cost is Nร
predict plus Nร metrics, not Nร train. The scenario becomes a dimension on the metrics rows (and
on the power_forecasts rows the metrics are computed from) rather than a new evaluation scope, so
degradation behaviour is a first-class property of every CV run instead of a separate study
somebody has to remember to run.
The acceptance criterion is manual_heuristic, not a fixed error threshold. The manual
heuristic consumes no NWP and is indifferent to recent telemetry staleness, so it barely degrades.
The manual heuristic is therefore the honest bar to clear, and a far better failure criterion than
any arbitrary staleness threshold. Concretely: at rungs 0โ2 of the degradation ladder, every time
series should still emit a forecast, and that forecast should still beat manual_heuristic. That is
T1.2, graceful
degradation;
the interval-calibration counterpart, PICP within tolerance in every regime, is T1.3, faithful
uncertainty.
This suite is shared machinery: the same transforms drive the continuous-integration (CI) degradation smoke-tests in Engineering Health and, later, the outage-shaped training augmentation that makes the weather-blind claim true rather than hopeful.
Scoring against estimates of past weather โ a diagnostic scope ๐ง
Once the estimates of past weather are ingested, scoring a CV run on an estimate of past weather rather than ENS separates the two components total error confounds: the weather-to-power response, which we can actually improve, and the implicit hedging against forecast error, which we cannot (NWP error is exogenous to us). Without the split, a change in the ENS-scored number could be either.
Scoring against estimates of past weather lands as a new evaluation_scope alongside
leaderboard / production_monitoring / ad_hoc, never as a fold. The ENS-scored leaderboard
stays the promotion criterion, because total error at real lead times is what NGED receives โ and
keeping estimates of past weather out of the fold set is what preserves both principle
8 and the
standing rejection of reanalysis-backed validation
folds.
The perfect-weather ceiling โ what it gates
Train and score on near-perfect weather, and the resulting skill bounds what we could gain by removing forecast error from the weather input โ the channel that more ensemble members, better ensemble post-processing and sharper interpolation all work through. If that ceiling sits close to today's ENS-scored skill, most of our error is not the weather forecast's fault and the effort belongs in the modelling instead. The ceiling bounds the skill that ingesting another NWP source could add. A low ceiling need not stop anyone trying a new source, because an autonomous study can test a new weather product quickly (see A new weather product with a few months of history). That study trains a lag-free XGBoost model on estimates of past weather, which the study calls the weather-response model. A lag-free variant of the ceiling's XGBoost model can serve as both, so the ceiling and the study can share one XGBoost model. The ceiling proper uses the champion's own feature set, lags included.
Two rungs, in increasing order of "cheating":
-
Gridded estimates of past weather, compared with each other. The version-1 plan is CAMS for irradiance and ERA5 for other variables (see which estimate of past weather to train on). ERA5 is a reanalysis, so it assimilates observations, but it is still a 31 km model field โ good, not perfect. A study of CEDA's UKV archive against ERA5 scored the two against each other, and #492 is where the perfect-weather ceiling sits.
-
Observations. Observations are measured at the site rather than averaged over a grid cell. Observations earn the second rung precisely because a gridded estimate's remaining error is not small. The UK Met Office's MIDAS Open (via CEDA) supplies hourly land-surface temperature, wind, and pressure from GB stations โ spatially sparse, so nearest-station matched. CAMS solar radiation is the equivalent rung for solar, and is also the planned estimate of past irradiance for training.
Three conditions on reading the result.
-
The ceiling must be trained on the better weather, not merely scored on it. Feeding reanalysis to an ENS-trained model measures a train/serve mismatch instead of a ceiling.
-
The ceiling bounds forecast error, not resolution. A finer forecast can carry site-relevant structure that a coarse analysis averages away: ERA5 is a 31 km field, while ICON-EU is ~6.5 km and post-2023 ENS is 9 km. A low ceiling measured on ERA5 therefore deprioritises a second NWP source without ruling one out. A 2 km estimate of past weather such as CEDA's UKV would narrow this gap for every variable except irradiance, and the observations rung closes it, since station and satellite data are at-site rather than grid-cell means.
-
It is a ceiling for the current model family and feature set. A model that cannot exploit perfect weather shows a low ceiling for reasons that have nothing to do with weather availability. That does not weaken the decision the ceiling informs โ a model that cannot use perfect weather will not be rescued by a better forecast of it โ but it does mean the ceiling is re-measured after any large modelling change rather than treated as a standing fact.
Time-slices for performance evaluation
We compute every metric separately per horizon slice, because the driver of model skill changes with lead time:
| Horizon slice | Industry term | Primary driver of model skill |
|---|---|---|
| 0โ6 hours | Intraday / Nowcasting | Lagged power & persistence. NWP is often too coarse to beat simple autoregressive features here. |
| 6โ36 hours | Day-Ahead | Deterministic NWP. Covers the critical day-ahead market gate; relies on the diurnal cycle + high-res weather. |
| Day 2โ7 | Short/Medium Range | Synoptic weather. Skill driven by mapping large weather fronts to power; ensemble spread starts to matter. |
| Day 8โ14 | Extended Range | Ensemble probabilities. Deterministic weather is essentially noise; skill comes from processing ensemble uncertainty. |
Coverage is broken down by season, by how heavily loaded the substation was, and by the lead-time slices above, not reported as one annual figure โ see Publishing results that others can compare against for why an averaged 90% can hide 70% coverage at the winter peaks, the periods when most flexibility is procured.
Measuring performance during switching events ๐ง
We will flag each timestep for whether it contains a switching event, and compute metrics separately
for periods with switching events in the model inputs (or in the forecast's valid_time). This
distinguishes models that perform well only on clean periods from models that handle switching
events in their inputs. In v1 the flags can come straight from NGED's logged switching events for
the trial area; a fleet-scale version would need the discrete detector described in Switching
events & latent demand, whose fate is an open question โ see the decision
point.
Delivering the probabilistic metrics ๐ง
Issue: #225
The 51-member ensemble is very likely underdispersed: XGBoost trained with squared error on the
control member (cv_assets.py:373) learns a conditional mean, so pushing 51 members through it
yields spread from weather uncertainty only โ no model or observation uncertainty. Underdispersed
ensembles are systematically overconfident, worst at short horizons where members haven't diverged.
Flexibility procurement is a tails problem (P90+ peaks), so this overconfidence hits the use case
directly.
Phases A and B already built the measurement machinery, and the remaining phases act on what those numbers show. Phases A and B (both shipped) deliver every metric in the evaluation-metrics reference, per horizon slice. The planned tail & exceedance metrics will sharpen the picture further: an underdispersed ensemble pushes its exceedance probabilities to 0 or 1 too early, so the Brier score and the quantile exceedance rates are the clearest before/after instruments for Phases C and D.
The theory behind this diagnosis โ the three-term uncertainty decomposition, why a deterministic model driven by an NWP ensemble captures only the weather term, and what the principled fix looks like โ is the durable explainer Probabilistic forecasting from NWP ensembles. This section is the plan that applies it.
Fix in four phases, each an independent PR (Phase D is itself several PRs). Phases A and B are pure evaluation (no model changes) and should land before any further MAE-driven changes.
Phase A โ horizon-sliced metrics โ
Shipped. compute_metrics now scores every metric per HORIZON_SLICES band (derived from
valid_time โ power_fcst_init_time, with the ensemble collapsed per forecast run), and
build_mlflow_aggregate_metrics logs overall per-slice keys like nmae__all__day_ahead.
Phase B โ probabilistic metrics from the existing ensemble โ
Shipped. compute_metrics now computes fair CRPS, the Fortin-corrected spread-skill ratio, pinball
loss at the 13 NGED delivery quantiles (plus their mean), and PICP and interval width for the six
symmetric delivery bands โ all member-aware, computed before the ensemble-mean collapse, per horizon
slice. Definitions, equations, and the design decisions (fair CRPS divisor, RMS spread, delivery
quantiles, MLflow allowlist) live in the evaluation-metrics
reference.
Phase C โ low-effort calibration (after B proves the diagnosis)
Decide based on Phase B's spread-skill numbers โ and on the rank (Talagrand) histogram of the
observations among the 51 members, computed ad hoc per horizon slice: a U-shape confirms plain
underdispersion (a single multiplicative inflation can fix it), whereas a sloped or asymmetric
histogram means bias or shape error, which a symmetric inflation cannot repair and which would push
toward a rank-dependent correction instead. Post-hoc spread inflation (a cut-down Ensemble Model
Output Statistics, or EMOS, after Gneiting et al. (2005)): per
horizon slice, fit a scalar s on the training window so that inflating members around the
ensemble mean (mean + sยท(member โ mean)) makes spread match error. Zero schema change, and no new
learned model โ the wrapper holds a handful of fitted coefficients, not a trained estimator. Fit on
train, apply on validation (no tuning on the fold being scored). Build the inflation as a wrapper
forecaster rather than as a step inside predict, so that the calibrated manual
heuristic and the later
degradation-conditional conformal
calibration share one
implementation and one metric path โ once we settle how a wrapper derives its class-level
MODEL_NAME from the model it wraps. The product calibrator under research in A new weather
product with a few months of
history would be the
wrapper's third consumer.
Spread inflation widens the fan but cannot reshape it (the inflated ensemble is still 51 point forecasts, just pushed apart). It is the stopgap the full fix below must beat to earn the effort of building it. [Issue
1156](https://github.com/openclimatefix/nged-substation-forecast/issues/1156)
plans EMOS and conformal variants of spread calibration, scored on the same rows as the quick-wins study.
Phase D โ ensemble of quantile forecasts (Representation 3 โ pooled Representation 2)
The full fix from the probabilistic-forecasting explainer: have the model emit a conditional distribution per ensemble member (per-member quantiles โ Representation 3 in the delivery tables), then recombine the 51 members with the linear-pool mixture into one set of delivered percentiles (Representation 2). Three steps, one PR each:
- Percentile representations in
PowerForecast(#262) โ extend the contract (and the Delta write/read paths) with the Rep 2 and Rep 3 percentile columns, alongside the existing deterministic-ensemble representation. - Quantile XGBoost model family
(#263) โ
objective: reg:quantileerrorwith severalquantile_alphas, as a separate model family emitting Rep 3. Sort each member's quantiles at predict time (monotonic rearrangement fixes quantile crossing). The lead-time feature and training on multiple members (xgboost-improvements โ the lead-time feature and ensemble-member training) are the double-counting mitigations discussed in the explainer โ land them first or measure without them consciously. - Linear-pool combining step (#264) โ pool the per-member quantiles into delivered Rep 2 percentiles (the pseudo-sample recipe in the explainer), with a per-horizon affine recalibration hook (fit on train) applied only if the pooled spread-skill/PICP numbers demand it. Scored with pinball/PICP/CRPS head-to-head against the Phase-C inflated deterministic champion. The quick-wins study found that crossing the quantile model's output with the 51 members left the 10% to 90% band covering 57% of outcomes, against a nominal 80%, and issue #1156 plans the follow-up checks on how to score and recombine the quantiles.
Grouping the results
Each CV run is tagged with metadata so we can group CV runs and compute average performance per group (e.g. "does lagged power always help, regardless of model sophistication?", or "how robust is each model to weather-forecast uncertainty โ estimates of past weather vs. operational NWP?"). Example tags:
| Tag | Example values |
|---|---|
time_series_type |
PV, Wind, disaggregated demand (primaries) |
model_family |
manual_heuristic, baseline_persistence, xgboost, pytorch_mlp, pytorch_graph_dp |
weather_source |
none, ecmwf_control, full_ecmwf_ensemble, cams_ceda_ukv, era5 |
input_features |
datetime, power_lag_24h, power_lag_7d, temperature |
training_strategy |
direct_multistep, horizon_as_feature, end_to_end |
generator_capacity_estimation |
none, simple_p99, convex_envelope, differentiable_physics |
switching_event_detection |
none, simple_statistical |
pre_training |
none, CAMS + ERA5, ERA5 alone |
Accuracy is published separately for each class of asset โ grid supply points, bulk supply
points, primary substations, and metered generators โ each against its own stated naive baseline,
for the reason given under Publishing results that others can compare
against.
The unweighted mae__all aggregate is dominated by the grid supply points for exactly that reason.
The time_series_type tag above is what carries the split.
The battery, the gas generator, and the biofuel plant are reported separately from the wind and solar sites, for the reason given under Publishing results that others can compare against โ pooling them with weather-driven generators would hide how well either group is forecast.
Every leaderboard row also carries two cost savings (ยฃ) figures โ one for flexibility procurement, one for curtailment โ designed in Estimating the money a better forecast saves. They are deliberately rough proxies, not a cost analysis.