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):