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.
- {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/.github/workflows/ci.yaml +3 -3
- {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/.github/workflows/publish.yaml +1 -1
- tsam_xarray-0.6.3/.release-please-manifest.json +3 -0
- {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/CHANGELOG.md +19 -0
- {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/PKG-INFO +1 -1
- {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/docs/examples/getting-started.ipynb +43 -9
- {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/src/tsam_xarray/_clustering.py +38 -0
- {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/src/tsam_xarray/_core.py +28 -23
- {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/src/tsam_xarray/_result.py +60 -2
- {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/src/tsam_xarray/_version.py +2 -2
- {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/test/test_aggregate.py +183 -0
- tsam_xarray-0.6.1/.release-please-manifest.json +0 -3
- {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/.github/dependabot.yml +0 -0
- {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/.github/workflows/dependabot-auto-merge.yaml +0 -0
- {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/.github/workflows/pr-title.yaml +0 -0
- {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/.github/workflows/release.yaml +0 -0
- {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/.gitignore +0 -0
- {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/.pre-commit-config.yaml +0 -0
- {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/.readthedocs.yaml +0 -0
- {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/.release-please-config.json +0 -0
- {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/CLAUDE.md +0 -0
- {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/LICENSE +0 -0
- {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/README.md +0 -0
- {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/codecov.yml +0 -0
- {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/docs/assets/multi-dim-input.png +0 -0
- {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/docs/assets/multi-dim-metrics.png +0 -0
- {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/docs/changelog.md +0 -0
- {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/docs/data-model.md +0 -0
- {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/docs/examples/clustering-io.ipynb +0 -0
- {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/docs/examples/multi-dim.ipynb +0 -0
- {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/docs/examples/segmentation.ipynb +0 -0
- {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/docs/examples/tuning.ipynb +0 -0
- {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/docs/gen_ref_pages.py +0 -0
- {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/docs/index.md +0 -0
- {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/docs/stylesheets/extra.css +0 -0
- {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/mkdocs.yml +0 -0
- {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/pyproject.toml +0 -0
- {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/scripts/generate_readme_images.py +0 -0
- {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/src/tsam_xarray/__init__.py +0 -0
- {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/src/tsam_xarray/_sample_data.py +0 -0
- {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/src/tsam_xarray/_tuning.py +0 -0
- {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/test/conftest.py +0 -0
- {tsam_xarray-0.6.1 → tsam_xarray-0.6.3}/test/test_parametrized.py +0 -0
- {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@
|
|
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@
|
|
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@
|
|
86
|
+
- uses: actions/setup-python@v7
|
|
87
87
|
with:
|
|
88
88
|
python-version: "3.12"
|
|
89
89
|
|
|
@@ -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
|
|
|
@@ -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
|
-
"
|
|
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
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
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
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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
|
|
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.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 6,
|
|
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
|
|
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
|
|
File without changes
|