Source code for openstef_beam.analysis.visualizations.timeseries_visualization

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

"""Time series visualization provider.

This module provides visualization for time series forecast comparisons,
displaying measurements alongside forecast quantiles with capacity limits.
"""

from typing import override

from openstef_beam.analysis.models import AnalysisAggregation, RunName, TargetMetadata, VisualizationOutput
from openstef_beam.analysis.plots import ForecastTimeSeriesPlotter
from openstef_beam.analysis.visualizations.base import ReportTuple, VisualizationProvider
from openstef_beam.evaluation import EvaluationSubsetReport


[docs] class TimeSeriesVisualization(VisualizationProvider): """Creates interactive time series plots comparing forecasts with actual measurements. Displays forecast quantiles as uncertainty bands overlaid with actual measurements on a timeline. Shows how well probabilistic forecasts capture reality over time and helps identify periods of poor performance or systematic biases. What you'll see: - Actual measurements as a line plot - Forecast quantiles as shaded uncertainty bands (darker = higher confidence) - Capacity limits as horizontal reference lines - Multiple model runs as different colored bands (when comparing models) Useful for: - Assessing forecast accuracy across different time periods - Identifying when uncertainty bands fail to contain actual values - Spotting systematic forecast biases or seasonal patterns - Understanding model behavior during extreme events Example: >>> from openstef_beam.analysis import AnalysisConfig >>> from openstef_beam.analysis.visualizations import TimeSeriesVisualization >>> >>> analysis_config = AnalysisConfig( ... visualization_providers=[ ... TimeSeriesVisualization(name="forecast_vs_actual"), ... ] ... ) """ connect_gaps: bool = True @property @override def supported_aggregations(self) -> set[AnalysisAggregation]: return {AnalysisAggregation.NONE, AnalysisAggregation.RUN_AND_NONE} @staticmethod def _add_capacity_limits( plotter: ForecastTimeSeriesPlotter, metadata: TargetMetadata, ) -> None: """Add upper and lower capacity limits to the plot. If upper_limit and lower_limit are provided, they are used directly. Otherwise, falls back to the symmetric limit field. Args: plotter: The forecast time series plotter instance metadata: Target metadata containing limit information """ if metadata.upper_limit is not None and metadata.lower_limit is not None: plotter.add_limit(value=metadata.upper_limit, name="Upper Limit") plotter.add_limit(value=metadata.lower_limit, name="Lower Limit") elif metadata.limit is not None: plotter.add_limit(value=metadata.limit, name="Upper Limit") plotter.add_limit(value=-metadata.limit, name="Lower Limit") @staticmethod def _get_first_target_data(reports: dict[RunName, list[ReportTuple]]) -> ReportTuple: """Extract metadata and report from the first target in the reports. Args: reports: Dictionary mapping run names to target report pairs Returns: Tuple of (metadata, report) from the first available target Raises: ValueError: If no reports are provided """ if not reports: raise ValueError("No reports provided for time series visualization.") first_run_reports = next(iter(reports.values())) if not first_run_reports: raise ValueError("No target reports found in the first run.") return first_run_reports[0]
[docs] @override def create_by_none( self, report: EvaluationSubsetReport, metadata: TargetMetadata, ) -> VisualizationOutput: plotter = ForecastTimeSeriesPlotter( connect_gaps=self.connect_gaps, sample_interval=report.subset.sample_interval, ) # Add measurements as the baseline plotter.add_measurements(report.get_measurements()) # Add forecast model with quantile predictions plotter.add_model( model_name=metadata.run_name, quantiles=report.get_quantile_predictions(), ) # Add capacity limits for context TimeSeriesVisualization._add_capacity_limits(plotter, metadata) figure = plotter.plot(title=f"Measurements vs Forecasts for {metadata.name}") return VisualizationOutput(name=self.name, figure=figure)
[docs] @override def create_by_run_and_none( self, reports: dict[RunName, list[ReportTuple]], ) -> VisualizationOutput: plotter = ForecastTimeSeriesPlotter(connect_gaps=self.connect_gaps) # Get reference data from the first target (all targets expected to be the same) first_metadata, first_report = TimeSeriesVisualization._get_first_target_data(reports) # Add measurements once (shared across all runs) plotter.add_measurements(first_report.get_measurements()) # Add capacity limits for context TimeSeriesVisualization._add_capacity_limits(plotter, first_metadata) # Add forecast models for each run for run_name, run_reports in reports.items(): for _metadata, report in run_reports: plotter.add_model( model_name=run_name, quantiles=report.get_quantile_predictions(), ) figure = plotter.plot(title="Forecast Time Series Comparison") return VisualizationOutput(name=self.name, figure=figure)
__all__ = ["TimeSeriesVisualization"]