From 1d4eb3124cd0f24ee20008debbab885801bd9122 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:32:33 +0200 Subject: [PATCH] feat(cluster): add original-vs-clustered comparison accessors Restore a first-class way to compare original vs clustered profiles, which regressed when clustering.plot.compare() was removed in v7 (#655). Reported in discussion #728. New accessors on Clustering (pre-serialization): - compare(variable=None) -> tidy Dataset(original, clustered), plot-ready - original / reconstructed / residuals / accuracy These un-rename tsam_xarray's internal dims (_period -> period) and align original/reconstructed dim order, so users select with the natural coord names instead of the raw aggregation_result's _period. Plotting is left to the user: compare() returns tidy data, so a chart is a one-liner (px.line(compare(var).to_dataframe()[['original','clustered']])). The tidy data helper is now upstream in tsam_xarray (FBumann/tsam_xarray#92). Docs: - migration-guide-v7: compare recipe (timeseries + duration curve) and multi-variable/period/scenario notes - clustering.md: replace stale clustering.metrics section (removed in v7) with clustering.accuracy / compare() - 08c-clustering notebook: demo cell plotting compare() with plotly express - fix broken tsam_xarray repo links (FZJ-IEK3-VSA -> FBumann) Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/notebooks/08c-clustering.ipynb | 111 +++++++++++++++------ docs/user-guide/migration-guide-v7.md | 85 ++++++++++++++-- docs/user-guide/optimization/clustering.md | 41 ++++++-- flixopt/clustering/base.py | 92 +++++++++++++++++ flixopt/plot_result.py | 2 +- tests/test_clustering/test_integration.py | 107 ++++++++++++++++++++ 6 files changed, 395 insertions(+), 43 deletions(-) diff --git a/docs/notebooks/08c-clustering.ipynb b/docs/notebooks/08c-clustering.ipynb index a3f43d8c4..3d0b6a284 100644 --- a/docs/notebooks/08c-clustering.ipynb +++ b/docs/notebooks/08c-clustering.ipynb @@ -21,7 +21,7 @@ " Install with: `pip install \"flixopt[full]\"`\n", "\n", "!!! tip \"tsam_xarray\"\n", - " flixopt uses [tsam_xarray](https://github.com/FZJ-IEK3-VSA/tsam_xarray) for clustering,\n", + " flixopt uses [tsam_xarray](https://github.com/FBumann/tsam_xarray) for clustering,\n", " which wraps [tsam](https://github.com/FZJ-IEK3-VSA/tsam). For advanced clustering options\n", " (custom algorithms, weights, tuning), see the tsam_xarray documentation." ] @@ -132,14 +132,39 @@ }, { "cell_type": "markdown", - "id": "d5e6fd0c", - "source": "!!! note \"Which variables get clustered?\"\n By default, **all** time-varying inputs influence cluster assignments with\n equal weight (1.0). To see which variables `cluster()` will feed to tsam,\n call `list(flow_system.transform.cluster_inputs())` — it lists every\n variable with a `time` dim (constants included).\n\n To restrict clustering to a subset, pass explicit weights via\n `ClusterConfig(weights={...})`. Variables listed with weight `0` are still\n aggregated but don't influence cluster assignments; variables not listed\n keep the default weight of `1.0`. Example:\n\n ```python\n from tsam import ClusterConfig\n\n cols = list(flow_system.transform.cluster_inputs())\n target = 'HeatDemand(Q_th)|fixed_relative_profile'\n weights = {target: 1, **{v: 0 for v in cols if v != target}}\n\n fs_clustered = flow_system.transform.cluster(\n n_clusters=8, cluster_duration='1D',\n cluster=ClusterConfig(weights=weights),\n extremes=ExtremeConfig(method='new_cluster', max_value=[target]),\n )\n ```", - "metadata": {} + "id": "8", + "metadata": {}, + "source": [ + "!!! note \"Which variables get clustered?\"\n", + " By default, **all** time-varying inputs influence cluster assignments with\n", + " equal weight (1.0). To see which variables `cluster()` will feed to tsam,\n", + " call `list(flow_system.transform.cluster_inputs())` — it lists every\n", + " variable with a `time` dim (constants included).\n", + "\n", + " To restrict clustering to a subset, pass explicit weights via\n", + " `ClusterConfig(weights={...})`. Variables listed with weight `0` are still\n", + " aggregated but don't influence cluster assignments; variables not listed\n", + " keep the default weight of `1.0`. Example:\n", + "\n", + " ```python\n", + " from tsam import ClusterConfig\n", + "\n", + " cols = list(flow_system.transform.cluster_inputs())\n", + " target = 'HeatDemand(Q_th)|fixed_relative_profile'\n", + " weights = {target: 1, **{v: 0 for v in cols if v != target}}\n", + "\n", + " fs_clustered = flow_system.transform.cluster(\n", + " n_clusters=8, cluster_duration='1D',\n", + " cluster=ClusterConfig(weights=weights),\n", + " extremes=ExtremeConfig(method='new_cluster', max_value=[target]),\n", + " )\n", + " ```" + ] }, { "cell_type": "code", "execution_count": null, - "id": "8", + "id": "9", "metadata": {}, "outputs": [], "source": [ @@ -164,7 +189,7 @@ { "cell_type": "code", "execution_count": null, - "id": "9", + "id": "10", "metadata": {}, "outputs": [], "source": [ @@ -176,20 +201,20 @@ }, { "cell_type": "markdown", - "id": "10", + "id": "11", "metadata": {}, "source": [ "## Understanding the Clustering\n", "\n", "Access clustering metadata via `fs.clustering`. For full access to the underlying\n", - "[tsam_xarray ClusteringResult](https://github.com/FZJ-IEK3-VSA/tsam_xarray),\n", + "[tsam_xarray ClusteringResult](https://github.com/FBumann/tsam_xarray),\n", "use `fs.clustering.clustering_result`." ] }, { "cell_type": "code", "execution_count": null, - "id": "11", + "id": "12", "metadata": {}, "outputs": [], "source": [ @@ -199,7 +224,37 @@ }, { "cell_type": "markdown", - "id": "12", + "id": "13", + "metadata": {}, + "source": [ + "### Compare Original vs Clustered Profiles\n", + "\n", + "`clustering.compare()` returns a tidy `Dataset` (`original` vs `clustered`) for\n", + "**all** clustered variables, on the original time axis. flixopt bundles the\n", + "`.plotly` accessor, so stacking the two onto a `profile` dim and faceting is a\n", + "one-liner. `clustering.accuracy` reports the aggregation error." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "14", + "metadata": {}, + "outputs": [], + "source": [ + "print(fs_clustered.clustering.accuracy)\n", + "\n", + "(\n", + " fs_clustered.clustering.compare() # all variables; subset via .sel(variable=...)\n", + " .to_dataarray(dim='profile')\n", + " .plotly.line(x='time', color='profile', facet_row='variable')\n", + " .update_yaxes(matches=None) # variables have different scales\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "15", "metadata": {}, "source": [ "### Apply Existing Clustering\n", @@ -225,7 +280,7 @@ }, { "cell_type": "markdown", - "id": "13", + "id": "16", "metadata": {}, "source": [ "## Method 3: Two-Stage Workflow (Recommended)\n", @@ -243,7 +298,7 @@ { "cell_type": "code", "execution_count": null, - "id": "14", + "id": "17", "metadata": {}, "outputs": [], "source": [ @@ -255,7 +310,7 @@ { "cell_type": "code", "execution_count": null, - "id": "15", + "id": "18", "metadata": {}, "outputs": [], "source": [ @@ -274,7 +329,7 @@ }, { "cell_type": "markdown", - "id": "16", + "id": "19", "metadata": {}, "source": [ "## Compare Results" @@ -283,7 +338,7 @@ { "cell_type": "code", "execution_count": null, - "id": "17", + "id": "20", "metadata": {}, "outputs": [], "source": [ @@ -332,7 +387,7 @@ }, { "cell_type": "markdown", - "id": "18", + "id": "21", "metadata": {}, "source": [ "## Expand Solution to Full Resolution\n", @@ -344,7 +399,7 @@ { "cell_type": "code", "execution_count": null, - "id": "19", + "id": "22", "metadata": {}, "outputs": [], "source": [ @@ -355,7 +410,7 @@ { "cell_type": "code", "execution_count": null, - "id": "20", + "id": "23", "metadata": {}, "outputs": [], "source": [ @@ -377,7 +432,7 @@ }, { "cell_type": "markdown", - "id": "21", + "id": "24", "metadata": {}, "source": [ "## Visualize Clustered Heat Balance" @@ -386,7 +441,7 @@ { "cell_type": "code", "execution_count": null, - "id": "22", + "id": "25", "metadata": {}, "outputs": [], "source": [ @@ -396,7 +451,7 @@ { "cell_type": "code", "execution_count": null, - "id": "23", + "id": "26", "metadata": {}, "outputs": [], "source": [ @@ -405,7 +460,7 @@ }, { "cell_type": "markdown", - "id": "24", + "id": "27", "metadata": {}, "source": [ "## API Reference\n", @@ -432,8 +487,8 @@ "| `timesteps_per_cluster` | Timesteps in each cluster (e.g., 96 for daily at 15 min) |\n", "| `cluster_assignments` | xr.DataArray mapping original segment to cluster ID |\n", "| `cluster_occurrences` | How many original segments each cluster represents |\n", - "| `clustering_result` | Full [tsam_xarray ClusteringResult](https://github.com/FZJ-IEK3-VSA/tsam_xarray) |\n", - "| `aggregation_result` | Full [tsam_xarray AggregationResult](https://github.com/FZJ-IEK3-VSA/tsam_xarray) (pre-IO only) |\n", + "| `clustering_result` | Full [tsam_xarray ClusteringResult](https://github.com/FBumann/tsam_xarray) |\n", + "| `aggregation_result` | Full [tsam_xarray AggregationResult](https://github.com/FBumann/tsam_xarray) (pre-IO only) |\n", "\n", "### Storage Behavior\n", "\n", @@ -451,7 +506,7 @@ }, { "cell_type": "markdown", - "id": "25", + "id": "28", "metadata": {}, "source": [ "## Summary\n", @@ -473,7 +528,7 @@ "4. **Storage handling** is configurable via `cluster_mode`\n", "5. **Use `apply_clustering()`** to apply the same clustering to different FlowSystem variants\n", "6. For advanced clustering options (weights, algorithms, segmentation, tuning), see\n", - " [tsam_xarray](https://github.com/FZJ-IEK3-VSA/tsam_xarray) and [tsam](https://github.com/FZJ-IEK3-VSA/tsam)\n", + " [tsam_xarray](https://github.com/FBumann/tsam_xarray) and [tsam](https://github.com/FZJ-IEK3-VSA/tsam)\n", "\n", "### Next Steps\n", "\n", @@ -497,9 +552,9 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.11" + "version": "3.13.2" } }, "nbformat": 4, "nbformat_minor": 5 -} \ No newline at end of file +} diff --git a/docs/user-guide/migration-guide-v7.md b/docs/user-guide/migration-guide-v7.md index 071d8a539..5b4962224 100644 --- a/docs/user-guide/migration-guide-v7.md +++ b/docs/user-guide/migration-guide-v7.md @@ -5,7 +5,7 @@ pip install --upgrade flixopt ``` v7.0.0 has a single breaking change: clustering now runs on - [tsam_xarray](https://github.com/FZJ-IEK3-VSA/tsam_xarray) instead of tsam. + [tsam_xarray](https://github.com/FBumann/tsam_xarray) instead of tsam. The `transform.cluster()` call signature is unchanged — only the `Clustering` result object and a few helpers changed. @@ -80,14 +80,83 @@ tsam_xarray, so the old "non-constant inputs" preview is no longer meaningful. See [`cluster_inputs()`](#cluster-inputs) for the v7 equivalent (different semantics — it includes constants). -### Removed: metrics, plotting, and original-data serialization +### Changed: metrics, plotting, and original-data serialization -`Clustering.metrics` (RMSE/MAE), `clustering.plot.*`, and the -`include_original_data=...` flag on `to_netcdf()` / `to_dataset()` are gone. For -accuracy analysis or plotting, use `clustering.aggregation_result` (a tsam_xarray -`AggregationResult`) **before** serialization, or rebuild via +`Clustering.metrics` (RMSE/MAE), the `clustering.plot.heatmap()` / `.clusters()` +plots, and the `include_original_data=...` flag on `to_netcdf()` / `to_dataset()` +are gone. For accuracy analysis or plotting, use the accessors below (backed by a +tsam_xarray `AggregationResult`) **before** serialization, or rebuild via `transform.apply_clustering(...)` after loading. +#### Comparing original vs clustered profiles + +`clustering.compare()` returns a tidy `xr.Dataset` (data vars `original` and +`clustered`, on the **original** time axis) for **all** clustered variables — +select a subset on the dataset itself, e.g. `.sel(variable=...)`. flixopt bundles +the `.plotly` accessor (`xarray_plotly`), so plotting all variables at once is a +one-liner (stack `original`/`clustered` onto a `profile` dim, then facet): + +```python +fs_clustered = flow_system.transform.cluster(n_clusters=8, cluster_duration='1D') +clustering = fs_clustered.clustering + +( + clustering.compare() # all variables; subset via clustering.compare().sel(variable=...) + .to_dataarray(dim='profile') + .plotly.line(x='time', color='profile', facet_row='variable') + .update_yaxes(matches=None) # variables have different scales +) +``` + +For a single variable, `clustering.compare('HeatDemand(Q)|fixed_relative_profile')` +returns just that column. + +Related accessors (all on the original time axis, with the original dim names): + +| Accessor | Meaning | +|---|---| +| `clustering.original` | the input time series (dims: `variable`, `time`, plus periods/scenarios) | +| `clustering.reconstructed` | the clustered profile mapped back onto full time (same dims/order as `original`) | +| `clustering.residuals` | `original - reconstructed` | +| `clustering.accuracy` | `AccuracyMetrics` — `rmse`/`mae`/… per `variable`, plus `weighted_rmse`/… | + +Available column names are `list(clustering.original['variable'].values)` +(equivalently `flow_system.transform.cluster_inputs()`). + +##### Multiple variables, periods, and scenarios + +These accessors keep **every** extra dimension: all time-varying inputs are +stacked on a `variable` axis, and periods/scenarios stay as their own dims. So +`clustering.original` is `(variable, time)` for a plain system and +`(period, scenario, variable, time)` with both — `clustering.compare()` mirrors +that. `accuracy.rmse` is resolved per `(variable, period, scenario)` and +`accuracy.weighted_rmse` per `(period, scenario)`; clustering runs independently +per slice. Select and facet with the natural coordinate names: + +```python +cmp = clustering.compare('HeatDemand(Q)|fixed_relative_profile') # dims: (period, scenario, time) +cmp.sel(period=2030, scenario='high') # a single 1-D slice + +# facet periods/scenarios with the natural coordinate names +( + cmp.to_dataarray(dim='profile') + .plotly.line(x='time', color='profile', facet_row='period', facet_col='scenario') +) +``` + +!!! note "Raw tsam_xarray access" + `clustering.aggregation_result` still exposes the underlying tsam_xarray + `AggregationResult` if you need it. Note it is the **raw** result, on which + flixopt's reserved-dim renames are still applied — its period dim is + `_period` (and `cluster` is `_cluster`). The `compare()` / `original` / + `reconstructed` / `residuals` / `accuracy` accessors above un-rename these + for you, so prefer them. + +!!! warning "Pre-serialization only" + These accessors hold the original data and are **not** persisted by + `to_netcdf()` / `to_json()`. Access them before saving, or rebuild the result + on a freshly loaded FlowSystem with `transform.apply_clustering(...)`. + ### Expansion: `disaggregate()` replaces `expand_data()` / `timestep_mapping` ```python @@ -157,7 +226,7 @@ fs_clustered = flow_system.transform.cluster( - [ ] Remove `clustering_group` / `clustering_weight` from `TimeSeriesData`; pass weights explicitly - [ ] Replace `expand_data()` / `timestep_mapping` with `disaggregate()` - [ ] Update removed `Clustering` properties (see table above) -- [ ] Move any metrics/plotting to `clustering.aggregation_result` before serialization +- [ ] Replace `clustering.metrics` with `clustering.accuracy`; use `clustering.compare()` (+ your plotting library) for original-vs-clustered plots (before serialization) - [ ] Re-run `transform.cluster()` for any NetCDF saved with v6 --- @@ -166,6 +235,6 @@ fs_clustered = flow_system.transform.cluster( - [Clustering User Guide](optimization/clustering.md) - [Clustering Notebooks](../notebooks/08c-clustering.ipynb) -- [tsam_xarray](https://github.com/FZJ-IEK3-VSA/tsam_xarray) +- [tsam_xarray](https://github.com/FBumann/tsam_xarray) - [CHANGELOG](https://github.com/flixOpt/flixopt/blob/main/CHANGELOG.md) - [GitHub Issues](https://github.com/flixOpt/flixopt/issues) diff --git a/docs/user-guide/optimization/clustering.md b/docs/user-guide/optimization/clustering.md index 5d9710784..2be0ed202 100644 --- a/docs/user-guide/optimization/clustering.md +++ b/docs/user-guide/optimization/clustering.md @@ -136,19 +136,48 @@ fs_clustered = flow_system.transform.cluster( ### Clustering Quality Metrics -After clustering, access quality metrics to evaluate the aggregation accuracy: +After clustering, evaluate the aggregation accuracy via `clustering.accuracy`: ```python fs_clustered = flow_system.transform.cluster(n_clusters=8, cluster_duration='1D') +clustering = fs_clustered.clustering -# Access clustering metrics (xr.Dataset) -metrics = fs_clustered.clustering.metrics -print(metrics) # Shows RMSE, MAE, etc. per time series +# Aggregate, column-weighted metrics +print(clustering.accuracy) # AccuracyMetrics(weighted_rmse=..., weighted_mae=...) -# Access specific metric -rmse = metrics['RMSE'] # xr.DataArray with dims [time_series, period?, scenario?] +# Per-variable RMSE (xr.DataArray with dims [variable, period?, scenario?]) +rmse = clustering.accuracy.rmse ``` +### Comparing Original vs Clustered Profiles + +`clustering.compare()` returns a tidy `xr.Dataset` (`original` and `clustered` +on the original time axis) for **all** clustered variables — the v7 replacement +for the removed `clustering.plot.compare()`. Facet to plot them all at once, or +select a subset on the dataset with `.sel(variable=...)`: + +```python +# flixopt bundles the `.plotly` accessor (xarray_plotly) +( + clustering.compare() # all variables; subset via clustering.compare().sel(variable=...) + .to_dataarray(dim='profile') + .plotly.line(x='time', color='profile', facet_row='variable') + .update_yaxes(matches=None) # variables have different scales +) +``` + +Related accessors: `clustering.original`, `clustering.reconstructed`, and +`clustering.residuals` (all on the original time axis, with periods/scenarios +preserved). + +!!! note "Pre-serialization only" + `accuracy` / `compare()` / `original` / `reconstructed` need the full + clustering data, available before saving. They are **not** persisted by + `to_netcdf()` / `to_json()`; rebuild on a loaded FlowSystem with + `transform.apply_clustering(...)` if needed. See the + [v7 migration guide](../migration-guide-v7.md#comparing-original-vs-clustered-profiles) + for the multi-variable / multi-period recipe. + ## Storage Modes Storage behavior during clustering is controlled via the `cluster_mode` parameter: diff --git a/flixopt/clustering/base.py b/flixopt/clustering/base.py index 425cf33e1..f50122142 100644 --- a/flixopt/clustering/base.py +++ b/flixopt/clustering/base.py @@ -17,6 +17,7 @@ from pathlib import Path import xarray as xr + from tsam_xarray import AccuracyMetrics as TsamXarrayAccuracyMetrics from tsam_xarray import AggregationResult as TsamXarrayAggregationResult from tsam_xarray import ClusteringResult @@ -327,12 +328,103 @@ def aggregation_result(self) -> TsamXarrayAggregationResult: Only available before serialization. After loading from file, use clustering_result for structure-only access. + The returned object holds the **raw** tsam_xarray result, on which + flixopt's reserved-dim renames are still applied (the period dim is + ``_period``). For a friendlier view with the original dim names, use + the ``original`` / ``reconstructed`` / ``residuals`` / ``accuracy`` + properties or ``compare()`` instead. + Raises: ValueError: If accessed on a Clustering loaded from JSON/NetCDF. """ self._require_full_data('aggregation_result') return self._aggregation_result + @property + def original(self) -> xr.DataArray: + """Original (input) time series fed to clustering, on the original time axis. + + All time-varying inputs are stacked on a ``variable`` dim. Dims are + ``(*dim_names, variable, time)`` — e.g. ``(period, scenario, variable, time)``. + + Only available before serialization. + """ + self._require_full_data('original') + return self._unrename(self._aggregation_result.original) + + @property + def reconstructed(self) -> xr.DataArray: + """Clustered profiles mapped back onto the original time axis. + + Same dims and shape as ``original`` (dim order aligned with it), so the + two can be compared, subtracted, or plotted directly. + + Only available before serialization. + """ + self._require_full_data('reconstructed') + da = self._unrename(self._aggregation_result.reconstructed) + return da.transpose(*self.original.dims) + + @property + def residuals(self) -> xr.DataArray: + """``original - reconstructed``, the per-timestep aggregation error. + + Only available before serialization. + """ + self._require_full_data('residuals') + return self._unrename(self._aggregation_result.residuals) + + @property + def accuracy(self) -> TsamXarrayAccuracyMetrics: + """tsam_xarray ``AccuracyMetrics`` (per-variable and column-weighted). + + Exposes ``rmse`` / ``mae`` / ``rmse_duration`` (dims ``(variable, *dim_names)``) + and the aggregate ``weighted_rmse`` / ``weighted_mae`` / ``weighted_rmse_duration`` + (dims ``(*dim_names,)``). Dim names are un-renamed to match ``original``. + + Only available before serialization. + """ + import dataclasses + + self._require_full_data('accuracy') + acc = self._aggregation_result.accuracy + return dataclasses.replace( + acc, **{f.name: self._unrename(getattr(acc, f.name)) for f in dataclasses.fields(acc)} + ) + + def compare(self, variable: str | list[str] | None = None) -> xr.Dataset: + """Tidy original-vs-clustered comparison, ready for plotting. + + Returns a Dataset with data_vars ``original`` and ``clustered`` on the + original time axis and matching dim order, so ``.to_dataframe()`` and + plotting libraries need no reshaping. This is the v7 replacement for the + removed ``clustering.plot.compare()``. + + Args: + variable: Optional column name (or list) to select from the + ``variable`` dim. Available names are + ``list(clustering.original['variable'].values)``. Defaults to + all variables. + + Returns: + xr.Dataset with variables ``original`` and ``clustered``. + + Examples: + >>> import plotly.express as px + >>> cmp = clustering.compare('HeatDemand(Q)|fixed_relative_profile') + >>> px.line(cmp.to_dataframe()[['original', 'clustered']]).show() + + Only available before serialization. + """ + import xarray as xr + + original = self.original + reconstructed = self.reconstructed + if variable is not None: + original = original.sel(variable=variable) + reconstructed = reconstructed.sel(variable=variable) + return xr.Dataset({'original': original, 'clustered': reconstructed}) + def __len__(self) -> int: """Number of (period, scenario) combinations.""" return len(self._clustering_result.clusterings) diff --git a/flixopt/plot_result.py b/flixopt/plot_result.py index caf6aaabd..4c8d3a0ae 100644 --- a/flixopt/plot_result.py +++ b/flixopt/plot_result.py @@ -41,7 +41,7 @@ class PlotResult: Customizing the figure: - >>> result = clustering.plot.compare() + >>> result = flow_system.stats.plot.balance('Bus') >>> result.update(title='My Custom Title').show() """ diff --git a/tests/test_clustering/test_integration.py b/tests/test_clustering/test_integration.py index 5793633f1..be3dbc711 100644 --- a/tests/test_clustering/test_integration.py +++ b/tests/test_clustering/test_integration.py @@ -6,6 +6,7 @@ import xarray as xr from flixopt import FlowSystem +from flixopt.clustering import Clustering class TestWeights: @@ -642,6 +643,112 @@ def test_weights_for_excluded_variable_raises(self): ) +class TestClusteringCompare: + """Tests for the original-vs-clustered comparison accessors. + + These replace the removed ``clustering.plot.compare()`` and are the + documented v7 way to inspect aggregation quality (see migration-guide-v7). + """ + + def _system(self, n_hours: int = 168, periods=None, scenarios=None): + pytest.importorskip('tsam') + from flixopt import Bus, Effect, Flow, Sink, Source + from flixopt.core import TimeSeriesData + + fs = FlowSystem( + timesteps=pd.date_range('2024-01-01', periods=n_hours, freq='h'), + periods=periods, + scenarios=scenarios, + ) + demand = np.sin(np.linspace(0, 14 * np.pi, n_hours)) + 2 + bus = Bus('electricity') + fs.add_elements( + Effect('costs', '€', is_standard=True, is_objective=True), + Source('grid', outputs=[Flow('grid_in', bus='electricity', size=100)]), + Sink( + 'demand', + inputs=[ + Flow('demand_out', bus='electricity', size=100, fixed_relative_profile=TimeSeriesData(demand / 100)) + ], + ), + bus, + ) + return fs + + VAR = 'demand(demand_out)|fixed_relative_profile' + + def test_original_and_reconstructed_aligned(self): + """original / reconstructed share dims, shape, and dim order, on the original time axis.""" + clustering = self._system().transform.cluster(n_clusters=2, cluster_duration='1D').clustering + + assert clustering.original.dims == clustering.reconstructed.dims + assert clustering.original.sizes == clustering.reconstructed.sizes + assert clustering.original.sizes['time'] == 168 + assert self.VAR in list(clustering.original['variable'].values) + + def test_residuals_equal_original_minus_reconstructed(self): + """residuals == original - reconstructed.""" + clustering = self._system().transform.cluster(n_clusters=2, cluster_duration='1D').clustering + xr.testing.assert_allclose(clustering.residuals, clustering.original - clustering.reconstructed) + + def test_compare_returns_tidy_dataset(self): + """compare() yields a Dataset with original/clustered vars ready for plotting.""" + clustering = self._system().transform.cluster(n_clusters=2, cluster_duration='1D').clustering + + cmp = clustering.compare(self.VAR) + assert set(cmp.data_vars) == {'original', 'clustered'} + assert 'variable' not in cmp.dims # single variable selected out + assert cmp.sizes['time'] == 168 + + # No variable filter keeps the variable dim + assert 'variable' in clustering.compare().dims + + def test_accuracy_exposed(self): + """accuracy carries per-variable and weighted metrics with unrenamed dims.""" + clustering = self._system().transform.cluster(n_clusters=2, cluster_duration='1D').clustering + acc = clustering.accuracy + assert 'variable' in acc.rmse.dims + assert float(acc.weighted_rmse) >= 0.0 + + def test_period_dim_is_unrenamed(self): + """The friendly accessors expose `period`, not the internal `_period`.""" + periods = pd.Index([2025, 2030], name='period') + clustering = self._system(periods=periods).transform.cluster(n_clusters=2, cluster_duration='1D').clustering + + for da in (clustering.original, clustering.reconstructed, clustering.residuals): + assert 'period' in da.dims + assert '_period' not in da.dims + assert 'period' in clustering.accuracy.rmse.dims + + # selection works with the natural coordinate name + one = clustering.compare(self.VAR).sel(period=2030) + assert 'period' not in one.dims + + def test_accessors_raise_after_serialization(self): + """The data accessors need the full AggregationResult (pre-serialization only).""" + clustering = self._system().transform.cluster(n_clusters=2, cluster_duration='1D').clustering + reloaded = Clustering( + clustering_result=clustering.clustering_result, + original_timesteps=clustering.original_timesteps, + ) + for attr in ('original', 'reconstructed', 'residuals', 'accuracy'): + with pytest.raises(ValueError, match='requires full AggregationResult'): + getattr(reloaded, attr) + with pytest.raises(ValueError, match='requires full AggregationResult'): + reloaded.compare() + + def test_compare_is_plot_ready(self): + """compare().to_dataframe() yields the columns the docs' px.line recipe plots.""" + pytest.importorskip('plotly') + import plotly.express as px + + clustering = self._system().transform.cluster(n_clusters=2, cluster_duration='1D').clustering + df = clustering.compare(self.VAR).to_dataframe()[['original', 'clustered']] + assert list(df.columns) == ['original', 'clustered'] + fig = px.line(df) + assert len(fig.data) == 2 + + class TestClusteringModuleImports: """Tests for flixopt.clustering module imports."""