Source code for tsam.api

"""New simplified API for tsam aggregation."""

from __future__ import annotations

import warnings
from typing import cast

import pandas as pd

from tsam.config import (
    EXTREME_METHOD_MAPPING,
    METHOD_MAPPING,
    REPRESENTATION_MAPPING,
    ClusterConfig,
    ClusteringResult,
    Distribution,
    ExtremeConfig,
    MinMaxMean,
    Representation,
    SegmentConfig,
)
from tsam.exceptions import LegacyAPIWarning
from tsam.result import AccuracyMetrics, AggregationResult
from tsam.timeseriesaggregation import TimeSeriesAggregation, unstackToPeriods


def _parse_duration_hours(value: int | float | str, param_name: str) -> float:
    """Parse a duration value to hours.

    Accepts:
    - int/float: interpreted as hours (e.g., 24 → 24.0 hours)
    - str: pandas Timedelta string (e.g., '24h', '1d', '15min')

    Returns duration in hours as float.
    """
    if isinstance(value, (int, float)):
        return float(value)
    if isinstance(value, str):
        try:
            td = pd.Timedelta(value)
            return td.total_seconds() / 3600
        except ValueError as e:
            raise ValueError(
                f"{param_name}: invalid duration string '{value}': {e}"
            ) from e
    raise TypeError(
        f"{param_name} must be int, float, or string, got {type(value).__name__}"
    )


