tsam-xarray 0.5.2__tar.gz → 0.6.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. {tsam_xarray-0.5.2 → tsam_xarray-0.6.1}/.github/workflows/dependabot-auto-merge.yaml +1 -1
  2. {tsam_xarray-0.5.2 → tsam_xarray-0.6.1}/.github/workflows/release.yaml +1 -1
  3. tsam_xarray-0.6.1/.release-please-manifest.json +3 -0
  4. {tsam_xarray-0.5.2 → tsam_xarray-0.6.1}/CHANGELOG.md +27 -0
  5. {tsam_xarray-0.5.2 → tsam_xarray-0.6.1}/PKG-INFO +2 -2
  6. {tsam_xarray-0.5.2 → tsam_xarray-0.6.1}/docs/examples/multi-dim.ipynb +39 -1
  7. {tsam_xarray-0.5.2 → tsam_xarray-0.6.1}/pyproject.toml +1 -1
  8. {tsam_xarray-0.5.2 → tsam_xarray-0.6.1}/src/tsam_xarray/_clustering.py +31 -64
  9. {tsam_xarray-0.5.2 → tsam_xarray-0.6.1}/src/tsam_xarray/_core.py +264 -28
  10. {tsam_xarray-0.5.2 → tsam_xarray-0.6.1}/src/tsam_xarray/_result.py +1 -4
  11. {tsam_xarray-0.5.2 → tsam_xarray-0.6.1}/src/tsam_xarray/_version.py +2 -2
  12. {tsam_xarray-0.5.2 → tsam_xarray-0.6.1}/test/test_aggregate.py +172 -73
  13. {tsam_xarray-0.5.2 → tsam_xarray-0.6.1}/test/test_parametrized.py +0 -58
  14. tsam_xarray-0.5.2/.release-please-manifest.json +0 -3
  15. {tsam_xarray-0.5.2 → tsam_xarray-0.6.1}/.github/dependabot.yml +0 -0
  16. {tsam_xarray-0.5.2 → tsam_xarray-0.6.1}/.github/workflows/ci.yaml +0 -0
  17. {tsam_xarray-0.5.2 → tsam_xarray-0.6.1}/.github/workflows/pr-title.yaml +0 -0
  18. {tsam_xarray-0.5.2 → tsam_xarray-0.6.1}/.github/workflows/publish.yaml +0 -0
  19. {tsam_xarray-0.5.2 → tsam_xarray-0.6.1}/.gitignore +0 -0
  20. {tsam_xarray-0.5.2 → tsam_xarray-0.6.1}/.pre-commit-config.yaml +0 -0
  21. {tsam_xarray-0.5.2 → tsam_xarray-0.6.1}/.readthedocs.yaml +0 -0
  22. {tsam_xarray-0.5.2 → tsam_xarray-0.6.1}/.release-please-config.json +0 -0
  23. {tsam_xarray-0.5.2 → tsam_xarray-0.6.1}/CLAUDE.md +0 -0
  24. {tsam_xarray-0.5.2 → tsam_xarray-0.6.1}/LICENSE +0 -0
  25. {tsam_xarray-0.5.2 → tsam_xarray-0.6.1}/README.md +0 -0
  26. {tsam_xarray-0.5.2 → tsam_xarray-0.6.1}/codecov.yml +0 -0
  27. {tsam_xarray-0.5.2 → tsam_xarray-0.6.1}/docs/assets/multi-dim-input.png +0 -0
  28. {tsam_xarray-0.5.2 → tsam_xarray-0.6.1}/docs/assets/multi-dim-metrics.png +0 -0
  29. {tsam_xarray-0.5.2 → tsam_xarray-0.6.1}/docs/changelog.md +0 -0
  30. {tsam_xarray-0.5.2 → tsam_xarray-0.6.1}/docs/data-model.md +0 -0
  31. {tsam_xarray-0.5.2 → tsam_xarray-0.6.1}/docs/examples/clustering-io.ipynb +0 -0
  32. {tsam_xarray-0.5.2 → tsam_xarray-0.6.1}/docs/examples/getting-started.ipynb +0 -0
  33. {tsam_xarray-0.5.2 → tsam_xarray-0.6.1}/docs/examples/segmentation.ipynb +0 -0
  34. {tsam_xarray-0.5.2 → tsam_xarray-0.6.1}/docs/examples/tuning.ipynb +0 -0
  35. {tsam_xarray-0.5.2 → tsam_xarray-0.6.1}/docs/gen_ref_pages.py +0 -0
  36. {tsam_xarray-0.5.2 → tsam_xarray-0.6.1}/docs/index.md +0 -0
  37. {tsam_xarray-0.5.2 → tsam_xarray-0.6.1}/docs/stylesheets/extra.css +0 -0
  38. {tsam_xarray-0.5.2 → tsam_xarray-0.6.1}/mkdocs.yml +0 -0
  39. {tsam_xarray-0.5.2 → tsam_xarray-0.6.1}/scripts/generate_readme_images.py +0 -0
  40. {tsam_xarray-0.5.2 → tsam_xarray-0.6.1}/src/tsam_xarray/__init__.py +0 -0
  41. {tsam_xarray-0.5.2 → tsam_xarray-0.6.1}/src/tsam_xarray/_sample_data.py +0 -0
  42. {tsam_xarray-0.5.2 → tsam_xarray-0.6.1}/src/tsam_xarray/_tuning.py +0 -0
  43. {tsam_xarray-0.5.2 → tsam_xarray-0.6.1}/test/conftest.py +0 -0
  44. {tsam_xarray-0.5.2 → tsam_xarray-0.6.1}/test/test_tuning.py +0 -0
@@ -13,7 +13,7 @@ jobs:
13
13
  if: github.actor == 'dependabot[bot]'
14
14
  runs-on: ubuntu-24.04
15
15
  steps:
16
- - uses: dependabot/fetch-metadata@v2
16
+ - uses: dependabot/fetch-metadata@v3
17
17
  id: metadata
18
18
 
19
19
  - name: Generate app token
@@ -20,7 +20,7 @@ jobs:
20
20
  app-id: ${{ secrets.APP_ID }}
21
21
  private-key: ${{ secrets.APP_PRIVATE_KEY }}
22
22
 
23
- - uses: googleapis/release-please-action@v4
23
+ - uses: googleapis/release-please-action@v5
24
24
  id: release
25
25
  with:
