Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ graph LR
D --- F1[".cluster_representatives<br/><i>cluster, timestep, *cluster_dims, *slice_dims</i>"]
D --- F2[".reconstructed<br/><i>same shape as input</i>"]
D --- F3[".cluster_assignments<br/><i>period, *slice_dims</i>"]
D --- F4[".cluster_weights<br/><i>cluster, *slice_dims</i>"]
D --- F4[".cluster_counts<br/><i>cluster, *slice_dims</i>"]
D --- F5[".segment_durations<br/><i>cluster, timestep, *slice_dims | None</i>"]

Meta --- A[".accuracy<br/><b>→ AccuracyMetrics</b>"]
Expand Down
2 changes: 1 addition & 1 deletion docs/examples/getting-started.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -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\")"
Comment on lines 110 to +111

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Finish the cluster_weightscluster_counts terminology migration. The changed code uses counts, but user-facing notebook output and a test name still expose the deprecated “weights” wording.

  • docs/examples/getting-started.ipynb#L110-L111: rename the label and .to_dataframe() column from weight to count or cluster_counts.
  • docs/examples/multi-dim.ipynb#L118-L118: rename the .to_dataframe("weight") column.
  • test/test_parametrized.py#L302-L307: rename test_cluster_occurrences_matches_weights to a count-oriented name.
📍 Affects 3 files
  • docs/examples/getting-started.ipynb#L110-L111 (this comment)
  • docs/examples/multi-dim.ipynb#L118-L118
  • test/test_parametrized.py#L302-L307
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/examples/getting-started.ipynb` around lines 110 - 111, Complete the
cluster_weights-to-cluster_counts terminology migration: in
docs/examples/getting-started.ipynb lines 110-111, rename the displayed
“weights” label and the to_dataframe column to “count” or “cluster_counts”; in
docs/examples/multi-dim.ipynb line 118, rename the “weight” column similarly;
and in test/test_parametrized.py lines 302-307, rename
test_cluster_occurrences_matches_weights to a count-oriented name.

]
},
{
Expand Down
4 changes: 2 additions & 2 deletions docs/examples/multi-dim.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
"```"
]
},
Expand All @@ -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\")"
]
},
{
Expand Down
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down
7 changes: 4 additions & 3 deletions src/tsam_xarray/_clustering.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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},
Expand Down Expand Up @@ -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,
Expand Down
37 changes: 30 additions & 7 deletions src/tsam_xarray/_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
*,
Expand Down Expand Up @@ -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' "
Expand Down Expand Up @@ -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},
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"),
Expand Down
28 changes: 23 additions & 5 deletions src/tsam_xarray/_result.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import warnings
from dataclasses import dataclass
from typing import TYPE_CHECKING

Expand Down Expand Up @@ -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)``.
Expand All @@ -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
Expand All @@ -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])
Comment on lines +115 to +133

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Complete the changed docstrings with the required Google-style sections. The same documentation-contract gap appears across the changed source files.

  • src/tsam_xarray/_result.py#L115-L133: add Returns: sections for cluster_weights and n_clusters.
  • src/tsam_xarray/_core.py#L22-L33: add Args: and Returns: for _cluster_counts.
  • src/tsam_xarray/_core.py#L195-L201: add Args: and Raises: for _validate_no_cluster_config_weights.

As per coding guidelines, src/**/*.py docstrings must use Google-style sections where applicable.

📍 Affects 2 files
  • src/tsam_xarray/_result.py#L115-L133 (this comment)
  • src/tsam_xarray/_core.py#L22-L33
  • src/tsam_xarray/_core.py#L195-L201
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/tsam_xarray/_result.py` around lines 115 - 133, Complete the Google-style
docstrings at src/tsam_xarray/_result.py lines 115-133 by adding Returns
sections for cluster_weights and n_clusters; at src/tsam_xarray/_core.py lines
22-33, add Args and Returns sections for _cluster_counts; and at
src/tsam_xarray/_core.py lines 195-201, add Args and Raises sections for
_validate_no_cluster_config_weights, documenting each parameter, return value,
and raised exception accurately.

Source: Coding guidelines


@property
def n_timesteps_per_period(self) -> int:
Expand Down Expand Up @@ -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
Expand Down
27 changes: 22 additions & 5 deletions test/test_aggregate.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,15 +57,27 @@ 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,
n_clusters=4,
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()
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand Down
14 changes: 7 additions & 7 deletions test/test_parametrized.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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):
Expand Down
Loading