tsam-xarray 0.5.2__tar.gz → 0.6.0__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.0}/.github/workflows/dependabot-auto-merge.yaml +1 -1
  2. {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/.github/workflows/release.yaml +1 -1
  3. tsam_xarray-0.6.0/.release-please-manifest.json +3 -0
  4. {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/CHANGELOG.md +19 -0
  5. {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/PKG-INFO +2 -2
  6. {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/pyproject.toml +1 -1
  7. {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/src/tsam_xarray/_clustering.py +31 -64
  8. {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/src/tsam_xarray/_core.py +0 -2
  9. {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/src/tsam_xarray/_result.py +1 -4
  10. {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/src/tsam_xarray/_version.py +2 -2
  11. {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/test/test_aggregate.py +9 -73
  12. {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/test/test_parametrized.py +0 -58
  13. tsam_xarray-0.5.2/.release-please-manifest.json +0 -3
  14. {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/.github/dependabot.yml +0 -0
  15. {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/.github/workflows/ci.yaml +0 -0
  16. {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/.github/workflows/pr-title.yaml +0 -0
  17. {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/.github/workflows/publish.yaml +0 -0
  18. {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/.gitignore +0 -0
  19. {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/.pre-commit-config.yaml +0 -0
  20. {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/.readthedocs.yaml +0 -0
  21. {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/.release-please-config.json +0 -0
  22. {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/CLAUDE.md +0 -0
  23. {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/LICENSE +0 -0
  24. {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/README.md +0 -0
  25. {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/codecov.yml +0 -0
  26. {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/docs/assets/multi-dim-input.png +0 -0
  27. {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/docs/assets/multi-dim-metrics.png +0 -0
  28. {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/docs/changelog.md +0 -0
  29. {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/docs/data-model.md +0 -0
  30. {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/docs/examples/clustering-io.ipynb +0 -0
  31. {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/docs/examples/getting-started.ipynb +0 -0
  32. {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/docs/examples/multi-dim.ipynb +0 -0
  33. {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/docs/examples/segmentation.ipynb +0 -0
  34. {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/docs/examples/tuning.ipynb +0 -0
  35. {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/docs/gen_ref_pages.py +0 -0
  36. {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/docs/index.md +0 -0
  37. {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/docs/stylesheets/extra.css +0 -0
  38. {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/mkdocs.yml +0 -0
  39. {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/scripts/generate_readme_images.py +0 -0
  40. {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/src/tsam_xarray/__init__.py +0 -0
  41. {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/src/tsam_xarray/_sample_data.py +0 -0
  42. {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/src/tsam_xarray/_tuning.py +0 -0
  43. {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/test/conftest.py +0 -0
  44. {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/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.0"
3
+ }
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.6.0](https://github.com/FBumann/tsam_xarray/compare/v0.5.2...v0.6.0) (2026-05-27)
4
+
5
+
6
+ ### ⚠ BREAKING CHANGES
7
+
8
+ * **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))
9
+
10
+
11
+ ### Refactors
12
+
13
+ * 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))
14
+
15
+
16
+ ### Dependencies
17
+
18
+ * Bump minimum `tsam` to `>=3.4.0` (required for the `time_index` round-trip above).
19
+ * Bump `googleapis/release-please-action` from 4 to 5. ([#82](https://github.com/FBumann/tsam_xarray/pull/82))
20
+ * Bump `dependabot/fetch-metadata` from 2 to 3. ([#81](https://github.com/FBumann/tsam_xarray/pull/81))
21
+
3
22
  ## [0.5.2](https://github.com/FBumann/tsam_xarray/compare/v0.5.1...v0.5.2) (2026-04-01)
4
23
 
5
24
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tsam_xarray
3
- Version: 0.5.2
3
+ Version: 0.6.0
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'
@@ -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
  )
@@ -475,7 +475,6 @@ def _aggregate_single(
475
475
  cluster_dim=col_dims,
476
476
  slice_dims=[],
477
477
  clusterings={(): tsam_result.clustering},
478
- time_coords=pd.DatetimeIndex(da.coords[time_dim].values),
479
478
  )
480
479
 
481
480
  return AggregationResult(
@@ -562,7 +561,6 @@ def _concat_results(
562
561
  cluster_dim=first.clustering.cluster_dim,
563
562
  slice_dims=slice_dims,
564
563
  clusterings=merged_clusterings,
565
- time_coords=first.clustering.time_coords,
566
564
  )
567
565
 
568
566
  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.0'
22
+ __version_tuple__ = version_tuple = (0, 6, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -1283,30 +1283,8 @@ class TestClusteringDisaggregate:
1283
1283
  expected = result.disaggregate(result.cluster_representatives)
1284
1284
  xr.testing.assert_allclose(dis, expected)
1285
1285
 
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."""
1286
+ def test_legacy_time_coords_json(self, tmp_path):
1287
+ """Legacy JSON with outer ``time_coords`` loads with a warning."""
1310
1288
  import json
1311
1289
 
1312
1290
  da = _make_da()
@@ -1320,61 +1298,19 @@ class TestClusteringDisaggregate:
1320
1298
  path = tmp_path / "clustering.json"
1321
1299
  result.clustering.to_json(str(path))
1322
1300
 
1323
- # Strip time_coords from saved JSON
1301
+ # Forge a pre-0.6 file: outer time_coords + inner blob without time_index.
1324
1302
  with open(path) as f:
1325
1303
  data = json.load(f)
1326
- del data["time_coords"]
1304
+ tc = data["clusterings"][0]["clustering"].pop("time_index")
1305
+ data["time_coords"] = tc
1327
1306
  with open(path, "w") as f:
1328
1307
  json.dump(data, f)
1329
1308
 
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
1309
+ with pytest.warns(DeprecationWarning, match="legacy tsam_xarray JSON"):
1310
+ clustering = tsam_xarray.load_clustering(str(path))
1342
1311
 
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
- )
1312
+ dis = clustering.disaggregate(result.cluster_representatives)
1313
+ assert dis.indexes["time"].equals(da_flat.indexes["time"])
1378
1314
 
1379
1315
  def test_1d_disaggregate(self, tmp_path):
1380
1316
  """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