Limitations, and what the harness caught¶
A forecasting system that only reports its wins is a marketing artifact. This page is the other half: what this system cannot currently do, what it got wrong before the evaluation harness caught it, and what would change its conclusions.
Read this page before you trust any number produced by this repository.
1. The binding constraint: archive age¶
Every method here needs months of stored forecast vintages. Ground truth alone is not enough.
You cannot reconstruct what a provider said last Tuesday about last Wednesday unless you recorded it last Tuesday. A forecast archive is the only input to this system that cannot be backfilled, bought, or recovered — and its clock starts the day you begin polling.
A newly started live archive may contain only a handful of snapshots. That is not enough to backtest anything, and the system says so rather than inventing a leaderboard. A typical early-run message is:
hourly: 0 score rows -> data/scores/scores_hourly_live_expanding_<evaluation_id>.parquet
hourly: no rolling-origin folds. The archive spans 0.0 days of issue times
(2026-03-22 16:19:40 .. 2026-03-22 16:28:15) but a fold needs
initial_train_days + step_days = 97. Keep polling, or backtest --source
synthetic against an Open-Meteo backfill.
The single highest-value action available to this project is to start the polling cron today. Every week of delay is a week of training data that can never be recovered. See Advanced usage for the recommended cadence.
2. The station has holes¶
The qc command will show you this, and you should look. The sample station
database contains 76,507 samples spanning 2025-06-14 → 2026-07-13, but:
- Scoreable hours cluster in two islands: June–July 2025 and March–May 2026. There is a ~7-month outage in between.
- Only ~1,700 of ~9,400 calendar hours (~18%) have an instantaneous truth value.
- Pressure is missing for ~32% of samples; other channels ~4%.
This is not a bug in grounded-weather-forecast — it is what the station recorded — but it has teeth. It means the expanding training window is seasonally unrepresentative (all-summer training, spring testing), which is precisely the condition that broke grounding (§4.1). It also means the rolling window is often empty (a 180-day window ending in March 2026 covers only the outage), so the rolling/expanding comparison the design promises is currently not informative on this data.
3. What the synthetic backfill can and cannot tell you¶
The Open-Meteo Previous Runs backfill is the cold-start escape hatch. It can produce a substantial local exploratory dataset, but no backfilled parquet or score artifact is shipped with this repository. Any concrete leaderboard result must therefore be regenerated against the operator's own station record and configuration; numbers shown below are historical illustrations, not packaged or production evidence.
But its limits are structural, not incidental:
| Limitation | Consequence |
|---|---|
| Open-data NWP models only (ECMWF, GFS, ICON via Open-Meteo). | The leaderboard says nothing about Google, Tomorrow.io, AccuWeather-style blends, NWS's human-edited grids, or MET Norway. These are exactly the sources most likely to be diverse, and they can only be acquired live. |
Leads are exact 24-hour multiples (offsets previous_day1..7). |
Only the buckets ≥ 24 h are populated. The 0–24 h buckets — where the product actually lives, and where anchoring earns its keep — are entirely unevaluated on this data. |
| Day-0 is deliberately excluded. | The unsuffixed Previous Runs field is the latest run for a past hour, whose effective lead is near zero. Including it would have filled the short-lead buckets with what is essentially an analysis and made anchoring look miraculous. Excluding it is honest, and it is why the short-lead gap above cannot be papered over. |
Synthetic and live are never pooled (MixedProvenanceError). |
Correct, but it means you currently have no leaderboard for the providers you actually serve from. |
The visible symptom in the leaderboard: anchored_grounded_equal_weight and
grounded_equal_weight have identical MAE to three decimals. That is not a
bug — anchoring only looks for its residual among rows with lead under
_ANCHOR_MAX_LEAD (6 hours), and backfilled leads start at 24. With no anchor
row, the τ grid search selects "no anchoring" and the wrapper degrades exactly to
its base. The mechanism works; the data simply cannot exercise it.
4. Three things the harness caught¶
These are the reason the evaluation infrastructure exists, and they are more valuable than any of the code they corrected.
4.1 Grounding was making forecasts worse¶
The core thesis of the whole project is grounding ≫ everything else. The first
real leaderboard said the opposite: grounded_equal_weight was losing to an
uncorrected equal-weight blend at 48–168 h leads, and its bias column read
+1.2 to +1.4 °C — the correction was injecting bias.
The cause, traced by inspecting the fitted coefficients fold by fold:
- Least squares regressing truth on a noisy forecast yields a slope below 1 (regression dilution). Fitted slopes came out at 0.76–0.89.
- A slope below 1 shrinks predictions toward the training-period mean.
- The training window was all-summer (mean 21.4 °C); the test folds were spring (mean 13–20 °C).
- So the "correction" was a warm tilt, and it was worst exactly where the test period was coldest (one fold: test mean 13.2 °C → bias +2.10 °C).
The fix was to default the slope to 1 — a bias-only correction, which is equivariant to level shifts and cannot do this. It beat the free-slope variant in every lead bucket:
| bucket | bias-only (now default) | free slope (previous default) |
|---|---|---|
| 24–48 h | MAE 1.367, bias +0.23 | MAE 1.471, bias +0.56 |
| 48–96 h | MAE 1.713, bias +0.67 | MAE 2.104, bias +1.45 |
| 96–168 h | MAE 1.775, bias +0.26 | MAE 2.143, bias +1.30 |
| 168–240 h | MAE 2.345, bias −0.44 | MAE 2.368, bias +1.21 |
Both variants remain registered (grounded_equal_weight, affine_equal_weight)
so a longer, seasonally representative archive can earn the slope back. Recorded
as ADR 0004.
The generalizable lesson: a correction fitted on an unrepresentative window
is not a neutral no-op — it is an active source of error, and the bias column
is what exposes it. A leaderboard that reported only MAE would have shown
grounding as merely "not helping", and the real mechanism would have gone
unnoticed.
4.2 The online experts could not adapt to drift¶
Online expert aggregation is in the method set for exactly one reason: it is supposed to notice when a provider silently swaps its backend model, and reallocate weight within days, with no refit.
The first implementation could not do this at all. On a synthetic stream where the good and bad experts swap halfway through, both EWA and BOA finished with weight 0.9999 on the wrong expert.
The cause is textbook and easy to miss: both algorithms use a learning rate that
decays like 1/√t. An expert that dominates the first half accumulates a
log-weight lead that the second half's smaller learning rates cannot overturn.
Vanilla EWA and BOA bound regret against the best fixed expert — not the best
sequence of experts. They are, by construction, unable to follow a regime
change.
The fix is fixed share (Herbster–Warmuth): after each update, redistribute a small fraction of the awake mass uniformly, flooring every weight and capping the weight ratio, so a recovering expert can climb back. Sweeping the share rate:
| share | stationary MAE ÷ best expert | drift: weight on the correct expert |
|---|---|---|
| 0.020 | 1.24–1.30 ✗ | 0.75 |
| 0.010 | 1.06–1.08 | 0.85 |
| 0.005 | 1.00–1.01 ✓ | 0.92 ✓ |
| 0.002 | 0.99 | 0.96 |
At share = 0.005 drift adaptation is essentially free: the aggregators track
the regime change and match the best single expert on stationary data. My
initial guess of 0.02 was simply mistuned, and the sweep — not intuition — found
it.
The generalizable lesson: a method's headline property (here, "adaptive to drift") is a claim, and claims need tests. Without a drift fixture, this would have shipped as a method that was strictly worse than inverse-MSE while carrying a docstring boasting about drift adaptation.
4.3 The climatology baseline was so bad it flattered everything¶
The climatology baseline initially posted MAE of 7.2–8.9 °C. Since climatology is a floor — the thing every real method must obviously beat — a broken floor makes every method look good for free.
The cause: fitting an annual harmonic (sin(2π·month/12), cos(2π·month/12))
on a training window shorter than a year. Over a 3-month arc those two regressors
are nearly collinear, so unpenalized least squares fits huge, mutually cancelling
coefficients — which explode the instant they are extrapolated into a season the
window never saw.
A ridge penalty (intercept unpenalized) shrinks the seasonal terms toward zero, so a short archive degrades gracefully to "mean plus diurnal cycle". MAE dropped to 4.87–5.31 °C: still bad, as climatology should be, but now a meaningful floor.
5. What one historical exploratory leaderboard did and did not say¶
In one prior local run using 13 months of backfilled ECMWF/GFS/ICON forecasts against one station (hourly temperature, expanding window, 45 folds), the report looked as follows. These values are illustrative and must not be interpreted as the repository's current evidence or as a release eligible for live serving:
| lead bucket | winner | MAE (°C) | skill vs best single provider | DM p |
|---|---|---|---|---|
| 24–48 h | gbm |
1.288 | +16.0% | 0.023 |
| 48–96 h | gbm |
1.572 | +16.0% | 0.031 |
| 96–168 h | equal_weight |
1.748 | +16.8% | <0.001 |
| 168–240 h | inverse_mse |
2.296 | +13.8% | 0.053 |
Read carefully, this says:
- Blending genuinely beats the best single provider, by 13–17%, with statistical significance at most leads. That is the headline, and it survives a Diebold–Mariano test with HAC variance.
- The forecast-combination puzzle is real and visible. Raw equal-weight wins the 96–168 h slice outright, and is within 0.03 °C of the best method on the n-weighted aggregate (1.770 vs 1.761). All the sophistication buys very little over an arithmetic mean of grounded sources — exactly as five decades of the combination literature predict.
- The diversification ceiling is low. Measured error correlations give an effective ensemble size of 1.3 independent sources out of 3 NWP models (ρ ≈ 0.66), and 1.8 out of 8 live providers (ρ ≈ 0.51). Equal-weighting 8 correlated providers reduces error variance to 0.57× a single provider's — not the 0.12× that independence would give.
It does not say:
- Anything about the providers you actually serve from (§3).
- Anything about leads under 24 hours (§3) — including the anchoring stage, whose advantage is verified only on synthetic fixtures.
- Anything about wind, precipitation or PoP at production quality — those slices
exist but are thin, and precipitation in particular is zero-inflated in a way
that MAE handles badly (Brier and reliability are implemented and reported, and
csgd_emosnow models occurrence and amount jointly via its censored mass; serve-time pop/amount coherence remains future work — see Future work).
6. Statistical caveats¶
- Diebold–Mariano power is limited in thin slices. The daily buckets in
particular have tens to hundreds of paired samples.
nis printed beside every p-value precisely so a thin slice announces its own weakness. Treatpin D8–10 as advisory. - A per-slice winner selected on the same scores used to rank is a form of selection bias. With ~40 registered methods × 10 buckets, some winners are noise. Promotion now requires a common-case comparison, at least 80% coverage, and significant improvement over the best reference when choosing a challenger; the leaderboard also prints BH-FDR q-values (e-BH on live boards) beside every DM p-value and winner's-curse-corrected columns beside every argmin winner. This mitigates, but does not eliminate, repeated-selection bias. The self-verification loop — scoring what we actually served against what actually happened — is the intended cure, and it needs live history to work.
biasis computed on the same rows as MAE; a method with low MAE and high bias is correctable, and that is a signal worth acting on rather than a contradiction.
7. Modeling gaps (deliberate, ordered)¶
Point-first was a deliberate decision; the following are known, sequenced, and non-blocking:
| Gap | Status |
|---|---|
| Precipitation occurrence vs amount | Partially shipped: csgd_emos fits a censored shifted-gamma whose censored mass is the dry probability, and precip_sparse_shrink handles the sparse daily case. The remaining gap is serve-time coherence between pop and amounts. |
| Wind direction | Circular; needs its own metric and blending rule. Deferred. |
| Condition enum, UV, solar | Deferred. The condition slug should be derived from blended precip/cloud at the end, never blended as an enum. |
| Cloud, visibility | Excluded: the station cannot verify them, so they cannot be scored, so they cannot be blended honestly. |
8. Known assumptions worth challenging¶
- Sea-level pressure is derived from station pressure + elevation +
temperature, because the station's
RelPressis not actually sea-level reduced (it reads ≈AbsPressat 1,400 m). If your station does reduce properly, this is wrong for you and the mapping should change. - The precipitation counter reset rule — a decrease below
precip_reset_fraction(0.5) of the prior value opens a new reset epoch, and within an epoch rain is credited only above the running maximum — is untested against heavy rain, because the sample database contains almost none (max event total: 1.31 in). The 0.5 fraction in particular is a guess calibrated against jitter, not against a storm.rainofhourlyis retained as a cross-validator. Revisit when a real storm is in the archive. See Methods: precipitation. - PoP threshold is 0.254 mm (0.01 in) — the standard "measurable" threshold, but a choice.
- The 12-hour staleness cap materially determines which sources are "available" at each snapshot for 6-hourly providers. It is a config knob and its effect on coverage should be checked against the correlation report.
pyreflyandtydisagree occasionally; both are run because their disagreements have been useful. Neither is authoritative.
9. Operational limits¶
- No daemon, no scheduler, no fetch-fresh.
predictis a batch command that reads whatever the upstream cron has written. Freshness is the cron's responsibility, andpredictrefuses to serve from stale data rather than guessing. - Single location. Location is config, not a key in the model store. Adding a second station means a second config and a second dataset directory — no code change, but no shared learning either.
- Pipeline mutators are serialized by a coarse lock.
build-dataset,backtest,report,alignment,backfill,truth-qc, andprune-scorestake an exclusive lock on<dataset dir>/pipeline.lock; a second mutator waits up to 60 s, then exits with code75(EX_TEMPFAIL) instead of racing — the 2026-08-08 incident, where a scheduledprune-scoresdeleted files a concurrent manualreporthad already listed, is the motivating case.predictdeliberately does not take the lock (serving must never wait behind an hour-long report); its scores-directory scans retry once on a missing file so a half-pruned listing is never served. Forecast-history appends themselves are locked and atomically replaced.
10. What would change the conclusions¶
In rough order of impact:
- Six months of live archive. Everything above is provisional until the commercial providers and the 0–24 h leads are in it. This is the whole game.
- A seasonally complete year of station truth. It would let the free-slope
grounding variant be re-tested fairly — the slope may well earn its keep once
the training window spans all four seasons, and
affine_equal_weightis still on the leaderboard waiting to find out. - A live archive that spans a provider's model change. That is the event the online experts exist for, and it has not yet been observed in real data.
- Real precipitation. Every precipitation conclusion here rests on a station record that has barely rained.