Skip to content

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 NaN area_harvested_ha with 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:

yield = trend(year, county)

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.root is 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_columns is the shared primitive; impute_missing_area is 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_years needs the canonical pred.parquet for trailing medians. If features have never been built for this experiment key, the stub raises FileNotFoundError.
  • lib/results/run_result.py discovery with old run_dirs: run_dirs created before PR #369 have the single-level forecast/{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.py glob mismatch: the export path glob was updated to forecast/*/*/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.mdresidual_mode mandatory + validate gate
  • stages.mdrun_forecast.py function 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.