tsam-xarray 0.6.1__tar.gz → 0.6.2__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.2/.release-please-manifest.json +3 -0
  2. {tsam_xarray-0.6.1 → tsam_xarray-0.6.2}/CHANGELOG.md +12 -0
  3. {tsam_xarray-0.6.1 → tsam_xarray-0.6.2}/PKG-INFO +1 -1
  4. {tsam_xarray-0.6.1 → tsam_xarray-0.6.2}/docs/examples/getting-started.ipynb +43 -9
  5. {tsam_xarray-0.6.1 → tsam_xarray-0.6.2}/src/tsam_xarray/_core.py +28 -23
  6. {tsam_xarray-0.6.1 → tsam_xarray-0.6.2}/src/tsam_xarray/_result.py +60 -2
  7. {tsam_xarray-0.6.1 → tsam_xarray-0.6.2}/src/tsam_xarray/_version.py +2 -2
  8. {tsam_xarray-0.6.1 → tsam_xarray-0.6.2}/test/test_aggregate.py +124 -0
  9. tsam_xarray-0.6.1/.release-please-manifest.json +0 -3
  10. {tsam_xarray-0.6.1 → tsam_xarray-0.6.2}/.github/dependabot.yml +0 -0
  11. {tsam_xarray-0.6.1 → tsam_xarray-0.6.2}/.github/workflows/ci.yaml +0 -0
  12. {tsam_xarray-0.6.1 → tsam_xarray-0.6.2}/.github/workflows/dependabot-auto-merge.yaml +0 -0
  13. {tsam_xarray-0.6.1 → tsam_xarray-0.6.2}/.github/workflows/pr-title.yaml +0 -0
  14. {tsam_xarray-0.6.1 → tsam_xarray-0.6.2}/.github/workflows/publish.yaml +0 -0
  15. {tsam_xarray-0.6.1 → tsam_xarray-0.6.2}/.github/workflows/release.yaml +0 -0
  16. {tsam_xarray-0.6.1 → tsam_xarray-0.6.2}/.gitignore +0 -0
  17. {tsam_xarray-0.6.1 → tsam_xarray-0.6.2}/.pre-commit-config.yaml +0 -0
  18. {tsam_xarray-0.6.1 → tsam_xarray-0.6.2}/.readthedocs.yaml +0 -0
  19. {tsam_xarray-0.6.1 → tsam_xarray-0.6.2}/.release-please-config.json +0 -0
  20. {tsam_xarray-0.6.1 → tsam_xarray-0.6.2}/CLAUDE.md +0 -0
  21. {tsam_xarray-0.6.1 → tsam_xarray-0.6.2}/LICENSE +0 -0
  22. {tsam_xarray-0.6.1 → tsam_xarray-0.6.2}/README.md +0 -0
  23. {tsam_xarray-0.6.1 → tsam_xarray-0.6.2}/codecov.yml +0 -0
  24. {tsam_xarray-0.6.1 → tsam_xarray-0.6.2}/docs/assets/multi-dim-input.png +0 -0
  25. {tsam_xarray-0.6.1 → tsam_xarray-0.6.2}/docs/assets/multi-dim-metrics.png +0 -0
  26. {tsam_xarray-0.6.1 → tsam_xarray-0.6.2}/docs/changelog.md +0 -0
  27. {tsam_xarray-0.6.1 → tsam_xarray-0.6.2}/docs/data-model.md +0 -0
  28. {tsam_xarray-0.6.1 → tsam_xarray-0.6.2}/docs/examples/clustering-io.ipynb +0 -0
  29. {tsam_xarray-0.6.1 → tsam_xarray-0.6.2}/docs/examples/multi-dim.ipynb +0 -0
  30. {tsam_xarray-0.6.1 → tsam_xarray-0.6.2}/docs/examples/segmentation.ipynb +0 -0
  31. {tsam_xarray-0.6.1 → tsam_xarray-0.6.2}/docs/examples/tuning.ipynb +0 -0
  32. {tsam_xarray-0.6.1 → tsam_xarray-0.6.2}/docs/gen_ref_pages.py +0 -0
  33. {tsam_xarray-0.6.1 → tsam_xarray-0.6.2}/docs/index.md +0 -0
  34. {tsam_xarray-0.6.1 → tsam_xarray-0.6.2}/docs/stylesheets/extra.css +0 -0
  35. {tsam_xarray-0.6.1 → tsam_xarray-0.6.2}/mkdocs.yml +0 -0
  36. {tsam_xarray-0.6.1 → tsam_xarray-0.6.2}/pyproject.toml +0 -0
  37. {tsam_xarray-0.6.1 → tsam_xarray-0.6.2}/scripts/generate_readme_images.py +0 -0
  38. {tsam_xarray-0.6.1 → tsam_xarray-0.6.2}/src/tsam_xarray/__init__.py +0 -0
  39. {tsam_xarray-0.6.1 → tsam_xarray-0.6.2}/src/tsam_xarray/_clustering.py +0 -0
  40. {tsam_xarray-0.6.1 → tsam_xarray-0.6.2}/src/tsam_xarray/_sample_data.py +0 -0
  41. {tsam_xarray-0.6.1 → tsam_xarray-0.6.2}/src/tsam_xarray/_tuning.py +0 -0
  42. {tsam_xarray-0.6.1 → tsam_xarray-0.6.2}/test/conftest.py +0 -0
  43. {tsam_xarray-0.6.1 → tsam_xarray-0.6.2}/test/test_parametrized.py +0 -0
  44. {tsam_xarray-0.6.1 → tsam_xarray-0.6.2}/test/test_tuning.py +0 -0
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.6.2"
3
+ }
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.6.2](https://github.com/FBumann/tsam_xarray/compare/v0.6.1...v0.6.2) (2026-07-21)
4
+
5
+
6
+ ### Features
7
+
8
+ * 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))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * 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))
14
+
3
15
  ## [0.6.1](https://github.com/FBumann/tsam_xarray/compare/v0.6.0...v0.6.1) (2026-07-09)
4
16
 
5
17
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tsam_xarray
3
- Version: 0.6.1
3
+ Version: 0.6.2
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",
@@ -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.2'
22
+ __version_tuple__ = version_tuple = (0, 6, 2)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -1529,3 +1529,127 @@ class TestSliceEdgeCases:
1529
1529
  _validate_consistent_cluster_counts(
1530
1530
  [r1, r2], [("low",), ("high",)]
1531
1531
  ) # should not raise
1532
+
1533
+
1534
+ def _make_time_last_da() -> xr.DataArray:
1535
+ """DataArray with time as the LAST dim (the #92 papercut case)."""
1536
+ time = pd.date_range("2020-01-01", periods=10 * 24, freq="h")
1537
+ rng = np.random.default_rng(0)
1538
+ return xr.DataArray(
1539
+ rng.random((2, 2, len(time))),
1540
+ dims=["scenario", "variable", "time"],
1541
+ coords={
1542
+ "scenario": ["a", "b"],
1543
+ "variable": ["solar", "wind"],
1544
+ "time": time,
1545
+ },
1546
+ name="power",
1547
+ )
1548
+
1549
+
1550
+ class TestDimOrderConsistency:
1551
+ """original / reconstructed / residuals must share dim order (#92)."""
1552
+
1553
+ def test_time_last_input_aligned(self):
1554
+ da = _make_time_last_da()
1555
+ result = tsam_xarray.aggregate(
1556
+ da, time_dim="time", cluster_dim="variable", n_clusters=4
1557
+ )
1558
+ assert result.original.dims == da.dims
1559
+ assert result.reconstructed.dims == result.original.dims
1560
+ assert result.residuals.dims == result.original.dims
1561
+
1562
+ def test_time_first_input_aligned(self):
1563
+ da = _make_da(scenarios=["low", "high"])
1564
+ result = tsam_xarray.aggregate(
1565
+ da,
1566
+ time_dim="time",
1567
+ cluster_dim=["variable", "region"],
1568
+ n_clusters=4,
1569
+ )
1570
+ assert result.reconstructed.dims == result.original.dims
1571
+ assert result.residuals.dims == result.original.dims
1572
+
1573
+ def test_single_dim_no_slice(self):
1574
+ da = _make_time_last_da().isel(scenario=0).drop_vars("scenario")
1575
+ result = tsam_xarray.aggregate(
1576
+ da, time_dim="time", cluster_dim="variable", n_clusters=4
1577
+ )
1578
+ assert result.reconstructed.dims == result.original.dims == da.dims
1579
+
1580
+ def test_non_alphabetical_coord_order_preserved(self):
1581
+ """reconstructed keeps the input's coord order, not tsam's sorted one."""
1582
+ time = pd.date_range("2020-01-01", periods=5 * 24, freq="h")
1583
+ rng = np.random.default_rng(0)
1584
+ da = xr.DataArray(
1585
+ rng.random((len(time), 3)),
1586
+ dims=["time", "variable"],
1587
+ coords={"time": time, "variable": ["wind", "solar", "demand"]},
1588
+ name="p",
1589
+ )
1590
+ result = tsam_xarray.aggregate(
1591
+ da, time_dim="time", cluster_dim="variable", n_clusters=3
1592
+ )
1593
+ assert list(result.reconstructed["variable"].values) == [
1594
+ "wind",
1595
+ "solar",
1596
+ "demand",
1597
+ ]
1598
+ assert not bool(result.reconstructed.isnull().any())
1599
+
1600
+
1601
+ class TestCompare:
1602
+ """AggregationResult.compare() and .to_dataframe() (#92)."""
1603
+
1604
+ def test_compare_adds_variant_dim(self):
1605
+ da = _make_time_last_da()
1606
+ result = tsam_xarray.aggregate(
1607
+ da, time_dim="time", cluster_dim="variable", n_clusters=4
1608
+ )
1609
+ cmp = result.compare()
1610
+ assert cmp.dims == ("variant", *result.original.dims)
1611
+ assert list(cmp["variant"].values) == ["original", "reconstructed"]
1612
+ assert cmp.name == "power"
1613
+ xr.testing.assert_equal(cmp.sel(variant="original", drop=True), result.original)
1614
+ xr.testing.assert_equal(
1615
+ cmp.sel(variant="reconstructed", drop=True), result.reconstructed
1616
+ )
1617
+
1618
+ def test_compare_with_selection(self):
1619
+ da = _make_time_last_da()
1620
+ result = tsam_xarray.aggregate(
1621
+ da, time_dim="time", cluster_dim="variable", n_clusters=4
1622
+ )
1623
+ cmp = result.compare(variable="solar")
1624
+ assert "variable" not in cmp.dims
1625
+ assert cmp.sizes["variant"] == 2
1626
+
1627
+ def test_to_dataframe_is_long_form(self):
1628
+ da = _make_time_last_da()
1629
+ result = tsam_xarray.aggregate(
1630
+ da, time_dim="time", cluster_dim="variable", n_clusters=4
1631
+ )
1632
+ df = result.to_dataframe(variable="solar")
1633
+ assert "variant" in df.columns
1634
+ assert "power" in df.columns
1635
+ assert set(df["variant"].unique()) == {"original", "reconstructed"}
1636
+ n_time = da.sizes["time"]
1637
+ assert len(df) == 2 * da.sizes["scenario"] * n_time
1638
+
1639
+ def test_to_dataframe_unnamed_uses_value(self):
1640
+ da = _make_time_last_da().rename(None)
1641
+ result = tsam_xarray.aggregate(
1642
+ da, time_dim="time", cluster_dim="variable", n_clusters=4
1643
+ )
1644
+ df = result.to_dataframe()
1645
+ assert "value" in df.columns
1646
+
1647
+ def test_to_dataframe_name_collision_with_variant(self):
1648
+ """An input named 'variant' falls back to 'value' (no insert clash)."""
1649
+ da = _make_time_last_da().rename("variant")
1650
+ result = tsam_xarray.aggregate(
1651
+ da, time_dim="time", cluster_dim="variable", n_clusters=4
1652
+ )
1653
+ df = result.to_dataframe(variable="solar")
1654
+ assert "value" in df.columns
1655
+ 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