OpenSTEF Workshop: Forecasting Energy for a Solar Park#
This workshop follows one normalized solar park near Oosterwolde through a complete OpenSTEF workflow. We use the same target and forecast period while exploring the modular layers of OpenSTEF:
Explore real grid data: measurements, weather forecasts, profiles, and prices
Train a default XGBoost model and inspect the forecast, preprocessing and explainability.
Train an XGBoost + GBLinear ensemble with
openstef-metaand compare its predictionsForecast with Chronos-2 through
openstef-foundation-modelsCompare the output of the ensemble with Chronos-2
Run an optional short
openstef-beambacktest
There are questions throughout the workshop to encourage active learning. Try to answer these as you go through the workshop.
Step 1 - Download the Benchmark Dataset#
Download the files for one target through load_liander_dataset. The loader also fetches the shared profiles and prices, then caches everything in its default ./liander_dataset directory.
# Load the selected benchmark files and cache them locally.
# Define the target path used by the dataset loader.
DATASET_DIR = Path("./liander_dataset")
TARGET_GROUP = "solar_park"
TARGET_NAME = "Within 15 kilometers of Oosterwolde_normalized"
TARGET_PATH = f"{TARGET_GROUP}/{TARGET_NAME}"
# Download only the selected target and required metadata.
dataset = load_liander_dataset(target=TARGET_PATH, extra_files=["liander2024_targets.yaml"])
Step 2 - Explore the Dataset#
2.1 Meet Our Target: Solar Park near Oosterwolde#
The target provider reads the benchmark metadata and target-specific files from the downloaded snapshot. We select one normalized solar park so every later comparison uses the same target.
# Select the solar-park target used throughout the workshop.
# Load target metadata from the benchmark snapshot.
from openstef_beam.benchmarking.benchmarks.liander2024 import Liander2024TargetProvider
target_provider = Liander2024TargetProvider(data_dir=DATASET_DIR)
# Resolve the named target for all later data requests.
targets = target_provider.get_targets()
target = next(item for item in targets if item.name == TARGET_NAME and item.group_name == TARGET_GROUP)
print(f"Target: {target.name}")
print(f"Location: ({target.latitude:.3f}°N, {target.longitude:.3f}°E)")
print(f"Description: {target.description}")
print(f"Category: {target.group_name}")
print(f"Capacity limits: {target.lower_limit:.3f} MW to {target.upper_limit:.3f} MW")
print(f"Training starts: {target.train_start.date()}")
print(f"Benchmark period: {target.benchmark_start.date()} to {target.benchmark_end.date()}")
Target: Within 15 kilometers of Oosterwolde_normalized
Location: (52.988°N, 6.295°E)
Description: Solar park within 15 kilometers of Oosterwolde (Normalized load)
Category: solar_park
Capacity limits: -0.853 MW to 0.003 MW
Training starts: 2024-02-01
Benchmark period: 2024-03-01 to 2024-12-31
2.2 Load Measurements#
Active power is measured every 15 minutes. The measurements show the target’s daily and weather-driven variability.
Q2.2.1 What daily, seasonal, and event-driven patterns can you see?
Q2.2.2 Where would a forecast error matter most operationally?
Q2.2.3 If you zoom in, can you find unexpected deviations or anomalies in the measurements?
Shape: 32,160 rows x 1 columns
Period: 2024-02-01 00:00:00+00:00 to 2024-12-31 23:45:00+00:00
Columns: ['load']
2.3 Weather Forecasts#
Solar radiation drives PV generation, while temperature and wind provide additional context. These are versioned weather forecasts with an available_at timestamp, which prevents look-ahead bias during backtesting.
Q2.3.1 Which weather variable appears most connected to the solar signal?
Q2.3.2 Which weather variable would you expect to not impact the solar signal?
Q2.3.3 What would change if we used weather measurements instead of forecasts?
Shape: 35,229 rows x 11 columns
Weather features: ['temperature_2m', 'relative_humidity_2m', 'surface_pressure', 'cloud_cover', 'wind_speed_10m', 'wind_speed_80m', 'wind_direction_10m', 'shortwave_radiation', 'direct_radiation', 'diffuse_radiation', 'direct_normal_irradiance']
2.4 Standard Consumption Profiles#
Standard consumption profiles capture recurring patterns and show how OpenSTEF can combine target-specific data with reusable contextual features.
Q2.4.1 What repeating patterns are visible in the two-week window?
Q2.4.2 To what extent would you expect the typical profiles to explain the target’s load patterns?
Profile columns: ['E1A_AZI_A', 'E1A_AMI_A', 'E1B_AZI_A', 'E1B_AMI_A', 'E1C_AZI_A', 'E1C_AMI_A', 'E2A_AZI_A', 'E2A_AMI_A', 'E2B_AZI_A', 'E2B_AMI_A', 'E3A_A', 'E3B_A', 'E3C_A', 'E3D_A', 'E4A_A']
2.5 Day-Ahead Energy Prices#
Day-ahead prices provide an operational signal that a forecasting workflow can use alongside weather and history.
Q2.5.1 Can you identify negative-price periods?
Q2.5.2 How do negative-price periods align with expected low-demand or high-generation times?
Q2.5.3 When can we expect high energy prices?
Price columns: ['EPEX_NL']
Step 3 - Train a Default XGBoost Model#
3.1 Training Window and Model Setup#
We now compose the aligned input datasets and choose one training and forecast period. XGBoost captures non-linear feature interactions well, but tree-based models do not extrapolate beyond values represented in training data.
Q3.1.1 Which patterns should trees capture well in a solar park?
Q3.1.2 Where might a tree-based model struggle, especially near unseen peaks?
Q3.1.3 What evidence would distinguish a model limitation from a bad input feature?
from openstef_core.datasets import VersionedTimeSeriesDataset
from openstef_core.types import AvailableAt, LeadTime, Q, Quantile
from openstef_models.presets import ForecastingWorkflowConfig, create_forecasting_workflow
# Align all target inputs on one resolved time series.
combined_dataset = VersionedTimeSeriesDataset.concat(
[load_dataset, weather_dataset, prices_dataset, profiles_dataset],
mode="left",
).select_version()
TRAIN_START = datetime.fromisoformat("2024-02-01T00:00:00Z")
TRAIN_END = datetime.fromisoformat("2024-05-12T00:00:00Z")
FORECAST_START = TRAIN_END
FORECAST_HORIZON = LeadTime.from_string("P7D")
FORECAST_END = FORECAST_START + FORECAST_HORIZON.value
# Keep model history separate from the forecast-only series used in plots.
# Define the training, prediction-context, and visible forecast windows.
train_dataset = combined_dataset.filter_by_range(start=TRAIN_START, end=TRAIN_END)
predict_dataset = combined_dataset.filter_by_range(
start=FORECAST_START - timedelta(days=14),
end=FORECAST_END,
)
forecast_dataset = predict_dataset.filter_by_range(start=FORECAST_START, end=FORECAST_END)
# Use one quantile grid for every model comparison.
QUANTILES = [Q(0.05), Q(0.1), Q(0.3), Q(0.5), Q(0.7), Q(0.9), Q(0.95)]
# Report the selected windows and training shape.
print(f"Training: {TRAIN_START.date()} to {TRAIN_END.date()}")
print(f"Forecast: {FORECAST_START} to {FORECAST_END}")
print(f"Training features: {len(train_dataset.data.columns)} columns, {len(train_dataset.data):,} rows")
Training: 2024-02-01 to 2024-05-12
Forecast: 2024-05-12 00:00:00+00:00 to 2024-05-19 00:00:00+00:00
Training features: 28 columns, 9,696 rows
# Fit the default XGBoost workflow on the shared training window.
# Create the model configuration and feature mappings.
xgb_workflow = create_forecasting_workflow(
config=ForecastingWorkflowConfig(
model_id="demo_xgboost_default",
model="xgboost",
horizons=[FORECAST_HORIZON],
quantiles=QUANTILES,
target_column="load",
radiation_column="shortwave_radiation",
wind_speed_column="wind_speed_80m",
pressure_column="surface_pressure",
temperature_column="temperature_2m",
relative_humidity_column="relative_humidity_2m",
energy_price_column="EPEX_NL",
rolling_aggregate_features=["mean", "median", "max", "min"],
mlflow_storage=None,
verbosity=0,
)
)
# Fit XGBoost once on the prepared training data.
print("Training default XGBoost model...")
xgb_fit_result = xgb_workflow.fit(train_dataset)
print("Training complete.")
Training default XGBoost model...
Training complete.
3.2 Forecast Preview: Default XGBoost#
The plot shows observed load, the median forecast, uncertainty bands, and target limits.
Q3.2.1 Where does the forecast track the measured signal, and where does it miss?
Q3.2.2 Are the uncertainty bands wider at moments where you expect more risk?
Q3.2.3 How often does the forecast cross an operational limit?
3.3 Explainability: Default XGBoost#
Explainability connects a forecast back to the signals the model used. Global feature importance is a useful summary; per-timestep contributions show how those signals moved individual predictions.
Q3.3.1 Which features does the model find useful?
Q3.3.2 Do those features match your understanding of solar production?
Q3.3.3 Do the patterns in the per-timestep contributions align with your expectations?
Contribution data: 672 rows x 74 columns
3.4 Inspect Derived Features#
OpenSTEF’s preprocessing pipeline derives features from raw data. The fitted result makes this modular step visible: selectors and feature adders prepare the input, a forecaster produces predictions, and postprocessing orders the quantiles.
Q3.4.1 Which columns are raw inputs and which were created by preprocessing?
Q3.4.2 Why do cyclic time features help represent daily or weekly behavior?
Q3.4.3 Which preprocessing step would you change first for another target type?
# Read the transformed training data returned by the fit operation.
prepared_train = xgb_fit_result.input_data_train
input_features = prepared_train.data
print("All input features: ", list(input_features.columns))
print()
# Inspect the features generated by the fitted preprocessing pipeline that added extra features
preprocessing_transforms = xgb_workflow.model.preprocessing.transforms
for transform in preprocessing_transforms:
if hasattr(transform, "features_added") and len(features_added := transform.features_added()) > 0:
print(f"{type(transform).__name__} added features: {features_added}")
All input features: ['load', 'temperature_2m', 'relative_humidity_2m', 'surface_pressure', 'cloud_cover', 'wind_speed_10m', 'wind_speed_80m', 'wind_direction_10m', 'shortwave_radiation', 'direct_radiation', 'diffuse_radiation', 'direct_normal_irradiance', 'EPEX_NL', 'E1A_AZI_A', 'E1A_AMI_A', 'E1B_AZI_A', 'E1B_AMI_A', 'E1C_AZI_A', 'E1C_AMI_A', 'E2A_AZI_A', 'E2A_AMI_A', 'E2B_AZI_A', 'E2B_AMI_A', 'E3A_A', 'E3B_A', 'E3C_A', 'E3D_A', 'E4A_A', 'load_lag_P14D', 'load_lag_P13D', 'load_lag_P12D', 'load_lag_P11D', 'load_lag_P10D', 'load_lag_P9D', 'load_lag_P8D', 'load_lag_P7D', 'windspeed_hub_height', 'wind_power', 'saturation_vapour_pressure', 'vapour_pressure', 'dewpoint', 'air_density', 'dni', 'gti', 'season_sine', 'season_cosine', 'day_of_week_sine', 'day_of_week_cosine', 'month_sine', 'month_cosine', 'time_of_day_sine', 'time_of_day_cosine', 'daylight_continuous', 'rolling_mean_load_P1D', 'rolling_median_load_P1D', 'rolling_max_load_P1D', 'rolling_min_load_P1D', 'is_holiday', 'is_ascension_day', 'is_christmas_day', 'is_easter_monday', 'is_easter_sunday', 'is_good_friday', 'is_king_s_day', 'is_liberation_day', 'is_new_year_s_day', 'is_pentecost', 'is_pentecost_monday', 'is_second_day_of_christmas', 'is_week_day', 'is_weekend_day', 'is_sunday', 'month_of_year', 'quarter_of_year', 'sample_weight']
LagsAdder added features: ['load_lag_P14D', 'load_lag_P13D', 'load_lag_P12D', 'load_lag_P11D', 'load_lag_P10D', 'load_lag_P9D', 'load_lag_P8D', 'load_lag_P7D']
WindPowerFeatureAdder added features: ['wind_power']
AtmosphereDerivedFeaturesAdder added features: ['saturation_vapour_pressure', 'vapour_pressure', 'dewpoint', 'air_density']
RadiationDerivedFeaturesAdder added features: ['dni', 'gti']
CyclicFeaturesAdder added features: ['time_of_day_sine', 'season_sine', 'day_of_week_sine', 'month_sine', 'time_of_day_cosine', 'season_cosine', 'day_of_week_cosine', 'month_cosine']
DaylightFeatureAdder added features: ['daylight_continuous']
RollingAggregatesAdder added features: ['rolling_mean_load_P1D', 'rolling_median_load_P1D', 'rolling_max_load_P1D', 'rolling_min_load_P1D']
HolidayFeatureAdder added features: ['is_holiday', 'is_ascension_day', 'is_christmas_day', 'is_easter_monday', 'is_easter_sunday', 'is_good_friday', 'is_king_s_day', 'is_liberation_day', 'is_new_year_s_day', 'is_pentecost', 'is_pentecost_monday', 'is_second_day_of_christmas']
DatetimeFeaturesAdder added features: ['is_week_day', 'is_weekend_day', 'is_sunday', 'month_of_year', 'quarter_of_year']
SampleWeighter added features: ['sample_weight']
Step 4 - Ensemble Learning: Learn Which Model to Trust#
4.1 Ensemble Training#
openstef-meta adds an ensemble workflow around multiple base models. The learned-weights combiner sees the base-model forecasts and learns which one to trust under different conditions.
Here the ensemble uses XGBoost and GBLinear. They share the common workflow configuration, while each model keeps its own forecasting behavior and model-specific preprocessing.
Q4.1.1 What does an ensemble add that simply averaging two forecasts would not?
Q4.1.2 When would you expect the ensemble to favor tree-based models, and when linear models?
Q4.1.3 What evidence would show that the combiner learned useful behavior instead of fitting noise?
%%capture
# Fit the learned ensemble and generate its forecast for comparison.
# Define the two-model ensemble configuration.
from openstef_meta.presets import EnsembleForecastingWorkflowConfig, create_ensemble_forecasting_workflow
ensemble_config = EnsembleForecastingWorkflowConfig(
model_id="demo_xgboost_gblinear_ensemble",
ensemble_type="learned_weights",
base_models=["xgboost", "gblinear"],
combiner_model="lgbm",
horizons=[FORECAST_HORIZON],
quantiles=QUANTILES,
target_column="load",
radiation_column="shortwave_radiation",
wind_speed_column="wind_speed_80m",
pressure_column="surface_pressure",
temperature_column="temperature_2m",
relative_humidity_column="relative_humidity_2m",
energy_price_column="EPEX_NL",
mlflow_storage=None,
verbosity=0,
)
# Fit the combiner and request its forecast.
ensemble_workflow = create_ensemble_forecasting_workflow(ensemble_config)
ensemble_fit_result = ensemble_workflow.fit(train_dataset)
ensemble_forecast: ForecastDataset = ensemble_workflow.predict(
predict_dataset,
forecast_start=FORECAST_START,
)
print(f"Base models: {list(ensemble_config.base_models)}")
print(f"Combiner: {ensemble_config.combiner_model}")
print(f"Forecast rows: {len(ensemble_forecast.data):,}")
4.2 Base Models and Ensemble Forecasts#
The ensemble trains XGBoost and GBLinear as base models, then combines their quantile forecasts. Plotting all three outputs together makes the learned combination visible.
Q4.2.1 Where does the ensemble follow one base model more closely than the other?
Q4.2.2 Which forecast has the most useful uncertainty band for this target?
Q4.2.3 What would you inspect before deciding that the ensemble improves on both base models?
[2026-09-11 11:49:18,406][WARNING] No aggregation functions specified. Returning original data.
[2026-09-11 11:49:18,532][WARNING] No aggregation functions specified. Returning original data.
Step 5 - Foundation Model Forecasting with Chronos-2#
5.1 Chronos Forecasting#
openstef-foundation-models adds Chronos-2, a pretrained foundation model for zero-shot probabilistic forecasting. It does not train on this target. Instead, it receives recent load history and known-future covariates, then produces a forecast through the same workflow interface.
We use the compact published checkpoint so the workshop stays practical on CPU.
Q5.1.1 What is different about zero-shot forecasting compared with fitting e.g. XGBoost here?
Q5.1.2 Where does a foundation model like Chronos-2 get the required information to do inference from?
# Run the zero-shot Chronos workflow with the same forecast horizon.
# Load the compact foundation-model checkpoint and workflow factory.
from openstef_foundation_models.models import Chronos2
from openstef_foundation_models.presets.forecasting_workflow import (
ForecastingWorkflowConfig as FoundationForecastingWorkflowConfig,
)
from openstef_foundation_models.presets.forecasting_workflow import (
create_forecasting_workflow as create_foundation_forecasting_workflow,
)
from openstef_models.utils.feature_selection import Include
# Configure Chronos with the target and known-future weather features.
chronos_config = FoundationForecastingWorkflowConfig(
model="chronos2",
checkpoint=Chronos2.SMALL.checkpoint(),
quantiles=QUANTILES,
horizons=[FORECAST_HORIZON],
target_column="load",
selected_features=Include(
"load",
"shortwave_radiation",
"wind_speed_80m",
"temperature_2m",
),
model_id="demo_chronos2",
)
# Build the zero-shot workflow once.
chronos_workflow = create_foundation_forecasting_workflow(chronos_config)
chronos_window = combined_dataset.filter_by_range(
start=FORECAST_START - timedelta(days=60),
end=FORECAST_END,
)
# Generate the Chronos forecast.
chronos_forecast: ForecastDataset = chronos_workflow.predict(
chronos_window,
forecast_start=FORECAST_START,
)
print(f"Chronos model fitted: {chronos_workflow.model.is_fitted}")
print(f"Forecast rows: {len(chronos_forecast.data):,}")
2026-09-11 11:49:19.023439290 [W:onnxruntime:Default, device_discovery.cc:146 GetPciBusId] Skipping pci_bus_id for PCI path at "/sys/devices/LNXSYSTM:00/LNXSYBUS:00/ACPI0004:00/MSFT1000:00/5620e0c7-8062-4dce-aeb7-520c7ef76171" because filename "5620e0c7-8062-4dce-aeb7-520c7ef76171" did not match expected pattern of [0-9a-f]+:[0-9a-f]+:[0-9a-f]+[.][0-9a-f]+
[2026-09-11 11:49:19,133][INFO] DefaultProviderPolicy selected execution-provider chain ['CPUExecutionProvider'] for checkpoint 'chronos2' (precision=fp32, static_shapes=False) on linux.
[2026-09-11 11:49:19,385][INFO] ONNX Runtime session built; realized providers: ['CPUExecutionProvider'].
Chronos model fitted: True
Forecast rows: 672
Step 6 - Compare the Ensemble with Chronos-2#
6.1 Final Forecast Comparison#
Compare the learned ensemble with the zero-shot Chronos-2 forecast at the same origin. The base-model comparison above explains how the ensemble is formed; this final figure compares the ensemble output with a separate forecasting approach.
Q6.1.1 Which forecast follows the measured signal most closely?
Q6.1.2 Where do the ensemble and Chronos-2 disagree most strongly?
Q6.1.3 Which uncertainty band would be more useful operationally, and why?
Step 7 - Short Backtest with OpenSTEF BEAM#
7.1 Backtest Configuration and Execution#
One forecast plot builds intuition, but it does not tell us how a model behaves across repeated forecast origins. openstef-beam provides the backtesting and evaluation workflow. We keep the same target and shorten the benchmark period for a workshop run.
The short benchmark spans four weeks with weekly retraining and forecasting every 24 hours while keeping each forecast window at 7 days.
Note
The benchmark is disabled by default so documentation builds stay fast. To run it locally,
set RUN_BENCHMARK = True in the setup cell below and execute the benchmark cells.
Q7.1.1 How many versions of a forecasted timestamp are there with forecasting every day with a forecast window of 7 days?
Q7.1.2 What should we check in the benchmark output before claiming one model is better?
Q7.1.3 Why must a backtest use the forecast version available at the time?
# Configure benchmark storage, model variants, and evaluation inputs.
# Import the benchmark runner, storage, and visualization components.
from openstef_beam.analysis import AnalysisConfig
from openstef_beam.analysis.visualizations import TimeSeriesVisualization
from openstef_beam.benchmarking.baselines.openstef4 import create_openstef4_preset_backtest_forecaster
from openstef_beam.benchmarking.benchmark_pipeline import BenchmarkContext, BenchmarkPipeline
from openstef_beam.benchmarking.benchmarks.liander2024 import (
Liander2024Category,
)
from openstef_beam.benchmarking.callbacks.strict_execution_callback import StrictExecutionCallback
from openstef_beam.benchmarking.models import BenchmarkTarget
from openstef_beam.benchmarking.storage.base import BenchmarkStorage
from openstef_beam.benchmarking.storage.local_storage import LocalBenchmarkStorage
from openstef_beam.evaluation import EvaluationConfig, Window
from openstef_foundation_models.integrations.beam import FoundationModelBacktestForecaster
# Set this to True when running the benchmark interactively.
RUN_BENCHMARK = False
# Define separate output locations for each model and the comparison.
OUTPUT_PATH = Path("./benchmark_results")
BENCHMARK_RESULTS_PATH_XGBOOST = OUTPUT_PATH / "XGBoost"
BENCHMARK_RESULTS_PATH_GBLINEAR = OUTPUT_PATH / "GBLinear"
BENCHMARK_RESULTS_PATH_CHRONOS = OUTPUT_PATH / "Chronos-2"
# Share the feature and horizon configuration across classical models.
common_config = ForecastingWorkflowConfig(
model_id="benchmark_model_",
run_name=None,
model="flatliner",
horizons=[FORECAST_HORIZON],
quantiles=QUANTILES,
model_reuse_enable=True,
mlflow_storage=None,
radiation_column="shortwave_radiation",
wind_speed_column="wind_speed_80m",
pressure_column="surface_pressure",
temperature_column="temperature_2m",
relative_humidity_column="relative_humidity_2m",
energy_price_column="EPEX_NL",
rolling_aggregate_features=["mean", "median", "max", "min"],
verbosity=0,
)
xgboost_config = common_config.model_copy(update={"model": "xgboost"})
gblinear_config = common_config.model_copy(update={"model": "gblinear"})
# Force fresh benchmark artifacts when rerunning the workshop.
storage_xgboost = LocalBenchmarkStorage(base_path=BENCHMARK_RESULTS_PATH_XGBOOST, skip_when_existing=False)
storage_gblinear = LocalBenchmarkStorage(base_path=BENCHMARK_RESULTS_PATH_GBLINEAR, skip_when_existing=False)
storage_chronos = LocalBenchmarkStorage(base_path=BENCHMARK_RESULTS_PATH_CHRONOS, skip_when_existing=False)
# Restrict the benchmark to the workshop target and period.
class ShortBenchmarkTargetProvider(Liander2024TargetProvider):
"""One target with a short benchmark period for a fast workshop run."""
target_name: str
target_group: str
def get_targets(self, filter_args: list[Liander2024Category] | None = None) -> list[BenchmarkTarget]:
"""Return the selected target with a shortened benchmark period."""
targets = super().get_targets(filter_args)
targets = [item for item in targets if item.name == self.target_name and item.group_name == self.target_group]
for item in targets:
item.benchmark_start = datetime.fromisoformat("2024-05-01T00:00:00Z")
item.benchmark_end = datetime.fromisoformat("2024-05-29T23:59:59Z")
return targets
single_target_provider = ShortBenchmarkTargetProvider(
data_dir=DATASET_DIR,
target_name=TARGET_NAME,
target_group=TARGET_GROUP,
)
targets = single_target_provider.get_targets()
assert len(targets) == 1, f"Expected 1 target, got {len(targets)}"
print(f"Benchmarking: {targets[0].name}")
print(f"Short benchmark period: {targets[0].benchmark_start.date()} to {targets[0].benchmark_end.date()}")
# Evaluate over a 1-day evaluation window at the selected day-ahead availability.
WORKSHOP_EVALUATION_CONFIG = EvaluationConfig(
available_ats=[AvailableAt.from_string("D-1T06:00")],
lead_times=[],
windows=[Window(lag=timedelta(hours=0), size=timedelta(days=1))],
)
# Configure which visualizations and analysis should be included in the benchmark analysis
WORKSHOP_ANALYSIS_CONFIG = AnalysisConfig(
visualization_providers=[
TimeSeriesVisualization(name="time_series"),
WindowedMetricVisualization(
name="rMAE_windowed_1D",
metric=("rMAE", Quantile(0.5)),
window=Window(lag=timedelta(hours=0), size=timedelta(days=1)),
),
WindowedMetricVisualization(
name="rCRPS_windowed_1D",
metric="rCRPS",
window=Window(lag=timedelta(hours=0), size=timedelta(days=1)),
),
GroupedTargetMetricVisualization(
name="rMAE_grouped",
metric="rMAE",
quantile=Quantile(0.5),
),
GroupedTargetMetricVisualization(
name="rCRPS_grouped",
metric="rCRPS",
),
GroupedTargetMetricVisualization(
name="rCRPS_sample_weighted_grouped",
metric="rCRPS_sample_weighted",
),
SummaryTableVisualization(
name="summary",
),
QuantileProbabilityVisualization(
name="quantile_probability",
),
],
)
def create_workshop_benchmark_runner(
storage: BenchmarkStorage,
) -> BenchmarkPipeline[BenchmarkTarget, list[Liander2024Category]]:
"""Create a runner with one-day evaluation metrics and all standard plots."""
return BenchmarkPipeline[BenchmarkTarget, list[Liander2024Category]](
backtest_config=BacktestConfig(
prediction_sample_interval=timedelta(minutes=15),
predict_interval=timedelta(hours=24),
train_interval=timedelta(days=7),
),
evaluation_config=WORKSHOP_EVALUATION_CONFIG,
analysis_config=WORKSHOP_ANALYSIS_CONFIG,
target_provider=single_target_provider,
storage=storage,
callbacks=[StrictExecutionCallback()],
)
Benchmarking: Within 15 kilometers of Oosterwolde_normalized
Short benchmark period: 2024-05-01 to 2024-05-29
# Run the classical-model backtests over the shortened benchmark period.
# Define a shared runner call for each trainable model.
def run_benchmark(
storage: BenchmarkStorage,
workflow_config: ForecastingWorkflowConfig,
run_name: str,
) -> None:
"""Run one trainable workflow through the short BEAM benchmark."""
create_workshop_benchmark_runner(storage).run(
forecaster_factory=create_openstef4_preset_backtest_forecaster(
workflow_config=workflow_config,
),
run_name=run_name,
n_processes=1,
)
if RUN_BENCHMARK:
run_benchmark(storage_xgboost, xgboost_config, "xgboost")
run_benchmark(storage_gblinear, gblinear_config, "gblinear")
# Adapt the loaded Chronos workflow to the BEAM backtest interface.
def create_chronos_benchmark_forecaster(
_context: BenchmarkContext,
_target: BenchmarkTarget,
) -> FoundationModelBacktestForecaster:
"""Reuse the 7-day Chronos workflow across the benchmark windows."""
# Reuse the loaded Chronos workflow for each backtest origin.
return FoundationModelBacktestForecaster.from_workflow(
chronos_workflow,
predict_length=FORECAST_HORIZON.value,
predict_context_length=timedelta(days=60),
)
create_workshop_benchmark_runner(storage_chronos).run(
forecaster_factory=create_chronos_benchmark_forecaster,
run_name="chronos2",
n_processes=1,
)
print("All three short backtests complete.")
else:
print("Benchmark skipped. Set RUN_BENCHMARK = True to run it locally.")
Benchmark skipped. Set RUN_BENCHMARK = True to run it locally.
Step 8 - Compare Backtest Results#
8.1 Metrics and Reports#
In order to compare different benchmark runs, we use the BenchmarkComparisonPipeline to generate standardized reports.
Note
The benchmark results above are generated only when the backtest is run in a notebook. They are not generated on the documentation page.
Q8.1.1 In
summary.html, which model has the lowest rMAE and rCRPS? Are they the same model?Q8.1.2 In
rMAE_windowed_1D.html, does the ranking stay stable through the benchmark period?Q8.1.3 Does the output structure make the result easy to reproduce and audit?
# Generate the comparison reports for all completed benchmark runs.
# Import the report comparison pipeline.
from openstef_beam.benchmarking import BenchmarkComparisonPipeline
# Configure the output location and target scope.
COMPARISON_RESULT_PATH = OUTPUT_PATH / "comparison"
if RUN_BENCHMARK:
comparison_pipeline = BenchmarkComparisonPipeline(
analysis_config=WORKSHOP_ANALYSIS_CONFIG,
storage=LocalBenchmarkStorage(base_path=COMPARISON_RESULT_PATH, skip_when_existing=False),
target_provider=single_target_provider,
)
# Combine all model runs into the workshop reports.
comparison_pipeline.run(
run_data={
"xgboost": storage_xgboost,
"gblinear": storage_gblinear,
"chronos2": storage_chronos,
}
)
print(f"Results saved to: {COMPARISON_RESULT_PATH.resolve()}")
else:
print("Comparison skipped because RUN_BENCHMARK is False.")
Comparison skipped because RUN_BENCHMARK is False.
Output Structure#
benchmark_results/
├── XGBoost/
├── GBLinear/
├── Chronos-2/
└── comparison/
└── global/D-1T0600/
├── rCRPS_windowed_1D.html
├── rMAE_grouped.html
└── summary.html
Step 9 - Workshop Summary#
This workshop followed one target through OpenSTEF’s modular forecasting stack:
Explored target measurements, versioned weather, profiles, and prices
Trained XGBoost
Built an ensemble with XGBoost and GBLinear through
openstef-metaForecast with Chronos-2 through
openstef-foundation-modelsCompared the ensemble with Chronos-2
Backtested XGBoost, GBLinear, and Chronos-2 with
openstef-beam
Some final questions below to round-up this workshop.
Q9.1.1 Which model would you deploy for this target, and which evidence supports that choice?
Q9.1.2 What would you change first for a second target: data selection, preprocessing, model, or benchmark period?
Q9.1.3 What does the modularity of OpenSTEF enable in terms of model development and benchmarking?