Source code for openstef_foundation_models.integrations.beam

# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project <openstef@lfenergy.org>
#
# SPDX-License-Identifier: MPL-2.0

"""Backtesting integration with openstef-beam.

Bridges beam's backtesting interface
(:class:`~openstef_beam.backtesting.backtest_forecaster.mixins.BacktestForecasterMixin`)
to an OpenSTEF
:class:`~openstef_models.workflows.custom_forecasting_workflow.CustomForecastingWorkflow`.
:class:`FoundationModelBacktestForecaster` wraps a **single, already-built** workflow
instance and reuses it across every backtest window, so an expensive backend (e.g. a
loaded ONNX session) is created once and shared — there is no per-window model loading.
Every window is forecast by calling the workflow's own
:meth:`~CustomForecastingWorkflow.predict`, so the model's preprocessing (feature
selection / covariates) and postprocessing (quantile sorting) apply uniformly.

Foundation models such as Chronos-2 are zero-shot, so :meth:`fit` is a no-op and the
default window config disables training.

Forecasting can run either one window at a time (:meth:`predict`) or with multiple
windows stacked into a single backend call (:meth:`predict_batch`, enabled by setting
:attr:`batch_size`), which beam's :class:`BacktestPipeline` selects automatically.
"""

from collections.abc import Sequence
from datetime import datetime, timedelta
from typing import Self, override

from pydantic import Field

from openstef_beam.backtesting.backtest_forecaster.mixins import (
    BacktestBatchForecasterMixin,
    BacktestForecasterConfig,
    BacktestForecasterMixin,
)
from openstef_beam.backtesting.restricted_horizon_timeseries import RestrictedHorizonVersionedTimeSeries
from openstef_core.base_model import BaseModel
from openstef_core.datasets import TimeSeriesDataset
from openstef_core.types import Quantile
from openstef_models.workflows.custom_forecasting_workflow import CustomForecastingWorkflow

#: Default backtest window settings for a zero-shot, load-once foundation model: training
#: disabled, a generous 60-day context (the model truncates it to its own window
#: internally), and a 48-hour prediction length. :meth:`FoundationModelBacktestForecaster.from_workflow`
#: tailors the prediction length to a specific workflow's horizon.
DEFAULT_BACKTEST_CONFIG = BacktestForecasterConfig(
    requires_training=False,
    predict_length=timedelta(hours=48),
    predict_min_length=timedelta(minutes=15),
    predict_context_length=timedelta(days=60),
    predict_context_min_coverage=0.0,
    training_context_length=timedelta(0),
    training_context_min_coverage=0.0,
)


[docs] class FoundationModelBacktestForecaster(BaseModel, BacktestBatchForecasterMixin, BacktestForecasterMixin): """Backtest wrapper around a single, shared forecasting workflow. The wrapped :attr:`workflow` is built once and reused for every prediction window (load-once). Each window is forecast through :meth:`~CustomForecastingWorkflow.predict` with the window horizon as the forecast start, so the adapter never reaches into the workflow's model. Setting :attr:`batch_size` greater than 1 makes beam's pipeline route consecutive prediction windows through :meth:`predict_batch`, which issues a single batched ``workflow.predict_batch`` call (one backend call inside the forecaster) instead of one call per window. Prefer :meth:`from_workflow`, which sizes the window to the workflow's own horizon:: adapter = FoundationModelBacktestForecaster.from_workflow(workflow) The raw constructor takes a workflow and a window :attr:`config`; the config defaults to a generic zero-shot, load-once setup (:data:`DEFAULT_BACKTEST_CONFIG`). """ workflow: CustomForecastingWorkflow = Field( description="The shared, pre-built forecasting workflow to run for every window." ) config: BacktestForecasterConfig = Field( default=DEFAULT_BACKTEST_CONFIG, description="Backtest window configuration. Defaults to a generic zero-shot, load-once setup; " "use from_workflow to size the prediction length to the workflow's horizon.", ) batch_size: int | None = Field( default=None, description="Max prediction windows stacked into one backend call. None or 1 means one window at a time.", )
[docs] @classmethod def from_workflow( cls, workflow: CustomForecastingWorkflow, *, predict_length: timedelta | None = None, predict_context_length: timedelta = DEFAULT_BACKTEST_CONFIG.predict_context_length, batch_size: int | None = None, ) -> Self: """Build a load-once adapter with a zero-shot window config sized to *workflow*. Args: workflow: The pre-built workflow to reuse across every backtest window. predict_length: Forecast horizon per window. Defaults to the workflow's maximum configured horizon. predict_context_length: History fed to the model as context. batch_size: Max prediction windows stacked into one backend call. ``None`` or ``1`` forecasts one window at a time. Returns: A configured adapter wrapping *workflow*. """ config = DEFAULT_BACKTEST_CONFIG.model_copy( update={ "predict_length": predict_length if predict_length is not None else workflow.model.max_horizon.value, "predict_context_length": predict_context_length, } ) return cls(workflow=workflow, config=config, batch_size=batch_size)
@property @override def quantiles(self) -> list[Quantile]: return self.workflow.model.quantiles
[docs] @override def fit(self, data: RestrictedHorizonVersionedTimeSeries) -> None: """No-op: foundation models are zero-shot and need no per-window training."""
def _build_window(self, data: RestrictedHorizonVersionedTimeSeries) -> TimeSeriesDataset | None: """Build the context+horizon window for a single backtest event. Args: data: Time series with context, restricted to the event horizon. Returns: The window to forecast, or ``None`` when there is no observed target history before the horizon (no reliable forecast can be produced). """ window = data.get_window( start=data.horizon - self.config.predict_context_length, end=data.horizon + self.config.predict_length, available_before=data.horizon, ) target = window.data[self.workflow.model.target_column] if target[target.index < data.horizon].notna().sum() == 0: return None return window
[docs] @override def predict(self, data: RestrictedHorizonVersionedTimeSeries) -> TimeSeriesDataset | None: """Forecast a single backtest window through the workflow. Returns: The workflow forecast for the window, or ``None`` when there is no observed target history before the horizon (no reliable forecast can be produced). """ window = self._build_window(data) if window is None: return None return self.workflow.predict(data=window, forecast_start=data.horizon)
[docs] @override def predict_batch(self, batch: list[RestrictedHorizonVersionedTimeSeries]) -> Sequence[TimeSeriesDataset | None]: """Forecast a batch of backtest windows in a single backend call. Each event becomes one window with its own forecast start; events with no usable target history are dropped and mapped back to ``None`` at their original index. The remaining windows are forecast with a single ``workflow.predict_batch`` call, then scattered back into input order. Args: batch: Backtest events to forecast. Returns: One forecast (or ``None``) per input event, in input order. """ results: list[TimeSeriesDataset | None] = [None] * len(batch) prepared: list[tuple[int, TimeSeriesDataset, datetime]] = [] for i, data in enumerate(batch): window = self._build_window(data) if window is not None: prepared.append((i, window, data.horizon)) if not prepared: return results windows = [window for _, window, _ in prepared] starts = [start for _, _, start in prepared] forecasts = self.workflow.predict_batch(data=windows, forecast_start=starts) for (i, _, _), forecast in zip(prepared, forecasts, strict=True): results[i] = forecast return results
__all__ = [ "DEFAULT_BACKTEST_CONFIG", "FoundationModelBacktestForecaster", ]