"""Configuration classes for tsam aggregation."""
from __future__ import annotations
import warnings
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Literal
import pandas as pd
if TYPE_CHECKING:
from tsam.result import AggregationResult
# Type aliases for clarity
ClusterMethod = Literal[
"averaging",
"kmeans",
"kmedoids",
"kmaxoids",
"hierarchical",
"contiguous",
]
RepresentationMethod = Literal[
"mean",
"medoid",
"maxoid",
"distribution",
"distribution_minmax",
"minmax_mean",
]
ExtremeMethod = Literal[
"append",
"replace",
"new_cluster",
]
Solver = Literal["highs", "cbc", "gurobi", "cplex"]
@dataclass(frozen=True)
class Distribution:
"""Representation that preserves the value distribution (duration curve).
Parameters
----------
scope : "cluster" or "global", default "cluster"
"cluster": preserve each cluster's distribution separately
"global": preserve the overall time series distribution
preserve_minmax : bool, default False
If True, also preserves min/max values per timestep
(equivalent to old "distribution_minmax").
"""
scope: Literal["cluster", "global"] = "cluster"
preserve_minmax: bool = False
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary for JSON serialization."""
result: dict[str, Any] = {"type": "distribution"}
if self.scope != "cluster":
result["scope"] = self.scope
if self.preserve_minmax:
result["preserve_minmax"] = self.preserve_minmax
return result
@classmethod
def from_dict(cls, data: dict) -> Distribution:
"""Create from dictionary (e.g., loaded from JSON)."""
return cls(
scope=data.get("scope", "cluster"),
preserve_minmax=data.get("preserve_minmax", False),
)
@dataclass(frozen=True)
class MinMaxMean:
"""Representation combining min, max, and mean per column.
Columns not listed in max_columns or min_columns default to mean.
Parameters
----------
max_columns : list[str]
Columns represented by their maximum value across cluster members.
min_columns : list[str]
Columns represented by their minimum value across cluster members.
"""
max_columns: list[str] = field(default_factory=list)
min_columns: list[str] = field(default_factory=list)
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary for JSON serialization."""
result: dict[str, Any] = {"type": "minmax_mean"}
if self.max_columns:
result["max_columns"] = self.max_columns
if self.min_columns:
result["min_columns"] = self.min_columns
return result
@classmethod
def from_dict(cls, data: dict) -> MinMaxMean:
"""Create from dictionary (e.g., loaded from JSON)."""
return cls(
max_columns=data.get("max_columns", []),
min_columns=data.get("min_columns", []),
)
# Union type for representation (strings remain valid for backward compat)
Representation = RepresentationMethod | Distribution | MinMaxMean
def _resolve_representation(rep: Representation) -> Representation:
"""Normalize a string representation shortcut to an object when needed.
Returns the input unchanged for objects and simple string methods
(mean, medoid, maxoid). Converts distribution/distribution_minmax/minmax_mean
strings to their corresponding objects.
"""
if isinstance(rep, (Distribution, MinMaxMean)):
return rep
if rep == "distribution":
return Distribution()
if rep == "distribution_minmax":
return Distribution(preserve_minmax=True)
if rep == "minmax_mean":
return MinMaxMean()
# Simple string methods: mean, medoid, maxoid
return rep
def _representation_to_dict(rep: Representation) -> str | dict[str, Any]:
"""Serialize a representation value to a JSON-compatible format."""
if isinstance(rep, (Distribution, MinMaxMean)):
return rep.to_dict()
return rep
def _representation_from_dict(data: str | dict) -> Representation:
"""Deserialize a representation value from a JSON-compatible format."""
if isinstance(data, str):
return data # type: ignore[return-value]
# It's a dict with a "type" key
rep_type = data.get("type")
if rep_type == "distribution":
return Distribution.from_dict(data)
if rep_type == "minmax_mean":
return MinMaxMean.from_dict(data)
raise ValueError(f"Unknown representation type: {rep_type!r}")
[docs]
@dataclass(frozen=True)
class ClusterConfig:
"""Configuration for the clustering algorithm.
Parameters
----------
method : str, default "hierarchical"
Clustering algorithm to use:
- "averaging": Sequential averaging of periods
- "kmeans": K-means clustering (fast, uses centroids)
- "kmedoids": K-medoids using MILP optimization (uses actual periods)
- "kmaxoids": K-maxoids (selects most dissimilar periods)
- "hierarchical": Agglomerative hierarchical clustering
- "contiguous": Hierarchical with temporal contiguity constraint
representation : str, Distribution, or MinMaxMean, optional
How to represent cluster centers. Accepts either a string shortcut
or a typed representation object for additional options:
String shortcuts:
- "mean": Centroid (average of cluster members)
- "medoid": Actual period closest to centroid
- "maxoid": Actual period most dissimilar to others
- "distribution": Preserve value distribution (duration curve)
- "distribution_minmax": Distribution + preserve min/max values
- "minmax_mean": Combine min/max/mean per timestep
Typed objects (for additional options):
- ``Distribution(scope="cluster"|"global", preserve_minmax=False)``:
Preserve value distribution. ``scope`` controls whether each
cluster's distribution is preserved separately ("cluster") or
the overall time series distribution ("global").
- ``MinMaxMean(max_columns=[...], min_columns=[...])``:
Combine min/max/mean per column. Columns not listed default to mean.
Default depends on method:
- "mean" for averaging, kmeans
- "medoid" for kmedoids, hierarchical, contiguous
- "maxoid" for kmaxoids
weights : dict[str, float], optional
Per-column weights for clustering distance calculation.
Higher weight = more influence on clustering.
Example: {"demand": 2.0, "solar": 1.0}
normalize_column_means : bool, default False
Normalize all columns to the same mean before clustering.
Useful when columns have very different scales.
use_duration_curves : bool, default False
Sort values within each period before clustering.
Matches periods by their value distribution rather than timing.
include_period_sums : bool, default False
Include period totals as additional features for clustering.
Helps preserve total energy/load values.
solver : str, default "highs"
MILP solver for kmedoids method.
Options: "highs" (default, open source), "cbc", "gurobi", "cplex"
"""
method: ClusterMethod = "hierarchical"
representation: Representation | None = None
weights: dict[str, float] | None = None
normalize_column_means: bool = False
use_duration_curves: bool = False
include_period_sums: bool = False
solver: Solver = "highs"
[docs]
def get_representation(self) -> Representation:
"""Get the representation, using default if not specified."""
if self.representation is not None:
return self.representation
# Default representation based on clustering method
defaults: dict[ClusterMethod, RepresentationMethod] = {
"averaging": "mean",
"kmeans": "mean",
"kmedoids": "medoid",
"kmaxoids": "maxoid",
"hierarchical": "medoid",
"contiguous": "medoid",
}
return defaults.get(self.method, "mean")
[docs]
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary for JSON serialization."""
result: dict[str, Any] = {"method": self.method}
if self.representation is not None:
result["representation"] = _representation_to_dict(self.representation)
if self.weights is not None:
result["weights"] = self.weights
if self.normalize_column_means:
result["normalize_column_means"] = self.normalize_column_means
if self.use_duration_curves:
result["use_duration_curves"] = self.use_duration_curves
if self.include_period_sums:
result["include_period_sums"] = self.include_period_sums
if self.solver != "highs":
result["solver"] = self.solver
return result
[docs]
@classmethod
def from_dict(cls, data: dict) -> ClusterConfig:
"""Create from dictionary (e.g., loaded from JSON)."""
rep_data = data.get("representation")
representation = (
_representation_from_dict(rep_data) if rep_data is not None else None
)
return cls(
method=data.get("method", "hierarchical"),
representation=representation,
weights=data.get("weights"),
normalize_column_means=data.get("normalize_column_means", False),
use_duration_curves=data.get("use_duration_curves", False),
include_period_sums=data.get("include_period_sums", False),
solver=data.get("solver", "highs"),
)
[docs]
@dataclass(frozen=True)
class SegmentConfig:
"""Configuration for temporal segmentation within periods.
Segmentation reduces the temporal resolution within each typical period,
grouping consecutive timesteps into segments.
Parameters
----------
n_segments : int
Number of segments per period.
Must be less than or equal to the number of timesteps per period.
Example: period_duration=24 with hourly data has 24 timesteps,
so n_segments could be 1-24.
representation : str, Distribution, or MinMaxMean, default "mean"
How to represent each segment:
- "mean": Average value of timesteps in segment
- "medoid": Actual timestep closest to segment mean
- "distribution": Preserve distribution within segment
- ``Distribution(...)``: Distribution with additional options
- ``MinMaxMean(...)``: Per-column min/max/mean
"""
n_segments: int
representation: Representation = "mean"
def __post_init__(self) -> None:
if self.n_segments < 1:
raise ValueError(f"n_segments must be positive, got {self.n_segments}")
# Note: Upper bound validation (n_segments <= timesteps_per_period)
# is performed in api.aggregate() when period_duration is known.
[docs]
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary for JSON serialization."""
result: dict[str, Any] = {"n_segments": self.n_segments}
if self.representation != "mean":
result["representation"] = _representation_to_dict(self.representation)
return result
[docs]
@classmethod
def from_dict(cls, data: dict) -> SegmentConfig:
"""Create from dictionary (e.g., loaded from JSON)."""
rep_data = data.get("representation", "mean")
return cls(
n_segments=data["n_segments"],
representation=_representation_from_dict(rep_data),
)
@dataclass(frozen=True)
class ClusteringResult:
"""Clustering assignments that can be saved/loaded and applied to new data.
This class bundles all clustering and segmentation assignments from an
aggregation, enabling:
- Simple IO via to_json()/from_json()
- Applying the same clustering to different datasets via apply()
- Preserving the parameters used to create the clustering
Get this from `result.clustering` after running an aggregation.
Transfer Fields (used by apply())
----------------------------------
period_duration : float
Length of each period in hours (e.g., 24 for daily periods).
cluster_assignments : tuple[int, ...]
Cluster assignments for each original period.
Length equals the number of original periods in the data.
n_timesteps_per_period : int
Number of timesteps in each period. Used to validate that new data
has compatible structure when calling apply().
cluster_centers : tuple[int, ...], optional
Indices of original periods used as cluster centers.
If not provided, centers will be recalculated when applying.
segment_assignments : tuple[tuple[int, ...], ...], optional
Segment assignments per timestep, per typical period.
Only present if segmentation was used.
segment_durations : tuple[tuple[int, ...], ...], optional
Duration (in timesteps) per segment, per typical period.
Required if segment_assignments is present.
segment_centers : tuple[tuple[int, ...], ...], optional
Indices of timesteps used as segment centers, per typical period.
Required for fully deterministic segment replication.
preserve_column_means : bool, default True
Whether to rescale typical periods to match original data means.
rescale_exclude_columns : tuple[str, ...], optional
Column names to exclude from rescaling. Useful for binary columns.
representation : str, default "medoid"
How to compute typical periods from cluster members.
segment_representation : str, optional
How to compute segment values. Only used if segmentation is present.
temporal_resolution : float, optional
Time resolution of input data in hours. If not provided, inferred.
Reference Fields (for documentation, not used by apply())
---------------------------------------------------------
cluster_config : ClusterConfig, optional
Clustering configuration used to create this result.
segment_config : SegmentConfig, optional
Segmentation configuration used to create this result.
extremes_config : ExtremeConfig, optional
Extreme period configuration used to create this result.
Examples
--------
>>> # Get clustering from a result
>>> result = tsam.aggregate(df_wind, n_clusters=8)
>>> clustering = result.clustering
>>> # Save to file
>>> clustering.to_json("clustering.json")
>>> # Load from file
>>> clustering = ClusteringResult.from_json("clustering.json")
>>> # Apply to new data
>>> result2 = clustering.apply(df_all)
"""
# === Transfer fields (used by apply()) ===
period_duration: float
cluster_assignments: tuple[int, ...]
n_timesteps_per_period: int
cluster_centers: tuple[int, ...] | None = None
segment_assignments: tuple[tuple[int, ...], ...] | None = None
segment_durations: tuple[tuple[int, ...], ...] | None = None
segment_centers: tuple[tuple[int, ...], ...] | None = None
preserve_column_means: bool = True
rescale_exclude_columns: tuple[str, ...] | None = None
representation: Representation = "medoid"
segment_representation: Representation | None = None
temporal_resolution: float | None = None
extreme_cluster_indices: tuple[int, ...] | None = None
# === Reference fields (for documentation, not used by apply()) ===
cluster_config: ClusterConfig | None = None
segment_config: SegmentConfig | None = None
extremes_config: ExtremeConfig | None = None
def __post_init__(self) -> None:
if self.segment_assignments is not None and self.segment_durations is None:
raise ValueError(
"segment_durations must be provided when segment_assignments is specified"
)
if self.segment_durations is not None and self.segment_assignments is None:
raise ValueError(
"segment_assignments must be provided when segment_durations is specified"
)
if self.segment_centers is not None and self.segment_assignments is None:
raise ValueError(
"segment_assignments must be provided when segment_centers is specified"
)
@property
def n_clusters(self) -> int:
"""Number of clusters (typical periods)."""
return len(set(self.cluster_assignments))
@property
def n_original_periods(self) -> int:
"""Number of original periods in the source data."""
return len(self.cluster_assignments)
@property
def n_segments(self) -> int | None:
"""Number of segments per period, or None if no segmentation."""
if self.segment_durations is None:
return None
return len(self.segment_durations[0])
def __repr__(self) -> str:
has_centers = self.cluster_centers is not None
has_segments = self.segment_assignments is not None
lines = [
"ClusteringResult(",
f" period_duration={self.period_duration},",
f" n_original_periods={self.n_original_periods},",
f" n_clusters={self.n_clusters},",
f" has_cluster_centers={has_centers},",
]
if has_segments:
n_segments = len(self.segment_durations[0]) if self.segment_durations else 0
n_timesteps = (
len(self.segment_assignments[0]) if self.segment_assignments else 0
)
has_seg_centers = self.segment_centers is not None
lines.append(f" n_segments={n_segments},")
lines.append(f" n_timesteps_per_period={n_timesteps},")
lines.append(f" has_segment_centers={has_seg_centers},")
lines.append(")")
return "\n".join(lines)
def to_dataframe(self) -> pd.DataFrame:
"""Convert to a readable DataFrame.
Returns a DataFrame with one row per original period showing
cluster assignments.
Returns
-------
pd.DataFrame
DataFrame with cluster_assignments indexed by original period.
"""
df = pd.DataFrame(
{"cluster": list(self.cluster_assignments)},
index=pd.RangeIndex(len(self.cluster_assignments), name="original_period"),
)
if self.cluster_centers is not None:
center_set = set(self.cluster_centers)
df["is_center"] = [
i in center_set for i in range(len(self.cluster_assignments))
]
return df
def segment_dataframe(self) -> pd.DataFrame | None:
"""Get segment structure as a readable DataFrame.
Returns a DataFrame showing segment durations per typical period.
Returns None if no segmentation is defined.
Returns
-------
pd.DataFrame | None
DataFrame with typical periods as rows and segments as columns,
values are segment durations in timesteps.
"""
if self.segment_durations is None:
return None
n_clusters = len(self.segment_durations)
n_segments = len(self.segment_durations[0])
return pd.DataFrame(
list(self.segment_durations),
index=pd.RangeIndex(n_clusters, name="cluster"),
columns=pd.RangeIndex(n_segments, name="segment"),
)
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary for JSON serialization."""
# Transfer fields (always included)
result: dict[str, Any] = {
"period_duration": self.period_duration,
"cluster_assignments": list(self.cluster_assignments),
"n_timesteps_per_period": self.n_timesteps_per_period,
"preserve_column_means": self.preserve_column_means,
"representation": _representation_to_dict(self.representation),
}
if self.cluster_centers is not None:
result["cluster_centers"] = list(self.cluster_centers)
if self.segment_assignments is not None:
result["segment_assignments"] = [list(s) for s in self.segment_assignments]
if self.segment_durations is not None:
result["segment_durations"] = [list(s) for s in self.segment_durations]
if self.segment_centers is not None:
result["segment_centers"] = [list(s) for s in self.segment_centers]
if self.rescale_exclude_columns is not None:
result["rescale_exclude_columns"] = list(self.rescale_exclude_columns)
if self.segment_representation is not None:
result["segment_representation"] = _representation_to_dict(
self.segment_representation
)
if self.temporal_resolution is not None:
result["temporal_resolution"] = self.temporal_resolution
if self.extreme_cluster_indices is not None:
result["extreme_cluster_indices"] = list(self.extreme_cluster_indices)
# Reference fields (optional, for documentation)
if self.cluster_config is not None:
result["cluster_config"] = self.cluster_config.to_dict()
if self.segment_config is not None:
result["segment_config"] = self.segment_config.to_dict()
if self.extremes_config is not None:
result["extremes_config"] = self.extremes_config.to_dict()
return result
@classmethod
def from_dict(cls, data: dict) -> ClusteringResult:
"""Create from dictionary (e.g., loaded from JSON)."""
# Transfer fields
rep_data = data.get("representation", "medoid")
seg_rep_data = data.get("segment_representation")
kwargs: dict[str, Any] = {
"period_duration": data["period_duration"],
"cluster_assignments": tuple(data["cluster_assignments"]),
"n_timesteps_per_period": data["n_timesteps_per_period"],
"preserve_column_means": data.get("preserve_column_means", True),
"representation": _representation_from_dict(rep_data),
}
if "cluster_centers" in data:
kwargs["cluster_centers"] = tuple(data["cluster_centers"])
if "segment_assignments" in data:
kwargs["segment_assignments"] = tuple(
tuple(s) for s in data["segment_assignments"]
)
if "segment_durations" in data:
kwargs["segment_durations"] = tuple(
tuple(s) for s in data["segment_durations"]
)
if "segment_centers" in data:
kwargs["segment_centers"] = tuple(tuple(s) for s in data["segment_centers"])
if "rescale_exclude_columns" in data:
kwargs["rescale_exclude_columns"] = tuple(data["rescale_exclude_columns"])
if seg_rep_data is not None:
kwargs["segment_representation"] = _representation_from_dict(seg_rep_data)
if "temporal_resolution" in data:
kwargs["temporal_resolution"] = data["temporal_resolution"]
if "extreme_cluster_indices" in data:
kwargs["extreme_cluster_indices"] = tuple(data["extreme_cluster_indices"])
# Reference fields
if "cluster_config" in data:
kwargs["cluster_config"] = ClusterConfig.from_dict(data["cluster_config"])
if "segment_config" in data:
kwargs["segment_config"] = SegmentConfig.from_dict(data["segment_config"])
if "extremes_config" in data:
kwargs["extremes_config"] = ExtremeConfig.from_dict(data["extremes_config"])
return cls(**kwargs)
def to_json(self, path: str) -> None:
"""Save clustering result to a JSON file.
Parameters
----------
path : str
File path to save to.
Notes
-----
If the clustering used the 'replace' extreme method, a warning will be
issued because the saved clustering cannot be perfectly reproduced when
loaded and applied later. See :meth:`apply` for details.
Examples
--------
>>> result.clustering.to_json("clustering.json")
"""
import json
# Warn if using replace extreme method (transfer is not exact)
if (
self.extremes_config is not None
and self.extremes_config.method == "replace"
):
warnings.warn(
"Saving a clustering that used the 'replace' extreme method. "
"The 'replace' method creates a hybrid cluster representation "
"(some columns from the medoid, some from the extreme period) that "
"cannot be perfectly reproduced when loaded and applied later. "
"For exact transfer, use 'append' or 'new_cluster' extreme methods.",
UserWarning,
stacklevel=2,
)
with open(path, "w") as f:
json.dump(self.to_dict(), f, indent=2)
@classmethod
def from_json(cls, path: str) -> ClusteringResult:
"""Load clustering result from a JSON file.
Parameters
----------
path : str
File path to load from.
Returns
-------
ClusteringResult
Loaded clustering result.
Examples
--------
>>> clustering = ClusteringResult.from_json("clustering.json")
>>> result = clustering.apply(new_data)
"""
import json
with open(path) as f:
return cls.from_dict(json.load(f))
def apply(
self,
data: pd.DataFrame,
*,
temporal_resolution: float | None = None,
round_decimals: int | None = None,
numerical_tolerance: float = 1e-13,
) -> AggregationResult:
"""Apply this clustering to new data.
Uses the stored cluster assignments and transfer fields to aggregate
a different dataset with the same clustering structure deterministically.
Parameters
----------
data : pd.DataFrame
Input time series data with a datetime index.
Must have the same number of periods as the original data.
temporal_resolution : float, optional
Time resolution of input data in hours.
If not provided, uses stored temporal_resolution or infers from data index.
round_decimals : int, optional
Round output values to this many decimal places.
numerical_tolerance : float, default 1e-13
Tolerance for numerical precision issues.
Returns
-------
AggregationResult
Aggregation result using this clustering.
Notes
-----
**Extreme period transfer limitations:**
The 'replace' extreme method creates a hybrid cluster representation where
some columns use the medoid values and others use the extreme period values.
This hybrid representation cannot be perfectly reproduced during transfer.
When applying a clustering that used 'replace', a warning will be issued
and the transferred result will use the medoid representation for all columns.
For exact transfer with extreme periods, use 'append' or 'new_cluster'
extreme methods instead.
Examples
--------
>>> # Cluster on wind data, apply to full dataset
>>> result_wind = tsam.aggregate(df_wind, n_clusters=8)
>>> result_all = result_wind.clustering.apply(df_all)
>>> # Load saved clustering and apply
>>> clustering = ClusteringResult.from_json("clustering.json")
>>> result = clustering.apply(df)
"""
# Import here to avoid circular imports
from tsam.api import _build_old_params
from tsam.exceptions import LegacyAPIWarning
from tsam.result import AccuracyMetrics, AggregationResult
from tsam.timeseriesaggregation import TimeSeriesAggregation
# Warn if using replace extreme method (transfer is not exact)
if (
self.extremes_config is not None
and self.extremes_config.method == "replace"
):
warnings.warn(
"The 'replace' extreme method creates a hybrid cluster representation "
"(some columns from the cluster representative, some from the extreme period) "
"that cannot be perfectly reproduced during transfer. The transferred result "
"will use the stored cluster center periods directly, without the extreme "
"value injection that was applied during the original aggregation. "
"For exact transfer, use 'append' or 'new_cluster' extreme methods.",
UserWarning,
stacklevel=2,
)
# Use stored temporal_resolution if not provided
effective_temporal_resolution = (
temporal_resolution
if temporal_resolution is not None
else self.temporal_resolution
)
# Validate n_timesteps_per_period matches data
# Infer timestep duration from data if not provided
if effective_temporal_resolution is None:
if isinstance(data.index, pd.DatetimeIndex) and len(data.index) > 1:
inferred = (data.index[1] - data.index[0]).total_seconds() / 3600
else:
inferred = 1.0 # Default to hourly
else:
inferred = effective_temporal_resolution
inferred_timesteps = int(self.period_duration / inferred)
if inferred_timesteps != self.n_timesteps_per_period:
raise ValueError(
f"Data has {inferred_timesteps} timesteps per period "
f"(period_duration={self.period_duration}h, timestep={inferred}h), "
f"but clustering expects {self.n_timesteps_per_period} timesteps per period"
)
# Validate number of periods matches
n_periods_in_data = len(data) // self.n_timesteps_per_period
if n_periods_in_data != self.n_original_periods:
raise ValueError(
f"Data has {n_periods_in_data} periods, "
f"but clustering expects {self.n_original_periods} periods"
)
# Build minimal ClusterConfig with just the representation.
# We intentionally ignore stored cluster_config.weights since:
# 1. Weights were only used to compute the original assignments
# 2. Assignments are now fixed, so weights are irrelevant
# 3. New data may have different columns than the original
cluster = ClusterConfig(representation=self.representation)
# Use stored segment config if available, otherwise build from transfer fields
segments: SegmentConfig | None = None
n_segments: int | None = None
if self.segment_assignments is not None and self.segment_durations is not None:
n_segments = len(self.segment_durations[0])
segments = self.segment_config or SegmentConfig(
n_segments=n_segments,
representation=self.segment_representation or "mean",
)
# Build old API parameters, passing predefined values directly
# Note: Don't pass extremes config - extreme clusters are handled via
# extreme_cluster_indices and representations are computed from
# the periods assigned to those clusters in cluster_assignments
old_params = _build_old_params(
data=data,
n_clusters=self.n_clusters,
period_duration=self.period_duration,
temporal_resolution=effective_temporal_resolution,
cluster=cluster,
segments=segments,
extremes=None,
preserve_column_means=self.preserve_column_means,
rescale_exclude_columns=list(self.rescale_exclude_columns)
if self.rescale_exclude_columns
else None,
round_decimals=round_decimals,
numerical_tolerance=numerical_tolerance,
# Predefined values from this ClusteringResult
predef_cluster_assignments=self.cluster_assignments,
predef_cluster_centers=self.cluster_centers,
predef_extreme_cluster_indices=self.extreme_cluster_indices,
predef_segment_assignments=self.segment_assignments,
predef_segment_durations=self.segment_durations,
predef_segment_centers=self.segment_centers,
)
# Run aggregation using old implementation (suppress deprecation warning)
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 - preserve stored values
from tsam.api import _build_clustering_result
clustering_result = _build_clustering_result(
agg=agg,
n_segments=n_segments,
cluster_config=cluster,
segment_config=segments,
extremes_config=self.extremes_config,
preserve_column_means=self.preserve_column_means,
rescale_exclude_columns=list(self.rescale_exclude_columns)
if self.rescale_exclude_columns
else None,
temporal_resolution=effective_temporal_resolution,
)
# Build result object
return AggregationResult(
cluster_representatives=cluster_representatives,
cluster_weights=dict(agg.clusterPeriodNoOccur),
n_timesteps_per_period=agg.timeStepsPerPeriod,
segment_durations=self.segment_durations,
accuracy=accuracy,
clustering_duration=getattr(agg, "clusteringDuration", 0.0),
clustering=clustering_result,
is_transferred=True,
_aggregation=agg,
)
[docs]
@dataclass(frozen=True)
class ExtremeConfig:
"""Configuration for preserving extreme periods.
Extreme periods contain critical peak values that must be preserved
in the aggregated representation (e.g., peak demand for capacity sizing).
Parameters
----------
method : str, default "append"
How to handle extreme periods:
- "append": Add extreme periods as additional cluster centers
- "replace": Replace the nearest cluster center with the extreme
- "new_cluster": Add as new cluster and reassign affected periods
max_value : list[str], optional
Column names where the maximum value should be preserved.
The entire period containing that single extreme value becomes an extreme period.
Example: ["electricity_demand"] to preserve peak demand hour.
min_value : list[str], optional
Column names where the minimum value should be preserved.
Example: ["temperature"] to preserve coldest hour.
max_period : list[str], optional
Column names where the period with maximum total should be preserved.
Example: ["solar_generation"] to preserve highest solar day.
min_period : list[str], optional
Column names where the period with minimum total should be preserved.
Example: ["wind_generation"] to preserve lowest wind day.
"""
method: ExtremeMethod = "append"
max_value: list[str] = field(default_factory=list)
min_value: list[str] = field(default_factory=list)
max_period: list[str] = field(default_factory=list)
min_period: list[str] = field(default_factory=list)
[docs]
def has_extremes(self) -> bool:
"""Check if any extreme periods are configured."""
return bool(
self.max_value or self.min_value or self.max_period or self.min_period
)
[docs]
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary for JSON serialization."""
result: dict[str, Any] = {}
if self.method != "append":
result["method"] = self.method
if self.max_value:
result["max_value"] = self.max_value
if self.min_value:
result["min_value"] = self.min_value
if self.max_period:
result["max_period"] = self.max_period
if self.min_period:
result["min_period"] = self.min_period
return result
[docs]
@classmethod
def from_dict(cls, data: dict) -> ExtremeConfig:
"""Create from dictionary (e.g., loaded from JSON)."""
return cls(
method=data.get("method", "append"),
max_value=data.get("max_value", []),
min_value=data.get("min_value", []),
max_period=data.get("max_period", []),
min_period=data.get("min_period", []),
)
# Mapping from new API names to old API names
METHOD_MAPPING: dict[ClusterMethod, str] = {
"averaging": "averaging",
"kmeans": "k_means",
"kmedoids": "k_medoids",
"kmaxoids": "k_maxoids",
"hierarchical": "hierarchical",
"contiguous": "adjacent_periods",
}
REPRESENTATION_MAPPING: dict[RepresentationMethod, str] = {
"mean": "meanRepresentation",
"medoid": "medoidRepresentation",
"maxoid": "maxoidRepresentation",
"distribution": "distributionRepresentation",
"distribution_minmax": "distributionAndMinMaxRepresentation",
"minmax_mean": "minmaxmeanRepresentation",
}
EXTREME_METHOD_MAPPING: dict[ExtremeMethod, str] = {
"append": "append",
"replace": "replace_cluster_center",
"new_cluster": "new_cluster_center",
}