The primary goal of this package is to define the interface between any forecasting library and the forecasting model. The forecasting model can be implemented in any package / code base but needs to follow the protocol defined here.
There are three protocols: the required ForecastModel, plus two optional extensions — RetrainableModel (warm-start retrain) and BatchHindcastModel (efficient batch hindcast), both of which extend ForecastModel. A StatefulModel extension is reserved for future conceptual / hybrid models (see Warm-up and state below). The scope of a model (single station vs. group / national) is declared via artifact_scope, not split into separate protocols. SAP3 consumes the FI protocol through a thin adapter (built in SAPPHIRE_flow) that dispatches to its own StationForecastModel / GroupForecastModel. The first integration target is Nepal v1.
Core functionalities include:
Forecast Function predict()
Takes as input the ModelInputs and a trained artifact, and outputs the ModelOutput (Forecast).
Hindcast Function hindcast() — optional, strongly recommended
Lives on the optional BatchHindcastModel extension. Takes a trained artifact and a batch of issue datetimes, and outputs the ModelOutput (Hindcast) for all of them in one call. Functionally equivalent to looping predict() over historical issue times, but vectorized for efficiency. SAP3 uses the batch path whenever the model implements it and falls back to looping predict() otherwise — because SAP3 runs hindcasts routinely (skill evaluation), implementing it is strongly recommended.
Training Functions train() / retrain()
Produce a TrainedArtifact from training inputs. See the Training & Lifecycle Protocol below.
Status: implemented in
forecast_interface/interface/(protocol.py,scope.py,artifact.py). Theinputsparameters use FI-ownedModelInputs; onlyconfigremains provisional — typedAnyuntil the model-config type is co-designed with SAP3 (Q8). RichTrainedArtifactprovenance metadata is deferred to Phase 4; the group-artifact embedding-key / station-set-mismatch contract is v1 load-bearing (see theTrainedArtifactsection below and decision 1.10).
A model declares its scope via the artifact_scope attribute rather than implementing a scope-specific protocol.
class ArtifactScope(Enum):
STATION = "station" # one artifact per station
GROUP = "group" # one artifact covering multiple stationsA "national-group" model is a GROUP (it is just a group whose station set happens to be national). There is no separate national scope. (SAP3 has an internal VIRTUAL scope for combination models; that is SAP3-internal and not model-author-facing, so it is not part of this enum.)
train, predict, serialize_artifact and deserialize_artifact are required. Two optional capabilities each live on a separate extension protocol, so they are not forced on every model: warm-start retrain on RetrainableModel, and batch hindcast on BatchHindcastModel. SAP3 detects each with isinstance — isinstance(model, RetrainableModel) to decide whether to warm-start (else it falls back to train), and isinstance(model, BatchHindcastModel) to decide whether to batch-hindcast (else it loops predict).
| Member | Signature | Protocol | Notes |
|---|---|---|---|
input_requirement |
property -> InputRequirement |
ForecastModel |
Declares data needs and forecast targets — InputRequirement.targets (dict[str, TargetSpec]) names each target variable with its unit and supported output representations (see docs/input_requirement.md). |
artifact_scope |
attribute: ArtifactScope |
ForecastModel |
Declared scope (STATION / GROUP). |
train |
train(inputs, *, config, rng) -> TrainedArtifact |
ForecastModel |
Cold, full rebuild from scratch. The required baseline every model must support. |
predict |
predict(artifact, *, inputs, issue_datetime, rng) -> ModelResult |
ForecastModel |
Forecast. Returns FI's ModelResult → ModelOutput. |
hindcast |
hindcast(artifact, *, inputs, issue_datetimes, rng) -> ModelResult |
BatchHindcastModel |
Optional, strongly recommended. Batch hindcast over many issue datetimes in one call; same ModelResult return as predict. Absent it, SAP3 loops predict. |
serialize_artifact |
serialize_artifact(artifact) -> bytes |
ForecastModel |
Opaque byte serialization of a TrainedArtifact. |
deserialize_artifact |
deserialize_artifact(raw: bytes) -> TrainedArtifact |
ForecastModel |
Inverse of serialize_artifact. |
retrain |
retrain(base_artifact, inputs, *, config, rng) -> TrainedArtifact |
RetrainableModel |
Optional. Warm-start from an existing artifact, for models capable of it. Models that cannot warm-start simply do not implement it; callers fall back to train. |
The inputs parameters use ModelInputs; only config is typed Any (provisional, see status note above).
train, retrain, predict, and hindcast all take an injected rng: random.Random. Models MUST be deterministic under a fixed (data, config, seed) triple: the same inputs, the same config, and an RNG seeded the same way must produce identical artifacts and identical outputs. No model may call random / numpy.random global state or datetime.now() directly in its forecast logic — all nondeterminism is injected. This matches both SAP3's contract and the repository's dependency-injection rule.
TrainedArtifact is implemented as a marker Protocol (no members) — a semantic boundary type. It is an opaque, self-contained, deployment-portable object representing everything a model needs to produce forecasts:
- Opaque to FI: FI never inspects its internals. It is produced by
train/retrainand consumed bypredict/hindcast. - Self-contained:
serialize_artifactproducesbytesthat embed all weights, scalers, and metadata — with no absolute filesystem paths and no machine-local references. - Deployment-portable:
deserialize_artifact(serialize_artifact(a))must reconstruct an artifact that runs unchanged on another SAP3 instance.
Partly deferred. Rich provenance metadata (scope, region, training period, hashes, seed, product versions) is not part of the marker Protocol yet and lands in Phase 4.
The group-artifact embedding-key / station-set-mismatch contract, however, is load-bearing from v1, because GROUP artifacts ship from the start (decision 1.10) and east→west transfer is a Nepal v1 target (decision 1.6) — re-evaluate its earlier Phase 4 deferral. The contract: a GROUP artifact embeds the meaningful station strings it was trained on (which the model reads to key per-station state); it must define behaviour when the predict-time station set differs from the trained set — known stations use stored state, unknown stations are generalized from static attributes or rejected with an explicit error — and it must never silently mis-associate a prediction with the wrong station. Station strings are stable, meaningful identifiers that round-trip unchanged through serialize_artifact / deserialize_artifact and across deployments; the model never alters them.
FI's protocol is state-free in v0: predict / hindcast take no state parameter and return no state. This is a deliberate, lightweight default. It rests on separating two things commonly conflated as "warm-up":
- Warm-up period (cold spin-up) — the run of forcing a model needs before the issue time to spin its internal stores up from nothing. This needs no new channel: it is declared as
lookbackinInputRequirement. Any model that needs a spin-up period simply requires a sufficiently long lookback. Covered for all model types in v0. - Persisted warm state (cross-cycle snapshot) — SAP3's
prior_state/new_statebytes, carrying spun-up state from the previous cycle so it need not be re-spun each run. This is purely an optimization, and it is deferred from v0.
Why deferred, not dropped. The model integrating in v1 is pure ML: it reconstructs everything from its lookback window each cycle and needs neither a warm-up period nor persisted state. Warm-up state is required for conceptual / distributed models and may be required for hybrid models — but none exist in the interface yet. Building a state channel now would tax every (currently stateless) model author with state ceremony they never use, which conflicts with keeping the interface lightweight and easy for humans and machines to adhere to. We therefore ship the single clean signature and reserve state as a future extension.
The reserved extension is additive and non-breaking. When a conceptual / hybrid model that genuinely needs persisted state arrives, state support is added as an optional StatefulModel sub-protocol extending ForecastModel — exactly as RetrainableModel already does for warm-start. SAP3 detects it via isinstance(model, StatefulModel) and threads prior_state only for those models; existing ForecastModel implementations do not change a line. The precise shape of that extension (state as an extra predict parameter, a distinct method, or carried inside ModelResult) is deliberately left open until a real stateful model forces the decision — designing it against a concrete model beats guessing now.
Correction to the earlier framing. A previous note claimed
prior_statecould be "handled entirely inside the SAP3 adapter." That is not implementable: an adapter cannot inject state into apredictthat has no state parameter. The correct resolution is the additiveStatefulModelsub-protocol above.
SAP3 consistency. A state-free FI model maps onto SAP3 as a model that ignores prior_state and always runs WarmUpSource.FRESH — already legal SAP3 behaviour for stateless models. The single divergence to record: FI v0 does not use SAP3's warm-up-snapshot path; FI models warm up from lookback.
predict / hindcast return ModelResult → ModelOutput (defined below). ModelOutput is not replaced by SAP3's ForecastEnsemble: the SAP3 adapter maps ModelOutput into its own representation, never the other way around. The field-level mapping is implemented by the SAP3 adapter (in SAPPHIRE_flow).
predict / hindcast return ModelResult = ModelSuccess | ModelFailure rather than raising. In operational forecasting, failure for a given cycle or station is routine, not exceptional (gauges offline, degraded inputs), so it is modelled as a typed outcome carrying a structured FailureCause, not an exception. SAP3's except-and-return path stays only as a backstop for unanticipated bugs — anticipated failure must be returned, not raised.
Failure is represented at two levels, with a strict rule for which to use:
| Level | Type | Means | When |
|---|---|---|---|
| Whole-run | ModelFailure (the union branch) |
the model produced nothing at all | no artifact, invalid config, dependency down, whole input bundle malformed |
| Per-station / variable | VariableStatus.FAILURE inside ModelOutput |
the model ran and produced output for some stations/variables, but this one could not | that station's inputs missing or too degraded |
Rule: if the model can produce output for even one station/variable, it returns ModelSuccess with per-entry FAILURE / PARTIAL status; ModelFailure is reserved for total inability to produce anything.
ModelFailure carries cause: FailureCause (INPUT_DATA / RESOURCE / MODEL_ERROR / CONFIGURATION / DEPENDENCY) and a human-readable message. Per-station FAILURE entries currently carry only status + flags (e.g. DATA_AVAILABILITY); attaching a per-station FailureCause is a possible future enhancement, deferred to keep the per-entry surface light.
ModelOutput is the unified return type for both predict() and hindcast().
| Field | Type | Description |
|---|---|---|
model_name |
str |
Identifier of the model that produced the output |
issue_datetime |
datetime (UTC) |
Single issue datetime for the entire output |
variables |
dict[str, dict[str, VariableOutput]] |
Station-keyed: station_id → variable_name → VariableOutput |
success |
bool |
Computed property — True when all variables (across all stations) contain valid data |
variables is keyed first by station_id, then by variable_name. This supersedes the previous flat dict[str, VariableOutput].
- A single-station model returns a one-key dict (
{station_id: {variable_name: VariableOutput}}). - A group / national model returns one entry per station it forecasts.
- Missing stations are explicit
FAILUREentries, never absent keys. A caller can always look up every expected station; the absence of usable data is represented by aVariableOutputwithstatus == FAILURE, not by a missing key.
All data classes share a unified DataFrame schema with two required datetime columns:
| Column | Type | Description |
|---|---|---|
issue_datetime |
datetime (UTC) |
When the forecast/hindcast was issued |
datetime |
datetime (UTC) |
The target valid time of the prediction |
Forecast (predict): issue_datetime is constant across all rows (a single issue time).
Batch hindcast (BatchHindcastModel.hindcast): issue_datetime varies across rows (one block of rows per issue time in the batch).
predict and hindcast return the same ModelOutput type; the only distinction is whether issue_datetime is constant (forecast) or varies (batch hindcast) across rows. The varying-issue_datetime schema therefore only arises from the optional BatchHindcastModel path — a plain ForecastModel always emits a constant issue_datetime.
Each data class wraps a DataFrame with the two temporal columns above, plus class-specific value columns:
DeterministicData — columns: [issue_datetime, datetime, value]. A single point forecast. Valid output, but not operationally consumable by SAP3 on its own — SAP3 has no deterministic channel (it is always probabilistic). A deterministic-only model is a legitimate, possibly strong model; to deploy it operationally it must also supply forecast uncertainty — quantiles or trajectories it emits itself, or a downstream uncertainty wrapper that produces them. See the operational-floor note below.
QuantileData — columns: [issue_datetime, datetime, <quantile_level>, ...]
Quantile columns are named by their level as strings (e.g., "0.1", "0.5", "0.9"). Levels must be in (0, 1), sorted ascending, and unique. FI structural minimum: ≥ 3 levels (a centre plus two tails).
TrajectoryData — columns: [issue_datetime, datetime, "1", "2", ..., "<N>"]
Sample columns are named "1" through "<num_samples>". FI structural minimum: ≥ 8 samples. Models typically emit ~50 (deployment-specific, may be fewer).
EpistemicUncertaintyData — columns: [issue_datetime, datetime, std, range]
Captures model uncertainty as standard deviation and range. (Dropped at the SAP3 boundary in v0b; see the mapping doc.)
FI enforces only deployment-independent structural floors (≥3 quantiles, ≥8 trajectories; a probabilistic representation is required for operational use). The operational floors are SAP3 deployment config — min_operational_quantile_levels (≥7, with tail coverage ≤0.05 / ≥0.95) and min_operational_ensemble_size (≥20 members) — so they live on the SAP3 side, not in FI. To stop this failing silently at runtime, the model declares the representation(s) and the count it will emit (via TargetSpec / metadata), and SAP3 checks compatibility against its deployment floor at integration / registration time. Net rule: valid FI output and declared counts ≥ the deployment floor ⟹ operational; anything short is rejected loudly before any forecast runs, never silently dropped.
Groups data for a single output variable (within a single station):
| Field | Type | Description |
|---|---|---|
metadata |
VariableMetadata |
unit, timedelta, forecast_horizon, offset (no name — variable name is the dict key; no resolution enum — timedelta is the single time-step source). See Metadata semantics below. |
deterministic |
DeterministicData | None |
Point forecast |
quantiles |
QuantileData | None |
Quantile forecast |
trajectories |
TrajectoryData | None |
Ensemble trajectories |
epistemic_uncertainty |
EpistemicUncertaintyData | None |
Model uncertainty |
flags |
frozenset[ForecastFlag] |
Quality flags (empty = trusted) |
status |
VariableStatus |
SUCCESS, FAILURE, or PARTIAL |
At least one of deterministic, quantiles, or trajectories must be present when status is SUCCESS.
variables must contain at least one station entry, and each station's inner dict must contain at least one variable. When status is PARTIAL, at least one data representation must still be present (same rule as SUCCESS). A station that produced no usable data is represented by a FAILURE VariableOutput, not by an empty or missing entry.
forecast_horizon— number of forecast steps; consumed directly by the SAP3 adapter (ForecastEnsemble.forecast_horizon_steps). A cross-validator enforces it against the data: forpredict,forecast_horizonequals the row count; for batchhindcastit equals the rows perissue_datetime(one block per issue time).forecast_horizonequals the actual forecast steps present per issue block, so aPARTIAL/ short forecast declares a smallerforecast_horizonand still satisfies the validator.offset— number of steps (eachtimedeltalong) between the last observation and the first forecast step.offset = 1⇒ the first forecast valid time islast_obs + 1·timedelta(the usual next-step case);offset = 2⇒ a one-step gap.- No
name— the variable name is theModelOutput.variables[station][variable]dict key; duplicating it in metadata is omitted to avoid two disagreeing sources of truth. timedeltais the single time-step source — there is noresolutionenum (it would be a second, disagreeable source of truth). The time step is a precisetimedelta, matching thetimedelta-keyed input requirement (decision 1.12).
Quality flags that can be attached to a variable output:
HIGH_EPISTEMIC_UNCERTAINTY— model confidence is lowDATA_AVAILABILITY— input data was degraded
A variable with no flags is considered trusted.