26
26
  token: ${{ steps.app-token.outputs.token }}
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.6.1"
3
+ }
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.6.1](https://github.com/FBumann/tsam_xarray/compare/v0.6.0...v0.6.1) (2026-07-09)
4
+
5
+
6
+ ### Features
7
+
8
+ * add cluster_on to select which coordinates drive clustering ([#89](https://github.com/FBumann/tsam_xarray/issues/89)) ([ddff73b](https://github.com/FBumann/tsam_xarray/commit/ddff73b24243e53d5a182b9e8c794e2b57a8d1b7))
9
+ * validate extremes options against cluster_on ([#91](https://github.com/FBumann/tsam_xarray/issues/91)) ([2fa5a91](https://github.com/FBumann/tsam_xarray/commit/2fa5a91c5222f8b6dd6932d9572dec62e90f595b))
10
+
11
+ ## [0.6.0](https://github.com/FBumann/tsam_xarray/compare/v0.5.2...v0.6.0) (2026-05-27)
12
+
13
+
14
+ ### ⚠ BREAKING CHANGES
15
+
16
+ * **ClusteringResult:** `time_coords` attribute and the `_time_coords_to_dict` / `_time_coords_from_dict` helpers have been removed. The time index now lives inside the tsam `ClusteringResult` payload (`time_index`) and flows through `aggregate` / `disaggregate` natively. Pre-0.6 JSONs are still loadable — the legacy outer `time_coords` field is forwarded to the inner `time_index` with a `DeprecationWarning`. Re-save to silence the warning. ([#83](https://github.com/FBumann/tsam_xarray/pull/83))
17
+
18
+
19
+ ### Refactors
20
+
21
+ * Reuse tsam 3.4's `DatetimeIndex` round-trip in `disaggregate`, dropping the parallel `time_coords` field, the compact serialization helpers, and the manual `MultiIndex` truncation in `_disaggregate_single`. ([#83](https://github.com/FBumann/tsam_xarray/pull/83))
22
+
23
+
24
+ ### Dependencies
25
+
26
+ * Bump minimum `tsam` to `>=3.4.0` (required for the `time_index` round-trip above).
27
+ * Bump `googleapis/release-please-action` from 4 to 5. ([#82](https://github.com/FBumann/tsam_xarray/pull/82))
28
+ * Bump `dependabot/fetch-metadata` from 2 to 3. ([#81](https://github.com/FBumann/tsam_xarray/pull/81))
29
+
3
30
  ## [0.5.2](https://github.com/FBumann/tsam_xarray/compare/v0.5.1...v0.5.2) (2026-04-01)
4
31
 
5
32
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tsam_xarray
3
- Version: 0.5.2
3
+ Version: 0.6.1
4
4
  Summary: Lightweight xarray wrapper for tsam time series aggregation
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -13,7 +13,7 @@ Classifier: Programming Language :: Python :: 3.13
13
13
  Classifier: Programming Language :: Python :: 3.14
14
14
  Requires-Python: >=3.11
15
15
  Requires-Dist: bottleneck>=1.4
16
- Requires-Dist: tsam>=3.3.0
16
+ Requires-Dist: tsam>=3.4.0
17
17
  Requires-Dist: xarray>=2024.1
18
18
  Provides-Extra: plot
19
19
  Requires-Dist: plotly>=5; extra == 'plot'
@@ -11,7 +11,7 @@
11
11
  "- **`cluster_dim`** — dimensions clustered together (shared clustering)\n",
12
12
  "- **Auto-slicing** — remaining dimensions get independent clusterings\n",
13
13
  "\n",
14
- "This notebook covers stacking, slicing, and weights."
14
+ "This notebook covers stacking, slicing, weights, and selecting what to cluster on."
15
15
  ]
16
16
  },
17
17
  {
@@ -212,6 +212,44 @@
212
212
  ")\n",
213
213
  "result_w2.accuracy.rmse.to_dataframe(\"RMSE\")"
214
214
  ]
215
+ },
216
+ {
217
+ "cell_type": "markdown",
218
+ "metadata": {},
219
+ "source": [
220
+ "## Selecting what to cluster on\n",
221
+ "\n",
222
+ "Sometimes a variable should be *carried through* the aggregation without\n",
223
+ "influencing how the clusters are formed — e.g. a price or demand series you\n",
224
+ "want on the representative grid, but that shouldn't drive period selection.\n",
225
+ "\n",
226
+ "Use `cluster_on` to restrict the clustering to specific coordinates. Everything\n",
227
+ "else is still aggregated and reconstructed from the resulting clusters, but has\n",
228
+ "no effect on the cluster distances. This differs from a small `weights` value:\n",
229
+ "`cluster_on` fully removes the coordinate from the distance metric.\n",
230
+ "\n",
231
+ "Here we cluster on `solar` and `wind` only, while `demand` is carried along."
232
+ ]
233
+ },
234
+ {
235
+ "cell_type": "code",
236
+ "execution_count": null,
237
+ "metadata": {},
238
+ "outputs": [],
239
+ "source": [
240
+ "# Cluster on solar + wind only; demand is carried along but doesn't\n",
241
+ "# influence cluster selection.\n",
242
+ "result_on = tsam_xarray.aggregate(\n",
243
+ " da_single,\n",
244
+ " time_dim=\"time\",\n",
245
+ " cluster_dim=[\"variable\", \"region\"],\n",
246
+ " n_clusters=4,\n",
247
+ " cluster_on={\"variable\": [\"solar\", \"wind\"]},\n",
248
+ ")\n",
249
+ "# demand is still present in the output, aggregated from the same clusters\n",
250
+ "print(\"Variables:\", list(result_on.cluster_representatives.coords[\"variable\"].values))\n",
251
+ "result_on.accuracy.rmse.to_dataframe(\"RMSE\")"
252
+ ]
215
253
  }
216
254
  ],
217
255
  "metadata": {
@@ -20,7 +20,7 @@ classifiers = [
20
20
  ]
21
21
  dependencies = [
22
22
  "bottleneck>=1.4",
23
- "tsam>=3.3.0",
23
+ "tsam>=3.4.0",
24
24
  "xarray>=2024.1",
25
25
  ]
26
26
 
@@ -21,25 +21,6 @@ from tsam_xarray._core import (
21
21
  )
22
22
 
23
23
 
24
- def _time_coords_to_dict(tc: pd.DatetimeIndex) -> dict[str, Any] | list[str]:
25
- """Serialize a DatetimeIndex compactly when possible.
26
-
27
- Regular indices are stored as ``{start, periods, freq}`` (~3 values).
28
- Irregular indices fall back to a full ISO string list.
29
- """
30
- freq = pd.infer_freq(tc)
31
- if freq is not None:
32
- return {"start": tc[0].isoformat(), "periods": len(tc), "freq": freq}
33
- return [t.isoformat() for t in tc]
34
-
35
-
36
- def _time_coords_from_dict(raw: dict[str, Any] | list[str]) -> pd.DatetimeIndex:
37
- """Deserialize a DatetimeIndex from either compact or list format."""
38
- if isinstance(raw, dict):
39
- return pd.date_range(raw["start"], periods=raw["periods"], freq=raw["freq"])
40
- return pd.DatetimeIndex(raw)
41
-
42
-
43
24
  @dataclass(frozen=True, repr=False)
44
25
  class ClusteringResult:
45
26
  """Reusable clustering result with xarray dimension metadata.
@@ -54,8 +35,6 @@ class ClusteringResult:
54
35
  slice_dims: Dimension(s) aggregated independently.
55
36
  clusterings: Per-slice tsam clustering.
56
37
  Single entry ``{(): result}`` when no slicing.
57
- time_coords: Original time coordinates.
58
- Needed for ``disaggregate()``.
59
38
  n_clusters: Number of clusters.
60
39
  n_original_periods: Number of original periods.
61
40
  n_timesteps_per_period: Timesteps per period.
@@ -80,7 +59,6 @@ class ClusteringResult:
80
59
  cluster_dim: list[str]
81
60
  slice_dims: list[str]
82
61
  clusterings: dict[tuple[Hashable, ...], tsam.ClusteringResult]
83
- time_coords: pd.DatetimeIndex | None = field(default=None, repr=False)
84
62
  _cache: dict[str, Any] = field(
85
63
  default_factory=dict, repr=False, init=False, compare=False
86
64
  )
@@ -427,29 +405,10 @@ class ClusteringResult:
427
405
  Returns:
428
406
  Data with ``cluster`` and ``timestep`` replaced by
429
407
  the original ``time`` dimension.
430
-
431
- Raises:
432
- ValueError: If time coordinates are not available
433
- (e.g., loaded from an old JSON that predates
434
- this feature).
435
408
  """
436
- if self.time_coords is None:
437
- msg = (
438
- "No time coordinates available. "
439
- "This ClusteringResult was loaded from a JSON file "
440
- "that does not contain time coordinate data. "
441
- "Re-run aggregate() or save from a newer version."
442
- )
443
- raise ValueError(msg)
444
-
445
409
  slice_dims = self.slice_dims
446
410
  if not slice_dims:
447
- cr = self.clusterings[()]
448
- return _disaggregate_single(
449
- self.time_coords,
450
- cr,
451
- data,
452
- )
411
+ return _disaggregate_single(self.clusterings[()], data)
453
412
 
454
413
  import itertools
455
414
 
@@ -460,7 +419,7 @@ class ClusteringResult:
460
419
  sel = dict(zip(slice_dims, key, strict=True))
461
420
  data_slice = data.sel(sel)
462
421
  cr = _lookup_clustering(self.clusterings, key)
463
- results.append(_disaggregate_single(self.time_coords, cr, data_slice))
422
+ results.append(_disaggregate_single(cr, data_slice))
464
423
 
465
424
  return _concat_along_dims(results, slice_dims, slice_coords)
466
425
 
@@ -479,15 +438,12 @@ class ClusteringResult:
479
438
  "clustering": cr.to_dict(),
480
439
  }
481
440
  )
482
- data: dict[str, Any] = {
441
+ return {
483
442
  "time_dim": self.time_dim,
484
443
  "cluster_dim": self.cluster_dim,
485
444
  "slice_dims": self.slice_dims,
486
445
  "clusterings": entries,
487
446
  }
488
- if self.time_coords is not None:
489
- data["time_coords"] = _time_coords_to_dict(self.time_coords)
490
- return data
491
447
 
492
448
  def to_json(self, path: str | Path, **json_kwargs: Any) -> None:
493
449
  """Save clustering to JSON file.
@@ -510,21 +466,31 @@ class ClusteringResult:
510
466
  Returns:
511
467
  The loaded ``ClusteringResult``.
512
468
  """
469
+ # Backcompat: pre-0.6 wrappers stored the time index as an outer
470
+ # ``time_coords`` key while the inner tsam blob (written by tsam<3.4)
471
+ # had no ``time_index``. Forward it so disaggregate keeps datetimes.
472
+ if "time_coords" in data:
473
+ import warnings
474
+
475
+ warnings.warn(
476
+ "Loading a legacy tsam_xarray JSON with an outer 'time_coords' "
477
+ "field; re-save with to_json() to silence this warning.",
478
+ DeprecationWarning,
479
+ stacklevel=2,
480
+ )
481
+ for entry in data["clusterings"]:
482
+ entry["clustering"].setdefault("time_index", data["time_coords"])
483
+
513
484
  clusterings: dict[tuple[Hashable, ...], tsam.ClusteringResult] = {}
514
485
  for entry in data["clusterings"]:
515
486
  key = tuple(entry["key"])
516
487
  clusterings[key] = tsam.ClusteringResult.from_dict(entry["clustering"])
517
488
 
518
- time_coords: pd.DatetimeIndex | None = None
519
- if "time_coords" in data:
520
- time_coords = _time_coords_from_dict(data["time_coords"])
521
-
522
489
  return cls(
523
490
  time_dim=data["time_dim"],
524
491
  cluster_dim=data["cluster_dim"],
525
492
  slice_dims=data.get("slice_dims", []),
526
493
  clusterings=clusterings,
527
- time_coords=time_coords,
528
494
  )
529
495
 
530
496
  @classmethod
@@ -653,7 +619,6 @@ def _apply_single(
653
619
  cluster_dim=col_dims,
654
620
  slice_dims=[],
655
621
  clusterings={(): tsam_result.clustering},
656
- time_coords=pd.DatetimeIndex(da.coords[time_dim].values),
657
622
  )
658
623
 
659
624
  return AggregationResult(
@@ -670,11 +635,14 @@ def _apply_single(
670
635
 
671
636
 
672
637
  def _disaggregate_single(
673
- time_coords: pd.DatetimeIndex,
674
638
  cr: tsam.ClusteringResult,
675
639
  data: xr.DataArray,
676
640
  ) -> xr.DataArray:
677
- """Disaggregate a single (non-sliced) DataArray using a ClusteringResult."""
641
+ """Disaggregate a single (non-sliced) DataArray using a ClusteringResult.
642
+
643
+ Relies on tsam's ``cr.disaggregate()`` to return a DataFrame indexed
644
+ by the original ``DatetimeIndex`` stored on the clustering.
645
+ """
678
646
  other_dims = [str(d) for d in data.dims if d not in ("cluster", "timestep")]
679
647
  ordered = data.transpose("cluster", "timestep", *other_dims)
680
648
 
@@ -686,10 +654,11 @@ def _disaggregate_single(
686
654
  flat = ordered.values.reshape(n_clusters * n_timesteps, -1)
687
655
 
688
656
  if cr.segment_durations is not None:
689
- idx_tuples = []
690
- for c in clusters:
691
- for seg, dur in enumerate(cr.segment_durations[int(c)]):
692
- idx_tuples.append((int(c), seg, int(dur)))
657
+ idx_tuples = [
658
+ (int(c), seg, int(dur))
659
+ for c in clusters
660
+ for seg, dur in enumerate(cr.segment_durations[int(c)])
661
+ ]
693
662
  mi = pd.MultiIndex.from_tuples(
694
663
  idx_tuples, names=["cluster", "segment", "duration"]
695
664
  )
@@ -700,12 +669,10 @@ def _disaggregate_single(
700
669
 
701
670
  df = pd.DataFrame(flat, index=mi, columns=range(flat.shape[1]))
702
671
  expanded = cr.disaggregate(df)
703
-
704
- n_original = len(time_coords)
705
- vals = expanded.values[:n_original]
672
+ time_coords = expanded.index
706
673
 
707
674
  if other_dims:
708
- vals = vals.reshape(n_original, *other_sizes)
675
+ vals = expanded.values.reshape(len(time_coords), *other_sizes)
709
676
  result = xr.DataArray(
710
677
  vals,
711
678
  dims=["time", *other_dims],
@@ -716,7 +683,7 @@ def _disaggregate_single(
716
683
  result = result.assign_coords({d: data.coords[d]})
717
684
  else:
718
685
  result = xr.DataArray(
719
- vals[:, 0],
686
+ expanded.values[:, 0],
720
687
  dims=["time"],
721
688
  coords={"time": time_coords},
722
689
  )
@@ -14,6 +14,7 @@ import xarray as xr
14
14
  from tsam_xarray._result import AccuracyMetrics, AggregationResult
15
15
 
16
16
  Weights = dict[str, float] | dict[str, dict[str, float]] | None
17
+ ClusterOn = str | Sequence[str] | dict[str, Sequence[str]] | None
17
18
 
18
19
 
19
20
  def aggregate(
@@ -23,6 +24,7 @@ def aggregate(
23
24
  cluster_dim: Sequence[str] | str,
24
25
  n_clusters: int,
25
26
  weights: Weights = None,
27
+ cluster_on: ClusterOn = None,
26
28
  **tsam_kwargs: Any,
27
29
  ) -> AggregationResult:
28
30
  """Aggregate an xarray DataArray using tsam.
@@ -55,6 +57,37 @@ def aggregate(
55
57
  e.g. ``("solar", "north")`` gets weight
56
58
  ``2.0 * 1.5 = 3.0``.
57
59
 
60
+ cluster_on: Restrict which coordinates drive the
61
+ clustering. Coordinates not selected are still
62
+ aggregated and reconstructed from the resulting
63
+ clusters, but have no influence on how the clusters
64
+ are formed. ``None`` (default) clusters on
65
+ everything. Mirrors the ``weights`` formats:
66
+
67
+ - **List** (single ``cluster_dim``)::
68
+
69
+ cluster_on=["solar", "wind"]
70
+
71
+ - **Dict** (multiple ``cluster_dim``)::
72
+
73
+ cluster_on={"variable": ["solar", "wind"]}
74
+
75
+ A dim omitted from the dict is unrestricted (all
76
+ its coordinates are eligible). When several dims
77
+ are listed, a column must match on *all* of them
78
+ to be clustered on. At least one column must
79
+ remain selected.
80
+
81
+ Not compatible with ``ExtremeConfig(method=
82
+ "replace")`` — the carried columns are filled by
83
+ transferring the clustering, which cannot
84
+ reproduce the hybrid 'replace' representative. Use
85
+ a low ``weights`` value to de-emphasise a column
86
+ instead of excluding it. An ``ExtremeConfig`` also
87
+ may not reference an excluded coordinate, since
88
+ extreme periods are identified only on the
89
+ clustered-on columns.
90
+
58
91
  **tsam_kwargs: Additional keyword arguments passed to
59
92
  ``tsam.aggregate()``.
60
93
  """
@@ -65,10 +98,18 @@ def aggregate(
65
98
  da = _validate_data(da, time_dim, col_dims, slice_dims)
66
99
  _validate_no_cluster_config_weights(tsam_kwargs)
67
100
  per_dim_weights = _normalize_weights(weights, da, col_dims)
101
+ active_coords = _normalize_cluster_on(cluster_on, da, col_dims)
102
+ _validate_extremes_with_cluster_on(tsam_kwargs, active_coords, da)
68
103
 
69
104
  if not slice_dims:
70
105
  return _aggregate_single(
71
- da, n_clusters, time_dim, col_dims, per_dim_weights, tsam_kwargs
106
+ da,
107
+ n_clusters,
108
+ time_dim,
109
+ col_dims,
110
+ per_dim_weights,
111
+ active_coords,
112
+ tsam_kwargs,
72
113
  )
73
114
 
74
115
  slice_coords = {d: da.coords[d].values for d in slice_dims}
@@ -80,7 +121,13 @@ def aggregate(
80
121
  sel = dict(zip(slice_dims, key, strict=True))
81
122
  da_slice = da.sel(sel)
82
123
  r = _aggregate_single(
83
- da_slice, n_clusters, time_dim, col_dims, per_dim_weights, tsam_kwargs
124
+ da_slice,
125
+ n_clusters,
126
+ time_dim,
127
+ col_dims,
128
+ per_dim_weights,
129
+ active_coords,
130
+ tsam_kwargs,
84
131
  )
85
132
  results.append(r)
86
133
 
@@ -129,6 +176,68 @@ def _validate_no_cluster_config_weights(
129
176
  raise ValueError(msg)
130
177
 
131
178
 
179
+ def _validate_extremes_with_cluster_on(
180
+ tsam_kwargs: dict[str, Any],
181
+ active_coords: dict[str, set[str]] | None,
182
+ da: xr.DataArray,
183
+ ) -> None:
184
+ """Reject extremes settings incompatible with the cluster_on transfer.
185
+
186
+ cluster_on clusters on the active subset and transfers the result to the
187
+ carried columns via ``ClusteringResult.apply()``. Two extremes settings
188
+ don't survive that transfer:
189
+
190
+ - ``method="replace"`` builds a hybrid representative (some columns from the
191
+ medoid, some from the extreme period) that apply() cannot reproduce — tsam
192
+ silently falls back to the medoid. Rather than degrade quietly, error and
193
+ point to ``weights``, which de-emphasises a column without excluding it
194
+ (tsam clamps a low weight to a minimal one, so the column stays in the
195
+ single aggregation pass and 'replace' remains reproducible).
196
+ - An ExtremeConfig that references a coordinate cluster_on excluded: those
197
+ columns never enter the clustering, so tsam can't identify extremes on
198
+ them (it would raise a misleading "not found in data"). Surface the real
199
+ cause instead.
200
+ """
201
+ if active_coords is None:
202
+ return
203
+ extremes = tsam_kwargs.get("extremes")
204
+ if extremes is None:
205
+ return
206
+
207
+ if getattr(extremes, "method", None) == "replace":
208
+ msg = (
209
+ "extremes method 'replace' is not supported together with "
210
+ "cluster_on. cluster_on fully excludes the carried columns and "
211
+ "transfers the clustering to them, which cannot reproduce the "
212
+ "hybrid 'replace' representative. To reduce a column's influence "
213
+ "without excluding it, give it a low 'weights' value instead of "
214
+ "using cluster_on (weights never removes a column entirely, so "
215
+ "'replace' stays reproducible)."
216
+ )
217
+ raise ValueError(msg)
218
+
219
+ passive_coords: set[str] = set()
220
+ for dim, active_set in active_coords.items():
221
+ all_coords = {str(c) for c in da.coords[dim].values}
222
+ passive_coords |= all_coords - active_set
223
+
224
+ referenced = (
225
+ set(extremes.max_value)
226
+ | set(extremes.min_value)
227
+ | set(extremes.max_period)
228
+ | set(extremes.min_period)
229
+ )
230
+ excluded = referenced & passive_coords
231
+ if excluded:
232
+ msg = (
233
+ f"extremes references coordinates {excluded} that cluster_on "
234
+ "excluded from the clustering. Extreme periods can only be "
235
+ "identified on clustered-on columns — add these coordinates to "
236
+ "cluster_on, or remove them from the ExtremeConfig."
237
+ )
238
+ raise ValueError(msg)
239
+
240
+
132
241
  def _validate(
133
242
  da: xr.DataArray,
134
243
  time_dim: str,
@@ -265,7 +374,7 @@ def _representatives_to_da(
265
374
  df = df.copy()
266
375
  # With segmentation, index has 3 levels: (cluster, segment_step, segment_duration)
267
376
  # Without: 2 levels: (cluster, timestep)
268
- if df.index.nlevels == 3:
377
+ if isinstance(df.index, pd.MultiIndex) and df.index.nlevels == 3:
269
378
  df.index = df.index.droplevel(2) # drop segment_duration
270
379
  df.index.names = ["cluster", "timestep"]
271
380
 
@@ -339,6 +448,36 @@ def _metric_to_da(
339
448
  return xr.DataArray(series.to_xarray())
340
449
 
341
450
 
451
+ def _validate_dim_coord_mapping(
452
+ mapping: dict[str, Any],
453
+ da: xr.DataArray,
454
+ col_dims: list[str],
455
+ label: str,
456
+ ) -> None:
457
+ """Validate a ``{dim: coords}`` mapping references real dims and coords.
458
+
459
+ ``coords`` may be any iterable of coordinate names (a list, or the keys
460
+ of a ``{coord: weight}`` dict). Shared by ``weights`` and ``cluster_on``.
461
+ """
462
+ extra_dims = set(mapping.keys()) - set(col_dims)
463
+ if extra_dims:
464
+ msg = (
465
+ f"{label} has unknown dims {extra_dims}, "
466
+ f"must be subset of cluster_dim {col_dims}"
467
+ )
468
+ raise ValueError(msg)
469
+
470
+ for dim_name, coords in mapping.items():
471
+ valid_coords = set(str(c) for c in da.coords[dim_name].values)
472
+ unknown = set(str(c) for c in coords) - valid_coords
473
+ if unknown:
474
+ msg = (
475
+ f"{label} has unknown coords {unknown} for dim {dim_name!r}, "
476
+ f"valid coords: {sorted(valid_coords)}"
477
+ )
478
+ raise ValueError(msg)
479
+
480
+
342
481
  def _normalize_weights(
343
482
  weights: dict[str, float] | dict[str, dict[str, float]] | None,
344
483
  da: xr.DataArray,
@@ -370,27 +509,51 @@ def _normalize_weights(
370
509
  raise ValueError(msg)
371
510
  per_dim_weights = {col_dims[0]: weights} # type: ignore[dict-item]
372
511
 
373
- # Validate dim names exist in cluster_dim
374
- extra_dims = set(per_dim_weights.keys()) - set(col_dims)
375
- if extra_dims:
376
- msg = (
377
- f"weights has unknown dims {extra_dims}, "
378
- f"must be subset of cluster_dim {col_dims}"
379
- )
380
- raise ValueError(msg)
512
+ _validate_dim_coord_mapping(per_dim_weights, da, col_dims, "weights")
513
+ return per_dim_weights
381
514
 
382
- # Validate coord values exist in the DataArray
383
- for dim_name, coord_weights in per_dim_weights.items():
384
- valid_coords = set(str(c) for c in da.coords[dim_name].values)
385
- unknown = set(coord_weights.keys()) - valid_coords
386
- if unknown:
515
+
516
+ def _normalize_cluster_on(
517
+ cluster_on: ClusterOn,
518
+ da: xr.DataArray,
519
+ col_dims: list[str],
520
+ ) -> dict[str, set[str]] | None:
521
+ """Normalize cluster_on to a per-dim set of eligible coords.
522
+
523
+ A list/str form requires a single cluster_dim. Returns ``None``
524
+ when nothing is restricted (cluster on everything).
525
+ """
526
+ if cluster_on is None:
527
+ return None
528
+
529
+ if isinstance(cluster_on, dict):
530
+ per_dim: dict[str, list[str]] = {
531
+ k: [str(c) for c in v] for k, v in cluster_on.items()
532
+ }
533
+ else:
534
+ if len(col_dims) != 1:
387
535
  msg = (
388
- f"weights has unknown coords {unknown} for dim {dim_name!r}, "
389
- f"valid coords: {sorted(valid_coords)}"
536
+ "List-form cluster_on requires a single cluster_dim. "
537
+ "For multiple cluster_dim, use a dict: "
538
+ '{"dim_name": ["coord", ...]}.'
390
539
  )
391
540
  raise ValueError(msg)
541
+ coords = [cluster_on] if isinstance(cluster_on, str) else list(cluster_on)
542
+ per_dim = {col_dims[0]: [str(c) for c in coords]}
392
543
 
393
- return per_dim_weights
544
+ _validate_dim_coord_mapping(per_dim, da, col_dims, "cluster_on")
545
+
546
+ # An empty coord list for any restricted dim leaves no column to cluster
547
+ # on (the stacked columns are the full coordinate grid). Fail here, before
548
+ # any aggregation runs, rather than deep in the per-slice loop.
549
+ if any(not coords for coords in per_dim.values()):
550
+ msg = (
551
+ "cluster_on selected no columns — nothing to cluster on. "
552
+ "Select at least one coordinate present in the data."
553
+ )
554
+ raise ValueError(msg)
555
+
556
+ return {d: set(cs) for d, cs in per_dim.items()}
394
557
 
395
558
 
396
559
  def _translate_weights(
@@ -414,28 +577,103 @@ def _translate_weights(
414
577
  return flat
415
578
 
416
579
 
580
+ # tsam.aggregate() kwargs that ClusteringResult.apply() also accepts. Only
581
+ # these are forwarded to apply() when re-aggregating passive columns onto a
582
+ # clustering built from the active subset; the rest (period_duration, cluster,
583
+ # segments, extremes, ...) are already baked into the stored clustering.
584
+ _APPLY_KWARGS = frozenset(
585
+ {"temporal_resolution", "round_decimals", "numerical_tolerance"}
586
+ )
587
+
588
+
589
+ def _active_columns(
590
+ df: pd.DataFrame,
591
+ active_coords: dict[str, set[str]] | None,
592
+ col_dims: list[str],
593
+ ) -> list[Hashable]:
594
+ """Columns that drive the clustering, given a cluster_on selection.
595
+
596
+ A column is active only if, for every restricted dim, its coordinate is
597
+ in that dim's eligible set; unrestricted dims impose no constraint. With
598
+ no restriction (``active_coords is None``) every column is active. The
599
+ remaining columns are *passive*: still aggregated and reconstructed, but
600
+ excluded from the cluster distances.
601
+ """
602
+ if active_coords is None:
603
+ return list(df.columns)
604
+
605
+ def is_active(col: Hashable) -> bool:
606
+ pairs = (
607
+ zip(col_dims, col, strict=True)
608
+ if isinstance(col, tuple)
609
+ else [(col_dims[0], col)]
610
+ )
611
+ return all(
612
+ str(coord) in active_coords[dim]
613
+ for dim, coord in pairs
614
+ if dim in active_coords
615
+ )
616
+
617
+ return [col for col in df.columns if is_active(col)]
618
+
619
+
417
620
  def _aggregate_single(
418
621
  da: xr.DataArray,
419
622
  n_clusters: int,
420
623
  time_dim: str,
421
624
  col_dims: list[str],
422
625
  weights: dict[str, dict[str, float]] | None,
626
+ active_coords: dict[str, set[str]] | None,
423
627
  tsam_kwargs: dict[str, Any],
424
628
  ) -> AggregationResult:
425
- """Run a single tsam aggregation on a DataArray."""
629
+ """Run a single tsam aggregation on a DataArray.
630
+
631
+ When ``cluster_on`` restricts the clustering, the clustering runs on
632
+ the active subset only, then is applied back to the full frame so the
633
+ passive columns still get representatives and reconstruction.
634
+ """
426
635
  df = _to_dataframe(da, time_dim, col_dims)
427
636
 
428
637
  tsam_weights: dict[Hashable, float] | None = None
429
638
  if weights is not None:
430
639
  tsam_weights = _translate_weights(weights, df, col_dims)
431
640
 
432
- tsam_result = tsam.aggregate(
433
- df,
434
- n_clusters,
435
- weights=tsam_weights, # type: ignore[arg-type]
436
- **tsam_kwargs,
437
- )
641
+ active_cols = _active_columns(df, active_coords, col_dims)
438
642
 
643
+ if len(active_cols) == len(df.columns):
644
+ # Nothing carried passively — cluster on the full frame directly.
645
+ tsam_result = tsam.aggregate(
646
+ df,
647
+ n_clusters,
648
+ weights=tsam_weights, # type: ignore[arg-type]
649
+ **tsam_kwargs,
650
+ )
651
+ else:
652
+ # Cluster on the active subset, then apply that clustering back to the
653
+ # full frame so passive columns get representatives and reconstruction.
654
+ active_weights = (
655
+ None if tsam_weights is None else {c: tsam_weights[c] for c in active_cols}
656
+ )
657
+ clustering = tsam.aggregate(
658
+ df[active_cols],
659
+ n_clusters,
660
+ weights=active_weights, # type: ignore[arg-type]
661
+ **tsam_kwargs,
662
+ ).clustering
663
+ apply_kwargs = {k: v for k, v in tsam_kwargs.items() if k in _APPLY_KWARGS}
664
+ tsam_result = clustering.apply(df, **apply_kwargs)
665
+
666
+ return _result_from_tsam(tsam_result, da, df, time_dim, col_dims)
667
+
668
+
669
+ def _result_from_tsam(
670
+ tsam_result: Any,
671
+ da: xr.DataArray,
672
+ df: pd.DataFrame,
673
+ time_dim: str,
674
+ col_dims: list[str],
675
+ ) -> AggregationResult:
676
+ """Build an AggregationResult from a tsam aggregation result."""
439
677
  typical = _representatives_to_da(tsam_result.cluster_representatives, col_dims)
440
678
  reconstructed = _reconstructed_to_da(tsam_result.reconstructed, time_dim, col_dims)
441
679
 
@@ -475,7 +713,6 @@ def _aggregate_single(
475
713
  cluster_dim=col_dims,
476
714
  slice_dims=[],
477
715
  clusterings={(): tsam_result.clustering},
478
- time_coords=pd.DatetimeIndex(da.coords[time_dim].values),
479
716
  )
480
717
 
481
718
  return AggregationResult(
@@ -562,7 +799,6 @@ def _concat_results(
562
799
  cluster_dim=first.clustering.cluster_dim,
563
800
  slice_dims=slice_dims,
564
801
  clusterings=merged_clusterings,
565
- time_coords=first.clustering.time_coords,
566
802
  )
567
803
 
568
804
  return AggregationResult(
@@ -211,10 +211,7 @@ class AggregationResult:
211
211
 
212
212
  def _disaggregate_single(self, data: xr.DataArray) -> xr.DataArray:
213
213
  """Disaggregate without slice dims."""
214
- import pandas as pd
215
-
216
214
  from tsam_xarray._clustering import _disaggregate_single
217
215
 
218
- time_coords = pd.DatetimeIndex(self.original.coords["time"].values)
219
216
  cr = self.clustering.clusterings[()]
220
- return _disaggregate_single(time_coords, cr, data)
217
+ return _disaggregate_single(cr, data)
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.5.2'
22
- __version_tuple__ = version_tuple = (0, 5, 2)
21
+ __version__ = version = '0.6.1'
22
+ __version_tuple__ = version_tuple = (0, 6, 1)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -306,6 +306,169 @@ class TestWeights:
306
306
  )
307
307
 
308
308
 
309
+ @pytest.fixture
310
+ def da_3var() -> xr.DataArray:
311
+ """Single cluster_dim: solar/wind/price over time (region dropped)."""
312
+ return _make_da(variables=["solar", "wind", "price"]).isel(region=0, drop=True)
313
+
314
+
315
+ @pytest.fixture
316
+ def da_3var_region() -> xr.DataArray:
317
+ """Multi cluster_dim: solar/wind/price over time and region."""
318
+ return _make_da(variables=["solar", "wind", "price"])
319
+
320
+
321
+ class TestClusterOn:
322
+ """cluster_on restricts which coords drive clustering, keeping the rest."""
323
+
324
+ @pytest.mark.parametrize(
325
+ ("cluster_dim", "cluster_on", "subset"),
326
+ [
327
+ ("variable", ["solar", "wind"], ["solar", "wind"]),
328
+ ("variable", "solar", ["solar"]),
329
+ (
330
+ ["variable", "region"],
331
+ {"variable": ["solar", "wind"]},
332
+ ["solar", "wind"],
333
+ ),
334
+ ],
335
+ ids=["list", "string", "dict-multi"],
336
+ )
337
+ def test_matches_subset_clustering(
338
+ self,
339
+ da_3var: xr.DataArray,
340
+ da_3var_region: xr.DataArray,
341
+ cluster_dim: list[str] | str,
342
+ cluster_on: object,
343
+ subset: list[str],
344
+ ):
345
+ """cluster_on gives the same clustering as aggregating the subset alone,
346
+ while carried coords still appear in the output."""
347
+ da = da_3var if cluster_dim == "variable" else da_3var_region
348
+ restricted = tsam_xarray.aggregate(
349
+ da,
350
+ time_dim="time",
351
+ cluster_dim=cluster_dim,
352
+ n_clusters=4,
353
+ cluster_on=cluster_on,
354
+ )
355
+ reference = tsam_xarray.aggregate(
356
+ da.sel(variable=subset),
357
+ time_dim="time",
358
+ cluster_dim=cluster_dim,
359
+ n_clusters=4,
360
+ )
361
+ np.testing.assert_array_equal(
362
+ restricted.clustering.cluster_assignments.values,
363
+ reference.clustering.cluster_assignments.values,
364
+ )
365
+ reps = restricted.cluster_representatives
366
+ assert "price" in reps.coords["variable"].values
367
+ assert "price" in restricted.reconstructed.coords["variable"].values
368
+
369
+ def test_including_var_would_change_clustering(self, da_3var: xr.DataArray):
370
+ """Sanity: clustering on everything gives different assignments."""
371
+ restricted = tsam_xarray.aggregate(
372
+ da_3var,
373
+ time_dim="time",
374
+ cluster_dim="variable",
375
+ n_clusters=4,
376
+ cluster_on=["solar", "wind"],
377
+ )
378
+ full = tsam_xarray.aggregate(
379
+ da_3var, time_dim="time", cluster_dim="variable", n_clusters=4
380
+ )
381
+ assert (
382
+ restricted.clustering.cluster_assignments.values
383
+ != full.clustering.cluster_assignments.values
384
+ ).any()
385
+
386
+ def test_composes_with_weights(self, da_3var: xr.DataArray):
387
+ """cluster_on and weights coexist; weights apply to the active subset."""
388
+ result = tsam_xarray.aggregate(
389
+ da_3var,
390
+ time_dim="time",
391
+ cluster_dim="variable",
392
+ n_clusters=4,
393
+ cluster_on=["solar", "wind"],
394
+ weights={"solar": 5.0, "wind": 1.0},
395
+ )
396
+ assert result.n_clusters == 4
397
+ assert "price" in result.cluster_representatives.coords["variable"].values
398
+
399
+ def test_replace_extremes_rejected(self, da_3var: xr.DataArray):
400
+ """extremes='replace' can't survive the cluster_on transfer — error,
401
+ and steer users to weights instead of full exclusion."""
402
+ import tsam
403
+
404
+ with pytest.raises(ValueError, match=r"replace.*not supported.*cluster_on"):
405
+ tsam_xarray.aggregate(
406
+ da_3var,
407
+ time_dim="time",
408
+ cluster_dim="variable",
409
+ n_clusters=4,
410
+ cluster_on=["solar", "wind"],
411
+ extremes=tsam.ExtremeConfig(method="replace", max_value=["solar"]),
412
+ )
413
+
414
+ def test_extremes_on_excluded_column_rejected(self, da_3var: xr.DataArray):
415
+ """An ExtremeConfig referencing a cluster_on-excluded coord errors with
416
+ a clear cause, not tsam's misleading 'not found in data'."""
417
+ import tsam
418
+
419
+ with pytest.raises(ValueError, match=r"cluster_on excluded"):
420
+ tsam_xarray.aggregate(
421
+ da_3var,
422
+ time_dim="time",
423
+ cluster_dim="variable",
424
+ n_clusters=4,
425
+ cluster_on=["solar", "wind"],
426
+ extremes=tsam.ExtremeConfig(method="append", max_value=["price"]),
427
+ )
428
+
429
+ def test_append_extremes_allowed(self, da_3var: xr.DataArray):
430
+ """extremes='append' on an active column composes with cluster_on."""
431
+ import tsam
432
+
433
+ result = tsam_xarray.aggregate(
434
+ da_3var,
435
+ time_dim="time",
436
+ cluster_dim="variable",
437
+ n_clusters=4,
438
+ cluster_on=["solar", "wind"],
439
+ extremes=tsam.ExtremeConfig(method="append", max_value=["solar"]),
440
+ )
441
+ assert "price" in result.cluster_representatives.coords["variable"].values
442
+
443
+ @pytest.mark.parametrize(
444
+ ("cluster_dim", "cluster_on", "match"),
445
+ [
446
+ (["variable", "region"], ["solar"], "single cluster_dim"),
447
+ (["variable", "region"], {"scenario": ["low"]}, "unknown dims"),
448
+ ("variable", ["nuclear"], "unknown coords"),
449
+ ("variable", {"variable": []}, "nothing to cluster on"),
450
+ ],
451
+ ids=["list-multi", "unknown-dim", "unknown-coord", "empty"],
452
+ )
453
+ def test_invalid_cluster_on_raises(
454
+ self,
455
+ da_3var: xr.DataArray,
456
+ da_3var_region: xr.DataArray,
457
+ cluster_dim: list[str] | str,
458
+ cluster_on: object,
459
+ match: str,
460
+ ):
461
+ da = da_3var if cluster_dim == "variable" else da_3var_region
462
+ with pytest.raises(ValueError, match=match):
463
+ tsam_xarray.aggregate(
464
+ da,
465
+ time_dim="time",
466
+ cluster_dim=cluster_dim,
467
+ n_clusters=4,
468
+ cluster_on=cluster_on,
469
+ )
470
+
471
+
309
472
  class TestWeightTranslation:
310
473
  """Verify weight values are correctly translated."""
311
474
 
@@ -1283,30 +1446,8 @@ class TestClusteringDisaggregate:
1283
1446
  expected = result.disaggregate(result.cluster_representatives)
1284
1447
  xr.testing.assert_allclose(dis, expected)
1285
1448
 
1286
- def test_disaggregate_no_time_coords_raises(self):
1287
- """ClusteringInfo without time_coords raises ValueError."""
1288
- from tsam_xarray._clustering import ClusteringInfo
1289
-
1290
- da = _make_da()
1291
- da_flat = da.isel(region=0).drop_vars("region")
1292
- result = tsam_xarray.aggregate(
1293
- da_flat,
1294
- time_dim="time",
1295
- cluster_dim="variable",
1296
- n_clusters=4,
1297
- )
1298
- # Construct a ClusteringInfo without time_coords
1299
- ci = ClusteringInfo(
1300
- time_dim="time",
1301
- cluster_dim=["variable"],
1302
- slice_dims=[],
1303
- clusterings=result.clustering.clusterings,
1304
- )
1305
- with pytest.raises(ValueError, match="No time coordinates"):
1306
- ci.disaggregate(result.cluster_representatives)
1307
-
1308
- def test_json_backward_compat(self, tmp_path):
1309
- """Old JSON without time_coords loads fine, disaggregate raises."""
1449
+ def test_legacy_time_coords_json(self, tmp_path):
1450
+ """Legacy JSON with outer ``time_coords`` loads with a warning."""
1310
1451
  import json
1311
1452
 
1312
1453
  da = _make_da()
@@ -1320,61 +1461,19 @@ class TestClusteringDisaggregate:
1320
1461
  path = tmp_path / "clustering.json"
1321
1462
  result.clustering.to_json(str(path))
1322
1463
 
1323
- # Strip time_coords from saved JSON
1464
+ # Forge a pre-0.6 file: outer time_coords + inner blob without time_index.
1324
1465
  with open(path) as f:
1325
1466
  data = json.load(f)
1326
- del data["time_coords"]
1467
+ tc = data["clusterings"][0]["clustering"].pop("time_index")
1468
+ data["time_coords"] = tc
1327
1469
  with open(path, "w") as f:
1328
1470
  json.dump(data, f)
1329
1471
 
1330
- clustering = tsam_xarray.load_clustering(str(path))
1331
- assert clustering.time_coords is None
1332
- # apply() still works
1333
- new_result = clustering.apply(da_flat)
1334
- assert new_result.n_clusters == 4
1335
- # disaggregate() raises
1336
- with pytest.raises(ValueError, match="No time coordinates"):
1337
- clustering.disaggregate(result.cluster_representatives)
1338
-
1339
- def test_time_coords_in_json(self, tmp_path):
1340
- """time_coords are serialized in JSON."""
1341
- import json
1472
+ with pytest.warns(DeprecationWarning, match="legacy tsam_xarray JSON"):
1473
+ clustering = tsam_xarray.load_clustering(str(path))
1342
1474
 
1343
- da = _make_da()
1344
- da_flat = da.isel(region=0).drop_vars("region")
1345
- result = tsam_xarray.aggregate(
1346
- da_flat,
1347
- time_dim="time",
1348
- cluster_dim="variable",
1349
- n_clusters=4,
1350
- )
1351
- path = tmp_path / "clustering.json"
1352
- result.clustering.to_json(str(path))
1353
- with open(path) as f:
1354
- data = json.load(f)
1355
- assert "time_coords" in data
1356
- tc = data["time_coords"]
1357
- # Regular index → compact dict format
1358
- assert isinstance(tc, dict)
1359
- assert tc["periods"] == da_flat.sizes["time"]
1360
-
1361
- def test_time_coords_roundtrip(self, tmp_path):
1362
- """time_coords survive JSON round-trip."""
1363
- da = _make_da()
1364
- da_flat = da.isel(region=0).drop_vars("region")
1365
- result = tsam_xarray.aggregate(
1366
- da_flat,
1367
- time_dim="time",
1368
- cluster_dim="variable",
1369
- n_clusters=4,
1370
- )
1371
- path = tmp_path / "clustering.json"
1372
- result.clustering.to_json(str(path))
1373
- clustering = tsam_xarray.load_clustering(str(path))
1374
- np.testing.assert_array_equal(
1375
- result.clustering.time_coords,
1376
- clustering.time_coords,
1377
- )
1475
+ dis = clustering.disaggregate(result.cluster_representatives)
1476
+ assert dis.indexes["time"].equals(da_flat.indexes["time"])
1378
1477
 
1379
1478
  def test_1d_disaggregate(self, tmp_path):
1380
1479
  """Disaggregate works on 1D time series."""
@@ -505,61 +505,3 @@ class TestClusteringIORoundtrip:
505
505
  clustering = tsam_xarray.load_clustering(str(path))
506
506
  new_result = clustering.apply(agg_case.da)
507
507
  assert new_result.is_transferred
508
-
509
-
510
- class TestTimeCoordsSerialization:
511
- """Compact time_coords serialization helpers."""
512
-
513
- def test_regular_index_compact(self):
514
- """Regular DatetimeIndex is stored as {start, periods, freq}."""
515
- import pandas as pd
516
-
517
- from tsam_xarray._clustering import _time_coords_to_dict
518
-
519
- tc = pd.date_range("2025-01-01", periods=8760, freq="h")
520
- d = _time_coords_to_dict(tc)
521
- assert isinstance(d, dict)
522
- assert set(d) == {"start", "periods", "freq"}
523
- assert d["periods"] == 8760
524
-
525
- def test_regular_index_roundtrip(self):
526
- """Compact format round-trips exactly."""
527
- import pandas as pd
528
-
529
- from tsam_xarray._clustering import (
530
- _time_coords_from_dict,
531
- _time_coords_to_dict,
532
- )
533
-
534
- tc = pd.date_range("2025-01-01", periods=8760, freq="h")
535
- restored = _time_coords_from_dict(_time_coords_to_dict(tc))
536
- pd.testing.assert_index_equal(tc, restored)
537
-
538
- def test_irregular_index_fallback(self):
539
- """Irregular DatetimeIndex falls back to list of ISO strings."""
540
- import pandas as pd
541
-
542
- from tsam_xarray._clustering import (
543
- _time_coords_from_dict,
544
- _time_coords_to_dict,
545
- )
546
-
547
- tc = pd.DatetimeIndex(["2025-01-01", "2025-01-03", "2025-01-07"])
548
- d = _time_coords_to_dict(tc)
549
- assert isinstance(d, list)
550
- restored = _time_coords_from_dict(d)
551
- pd.testing.assert_index_equal(tc, restored)
552
-
553
- def test_old_list_format_still_loads(self):
554
- """List format (pre-compact) is still accepted by from_dict."""
555
- from tsam_xarray._clustering import _time_coords_from_dict
556
-
557
- raw = ["2025-01-01T00:00:00", "2025-01-01T01:00:00"]
558
- restored = _time_coords_from_dict(raw)
559
- assert len(restored) == 2
560
-
561
- def test_dict_roundtrip_uses_compact(self, agg_case: AggregateCase):
562
- """to_dict uses compact format for regular time indices."""
563
- result = _aggregate(agg_case)
564
- d = result.clustering.to_dict()
565
- assert isinstance(d["time_coords"], dict)
@@ -1,3 +0,0 @@
1
- {
2
- ".": "0.5.2"
3
- }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes