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.
- {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/.github/workflows/dependabot-auto-merge.yaml +1 -1
- {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/.github/workflows/release.yaml +1 -1
- tsam_xarray-0.6.0/.release-please-manifest.json +3 -0
- {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/CHANGELOG.md +19 -0
- {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/PKG-INFO +2 -2
- {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/pyproject.toml +1 -1
- {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/src/tsam_xarray/_clustering.py +31 -64
- {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/src/tsam_xarray/_core.py +0 -2
- {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/src/tsam_xarray/_result.py +1 -4
- {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/src/tsam_xarray/_version.py +2 -2
- {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/test/test_aggregate.py +9 -73
- {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/test/test_parametrized.py +0 -58
- tsam_xarray-0.5.2/.release-please-manifest.json +0 -3
- {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/.github/dependabot.yml +0 -0
- {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/.github/workflows/ci.yaml +0 -0
- {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/.github/workflows/pr-title.yaml +0 -0
- {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/.github/workflows/publish.yaml +0 -0
- {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/.gitignore +0 -0
- {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/.pre-commit-config.yaml +0 -0
- {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/.readthedocs.yaml +0 -0
- {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/.release-please-config.json +0 -0
- {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/CLAUDE.md +0 -0
- {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/LICENSE +0 -0
- {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/README.md +0 -0
- {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/codecov.yml +0 -0
- {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/docs/assets/multi-dim-input.png +0 -0
- {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/docs/assets/multi-dim-metrics.png +0 -0
- {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/docs/changelog.md +0 -0
- {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/docs/data-model.md +0 -0
- {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/docs/examples/clustering-io.ipynb +0 -0
- {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/docs/examples/getting-started.ipynb +0 -0
- {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/docs/examples/multi-dim.ipynb +0 -0
- {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/docs/examples/segmentation.ipynb +0 -0
- {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/docs/examples/tuning.ipynb +0 -0
- {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/docs/gen_ref_pages.py +0 -0
- {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/docs/index.md +0 -0
- {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/docs/stylesheets/extra.css +0 -0
- {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/mkdocs.yml +0 -0
- {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/scripts/generate_readme_images.py +0 -0
- {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/src/tsam_xarray/__init__.py +0 -0
- {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/src/tsam_xarray/_sample_data.py +0 -0
- {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/src/tsam_xarray/_tuning.py +0 -0
- {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/test/conftest.py +0 -0
- {tsam_xarray-0.5.2 → tsam_xarray-0.6.0}/test/test_tuning.py +0 -0
|
@@ -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.
|
|
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.
|
|
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'
|
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
691
|
-
for
|
|
692
|
-
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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.
|
|
22
|
-
__version_tuple__ = version_tuple = (0,
|
|
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
|
|
1287
|
-
"""
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
1331
|
-
|
|
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
|
-
|
|
1344
|
-
|
|
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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|