[docs] def aggregate( data: pd.DataFrame, n_clusters: int, *, period_duration: int | float | str = 24, temporal_resolution: float | str | None = None, cluster: ClusterConfig | None = None, segments: SegmentConfig | None = None, extremes: ExtremeConfig | None = None, preserve_column_means: bool = True, rescale_exclude_columns: list[str] | None = None, round_decimals: int | None = None, numerical_tolerance: float = 1e-13, ) -> AggregationResult: """Aggregate time series data into typical periods. This function reduces a time series dataset to a smaller set of representative "typical periods" using clustering algorithms. Parameters ---------- data : pd.DataFrame Input time series data with a datetime index. Each column represents a different variable (e.g., solar, wind, demand). The index should be a DatetimeIndex with regular intervals. n_clusters : int Number of clusters (typical periods) to create. Higher values = more accuracy but less data reduction. Typical range: 4-20 for energy system models. period_duration : int, float, or str, default 24 Length of each period. Accepts: - int/float: hours (e.g., 24 for daily, 168 for weekly) - str: pandas Timedelta string (e.g., '24h', '1d', '1w') temporal_resolution : float or str, optional Time resolution of input data. Accepts: - float: hours (e.g., 1.0 for hourly, 0.25 for 15-minute) - str: pandas Timedelta string (e.g., '1h', '15min', '30min') If not provided, inferred from the datetime index. cluster : ClusterConfig, optional Clustering configuration. If not provided, uses defaults: - method: "hierarchical" - representation: "medoid" segments : SegmentConfig, optional Segmentation configuration for reducing temporal resolution within periods. If not provided, no segmentation is applied. extremes : ExtremeConfig, optional Configuration for preserving extreme periods. If not provided, no extreme period handling is applied. preserve_column_means : bool, default True Rescale typical periods so each column's weighted mean matches the original data's mean. Ensures total energy/load is preserved when weights represent occurrence counts. rescale_exclude_columns : list[str], optional Column names to exclude from rescaling when preserve_column_means=True. Useful for binary/indicator columns (0/1 values) that should not be rescaled. If None (default), all columns are rescaled. round_decimals : int, optional Round output values to this many decimal places. If not provided, no rounding is applied. numerical_tolerance : float, default 1e-13 Tolerance for numerical precision issues. Controls when warnings are raised for aggregated values exceeding the original time series bounds. Increase this value to silence warnings caused by floating-point precision errors. Returns ------- AggregationResult Object containing: - cluster_representatives: DataFrame with aggregated periods - cluster_assignments: Which cluster each original period belongs to - cluster_weights: Occurrence count per cluster - accuracy: RMSE, MAE metrics - Methods: to_dict() Raises ------ ValueError If input data is invalid or parameters are inconsistent. TypeError If parameter types are incorrect. Examples -------- Basic usage with defaults: >>> import tsam >>> result = tsam.aggregate(df, n_clusters=8) >>> typical = result.cluster_representatives With custom clustering: >>> from tsam import aggregate, ClusterConfig >>> result = aggregate( ... df, ... n_clusters=8, ... cluster=ClusterConfig(method="kmeans", representation="mean"), ... ) With segmentation (reduce to 12 timesteps per period): >>> from tsam import aggregate, SegmentConfig >>> result = aggregate( ... df, ... n_clusters=8, ... segments=SegmentConfig(n_segments=12), ... ) Preserving peak demand periods: >>> from tsam import aggregate, ExtremeConfig >>> result = aggregate( ... df, ... n_clusters=8, ... extremes=ExtremeConfig(max_value=["demand"]), ... ) Transferring assignments to new data: >>> result1 = aggregate(df_wind, n_clusters=8) >>> result2 = result1.clustering.apply(df_all) See Also -------- ClusterConfig : Clustering algorithm configuration SegmentConfig : Temporal segmentation configuration ExtremeConfig : Extreme period preservation configuration AggregationResult : Result object with all outputs """ # Validate input if not isinstance(data, pd.DataFrame): raise TypeError(f"data must be a pandas DataFrame, got {type(data).__name__}") if not isinstance(n_clusters, int) or n_clusters < 1: raise ValueError(f"n_clusters must be a positive integer, got {n_clusters}") # Parse duration parameters to hours period_duration = _parse_duration_hours(period_duration, "period_duration") if period_duration <= 0: raise ValueError(f"period_duration must be positive, got {period_duration}") temporal_resolution = ( _parse_duration_hours(temporal_resolution, "temporal_resolution") if temporal_resolution is not None else None ) if temporal_resolution is not None and temporal_resolution <= 0: raise ValueError( f"temporal_resolution must be positive, got {temporal_resolution}" ) # Apply defaults if cluster is None: cluster = ClusterConfig() # Validate segments against data if segments is not None: # Calculate timesteps per period if temporal_resolution is not None: timesteps_per_period = int(period_duration / temporal_resolution) else: # Infer resolution from data index if isinstance(data.index, pd.DatetimeIndex) and len(data.index) > 1: inferred_resolution = ( data.index[1] - data.index[0] ).total_seconds() / 3600 timesteps_per_period = int(period_duration / inferred_resolution) else: # Fall back to assuming hourly resolution timesteps_per_period = int(period_duration) if segments.n_segments > timesteps_per_period: raise ValueError( f"n_segments ({segments.n_segments}) cannot exceed " f"timesteps per period ({timesteps_per_period})" ) # Validate extreme columns exist in data if extremes is not None: all_extreme_cols = ( extremes.max_value + extremes.min_value + extremes.max_period + extremes.min_period ) missing = set(all_extreme_cols) - set(data.columns) if missing: raise ValueError(f"Extreme period columns not found in data: {missing}") # Validate weight columns exist if cluster.weights is not None: missing = set(cluster.weights.keys()) - set(data.columns) if missing: raise ValueError(f"Weight columns not found in data: {missing}") # Build old API parameters old_params = _build_old_params( data=data, n_clusters=n_clusters, period_duration=period_duration, temporal_resolution=temporal_resolution, cluster=cluster, segments=segments, extremes=extremes, preserve_column_means=preserve_column_means, rescale_exclude_columns=rescale_exclude_columns, round_decimals=round_decimals, numerical_tolerance=numerical_tolerance, ) # Run aggregation using old implementation (suppress deprecation warning for internal use) with warnings.catch_warnings(): warnings.simplefilter("ignore", LegacyAPIWarning) agg = TimeSeriesAggregation(**old_params) cluster_representatives = agg.createTypicalPeriods() # Rename index levels for consistency with new API terminology cluster_representatives = cluster_representatives.rename_axis( index={"PeriodNum": "cluster", "TimeStep": "timestep"} ) # Build accuracy metrics accuracy_df = agg.accuracyIndicators() # Build rescale deviations DataFrame rescale_deviations_dict = getattr(agg, "_rescaleDeviations", {}) if rescale_deviations_dict: rescale_deviations = pd.DataFrame.from_dict( rescale_deviations_dict, orient="index" ) rescale_deviations.index.name = "column" else: rescale_deviations = pd.DataFrame( columns=["deviation_pct", "converged", "iterations"] ) accuracy = AccuracyMetrics( rmse=accuracy_df["RMSE"], mae=accuracy_df["MAE"], rmse_duration=accuracy_df["RMSE_duration"], rescale_deviations=rescale_deviations, ) # Build ClusteringResult clustering_result = _build_clustering_result( agg=agg, n_segments=segments.n_segments if segments else None, cluster_config=cluster, segment_config=segments, extremes_config=extremes, preserve_column_means=preserve_column_means, rescale_exclude_columns=rescale_exclude_columns, temporal_resolution=temporal_resolution, ) # Compute segment_durations as tuple of tuples segment_durations_tuple = None if segments and hasattr(agg, "segmentedNormalizedTypicalPeriods"): segmented_df = agg.segmentedNormalizedTypicalPeriods segment_durations_tuple = tuple( tuple( int(seg_dur) for _seg_step, seg_dur, _orig_start in segmented_df.loc[ period_idx ].index ) for period_idx in segmented_df.index.get_level_values(0).unique() ) # Build result object return AggregationResult( cluster_representatives=cluster_representatives, cluster_weights=dict(agg.clusterPeriodNoOccur), n_timesteps_per_period=agg.timeStepsPerPeriod, segment_durations=segment_durations_tuple, accuracy=accuracy, clustering_duration=getattr(agg, "clusteringDuration", 0.0), clustering=clustering_result, is_transferred=False, _aggregation=agg, )
def _build_clustering_result( agg: TimeSeriesAggregation, n_segments: int | None, cluster_config: ClusterConfig, segment_config: SegmentConfig | None, extremes_config: ExtremeConfig | None, preserve_column_means: bool, rescale_exclude_columns: list[str] | None, temporal_resolution: float | None, ) -> ClusteringResult: """Build ClusteringResult from a TimeSeriesAggregation object.""" # Get cluster centers (convert to Python ints for JSON serialization) # Handle extreme periods based on method: # - new_cluster/append: append extreme period indices (creates additional clusters) # - replace: keep original cluster centers # Note: replace creates a hybrid representation (some columns from medoid, some # from extreme period) that cannot be perfectly reproduced during transfer cluster_centers: tuple[int, ...] | None = None if agg.clusterCenterIndices is not None: center_indices = [int(x) for x in agg.clusterCenterIndices] if ( hasattr(agg, "extremePeriods") and agg.extremePeriods and extremes_config is not None and extremes_config.method in ("new_cluster", "append") ): # Add extreme period indices as new cluster centers for period_type in agg.extremePeriods: center_indices.append(int(agg.extremePeriods[period_type]["stepNo"])) cluster_centers = tuple(center_indices) # Compute segment data if segmentation was used segment_assignments: tuple[tuple[int, ...], ...] | None = None segment_durations: tuple[tuple[int, ...], ...] | None = None segment_centers: tuple[tuple[int, ...], ...] | None = None if n_segments is not None and hasattr(agg, "segmentedNormalizedTypicalPeriods"): segmented_df = agg.segmentedNormalizedTypicalPeriods segment_assignments_list = [] segment_durations_list = [] for period_idx in segmented_df.index.get_level_values(0).unique(): period_data = segmented_df.loc[period_idx] # Index levels: Segment Step, Segment Duration, Original Start Step assignments = [] durations = [] for seg_step, seg_dur, _orig_start in period_data.index: assignments.extend([int(seg_step)] * int(seg_dur)) durations.append(int(seg_dur)) segment_assignments_list.append(tuple(assignments)) segment_durations_list.append(tuple(durations)) segment_assignments = tuple(segment_assignments_list) segment_durations = tuple(segment_durations_list) # Extract segment center indices (only available for medoid/maxoid representations) if ( hasattr(agg, "segmentCenterIndices") and agg.segmentCenterIndices is not None ): # Check if any period has center indices (None for mean representation) if all(pc is not None for pc in agg.segmentCenterIndices): segment_centers = tuple( tuple(int(x) for x in period_centers) for period_centers in agg.segmentCenterIndices ) # Extract representation from configs representation = cluster_config.get_representation() segment_representation = segment_config.representation if segment_config else None # Extract extreme cluster indices if extremes were used extreme_cluster_indices: tuple[int, ...] | None = None if hasattr(agg, "extremeClusterIdx") and agg.extremeClusterIdx: extreme_cluster_indices = tuple(int(x) for x in agg.extremeClusterIdx) return ClusteringResult( period_duration=agg.hoursPerPeriod, cluster_assignments=tuple(int(x) for x in agg.clusterOrder), cluster_centers=cluster_centers, segment_assignments=segment_assignments, segment_durations=segment_durations, segment_centers=segment_centers, preserve_column_means=preserve_column_means, rescale_exclude_columns=tuple(rescale_exclude_columns) if rescale_exclude_columns else None, representation=representation, segment_representation=segment_representation, temporal_resolution=temporal_resolution, n_timesteps_per_period=agg.timeStepsPerPeriod, extreme_cluster_indices=extreme_cluster_indices, cluster_config=cluster_config, segment_config=segment_config, extremes_config=extremes_config, ) def _apply_representation_params( params: dict, representation: Representation, columns: list[str] ) -> None: """Apply representation parameters to the old API params dict. Handles both string shortcuts and typed representation objects (Distribution, MinMaxMean). """ if isinstance(representation, Distribution): if representation.preserve_minmax: params["representationMethod"] = "distributionAndMinMaxRepresentation" else: params["representationMethod"] = "distributionRepresentation" params["distributionPeriodWise"] = representation.scope == "cluster" elif isinstance(representation, MinMaxMean): params["representationMethod"] = "minmaxmeanRepresentation" # Build representationDict: columns not in max/min default to mean rep_dict: dict[str, str] = {} max_set = set(representation.max_columns) min_set = set(representation.min_columns) for col in columns: if col in max_set: rep_dict[col] = "max" elif col in min_set: rep_dict[col] = "min" else: rep_dict[col] = "mean" params["representationDict"] = rep_dict else: # String representation rep_mapped = REPRESENTATION_MAPPING.get(representation) if rep_mapped is None: raise ValueError( f"Unknown representation method: {representation!r}. " f"Valid options: {list(REPRESENTATION_MAPPING.keys())}" ) params["representationMethod"] = rep_mapped def _build_old_params( data: pd.DataFrame, n_clusters: int, period_duration: float, temporal_resolution: float | None, cluster: ClusterConfig, segments: SegmentConfig | None, extremes: ExtremeConfig | None, preserve_column_means: bool, rescale_exclude_columns: list[str] | None, round_decimals: int | None, numerical_tolerance: float, *, # Predefined parameters (used internally by ClusteringResult.apply()) predef_cluster_assignments: tuple[int, ...] | None = None, predef_cluster_centers: tuple[int, ...] | None = None, predef_extreme_cluster_indices: tuple[int, ...] | None = None, predef_segment_assignments: tuple[tuple[int, ...], ...] | None = None, predef_segment_durations: tuple[tuple[int, ...], ...] | None = None, predef_segment_centers: tuple[tuple[int, ...], ...] | None = None, ) -> dict: """Build parameters for the old TimeSeriesAggregation API.""" params: dict = { "timeSeries": data, "noTypicalPeriods": n_clusters, "hoursPerPeriod": period_duration, "rescaleClusterPeriods": preserve_column_means, "rescaleExcludeColumns": rescale_exclude_columns, "numericalTolerance": numerical_tolerance, } if temporal_resolution is not None: params["resolution"] = temporal_resolution if round_decimals is not None: params["roundOutput"] = round_decimals # Cluster config method = METHOD_MAPPING.get(cluster.method) if method is None: raise ValueError( f"Unknown cluster method: {cluster.method!r}. " f"Valid options: {list(METHOD_MAPPING.keys())}" ) params["clusterMethod"] = method representation = cluster.get_representation() _apply_representation_params(params, representation, data.columns.tolist()) params["sortValues"] = cluster.use_duration_curves params["sameMean"] = cluster.normalize_column_means params["evalSumPeriods"] = cluster.include_period_sums params["solver"] = cluster.solver if cluster.weights is not None: params["weightDict"] = cluster.weights if predef_cluster_assignments is not None: params["predefClusterOrder"] = list(predef_cluster_assignments) if predef_cluster_centers is not None: params["predefClusterCenterIndices"] = list(predef_cluster_centers) if predef_extreme_cluster_indices is not None: params["predefExtremeClusterIdx"] = list(predef_extreme_cluster_indices) # Segmentation config if segments is not None: params["segmentation"] = True params["noSegments"] = segments.n_segments seg_rep = segments.representation if isinstance(seg_rep, (Distribution, MinMaxMean)): seg_params: dict = {} _apply_representation_params(seg_params, seg_rep, data.columns.tolist()) params["segmentRepresentationMethod"] = seg_params["representationMethod"] if "distributionPeriodWise" in seg_params: params["distributionPeriodWise"] = seg_params["distributionPeriodWise"] if "representationDict" in seg_params: params["representationDict"] = seg_params["representationDict"] else: params["segmentRepresentationMethod"] = REPRESENTATION_MAPPING.get( seg_rep, "meanRepresentation" ) # Predefined segment parameters (from ClusteringResult) if predef_segment_assignments is not None: params["predefSegmentOrder"] = [list(s) for s in predef_segment_assignments] if predef_segment_durations is not None: params["predefSegmentDurations"] = [ list(s) for s in predef_segment_durations ] if predef_segment_centers is not None: params["predefSegmentCenters"] = [list(s) for s in predef_segment_centers] else: params["segmentation"] = False # Extreme config if extremes is not None and extremes.has_extremes(): params["extremePeriodMethod"] = EXTREME_METHOD_MAPPING[extremes.method] params["addPeakMax"] = extremes.max_value params["addPeakMin"] = extremes.min_value params["addMeanMax"] = extremes.max_period params["addMeanMin"] = extremes.min_period else: params["extremePeriodMethod"] = "None" return params def unstack_to_periods( data: pd.DataFrame, period_duration: int | float | str = 24, ) -> pd.DataFrame: """Reshape time series data into period structure for visualization. Transforms a flat time series into a DataFrame with periods as rows and timesteps as a MultiIndex level, suitable for creating heatmaps with plotly. Parameters ---------- data : pd.DataFrame Time series data with datetime index. period_duration : int, float, or str, default 24 Length of each period. Accepts: - int/float: hours (e.g., 24 for daily, 168 for weekly) - str: pandas Timedelta string (e.g., '24h', '1d', '1w') Returns ------- pd.DataFrame Reshaped data with shape (n_periods, n_timesteps_per_period) for each column. Suitable for ``px.imshow(result["column"].values.T)`` to create heatmaps. Examples -------- >>> import tsam >>> import plotly.express as px >>> >>> # Reshape data for heatmap visualization >>> unstacked = tsam.unstack_to_periods(df, period_duration=24) >>> >>> # Create heatmap with plotly >>> px.imshow( ... unstacked["Load"].values.T, ... labels={"x": "Day", "y": "Hour", "color": "Load"}, ... title="Load Heatmap" ... ) """ period_hours = _parse_duration_hours(period_duration, "period_duration") # Infer timestep resolution from data index timestep_hours = 1.0 # Default to hourly if isinstance(data.index, pd.DatetimeIndex) and len(data.index) > 1: timestep_hours = (data.index[1] - data.index[0]).total_seconds() / 3600 # Calculate timesteps per period timesteps_per_period = round(period_hours / timestep_hours) if timesteps_per_period < 1: raise ValueError( f"period_duration ({period_hours}h) is smaller than " f"data timestep resolution ({timestep_hours}h)" ) with warnings.catch_warnings(): warnings.simplefilter("ignore", LegacyAPIWarning) unstacked, _ = unstackToPeriods(data.copy(), timesteps_per_period) return cast("pd.DataFrame", unstacked)