tsam-xarray 0.6.1__tar.gz → 0.6.3__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.6.1 → tsam_xarray-0.6.3}/.github/workflows/ci.yaml +3 -3
  2. {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/.github/workflows/publish.yaml +1 -1
  3. tsam_xarray-0.6.3/.release-please-manifest.json +3 -0
  4. {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/CHANGELOG.md +19 -0
  5. {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/PKG-INFO +1 -1
  6. {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/docs/examples/getting-started.ipynb +43 -9
  7. {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/src/tsam_xarray/_clustering.py +38 -0
  8. {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/src/tsam_xarray/_core.py +28 -23
  9. {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/src/tsam_xarray/_result.py +60 -2
  10. {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/src/tsam_xarray/_version.py +2 -2
  11. {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/test/test_aggregate.py +183 -0
  12. tsam_xarray-0.6.1/.release-please-manifest.json +0 -3
  13. {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/.github/dependabot.yml +0 -0
  14. {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/.github/workflows/dependabot-auto-merge.yaml +0 -0
  15. {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/.github/workflows/pr-title.yaml +0 -0
  16. {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/.github/workflows/release.yaml +0 -0
  17. {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/.gitignore +0 -0
  18. {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/.pre-commit-config.yaml +0 -0
  19. {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/.readthedocs.yaml +0 -0
  20. {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/.release-please-config.json +0 -0
  21. {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/CLAUDE.md +0 -0
  22. {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/LICENSE +0 -0
  23. {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/README.md +0 -0
  24. {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/codecov.yml +0 -0
  25. {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/docs/assets/multi-dim-input.png +0 -0
  26. {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/docs/assets/multi-dim-metrics.png +0 -0
  27. {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/docs/changelog.md +0 -0
  28. {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/docs/data-model.md +0 -0
  29. {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/docs/examples/clustering-io.ipynb +0 -0
  30. {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/docs/examples/multi-dim.ipynb +0 -0
  31. {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/docs/examples/segmentation.ipynb +0 -0
  32. {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/docs/examples/tuning.ipynb +0 -0
  33. {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/docs/gen_ref_pages.py +0 -0
  34. {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/docs/index.md +0 -0
  35. {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/docs/stylesheets/extra.css +0 -0
  36. {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/mkdocs.yml +0 -0
  37. {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/pyproject.toml +0 -0
  38. {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/scripts/generate_readme_images.py +0 -0
  39. {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/src/tsam_xarray/__init__.py +0 -0
  40. {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/src/tsam_xarray/_sample_data.py +0 -0
  41. {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/src/tsam_xarray/_tuning.py +0 -0
  42. {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/test/conftest.py +0 -0
  43. {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/test/test_parametrized.py +0 -0
  44. {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/test/test_tuning.py +0 -0
@@ -30,7 +30,7 @@ jobs:
30
30
  with:
31
31
  enable-cache: true
32
32
 
33
- - uses: actions/setup-python@v6
33
+ - uses: actions/setup-python@v7
34
34
  with:
35
35
  python-version: "3.12"
36
36
 
@@ -56,7 +56,7 @@ jobs:
56
56
  with:
57
57
  enable-cache: true
58
58
 
59
- - uses: actions/setup-python@v6
59
+ - uses: actions/setup-python@v7
60
60
  with:
61
61
  python-version: ${{ matrix.python-version }}
62
62
 
@@ -83,7 +83,7 @@ jobs:
83
83
  with:
84
84
  enable-cache: true
85
85
 
86
- - uses: actions/setup-python@v6
86
+ - uses: actions/setup-python@v7
87
87
  with:
88
88
  python-version: "3.12"
89
89
 
@@ -31,7 +31,7 @@ jobs:
31
31
  with:
32
32
  enable-cache: true
33
33
 
34
- - uses: actions/setup-python@v6
34
+ - uses: actions/setup-python@v7
35
35
  with:
36
36
  python-version: "3.12"
37
37
 
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.6.3"
3
+ }
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.6.3](https://github.com/FBumann/tsam_xarray/compare/v0.6.2...v0.6.3) (2026-07-22)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * drop stored weights on apply() when columns are absent ([#100](https://github.com/FBumann/tsam_xarray/issues/100)) ([5616d1c](https://github.com/FBumann/tsam_xarray/commit/5616d1c14923c00e0bd795e7ea75193d457d6860))
9
+
10
+ ## [0.6.2](https://github.com/FBumann/tsam_xarray/compare/v0.6.1...v0.6.2) (2026-07-21)
11
+
12
+
13
+ ### Features
14
+
15
+ * first-class original-vs-reconstructed comparison on AggregationResult ([#93](https://github.com/FBumann/tsam_xarray/issues/93)) ([b3fd174](https://github.com/FBumann/tsam_xarray/commit/b3fd174cdbe379eb1de586aba5bfb75c0e4d21bb))
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * align reconstructed coordinate order with input (+ comparison recipe) ([#97](https://github.com/FBumann/tsam_xarray/issues/97)) ([afd6818](https://github.com/FBumann/tsam_xarray/commit/afd6818715704822bacfad6edfa5c6595e9f8fc4))
21
+
3
22
  ## [0.6.1](https://github.com/FBumann/tsam_xarray/compare/v0.6.0...v0.6.1) (2026-07-09)
4
23
 
5
24
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tsam_xarray
3
- Version: 0.6.1
3
+ Version: 0.6.3
4
4
  Summary: Lightweight xarray wrapper for tsam time series aggregation
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -124,7 +124,13 @@
124
124
  "cell_type": "markdown",
125
125
  "metadata": {},
126
126
  "source": [
127
- "## Reconstructed vs original"
127
+ "## Reconstructed vs original\n",
128
+ "\n",
129
+ "`result.compare()` stacks `original` and `reconstructed` along a `variant`\n",
130
+ "dimension (on the original time axis), so the comparison plots directly with\n",
131
+ "`color=\"variant\"` — no manual `concat`/`melt`. Pass a coordinate selection\n",
132
+ "(e.g. `variable=\"solar\"`) to focus on one column. `result.original` and\n",
133
+ "`result.reconstructed` share the same dim order, so they always line up."
128
134
  ]
129
135
  },
130
136
  {
@@ -133,14 +139,8 @@
133
139
  "metadata": {},
134
140
  "outputs": [],
135
141
  "source": [
136
- "import xarray as xr\n",
137
- "\n",
138
- "comparison = xr.concat(\n",
139
- " [da_simple.sel(variable=\"solar\"), result.reconstructed.sel(variable=\"solar\")],\n",
140
- " dim=\"source\",\n",
141
- ").assign_coords(source=[\"original\", \"reconstructed\"])\n",
142
- "comparison.plotly.line(\n",
143
- " x=\"time\", color=\"source\", title=\"Original vs reconstructed (solar)\"\n",
142
+ "result.compare(variable=\"solar\").plotly.line(\n",
143
+ " x=\"time\", color=\"variant\", title=\"Original vs reconstructed (solar)\"\n",
144
144
  ")"
145
145
  ]
146
146
  },
@@ -153,6 +153,38 @@
153
153
  "result.residuals.plotly.line(x=\"time\", facet_col=\"variable\", title=\"Residuals\")"
154
154
  ]
155
155
  },
156
+ {
157
+ "cell_type": "markdown",
158
+ "metadata": {},
159
+ "source": [
160
+ "Because `to_dataframe()` returns tidy long-form data, richer comparisons are a\n",
161
+ "few lines in the plotting library of your choice — no bespoke helper needed.\n",
162
+ "For example, load-duration curves (each series sorted descending, Original\n",
163
+ "dotted / Reconstructed solid):"
164
+ ]
165
+ },
166
+ {
167
+ "cell_type": "code",
168
+ "execution_count": null,
169
+ "metadata": {},
170
+ "outputs": [],
171
+ "source": [
172
+ "tidy = result.to_dataframe()\n",
173
+ "tidy[\"rank\"] = (\n",
174
+ " tidy.groupby([\"variant\", \"variable\"])[\"energy\"]\n",
175
+ " .rank(method=\"first\", ascending=False)\n",
176
+ " .astype(int)\n",
177
+ ")\n",
178
+ "duration = tidy.set_index([\"rank\", \"variant\", \"variable\"])[\"energy\"].to_xarray()\n",
179
+ "duration.plotly.line(\n",
180
+ " x=\"rank\",\n",
181
+ " color=\"variable\",\n",
182
+ " line_dash=\"variant\",\n",
183
+ " line_dash_map={\"original\": \"dot\", \"reconstructed\": \"solid\"},\n",
184
+ " title=\"Load-duration curves: original vs reconstructed\",\n",
185
+ ")"
186
+ ]
187
+ },
156
188
  {
157
189
  "cell_type": "markdown",
158
190
  "metadata": {},
@@ -195,6 +227,8 @@
195
227
  "metadata": {},
196
228
  "outputs": [],
197
229
  "source": [
230
+ "import xarray as xr\n",
231
+ "\n",
198
232
  "disaggregated = result.disaggregate(result.cluster_representatives)\n",
199
233
  "\n",
200
234
  "comparison = xr.concat(\n",
@@ -560,6 +560,43 @@ def _validate_apply(
560
560
  raise ValueError(msg)
561
561
 
562
562
 
563
+ def _drop_missing_weights(
564
+ cr: tsam.ClusteringResult, df: pd.DataFrame
565
+ ) -> tsam.ClusteringResult:
566
+ """Strip stored clustering weights when they reference absent columns.
567
+
568
+ tsam's ``ClusteringResult.apply()`` hard-fails when a stored weight column
569
+ is missing from the new data, which blocks transferring a clustering to a
570
+ differently-composed dataset. At apply time the cluster assignments and
571
+ centers are already fixed, so weights cannot change which periods are
572
+ selected — their only remaining effect is on the weighted accuracy
573
+ metrics. When a weighted column is absent we drop all weights and warn.
574
+
575
+ Tracking upstream fix: https://github.com/FZJ-IEK3-VSA/tsam/issues/396.
576
+ Once that lands, ``test_upstream_still_rejects_missing_weight_columns``
577
+ fails and this workaround can be removed.
578
+ """
579
+ import dataclasses
580
+ import warnings
581
+
582
+ weights = getattr(cr, "weights", None)
583
+ if not weights:
584
+ return cr
585
+ missing = set(weights) - set(df.columns)
586
+ if not missing:
587
+ return cr
588
+
589
+ warnings.warn(
590
+ f"Stored clustering weights reference columns absent from the new "
591
+ f"data ({sorted(map(str, missing))}); dropping all weights. "
592
+ f"Representatives and reconstruction are unaffected — only the "
593
+ f"weighted accuracy metrics change.",
594
+ UserWarning,
595
+ stacklevel=3,
596
+ )
597
+ return dataclasses.replace(cr, weights=None)
598
+
599
+
563
600
  def _apply_single(
564
601
  da: xr.DataArray,
565
602
  cr: tsam.ClusteringResult,
@@ -580,6 +617,7 @@ def _apply_single(
580
617
  from tsam_xarray._result import AccuracyMetrics, AggregationResult
581
618
 
582
619
  df = _to_dataframe(da, time_dim, col_dims)
620
+ cr = _drop_missing_weights(cr, df)
583
621
  tsam_result = cr.apply(df, **tsam_kwargs)
584
622
 
585
623
  typical = _representatives_to_da(tsam_result.cluster_representatives, col_dims)
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import itertools
6
+ import warnings
6
7
  from collections.abc import Hashable, Sequence
7
8
  from typing import Any
8
9
 
@@ -280,8 +281,6 @@ def _validate_data(
280
281
 
281
282
  # Dask arrays — compute before other checks
282
283
  if hasattr(da.data, "dask"):
283
- import warnings
284
-
285
284
  warnings.warn(
286
285
  "DataArray is backed by dask. Computing eagerly for tsam.",
287
286
  stacklevel=3,
@@ -640,28 +639,33 @@ def _aggregate_single(
640
639
 
641
640
  active_cols = _active_columns(df, active_coords, col_dims)
642
641
 
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}
642
+ with warnings.catch_warnings():
643
+ warnings.filterwarnings(
644
+ "ignore",
645
+ message=".*sorted alphabetically.*",
646
+ category=FutureWarning,
656
647
  )
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)
648
+ if len(active_cols) == len(df.columns):
649
+ tsam_result = tsam.aggregate(
650
+ df,
651
+ n_clusters,
652
+ weights=tsam_weights, # type: ignore[arg-type]
653
+ **tsam_kwargs,
654
+ )
655
+ else:
656
+ active_weights = (
657
+ None
658
+ if tsam_weights is None
659
+ else {c: tsam_weights[c] for c in active_cols}
660
+ )
661
+ clustering = tsam.aggregate(
662
+ df[active_cols],
663
+ n_clusters,
664
+ weights=active_weights, # type: ignore[arg-type]
665
+ **tsam_kwargs,
666
+ ).clustering
667
+ apply_kwargs = {k: v for k, v in tsam_kwargs.items() if k in _APPLY_KWARGS}
668
+ tsam_result = clustering.apply(df, **apply_kwargs)
665
669
 
666
670
  return _result_from_tsam(tsam_result, da, df, time_dim, col_dims)
667
671
 
@@ -676,6 +680,7 @@ def _result_from_tsam(
676
680
  """Build an AggregationResult from a tsam aggregation result."""
677
681
  typical = _representatives_to_da(tsam_result.cluster_representatives, col_dims)
678
682
  reconstructed = _reconstructed_to_da(tsam_result.reconstructed, time_dim, col_dims)
683
+ reconstructed = reconstructed.transpose(*da.dims).reindex_like(da)
679
684
 
680
685
  cw = tsam_result.cluster_weights
681
686
  cluster_ids = np.array(sorted(cw.keys()))
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  from dataclasses import dataclass
6
6
  from typing import TYPE_CHECKING
7
7
 
8
+ import pandas as pd
8
9
  import xarray as xr
9
10
 
10
11
  if TYPE_CHECKING:
@@ -71,7 +72,7 @@ class AggregationResult:
71
72
  *slice_dims)``.
72
73
  accuracy: Per-column and weighted accuracy metrics.
73
74
  reconstructed: Reconstructed time series
74
- (same shape as input).
75
+ (same shape and dim order as ``original``).
75
76
  original: The input data.
76
77
  clustering: Reusable clustering metadata.
77
78
  See `ClusteringResult`.
@@ -121,9 +122,66 @@ class AggregationResult:
121
122
 
122
123
  @property
123
124
  def residuals(self) -> xr.DataArray:
124
- """Difference between original and reconstructed data."""
125
+ """Difference between original and reconstructed data.
126
+
127
+ Shares the dim order of ``original`` and ``reconstructed``.
128
+ """
125
129
  return self.original - self.reconstructed
126
130
 
131
+ def compare(self, **sel: object) -> xr.DataArray:
132
+ """Stack ``original`` and ``reconstructed`` along a ``variant`` dim.
133
+
134
+ Returns a single DataArray on the original time axis with a new
135
+ ``variant`` coordinate ``["original", "reconstructed"]``, ready to
136
+ plot directly with a ``color=``/``hue="variant"`` grouping — no
137
+ ``melt`` step. This is the canonical way to eyeball aggregation
138
+ quality per column and per slice dim.
139
+
140
+ Args:
141
+ **sel: Optional label-based selection applied to both arrays
142
+ before stacking, e.g. ``compare(variable="solar")`` to
143
+ compare a single column.
144
+
145
+ Returns:
146
+ DataArray with dims ``("variant", *original.dims)``.
147
+
148
+ Examples:
149
+ >>> agg.compare(variable="solar").plotly.line(
150
+ ... x="time", color="variant"
151
+ ... )
152
+ """
153
+ original = self.original
154
+ reconstructed = self.reconstructed
155
+ if sel:
156
+ original = original.sel(sel)
157
+ reconstructed = reconstructed.sel(sel)
158
+ variant = pd.Index(["original", "reconstructed"], name="variant")
159
+ combined = xr.concat([original, reconstructed], dim=variant)
160
+ combined.name = self.original.name
161
+ return combined
162
+
163
+ def to_dataframe(self, **sel: object) -> pd.DataFrame:
164
+ """Tidy/long-form ``original`` vs ``reconstructed`` DataFrame.
165
+
166
+ A flat DataFrame with a ``variant`` column
167
+ (``"original"``/``"reconstructed"``), the ``time`` axis, every
168
+ cluster and slice dim, and a value column — ready to hand
169
+ straight to a plotting library.
170
+
171
+ Args:
172
+ **sel: Optional label-based selection forwarded to
173
+ `compare` (e.g. ``variable="solar"``).
174
+
175
+ Returns:
176
+ Long-form DataFrame with a ``variant`` column and one value
177
+ column (named after the input DataArray, or ``"value"``).
178
+ """
179
+ combined = self.compare(**sel)
180
+ name = combined.name
181
+ if name is None or str(name) == "variant":
182
+ name = "value"
183
+ return combined.to_dataframe(name=str(name)).reset_index()
184
+
127
185
  def disaggregate(self, data: xr.DataArray) -> xr.DataArray:
128
186
  """Map data on ``(cluster, timestep)`` back to original time.
129
187
 
@@ -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.6.1'
22
- __version_tuple__ = version_tuple = (0, 6, 1)
21
+ __version__ = version = '0.6.3'
22
+ __version_tuple__ = version_tuple = (0, 6, 3)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -2,6 +2,8 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import warnings
6
+
5
7
  import numpy as np
6
8
  import pandas as pd
7
9
  import pytest
@@ -1373,6 +1375,63 @@ class TestClusteringIO:
1373
1375
  xr.testing.assert_allclose(dis, new_result.reconstructed)
1374
1376
 
1375
1377
 
1378
+ class TestApplyWeightTransfer:
1379
+ """Transferring a weighted clustering to differently-composed data.
1380
+
1381
+ Works around tsam#396: ``ClusteringResult.apply()`` hard-fails when a
1382
+ stored weight column is missing from the new data. We drop the weights
1383
+ and warn instead, since at apply time weights cannot change selection.
1384
+ """
1385
+
1386
+ def _weighted_clustering(self):
1387
+ da = _make_da()
1388
+ da_flat = da.isel(region=0).drop_vars("region")
1389
+ result = tsam_xarray.aggregate(
1390
+ da_flat,
1391
+ time_dim="time",
1392
+ cluster_dim="variable",
1393
+ n_clusters=4,
1394
+ weights={"solar": 5.0, "wind": 1.0},
1395
+ )
1396
+ return result.clustering, da_flat
1397
+
1398
+ def test_apply_warns_and_drops_missing_weight_columns(self):
1399
+ """Applying to data without a weighted column warns, not raises."""
1400
+ clustering, da_flat = self._weighted_clustering()
1401
+ new_da = da_flat.sel(variable=["wind"]) # 'solar' (weighted) absent
1402
+ with pytest.warns(UserWarning, match="absent from the new data"):
1403
+ new_result = clustering.apply(new_da)
1404
+ assert "solar" not in new_result.cluster_representatives["variable"].values
1405
+ assert new_result.reconstructed.notnull().all()
1406
+
1407
+ def test_apply_no_warning_when_all_weight_columns_present(self):
1408
+ """Same composition keeps weights and emits no warning."""
1409
+ clustering, da_flat = self._weighted_clustering()
1410
+ with warnings.catch_warnings():
1411
+ warnings.simplefilter("error")
1412
+ new_result = clustering.apply(da_flat)
1413
+ assert new_result.n_clusters == 4
1414
+
1415
+ def test_upstream_still_rejects_missing_weight_columns(self):
1416
+ """Canary: when raw tsam stops raising, tsam#396 is fixed.
1417
+
1418
+ Remove ``_drop_missing_weights`` and this test once that lands.
1419
+ """
1420
+ import tsam
1421
+
1422
+ idx = pd.date_range("2020-01-01", periods=24 * 20, freq="h")
1423
+ rng = np.random.default_rng(0)
1424
+ df = pd.DataFrame(
1425
+ {"a": rng.random(len(idx)), "b": rng.random(len(idx))},
1426
+ index=idx,
1427
+ )
1428
+ cr = tsam.aggregate(
1429
+ df, 4, period_duration=24, weights={"a": 5.0, "b": 0.1}
1430
+ ).clustering
1431
+ with pytest.raises(ValueError, match="Weight columns not found"):
1432
+ cr.apply(df.drop(columns=["b"]))
1433
+
1434
+
1376
1435
  class TestClusteringDisaggregate:
1377
1436
  """Tests for ClusteringInfo.disaggregate()."""
1378
1437
 
@@ -1529,3 +1588,127 @@ class TestSliceEdgeCases:
1529
1588
  _validate_consistent_cluster_counts(
1530
1589
  [r1, r2], [("low",), ("high",)]
1531
1590
  ) # should not raise
1591
+
1592
+
1593
+ def _make_time_last_da() -> xr.DataArray:
1594
+ """DataArray with time as the LAST dim (the #92 papercut case)."""
1595
+ time = pd.date_range("2020-01-01", periods=10 * 24, freq="h")
1596
+ rng = np.random.default_rng(0)
1597
+ return xr.DataArray(
1598
+ rng.random((2, 2, len(time))),
1599
+ dims=["scenario", "variable", "time"],
1600
+ coords={
1601
+ "scenario": ["a", "b"],
1602
+ "variable": ["solar", "wind"],
1603
+ "time": time,
1604
+ },
1605
+ name="power",
1606
+ )
1607
+
1608
+
1609
+ class TestDimOrderConsistency:
1610
+ """original / reconstructed / residuals must share dim order (#92)."""
1611
+
1612
+ def test_time_last_input_aligned(self):
1613
+ da = _make_time_last_da()
1614
+ result = tsam_xarray.aggregate(
1615
+ da, time_dim="time", cluster_dim="variable", n_clusters=4
1616
+ )
1617
+ assert result.original.dims == da.dims
1618
+ assert result.reconstructed.dims == result.original.dims
1619
+ assert result.residuals.dims == result.original.dims
1620
+
1621
+ def test_time_first_input_aligned(self):
1622
+ da = _make_da(scenarios=["low", "high"])
1623
+ result = tsam_xarray.aggregate(
1624
+ da,
1625
+ time_dim="time",
1626
+ cluster_dim=["variable", "region"],
1627
+ n_clusters=4,
1628
+ )
1629
+ assert result.reconstructed.dims == result.original.dims
1630
+ assert result.residuals.dims == result.original.dims
1631
+
1632
+ def test_single_dim_no_slice(self):
1633
+ da = _make_time_last_da().isel(scenario=0).drop_vars("scenario")
1634
+ result = tsam_xarray.aggregate(
1635
+ da, time_dim="time", cluster_dim="variable", n_clusters=4
1636
+ )
1637
+ assert result.reconstructed.dims == result.original.dims == da.dims
1638
+
1639
+ def test_non_alphabetical_coord_order_preserved(self):
1640
+ """reconstructed keeps the input's coord order, not tsam's sorted one."""
1641
+ time = pd.date_range("2020-01-01", periods=5 * 24, freq="h")
1642
+ rng = np.random.default_rng(0)
1643
+ da = xr.DataArray(
1644
+ rng.random((len(time), 3)),
1645
+ dims=["time", "variable"],
1646
+ coords={"time": time, "variable": ["wind", "solar", "demand"]},
1647
+ name="p",
1648
+ )
1649
+ result = tsam_xarray.aggregate(
1650
+ da, time_dim="time", cluster_dim="variable", n_clusters=3
1651
+ )
1652
+ assert list(result.reconstructed["variable"].values) == [
1653
+ "wind",
1654
+ "solar",
1655
+ "demand",
1656
+ ]
1657
+ assert not bool(result.reconstructed.isnull().any())
1658
+
1659
+
1660
+ class TestCompare:
1661
+ """AggregationResult.compare() and .to_dataframe() (#92)."""
1662
+
1663
+ def test_compare_adds_variant_dim(self):
1664
+ da = _make_time_last_da()
1665
+ result = tsam_xarray.aggregate(
1666
+ da, time_dim="time", cluster_dim="variable", n_clusters=4
1667
+ )
1668
+ cmp = result.compare()
1669
+ assert cmp.dims == ("variant", *result.original.dims)
1670
+ assert list(cmp["variant"].values) == ["original", "reconstructed"]
1671
+ assert cmp.name == "power"
1672
+ xr.testing.assert_equal(cmp.sel(variant="original", drop=True), result.original)
1673
+ xr.testing.assert_equal(
1674
+ cmp.sel(variant="reconstructed", drop=True), result.reconstructed
1675
+ )
1676
+
1677
+ def test_compare_with_selection(self):
1678
+ da = _make_time_last_da()
1679
+ result = tsam_xarray.aggregate(
1680
+ da, time_dim="time", cluster_dim="variable", n_clusters=4
1681
+ )
1682
+ cmp = result.compare(variable="solar")
1683
+ assert "variable" not in cmp.dims
1684
+ assert cmp.sizes["variant"] == 2
1685
+
1686
+ def test_to_dataframe_is_long_form(self):
1687
+ da = _make_time_last_da()
1688
+ result = tsam_xarray.aggregate(
1689
+ da, time_dim="time", cluster_dim="variable", n_clusters=4
1690
+ )
1691
+ df = result.to_dataframe(variable="solar")
1692
+ assert "variant" in df.columns
1693
+ assert "power" in df.columns
1694
+ assert set(df["variant"].unique()) == {"original", "reconstructed"}
1695
+ n_time = da.sizes["time"]
1696
+ assert len(df) == 2 * da.sizes["scenario"] * n_time
1697
+
1698
+ def test_to_dataframe_unnamed_uses_value(self):
1699
+ da = _make_time_last_da().rename(None)
1700
+ result = tsam_xarray.aggregate(
1701
+ da, time_dim="time", cluster_dim="variable", n_clusters=4
1702
+ )
1703
+ df = result.to_dataframe()
1704
+ assert "value" in df.columns
1705
+
1706
+ def test_to_dataframe_name_collision_with_variant(self):
1707
+ """An input named 'variant' falls back to 'value' (no insert clash)."""
1708
+ da = _make_time_last_da().rename("variant")
1709
+ result = tsam_xarray.aggregate(
1710
+ da, time_dim="time", cluster_dim="variable", n_clusters=4
1711
+ )
1712
+ df = result.to_dataframe(variable="solar")
1713
+ assert "value" in df.columns
1714
+ assert list(df.columns).count("variant") == 1
@@ -1,3 +0,0 @@
1
- {
2
- ".": "0.6.1"
3
- }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes