Architecture¶
This page describes how grounded-weather-forecast is built: the layers, the contracts between them, the storage formats, the libraries, and the tooling gates. It is written for someone who is about to change the code.
For why the algorithms are what they are, see Theory.
1. Shape of the system¶
aw2sqlite.db crestline_forecasts.sqlite
(station observations) (provider forecast archive)
| |
| | Open-Meteo
v v Previous Runs API
+-----------------+ +------------------+ |
| dataset/station | | dataset/providers|<-------+
| dataset/qc | | dataset/snapshots| dataset/backfill
| dataset/truth | +------------------+
+-----------------+ |
| |
+------------------+--------------------+
v
+------------------+
| dataset/matrix | the supervised matrix:
+------------------+ one row per (snapshot, valid time)
|
+----------------+-----------------+
v v
+----------------+ +----------------+
| backtest/ | scores.parquet | serve/ |
| splits+engine |----------------->| selection |
+----------------+ ^ | predict |
| | +----------------+
v | |
+----------------+ | v
| reports/ |--------+ forecast JSON ---> predict_history
| leaderboard | self-verification <-------------------------+
+----------------+
^
|
+----------------+ +----------------+
| blenders/ | | metrics/ |
| (the registry)| | MAE/CRPS/DM... |
+----------------+ +----------------+
Two rules keep this from becoming a ball of mud:
contracts.pyandleads.pyare the only deep-import targets. Everything else talks through them.- Blenders import
contractsonly — neverdataset. A blending method must be expressible purely in terms of "here is a matrix of numbers and an availability mask". If a method needs to reach into the dataset layer, that is a signal the contract is wrong, not that the rule should be bent.
2. The contract¶
Everything hinges on four frozen dataclasses and one protocol, all in
contracts.py.
@dataclass(frozen=True)
class ForecastMatrix:
sources: tuple[str, ...] # column identity and order
values: FloatArray # (n, k) float64, NaN where unavailable
availability: BoolArray # (n, k) — explicitly ~isnan(values)
lead_hours: FloatArray # (n,)
features: pl.DataFrame # aligned context; NEVER truth columns
product: Product # controls lead buckets/capabilities
@dataclass(frozen=True)
class SupervisedSlice:
x: ForecastMatrix
y: FloatArray # truth; contains no NaN by construction
variable: VariableSpec
source_kind: SourceKind # LIVE | SYNTHETIC — one kind, enforced
@dataclass(frozen=True)
class BlendResult:
point: FloatArray
quantiles: FloatArray | None = None # (n, q) — reserved for wave 2
quantile_levels: tuple[float, ...] = ()
class Blender(Protocol):
method_id: str
def fit(self, train: SupervisedSlice) -> Self: ...
def predict(self, x: ForecastMatrix) -> BlendResult: ...
Design decisions worth knowing:
availabilityis materialized, not derived on the fly. It is~np.isnan(values), but every blender reads the same array rather than each re-deriving it, so "available" cannot mean two different things in two places. Every blender must renormalize over available sources — this is what makes a 24-hour provider and a 360-hour provider coexist without special-casing.- The
featuresframe may not contain truth.ForecastMatrix.__post_init__raises if any column starts witht__. This is a runtime guard against the most likely form of leakage. - Quantiles travel end to end through scores, forecast JSON, and served history.
EMOS, CSGD, IDR, and the conformal wrappers emit them today; point-only winners
are dressed with live residual quantiles at serve time, labeled per variable in
the document's
quantiles_sourcemap. BlendResult.pointmay containNaN, which means "this method has nothing to say about this row" (e.g. persistence with no recent observation). The engine stores that asnull; promotion compares all methods on one common-case mask and reports coverage, so declining difficult rows cannot manufacture a win.
Column naming¶
One convention, one place (contracts.py owns the builders and the parser):
| Pattern | Meaning |
|---|---|
fx__{source}__{var} |
hourly forecast from a source |
fxd__{source}__{var} |
daily forecast from a source |
age__{source} |
hours between that source's fetch and the snapshot |
obs__{var} |
station observation at issue time (past data — leakage-safe; the anchoring input) |
t__{var}__inst / t__{var}__mean |
truth, dual semantics |
t__{var} |
truth, single semantics (gust, precip, PoP) |
ewagg__{var} |
equal-weight aggregate of the hourly path over a local day |
__ is the structural separator. Column builders percent-escape separator and
percent characters inside source/variable segments; parse_fx_col() is the exact
inverse and refuses anything malformed.
3. The dataset layer¶
Readers¶
dataset/station.py— opens the observation DB read-only by URI (?immutable=1for a static snapshot;mode=rofor a live WAL database — neverimmutableon a file another process is writing). Handles mixed timestamp precision: the sample DB has 41k rows with microseconds and 35k without, so the parser tries both formats and coalesces. Missing tables, missing columns and NULL timestamps all degrade gracefully to empty/null rather than crashing — the upstream databases are explicitly documented as damaged.dataset/providers.py— reads hourly, daily, minutely, and completion rows in one SQLite transaction, filters every run to the configured coordinates, and joinshourly_points → source_forecasts → provider_results, filters tostatus = 'success', and recomputes lead from the ISOfetched_attext. It never readshorizon_hours,fetched_at_unixorrun_cycle, all of which are NULL in the sample archive. A source slug isprovidernormally,provider_modelwhen a provider exposes several models (soopen_meteo_ecmwf_ifs025is distinct fromopen_meteo_gfs_seamless).dataset/backfill.py— the Open-Meteo Previous Runs adapter. The HTTP fetcher is injected, so tests never touch the network.
Snapshots¶
dataset/snapshots.py implements the as-of join: snapshot_times() dedupes
forecast_runs.completed_at onto a 10-minute grid, and as_of_selection() uses
a join_asof(..., strategy="backward", tolerance=max_age) per source. The same
function serves a single snapshot at "now" when predicting — training and
serving share one code path by construction.
Matrices¶
dataset/matrix.py pivots long → wide, joins truth, and adds calendar features,
source ages, and issue-time observations. Two subtleties:
- Deterministic column order. Polars'
pivotanduniquedo not guarantee order, so the frames are stably sorted before pivoting. Without this, rebuilding the same dataset produced different parquet bytes and the fingerprint changed — which would make artifact staleness detection meaningless. to_forecast_matrix(..., sources=...). Serving pins the training source list, so a provider that is merely missing right now becomes an unavailable column rather than shifting every other blender's column indices.
4. Storage¶
Everything under [dataset].dir (git-ignored), all parquet:
| File | Grain |
|---|---|
truth_minute.parquet |
one row per station sample; values null when QC-flagged, plus {var}_qc bitmask |
truth_hourly.parquet |
one row per hour; both truth semantics for state vars, plus coverage counters |
truth_daily.parquet |
one row per local day; extremes and sums with DST-aware coverage_frac and rain_coverage |
forecasts_long.parquet |
one row per (source, fetched_at, valid_time) |
daily_long.parquet, minutely_long.parquet |
as above, daily / minutely grain |
hourly_matrix_live.parquet |
one row per (snapshot, valid hour) — the supervised matrix |
daily_matrix_live.parquet |
one row per (snapshot, target local date) |
hourly_matrix_synthetic.parquet |
the same shape, backfilled provenance |
ensembles.parquet |
append-deduped per-(model, valid_time, variable) ensemble mean/sd/p10–p90 statistics, as-of joined into ens__* feature columns |
runs.parquet |
rolling command ledger (pruned to 90 days / 50,000 rows) — the dashboard's pipeline heartbeat |
manifest.json |
row counts, per-file SHA-256, and the dataset fingerprint |
scores/scores_{product}_{kind}_{window}_{evaluation}.parquet |
one identified evaluation run, one row per (method, variable, test case) |
predict_history.parquet |
every emitted value, atomically appended with release/method/quantile attribution |
served_forecasts/*.json |
exact versioned documents for historical replay |
Matrices are keyed by provenance in the filename. ..._live and
..._synthetic can never collide on disk, which makes the provenance wall a
property of the filesystem and not merely of a runtime check.
[artifacts].dir holds the non-dataset state: alignment.json,
drift.json, truth_qc.json, releases/ (the promoted Model Releases),
state/ (the blender rehydration store), observability/ (write-only
glass-box snapshots), eprocess/ (sequential-gate wealth, era-keyed), and
history/ (the append-only quality/operations ledgers behind dashboard
zones H and I).
The scores frame is the pivot of the whole design. It is deliberately long and dumb:
evaluation_id | dataset_fingerprint | source_set_json | semantics | code_version
method_id | variable | product | source_kind | window | fold_origin
| issue_time | valid_time | lead_hours | lead_bucket | y_pred | y_true
| quantile_levels_json | quantiles_json
The engine writes it and stops. Every leaderboard, skill score, DM test and
winner-selection is a group_by over this frame, computed downstream. That is
what makes it possible to add a new report without re-running a 45-fold backtest,
and what keeps the engine from ever being tempted to declare a winner.
5. Blenders and the registry¶
blenders/registry.py maps method_id → factory. It stores factories, never
instances — the backtest engine calls the factory once per fold. This is not a
style preference; it is a leakage defence, and there is a test that asserts the
engine really does construct distinct objects.
Modules self-register on import; blenders/__init__.py imports them all.
| module | methods |
|---|---|
baselines.py |
persistence, climatology, best_provider, equal_weight |
grounding.py |
(not a method — the AffineGrounding stage others compose) |
combine.py |
grounded_equal_weight, grounded_median_equal_weight, affine_equal_weight, inverse_mse, inverse_mae |
trimmed.py |
trimmed_mean, grounded_trimmed_mean |
ewma_grounding.py |
ewma_grounded_equal_weight, ewma_inverse_mae |
harmonic_grounding.py |
harmonic_grounded_equal_weight |
anchoring.py |
anchored_grounded_equal_weight, anchored_inverse_mse, anchored_fitted_grounded, anchored_fitted_ewma, anchored_trend_grounded |
raft.py |
raft_grounded |
seamless.py |
seamless_regression |
invcov.py |
inverse_covariance |
damped.py |
damped_grounded_equal_weight |
analog.py |
analog_ensemble |
cluster.py |
cluster_equal_weight |
gbm.py |
gbm |
experts.py |
ewa, boa |
emos.py |
emos |
csgd.py |
csgd_emos (precip-scoped) |
idr.py |
idr, idr_bucket, idr_bucket_dcp |
conformal.py |
conformal_gew, conformal_ewma |
pop_calibration.py |
pop_platt, pop_beta (pop-scoped) |
sparse_shrink.py |
precip_sparse_shrink (daily-precip-scoped) |
daily_heads.py |
daily_marginal_emos, daily_path_extreme (daily-temp-scoped) |
available_methods() on the registry is the authority; the table is a map,
not a contract. Methods marked -scoped register with an explicit variable
set and never appear off-scope.
Two shared abstractions live in blenders/protocol.py:
renormalize_weights(w, availability)/masked_average(...)— the availability contract, implemented once.PerBucketFitter[S]/FittedBuckets[S]— fit one state object per lead bucket, with a global-fit fallback for thin buckets. Grounding, inverse-MSE and best-provider all use it; the generic parameterSis the per-bucket state (coefficients, a weight vector, a source ranking).
Anchored is a wrapper, not a special case. It takes a base blender factory
and implements the same protocol, so it appears on the leaderboard as its own
method and the engine needs no anchoring-specific code.
LightGBM is imported lazily via importlib, and gbm registers only if the
import succeeds — so the package stays importable on platforms where its wheels
lag (a real concern on new CPython releases). deptry is told about the dynamic
import explicitly.
6. Backtest engine¶
for fold in fold_plans(issue_time, truth_known_at, config, window):
for variable in request.variables:
train = to_supervised_slice(matrix[fold.train_rows], variable, ...)
test = to_supervised_slice(matrix[fold.test_rows], variable, ...)
for method_id in request.methods:
blender = get_factory(method_id)() # fresh instance, every fold
scores.append(blender.fit(train).predict(test.x))
splits.py computes fold plans in epoch microseconds (integers) rather than
datetimes, which sidesteps a pile of timezone-aware/naive arithmetic hazards and
makes the invariants trivially checkable. Fold plans are plain data — inspectable
and testable with no model in the loop.
7. Serving¶
build_snapshot(config, now) # the same as-of code the dataset build uses
-> fit each selected method on the LIVE matrix's scoreable history
-> predict the snapshot's future rows
-> hourly product
-> minutely product (interpolate + decayed observation residual)
-> daily product (own supervised targets, ewagg__* features ride along)
-> Forecast document (schema_version, provenance, per-value method attribution)
-> append to predict_history.parquet
serve/selection.py reads only live Evaluation Runs compatible with the current
dataset and requested issue time, then promotes the common-case, significance-aware
per-slice winners into a Model Release. Config pins override. A slice with no
compatible evidence uses the fit-free equal_weight fallback, marks the document
degraded, and records the reason. Promoted releases are the serving boundary —
evaluation evidence can never become a production decision by accident
(ADR 0005).
Every emitted value carries the method_id that produced it. When someone asks
"why is the 6 a.m. temperature 12 °C", the answer is in the document.
Serving is also instrumented without being observable-in-band: every command
that successfully loads its configuration appends to the runs.parquet ledger,
and each fitted blender's
compact internals are snapshotted to artifacts/observability/
(write-only, failures swallowed). The dashboard/ package renders both —
plus every other on-disk artifact — into the self-contained
reports/dashboard.html operator console on each report run; see
Operator Dashboard.
Parser and configuration-loading failures cannot be recorded because that configuration supplies the ledger destination.
8. Library choices¶
| Chosen | Why |
|---|---|
| polars | Expression API, real nullability (distinct from NaN — which matters enormously when "no truth" and "not a number" are different things), fast group-by/as-of joins, strict schemas. |
| numpy | The model boundary. Every blender sees plain float64 arrays. |
| scipy | Exactly one function: Student-t survival, for the Diebold–Mariano p-value. |
| scoringrules | CRPS. Wrapped entirely inside metrics/probabilistic.py, so swapping it out is a one-file change. |
| lightgbm | The stacker. Native missing-value handling is the feature that matters. |
stdlib sqlite3, tomllib, argparse, zoneinfo |
No dependency justifies replacing any of these. |
Deliberately not used, and why:
- pandas — polars' nullability semantics and expression engine are a better fit, and we would gain nothing.
- duckdb — the joins are not hard enough to justify a second engine for a small-data problem.
- statsmodels / a Kalman filter — see Theory: the state-space formulation is a monolith that cannot be A/B tested in stages, and carries permanent covariance-tuning cost. The decomposition (grounding + weighting + anchoring) gets most of it in inspectable pieces.
- hierarchicalforecast / MinT — structurally inapplicable to max/min targets (ADR 0002).
- opera (online aggregation) — EWA/BOA with fixed share is ~150 lines and we needed to control the sleeping-expert and fixed-share details precisely.
The upstream projects are not imported. The two SQLite files and their documented schemas are the entire interface (ADR 0001).
9. Correctness gates¶
Every change must survive:
uv run ruff check src --fix # lint.select = ALL
uv run ruff format src tests
uv run pyrefly check src # two independent type checkers,
uv run ty check src # because they disagree usefully
uv run lizard -Eduplicate -C 27 -x "*/dashboard/assets/*" src # complexity + duplication
uv run deptry src # declared deps == imported deps
uv run pytest tests/ --cov=src # >= 88% coverage (currently ~95%)
CI additionally runs the Semgrep guardrails (provider-QC grouping,
artifact-pointer paths) and the lockfile checks (uv lock --check,
scripts/check_lock_hosts.py) — see .github/workflows/ci.yml.
Type hints are first-class: pyrefly and ty both run, and they catch different
things (ty in particular refuses to narrow a match-guard capture, which forced
a genuinely clearer BlendResult validation).
Testing strategy¶
- Fixture databases are synthesized, never sampled.
*.db/*.sqliteare git-ignored, so CI has no sample data.tests/conftest.pybuilds aw2sqlite-shaped and omni-weather-shaped SQLite files programmatically — including the damage (NULLhorizon_hours, NULLfetched_at_unix, mixed timestamp precision), so the graceful-degradation paths are actually exercised. - A synthetic weather generator (
synthetic_hourly_matrix) produces a sinusoidal climate with known provider biases, known noise levels, and optional ragged horizons. This is what makes directional assertions possible: "grounding must beat raw on biased sources", "anchoring must win at 0–3 h and converge by 12 h", "the experts must concentrate on the better expert". - Protocol compliance is parametrized over the entire registry, so a new blender is automatically tested for shape correctness and for invariance to an all-NaN (never-available) source column.
- The leakage gauntlet — see Theory. The poisoning sentinel is the one to keep alive at all costs.
Determinism¶
The dataset build is byte-reproducible (stable sorts before every pivot), and the
manifest records a fingerprint over the file hashes. Fitted artifacts are
stored under that fingerprint, so predict can refuse to serve a model fitted
against a dataset that no longer exists. LightGBM is seeded and run with
deterministic: true.
10. Scale¶
Sized for one station with years of data, on a laptop:
- 3 years of minute truth ≈ 1.6 M rows.
- The hourly matrix is one row per (snapshot, valid hour): with ~26 snapshots/day and 150–300 valid hours each, ≈ 5–8 M rows × ~110 columns over 3 years — a 1–2 GB parquet, read with column projection.
- The real 13-month backfill in this repo produced 198k forecast points → a 66k-row synthetic matrix; a 12-method × 3-variable × 45-fold backtest over it runs in well under two minutes.
No premature optimization has been done, and none appears to be needed. The one
thing to watch is that the online experts are a Python row loop (O(n·k)) —
fine at this scale, the first thing to vectorize if it ever isn't.