diff --git a/docs/data-model.md b/docs/data-model.md index afd6739..110a416 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -32,7 +32,7 @@ graph LR D --- F1[".cluster_representatives
cluster, timestep, *cluster_dims, *slice_dims"] D --- F2[".reconstructed
same shape as input"] D --- F3[".cluster_assignments
period, *slice_dims"] - D --- F4[".cluster_weights
cluster, *slice_dims"] + D --- F4[".cluster_counts
cluster, *slice_dims"] D --- F5[".segment_durations
cluster, timestep, *slice_dims | None"] Meta --- A[".accuracy
→ AccuracyMetrics"] diff --git a/docs/examples/getting-started.ipynb b/docs/examples/getting-started.ipynb index 860cd66..549e938 100644 --- a/docs/examples/getting-started.ipynb +++ b/docs/examples/getting-started.ipynb @@ -108,7 +108,7 @@ "print(f\"Clusters: {result.n_clusters}\")\n", "print(f\"Timesteps per period: {result.n_timesteps_per_period}\")\n", "print(\"Cluster weights (days each represents):\")\n", - "result.cluster_weights.to_dataframe(\"weight\")" + "result.cluster_counts.to_dataframe(\"weight\")" ] }, { diff --git a/docs/examples/multi-dim.ipynb b/docs/examples/multi-dim.ipynb index 37d95c1..52113c3 100644 --- a/docs/examples/multi-dim.ipynb +++ b/docs/examples/multi-dim.ipynb @@ -98,7 +98,7 @@ " da_slice = da.sel(scenario=scenario)\n", " df = ... # flatten to DataFrame\n", " results[scenario] = tsam.aggregate(df, n_clusters=4)\n", - "# Then manually concat cluster_weights, accuracy, cluster_representatives...\n", + "# Then manually concat cluster_counts, accuracy, cluster_representatives...\n", "```" ] }, @@ -115,7 +115,7 @@ " n_clusters=4,\n", ")\n", "print(\"Result dims:\", result_sliced.cluster_representatives.dims)\n", - "result_sliced.cluster_weights.to_dataframe(\"weight\")" + "result_sliced.cluster_counts.to_dataframe(\"weight\")" ] }, { diff --git a/docs/index.md b/docs/index.md index 7e74e23..46c0ddf 100644 --- a/docs/index.md +++ b/docs/index.md @@ -21,7 +21,7 @@ result = tsam_xarray.aggregate( ) result.cluster_representatives # (cluster, timestep, variable, region) -result.cluster_weights # (cluster,) +result.cluster_counts # (cluster,) result.accuracy.rmse # (variable, region) result.reconstructed # same shape as input ``` diff --git a/src/tsam_xarray/_clustering.py b/src/tsam_xarray/_clustering.py index 98eb793..c391992 100644 --- a/src/tsam_xarray/_clustering.py +++ b/src/tsam_xarray/_clustering.py @@ -634,6 +634,7 @@ def _apply_single( import pandas as pd from tsam_xarray._core import ( + _cluster_counts, _metric_to_da, _reconstructed_to_da, _representatives_to_da, @@ -651,9 +652,9 @@ def _apply_single( ) reconstructed = _reconstructed_to_da(tsam_result.reconstructed, time_dim, col_dims) - cw = tsam_result.cluster_weights + cw = _cluster_counts(tsam_result) cluster_ids = np.array(sorted(cw.keys())) - cluster_weights_da = xr.DataArray( + cluster_counts_da = xr.DataArray( np.array([cw[k] for k in cluster_ids]), dims=[dim_names.cluster], coords={dim_names.cluster: cluster_ids}, @@ -693,7 +694,7 @@ def _apply_single( return AggregationResult( cluster_representatives=typical, cluster_assignments=assignments_da, - cluster_weights=cluster_weights_da, + cluster_counts=cluster_counts_da, segment_durations=seg_durations, accuracy=accuracy, reconstructed=reconstructed, diff --git a/src/tsam_xarray/_core.py b/src/tsam_xarray/_core.py index 0ab25d1..f6f25aa 100644 --- a/src/tsam_xarray/_core.py +++ b/src/tsam_xarray/_core.py @@ -5,7 +5,7 @@ import itertools import warnings from collections.abc import Hashable, Sequence -from typing import Any +from typing import Any, cast import numpy as np import pandas as pd @@ -19,6 +19,20 @@ ClusterOn = str | Sequence[str] | dict[str, Sequence[str]] | None +def _cluster_counts(tsam_result: Any) -> dict[int, float]: + """Per-cluster occurrence counts from a tsam result, across tsam versions. + + tsam v4 renamed ``AggregationResult.cluster_weights`` to ``cluster_counts`` + (the values were always occurrence counts, never weights). The v3 name is a + deprecated alias there and the only name on v3.4.x, so read the new name when + present and fall back to the old one. + """ + counts = getattr(tsam_result, "cluster_counts", None) + if counts is None: + counts = tsam_result.cluster_weights + return cast("dict[int, float]", counts) + + def aggregate( da: xr.DataArray, *, @@ -178,9 +192,18 @@ def _validate_time_dim(da: xr.DataArray, time_dim: str) -> None: def _validate_no_cluster_config_weights( tsam_kwargs: dict[str, Any], ) -> None: - """Reject deprecated weights in ClusterConfig.""" + """Reject deprecated weights in ClusterConfig. + + ``ClusterConfig.weights`` was a deprecated v3 field; tsam v4 removed it + entirely (constructing ``ClusterConfig(weights=...)`` raises ``TypeError`` + before reaching here). ``getattr`` keeps this a no-op on v4, where the + field no longer exists. + """ cluster_config = tsam_kwargs.get("cluster") - if cluster_config is not None and cluster_config.weights is not None: + if ( + cluster_config is not None + and getattr(cluster_config, "weights", None) is not None + ): msg = ( "ClusterConfig.weights is deprecated in tsam and not " "supported by tsam_xarray. Use the top-level 'weights' " @@ -704,9 +727,9 @@ def _result_from_tsam( reconstructed = _reconstructed_to_da(tsam_result.reconstructed, time_dim, col_dims) reconstructed = reconstructed.transpose(*da.dims).reindex_like(da) - cw = tsam_result.cluster_weights + cw = _cluster_counts(tsam_result) cluster_ids = np.array(sorted(cw.keys())) - cluster_weights_da = xr.DataArray( + cluster_counts_da = xr.DataArray( np.array([cw[k] for k in cluster_ids]), dims=[dim_names.cluster], coords={dim_names.cluster: cluster_ids}, @@ -748,7 +771,7 @@ def _result_from_tsam( return AggregationResult( cluster_representatives=typical, cluster_assignments=assignments_da, - cluster_weights=cluster_weights_da, + cluster_counts=cluster_counts_da, segment_durations=seg_durations, accuracy=accuracy, reconstructed=reconstructed, @@ -835,7 +858,7 @@ def _acc_field(field_name: str) -> xr.DataArray: return AggregationResult( cluster_representatives=_field("cluster_representatives"), cluster_assignments=_field("cluster_assignments"), - cluster_weights=_field("cluster_weights"), + cluster_counts=_field("cluster_counts"), segment_durations=_optional_field("segment_durations"), accuracy=AccuracyMetrics( rmse=_acc_field("rmse"), diff --git a/src/tsam_xarray/_result.py b/src/tsam_xarray/_result.py index 9801c27..fc2523a 100644 --- a/src/tsam_xarray/_result.py +++ b/src/tsam_xarray/_result.py @@ -2,6 +2,7 @@ from __future__ import annotations +import warnings from dataclasses import dataclass from typing import TYPE_CHECKING @@ -66,8 +67,10 @@ class AggregationResult: *slice_dims)``. cluster_assignments: Which cluster each period belongs to. Dims: ``(period, *slice_dims)``. - cluster_weights: Periods per cluster. - Dims: ``(cluster, *slice_dims)``. + cluster_counts: Periods per cluster. + Dims: ``(cluster, *slice_dims)``. Formerly + ``cluster_weights``, which remains as a deprecated + alias (following tsam v4's rename). segment_durations: Duration of each segment, or ``None``. Dims: ``(cluster, timestep, *slice_dims)``. @@ -83,7 +86,7 @@ class AggregationResult: cluster_representatives: xr.DataArray cluster_assignments: xr.DataArray - cluster_weights: xr.DataArray + cluster_counts: xr.DataArray segment_durations: xr.DataArray | None accuracy: AccuracyMetrics reconstructed: xr.DataArray @@ -109,10 +112,25 @@ def dim_names(self) -> DimNames: """Names of the structural output dimensions. See `DimNames`.""" return self.clustering.dim_names + @property + def cluster_weights(self) -> xr.DataArray: + """Deprecated alias for `cluster_counts`. + + Renamed to match tsam v4, where the values are occurrence counts + rather than weights. Will be removed in a future release. + """ + warnings.warn( + "AggregationResult.cluster_weights is deprecated; use " + "cluster_counts instead.", + FutureWarning, + stacklevel=2, + ) + return self.cluster_counts + @property def n_clusters(self) -> int: """Number of cluster representative clusters.""" - return int(self.cluster_weights.sizes[self.dim_names.cluster]) + return int(self.cluster_counts.sizes[self.dim_names.cluster]) @property def n_timesteps_per_period(self) -> int: @@ -249,7 +267,7 @@ def _make_slice_view(self, sel: dict[str, object]) -> AggregationResult: return AggregationResult( cluster_representatives=self.cluster_representatives.sel(sel), cluster_assignments=self.cluster_assignments.sel(sel), - cluster_weights=self.cluster_weights.sel(sel), + cluster_counts=self.cluster_counts.sel(sel), segment_durations=( self.segment_durations.sel(sel) if self.segment_durations is not None diff --git a/test/test_aggregate.py b/test/test_aggregate.py index 21581b4..bf912b4 100644 --- a/test/test_aggregate.py +++ b/test/test_aggregate.py @@ -57,7 +57,7 @@ def test_dims_and_shapes(self): assert result.cluster_representatives.sizes["cluster"] == 4 assert result.cluster_representatives.sizes["timestep"] == 24 - def test_cluster_weights_sum(self): + def test_cluster_counts_sum(self): da = _make_da() result = tsam_xarray.aggregate( da, @@ -65,7 +65,19 @@ def test_cluster_weights_sum(self): time_dim="time", cluster_dim=["variable", "region"], ) - assert int(result.cluster_weights.sum()) == 30 + assert int(result.cluster_counts.sum()) == 30 + + def test_cluster_weights_deprecated_alias(self): + da = _make_da() + result = tsam_xarray.aggregate( + da, + n_clusters=4, + time_dim="time", + cluster_dim=["variable", "region"], + ) + with pytest.warns(FutureWarning, match="cluster_counts"): + alias = result.cluster_weights + xr.testing.assert_identical(alias, result.cluster_counts) def test_cluster_assignments_shape(self): da = _make_da() @@ -587,12 +599,17 @@ def test_cluster_dim_overlaps_time(self): @pytest.mark.filterwarnings("ignore::DeprecationWarning") def test_cluster_config_weights_rejected(self): - """ClusterConfig.weights is deprecated and not supported.""" + """ClusterConfig.weights is deprecated and not supported. + + On tsam v3 the wrapper rejects it with a ValueError pointing to the + top-level ``weights``; on tsam v4 the field is gone and ``ClusterConfig`` + itself raises ``TypeError`` at construction. Either way it must fail. + """ from tsam import ClusterConfig da = _make_da() da_flat = da.isel(region=0).drop_vars("region") - with pytest.raises(ValueError, match=r"ClusterConfig\.weights"): + with pytest.raises((ValueError, TypeError), match=r"weights"): tsam_xarray.aggregate( da_flat, n_clusters=4, @@ -896,7 +913,7 @@ def test_custom_names_applied(self): ) assert set(result.cluster_representatives.dims) >= {"rep", "intra"} assert "orig_period" in result.cluster_assignments.dims - assert set(result.cluster_weights.dims) == {"rep", "region"} + assert set(result.cluster_counts.dims) == {"rep", "region"} assert result.n_clusters == 4 assert result.n_timesteps_per_period == 24 diff --git a/test/test_parametrized.py b/test/test_parametrized.py index 4e6f8b2..dc0c2f9 100644 --- a/test/test_parametrized.py +++ b/test/test_parametrized.py @@ -76,23 +76,23 @@ def test_all_clusters_used(self, agg_case: AggregateCase): assert len(unique) == agg_case.n_clusters # With slicing, each slice should use all clusters - def test_cluster_weights_sum(self, agg_case: AggregateCase): + def test_cluster_counts_sum(self, agg_case: AggregateCase): result = _aggregate(agg_case) if agg_case.expected_slice_dims: for key in np.ndindex( - *[result.cluster_weights.sizes[d] for d in agg_case.expected_slice_dims] + *[result.cluster_counts.sizes[d] for d in agg_case.expected_slice_dims] ): sel = { - d: result.cluster_weights.coords[d].values[i] + d: result.cluster_counts.coords[d].values[i] for d, i in zip( agg_case.expected_slice_dims, key, strict=True, ) } - assert int(result.cluster_weights.sel(sel).sum()) == agg_case.n_periods + assert int(result.cluster_counts.sel(sel).sum()) == agg_case.n_periods else: - assert int(result.cluster_weights.sum()) == agg_case.n_periods + assert int(result.cluster_counts.sum()) == agg_case.n_periods class TestAccuracyMetrics: @@ -300,11 +300,11 @@ def test_cluster_occurrences_dims(self, agg_case: AggregateCase): assert set(da.dims) == {"cluster"} | agg_case.expected_slice_dims def test_cluster_occurrences_matches_weights(self, agg_case: AggregateCase): - """Occurrences match AggregationResult.cluster_weights.""" + """Occurrences match AggregationResult.cluster_counts.""" result = _aggregate(agg_case) np.testing.assert_array_equal( result.clustering.cluster_occurrences.values, - result.cluster_weights.values, + result.cluster_counts.values, ) def test_cluster_occurrences_sum(self, agg_case: AggregateCase):