Pipeline: Multi-Year Forecast¶
Purpose¶
Since PR #369, the commodity-hindcast forecast pipeline can issue yield outlooks for
multiple season_year values from a single init_date — for example, wheat seasons 2026,
2027, and 2028 all issued on 2026-05-05. This page documents the mechanics: the path
layout that prevents artefact collision, the long-range climatology stub that fills
missing weather features, and the reason that all long-range forecasts collapse to
trend-only output once climo data runs out.
The Problem (Before PR #369)¶
All forecast artefacts lived under run_dir/forecast/{init_date}/. Issuing two forecasts
at the same init_date for different season_year values overwrote everything on the
second call, causing ValueError: No rows in pred.parquet for season_year=2027. Simultaneously,
for season_years beyond the climo zarr's coverage, the climo builder emitted 0 rows and
assembly failed with a ValueError listing missing z-score columns.
Storage Shape: forecast/{season_year}/{init_date}/¶
ForecastSlice.root (lib/results/results_slice.py) is the single source of truth:
@property
def root(self) -> Path:
"""Per-(season_year, init_date) root: run_dir/forecast/{season_year}/{init_date}/."""
return self.run_dir / 'forecast' / str(self.season_year) / f'{self.init_date:%Y-%m-%d}'
Every artefact path for a forecast slice derives from self.root. Four places in the
codebase previously hard-coded the old single-level path and were updated by PR #369:
run/preflight.py, stages/run_predict.py, lib/results/run_result.py (now iterates
two levels to discover all season_years), delivery/export.py (glob changed from
forecast/*/delivery/... to forecast/*/*/delivery/...).
Layout comparison:
BEFORE AFTER
run_dir/forecast/ run_dir/forecast/
2026-05-05/ 2026/
indices.zarr 2026-05-05/
features/pred.parquet indices.zarr
2026-06-12/ 2027/
... 2026-05-05/
indices.zarr
2028/
2026-05-05/
indices.zarr
Each (season_year, init_date) pair is now independent. The skip-if-exists guard in
run_features() checks both results.indices_zarr and results.features_parquet for
the specific (season_year, init_date) sub-directory, not the parent.
Calling Multiple Season Years¶
The CLI loop is external to the pipeline — the caller invokes forecast.run() once per
(season_year, init_date) pair; each call is fully independent and validate_residual_mode
fires at the top of every one. Each pair writes to a separate sub-directory so no call
can overwrite another's artefacts.
The Long-Range Climo Stub¶
Why it exists¶
The climo zarr covers only years for which observations have been collected. A season_year
of 2027 (wheat, season spanning Oct 2026 → Aug 2027) requires climatology data for calendar
years 2026 and 2027. If the zarr only covers through 2026, the 2027 calendar year is absent.
The stub lives in features/forecast_long_range_stub.py. Its filename announces it is
temporary. It fires only when the zarr does not cover all calendar years needed:
needed_cal_years = {
cfg.commodity.season_start_date(season_year).year,
cfg.commodity.harvest_date(season_year).year,
}
available_cal_years = {int(y) for y in ds['year'].values}
if needed_cal_years.issubset(available_cal_years):
return # zarr covers this season — normal climo builder runs
When it fires it emits three logger.warning lines listing the missing years and writes a
synthetic builders/climo.parquet using per-county trailing-3-year medians from the canonical
pred.parquet. The stub no-ops for season_years within zarr coverage, so it is safe to leave
in place even after the zarr is extended — removal criteria are in the module docstring.
Panel trailing-median imputation¶
The generalised imputer is impute_missing_panel_columns (lib/edit_and_imputation/imputation.py),
introduced in PR #369's Change 3. It accepts method dispatch over "trailing_median",
"trailing_mean", and "zero". Two callers:
_impute_forecast_area(run_forecast.py:316) — fills NaNarea_harvested_hawith trailing-3yr median from historical features.synthesise_long_range_climo_for_unseen_years(forecast_long_range_stub.py) — fills z-score climo features for unseen calendar years with per-county trailing-3yr median.
synthesise_long_range_stress_for_unseen_years is a parallel stub for stress-score features; stress has a bounded non-trivial range and uses a separate imputation path.
Why Long-Range Forecasts Are Trend-Only¶
This is the most important behavioural property of multi-year forecasting. The wheat model
carries a piecewise-linear season_doy_weather_weight schedule. For an init_date issued
before the target season starts, season-DOY ≤ 1, so w = 0 and the model reduces to:
The weather correction term vanishes entirely. PR #369 proved this empirically by running four different imputation methods and verifying they all collapse to the same prediction once the original schedule is applied:
| Method | Yield (bu/ac) | Weather corr |
|---|---|---|
zero |
47.675 | −3.907 |
trailing_median |
47.000 | −4.581 |
trailing_mean |
47.115 | −4.467 |
spike_plus5 (+5 SD probe) |
54.423 | +2.841 |
| All four, schedule restored | 51.581 | 0.0000 |
The variation seen across imputer methods without the schedule is an artefact of running with a zeroed-out schedule that gives the weather coefficient non-zero weight. With the correct schedule, all methods produce identical output. This is a deliberate model behaviour, not a stub limitation.
Consequence for end-users: a multi-year forecast issued on 2026-05-05 for wheat 2028 carries
no weather signal. It communicates only the trend model's projection. As the calendar
advances and init_date enters the target season's growing window, subsequent forecasts
issued at the same season_year will progressively incorporate real weather observations.
Mermaid Flow¶
flowchart TD
LOOP["Caller loop: season_years = [2026, 2027, 2028]"]
CALL["forecast.run(run_dir, season_year=Y, init_date=D)\nrun_forecast.py:143"]
GUARD["validate_residual_mode()\nonce per call"]
FS["ForecastSlice(run_dir, season_year=Y, init_date=D)\nroot = run_dir/forecast/Y/D/"]
CLIMO["materialise_forecast_indices()\nobservations up to init_date\n+ climo analogue beyond"]
STUB["synthesise_long_range_climo_for_unseen_years()\nforecast_long_range_stub.py\nfires only if zarr missing Y"]
FEAT["build_features()\n→ features/pred.parquet"]
AREA["_impute_forecast_area()\ntrailing-3yr median"]
PRED["run_predict_stage()\n→ walk_forward_preds.parquet"]
POST["_postprocess_forecast()\n→ postprocessed/national.parquet"]
DEL["_deliver_forecast()\n→ forecast/Y/D/delivery/"]
NOTE["If init_date before season start:\nseason-DOY ≤ 1 → w=0\noutput = trend(year, county) only"]
LOOP --> CALL
CALL --> GUARD
GUARD --> FS
FS --> CLIMO
CLIMO --> STUB
STUB --> FEAT
FEAT --> AREA
AREA --> PRED
PRED --> POST
POST --> DEL
PRED -. "trend-only\n(long-range)" .-> NOTE
Invariants¶
- Each
(season_year, init_date)pair writes to a fully isolated sub-directory. No call can overwrite another's artefacts. ForecastSlice.rootis the single source of truth for path construction. Do not concatenate the path string manually.- The long-range climo stub no-ops when the zarr covers the season — it is safe for in-season forecasts.
impute_missing_panel_columnsis the shared primitive;impute_missing_areais a thin single-column wrapper kept for backwards compatibility with existing callers (no-new-backwards-compat rule means no new wrappers should be added).- Stress-score stub (
synthesise_long_range_stress_for_unseen_years) is separate from the climo stub because stress has a bounded, non-trivial range and requires explicit per-column method choices.
Failure Modes¶
- Stub fires with no historical features:
synthesise_long_range_climo_for_unseen_yearsneeds the canonicalpred.parquetfor trailing medians. If features have never been built for this experiment key, the stub raisesFileNotFoundError. lib/results/run_result.pydiscovery with old run_dirs: run_dirs created before PR #369 have the single-levelforecast/{init_date}/layout.run_result.py's two-level iterator will not find them. Old run_dirs cannot be replayed against the new code.- Concurrent calls for the same
(season_year, init_date): the skip-if-exists guard is not atomic. Two parallel calls may both pass the guard and write simultaneously. Run sequentially per(season_year, init_date). delivery/export.pyglob mismatch: the export path glob was updated toforecast/*/*/delivery/...in PR #369. Any code that hard-codes the old single-level glob will silently find no forecast CSVs.
Cross-references¶
- forecast.md — single
(season_year, init_date)orchestrator - PR-369.md — full PR body including empirical trend-only proof
- PR-372.md —
residual_modemandatory + validate gate - stages.md —
run_forecast.pyfunction signatures
See Also¶
- ForecastSlice — the path-handle entity for each
(season_year, init_date)artefact subtree - Concept: hindcast_vs_forecast — the conceptual distinction between hindcast walk-forward folds and production forecast slices
PRs¶
- PR #369 — enabled multi-year forecasting; introduced
forecast/{season_year}/{init_date}/layout, long-range climo stub, and generalised panel imputer.