From 0d79826039ab311bdd09318c0adf189fda81efb7 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 3 Feb 2026 16:18:40 +0100 Subject: [PATCH 01/44] Update the CHANGELOG.md --- CHANGELOG.md | 69 +++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 63 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 916aa812b..8932f17f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,7 +52,7 @@ If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOp Until here --> -## [6.0.0] - Upcoming +## [6.0.0] - 2026-02-03 **Summary**: Major release featuring tsam v3 migration, complete rewrite of the clustering/aggregation system, 2-3x faster I/O for large systems, new `plotly` plotting accessor, FlowSystem comparison tools, and removal of deprecated v5.0 classes. @@ -226,12 +226,12 @@ comp = fx.Comparison([fs_base, fs_modified]) comp = fx.Comparison([fs1, fs2, fs3], names=['baseline', 'low_cost', 'high_eff']) # Side-by-side plots (auto-facets by 'case' dimension) -comp.statistics.plot.balance('Heat') -comp.statistics.flow_rates.plotly.line() +comp.stats.plot.balance('Heat') +comp.stats.flow_rates.plotly.line() # Access combined data with 'case' dimension comp.solution # xr.Dataset -comp.statistics.flow_rates # xr.Dataset +comp.stats.flow_rates # xr.Dataset # Compute differences relative to a reference case comp.diff() # vs first case @@ -262,6 +262,58 @@ flow_system.topology.set_component_colors('turbo', overwrite=False) # Only unse `Component.inputs`, `Component.outputs`, and `Component.flows` now use `FlowContainer` (dict-like) with dual access by index or label: `inputs[0]` or `inputs['Q_th']`. +#### `before_solve` Callback + +New callback parameter for `optimize()` and `rolling_horizon()` allows adding custom constraints before solving: + +```python +def add_constraints(fs): + model = fs.model + boiler = model.variables['Boiler(Q_th)|flow_rate'] + model.add_constraints(boiler >= 10, name='min_boiler') + +flow_system.optimize(solver, before_solve=add_constraints) + +# Works with rolling_horizon too +flow_system.optimize.rolling_horizon( + solver, + horizon=168, + before_solve=add_constraints +) +``` + +#### `cluster_mode` for StatusParameters + +New parameter to control status behavior at cluster boundaries: + +```python +fx.StatusParameters( + ..., + cluster_mode='relaxed', # Default: no constraint at boundaries, prevents phantom startups + # cluster_mode='cyclic', # Each cluster's final status equals its initial status +) +``` + +#### Comparison Class Enhancements + +- **`Comparison.inputs`**: Compare inputs across FlowSystems for easy side-by-side input parameter comparison +- **`data_only` parameter**: Get data without generating plots in Comparison methods +- **`threshold` parameter**: Filter small values when comparing + +#### Plotting Enhancements + +- **`threshold` parameter**: Added to all plotting methods to filter values below a threshold (default: `1e-5`) +- **`round_decimals` parameter**: Control decimal precision in `balance()`, `carrier_balance()`, and `storage()` plots +- **`flow_colors` property**: Map flows to their component's colors for consistent visualization + +#### `FlowSystem.from_old_dataset()` + +New method for loading datasets saved with older flixopt versions: + +```python +fs = fx.FlowSystem.from_old_dataset(old_dataset) +``` + ### 💥 Breaking Changes #### tsam v3 Migration @@ -306,17 +358,22 @@ fs.transform.cluster( - `FlowSystem.weights` returns `dict[str, xr.DataArray]` (unit weights instead of `1.0` float fallback) - `FlowSystemDimensions` type now includes `'cluster'` -- `statistics.plot.balance()`, `carrier_balance()`, and `storage()` now use `xarray_plotly.fast_bar()` internally (styled stacked areas for better performance) +- `stats.plot.balance()`, `carrier_balance()`, and `storage()` now use `xarray_plotly.fast_bar()` internally (styled stacked areas for better performance) +- `stats.plot.carrier_balance()` now combines inputs and outputs to show net flow per component, and aggregates per component by default ### 🗑️ Deprecated The following items are deprecated and will be removed in **v7.0.0**: +**Accessor renamed:** + +- `flow_system.statistics` → Use `flow_system.stats` (shorter, more convenient) + **Classes** (use FlowSystem methods instead): - `Optimization` class → Use `flow_system.optimize(solver)` - `SegmentedOptimization` class → Use `flow_system.optimize.rolling_horizon()` -- `Results` class → Use `flow_system.solution` and `flow_system.statistics` +- `Results` class → Use `flow_system.solution` and `flow_system.stats` - `SegmentedResults` class → Use segment FlowSystems directly **FlowSystem methods** (use `transform` or `topology` accessor instead): From 3aaa5c78de5fc9f73925dd973bf2b8db9dd07877 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 3 Feb 2026 17:03:13 +0100 Subject: [PATCH 02/44] Update to tsam v3.1.0 and add warnings for preserve_n_clusters=False --- docs/notebooks/08d-clustering-multiperiod.ipynb | 4 ++-- docs/notebooks/08f-clustering-segmentation.ipynb | 4 +++- flixopt/transform_accessor.py | 12 +++++------- pyproject.toml | 4 ++-- tests/test_cluster_reduce_expand.py | 7 ++++--- 5 files changed, 16 insertions(+), 15 deletions(-) diff --git a/docs/notebooks/08d-clustering-multiperiod.ipynb b/docs/notebooks/08d-clustering-multiperiod.ipynb index cb8b30d5a..dd10f01a0 100644 --- a/docs/notebooks/08d-clustering-multiperiod.ipynb +++ b/docs/notebooks/08d-clustering-multiperiod.ipynb @@ -185,7 +185,7 @@ "fs_clustered = flow_system.transform.cluster(\n", " n_clusters=3,\n", " cluster_duration='1D',\n", - " extremes=ExtremeConfig(method='replace', max_value=peak_series),\n", + " extremes=ExtremeConfig(method='replace', max_value=peak_series, preserve_n_clusters=True),\n", ")\n", "\n", "time_clustering = timeit.default_timer() - start\n", @@ -559,7 +559,7 @@ "fs_clustered = fs.transform.cluster(\n", " n_clusters=10,\n", " cluster_duration='1D',\n", - " extremes=ExtremeConfig(method='replace', max_value=['Demand(Flow)|fixed_relative_profile']),\n", + " extremes=ExtremeConfig(method='replace', max_value=['Demand(Flow)|fixed_relative_profile'], preserve_n_clusters=True),\n", ")\n", "\n", "# Visualize clustering quality\n", diff --git a/docs/notebooks/08f-clustering-segmentation.ipynb b/docs/notebooks/08f-clustering-segmentation.ipynb index bc1915de4..8d6aad181 100644 --- a/docs/notebooks/08f-clustering-segmentation.ipynb +++ b/docs/notebooks/08f-clustering-segmentation.ipynb @@ -488,7 +488,9 @@ " n_clusters=3,\n", " cluster_duration='1D',\n", " segments=SegmentConfig(n_segments=6),\n", - " extremes=ExtremeConfig(method='replace', max_value=['Building(Heat)|fixed_relative_profile']),\n", + " extremes=ExtremeConfig(\n", + " method='replace', max_value=['Building(Heat)|fixed_relative_profile'], preserve_n_clusters=True\n", + " ),\n", ")\n", "\n", "print(f'Original: {len(fs_multi.timesteps)} timesteps')\n", diff --git a/flixopt/transform_accessor.py b/flixopt/transform_accessor.py index 68f4d9cdf..033cba72f 100644 --- a/flixopt/transform_accessor.py +++ b/flixopt/transform_accessor.py @@ -1706,17 +1706,15 @@ def cluster( ) # Validate ExtremeConfig compatibility with multi-period/scenario systems - # Methods 'new_cluster' and 'append' can produce different n_clusters per period, + # Without preserve_n_clusters=True, methods can produce different n_clusters per period, # which breaks the xarray structure that requires uniform dimensions total_slices = len(periods) * len(scenarios) if total_slices > 1 and extremes is not None: - extreme_method = getattr(extremes, 'method', None) - if extreme_method in ('new_cluster', 'append'): + if not extremes.preserve_n_clusters: raise ValueError( - f'ExtremeConfig with method="{extreme_method}" is not supported for multi-period ' - f'or multi-scenario systems because it can produce different cluster counts per ' - f'period/scenario. Use method="replace" instead, which replaces existing clusters ' - f'with extreme periods while maintaining the requested n_clusters.' + 'ExtremeConfig must have preserve_n_clusters=True for multi-period ' + 'or multi-scenario systems to ensure consistent cluster counts across all slices. ' + 'Example: ExtremeConfig(method="new_cluster", max_value=[...], preserve_n_clusters=True)' ) # Build dim_names and clean key helper diff --git a/pyproject.toml b/pyproject.toml index 95b73a821..f6e1790ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,7 +63,7 @@ network_viz = [ # Full feature set (everything except dev tools) full = [ - "tsam >= 3.0.0, < 4", # Time series aggregation for clustering + "tsam > 3.1.0, < 4", # Time series aggregation for clustering "pyvis==0.3.2", # Visualizing FlowSystem Network "scipy >= 1.15.1, < 2", # Used by tsam. Prior versions have conflict with highspy. See https://github.com/scipy/scipy/issues/22257 "gurobipy >= 10.0.0, < 14; python_version < '3.14'", # No Python 3.14 wheels yet (expected Q1 2026) @@ -77,7 +77,7 @@ full = [ # Development tools and testing dev = [ - "tsam==3.0.0", # Time series aggregation for clustering + "tsam==3.1.1", # Time series aggregation for clustering "pytest==8.4.2", "pytest-xdist==3.8.0", "nbformat==5.10.4", diff --git a/tests/test_cluster_reduce_expand.py b/tests/test_cluster_reduce_expand.py index e3f4a8d72..7093e8b14 100644 --- a/tests/test_cluster_reduce_expand.py +++ b/tests/test_cluster_reduce_expand.py @@ -873,13 +873,13 @@ def test_extremes_new_cluster_increases_n_clusters(self, solver_fixture, timeste # The sum of cluster occurrences should equal n_original_clusters (8 days) assert int(fs_clustered.clustering.cluster_occurrences.sum()) == 8 - def test_extremes_new_cluster_rejected_for_multi_period(self, timesteps_8_days, periods_2): - """Test that method='new_cluster' is rejected for multi-period systems.""" + def test_extremes_requires_preserve_n_clusters_for_multi_period(self, timesteps_8_days, periods_2): + """Test that preserve_n_clusters=True is required for multi-period systems.""" from tsam import ExtremeConfig fs = create_system_with_periods(timesteps_8_days, periods_2) - with pytest.raises(ValueError, match='method="new_cluster" is not supported'): + with pytest.raises(ValueError, match='preserve_n_clusters=True'): fs.transform.cluster( n_clusters=2, cluster_duration='1D', @@ -902,6 +902,7 @@ def test_extremes_replace_works_for_multi_period(self, solver_fixture, timesteps extremes=ExtremeConfig( method='replace', max_value=['HeatDemand(Q)|fixed_relative_profile'], + preserve_n_clusters=True, ), ) From 4fdc445e66474fc77ea6d1f6528e8eaa04dcf5e6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 4 Feb 2026 07:24:31 +0000 Subject: [PATCH 03/44] [ci] prepare release v6.0.0 --- CITATION.cff | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CITATION.cff b/CITATION.cff index 752d35d15..1f0dde55b 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -2,8 +2,8 @@ cff-version: 1.2.0 message: "If you use this software, please cite it as below and consider citing the related publication." type: software title: "flixopt" -version: 6.0.0rc17 -date-released: 2026-02-02 +version: 6.0.0 +date-released: 2026-02-04 url: "https://github.com/flixOpt/flixopt" repository-code: "https://github.com/flixOpt/flixopt" license: MIT From 26ababd8574ba060a1a8ca854772314150701a2a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 4 Feb 2026 08:26:36 +0100 Subject: [PATCH 04/44] fix typo in deps --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f6e1790ba..68a501551 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,7 +63,7 @@ network_viz = [ # Full feature set (everything except dev tools) full = [ - "tsam > 3.1.0, < 4", # Time series aggregation for clustering + "tsam >= 3.1.0, < 4", # Time series aggregation for clustering "pyvis==0.3.2", # Visualizing FlowSystem Network "scipy >= 1.15.1, < 2", # Used by tsam. Prior versions have conflict with highspy. See https://github.com/scipy/scipy/issues/22257 "gurobipy >= 10.0.0, < 14; python_version < '3.14'", # No Python 3.14 wheels yet (expected Q1 2026) @@ -77,7 +77,7 @@ full = [ # Development tools and testing dev = [ - "tsam==3.1.1", # Time series aggregation for clustering + "tsam==3.1.0", # Time series aggregation for clustering "pytest==8.4.2", "pytest-xdist==3.8.0", "nbformat==5.10.4", From 8524d3209aa4b803352e6499302e83c60021587d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 4 Feb 2026 09:04:01 +0100 Subject: [PATCH 05/44] fix typo in README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7d20a2520..dfa8216a5 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ flow_system.optimize(fx.solvers.HighsSolver()) # 3. Analyze results flow_system.solution # Raw xarray Dataset -flow_system.statistics # Convenient analysis accessor +flow_system.stats # Convenient analysis accessor ``` **Get started with real examples:** From 19ce60fb0294fb02d76f29ef4dc4e20ee99c5bc5 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 4 Feb 2026 09:20:27 +0100 Subject: [PATCH 06/44] Revert citation temporarily --- CITATION.cff | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CITATION.cff b/CITATION.cff index 1f0dde55b..36281b81e 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -2,7 +2,7 @@ cff-version: 1.2.0 message: "If you use this software, please cite it as below and consider citing the related publication." type: software title: "flixopt" -version: 6.0.0 +version: 6.0.0rc17 date-released: 2026-02-04 url: "https://github.com/flixOpt/flixopt" repository-code: "https://github.com/flixOpt/flixopt" From 35bb3833a7a04a0e3b4099970164c2621e30916c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 4 Feb 2026 08:21:16 +0000 Subject: [PATCH 07/44] [ci] prepare release v6.0.0 --- CITATION.cff | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CITATION.cff b/CITATION.cff index 36281b81e..1f0dde55b 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -2,7 +2,7 @@ cff-version: 1.2.0 message: "If you use this software, please cite it as below and consider citing the related publication." type: software title: "flixopt" -version: 6.0.0rc17 +version: 6.0.0 date-released: 2026-02-04 url: "https://github.com/flixOpt/flixopt" repository-code: "https://github.com/flixOpt/flixopt" From c5f64faa922100ce7f5f4efdbb81a03c0b4dd18a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 4 Feb 2026 09:41:18 +0100 Subject: [PATCH 08/44] Improve json io --- flixopt/io.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/flixopt/io.py b/flixopt/io.py index 33599f1c4..0f5e71c06 100644 --- a/flixopt/io.py +++ b/flixopt/io.py @@ -558,7 +558,7 @@ def save_dataset_to_netcdf( if stack_vars: ds = _stack_equal_vars(ds) - ds.attrs = {'attrs': json.dumps(ds.attrs)} + ds.attrs = {'attrs': json.dumps(ds.attrs, ensure_ascii=False)} # Convert all DataArray attrs to JSON strings # Use ds.variables to avoid slow _construct_dataarray calls @@ -569,13 +569,13 @@ def save_dataset_to_netcdf( continue var = variables[var_name] if var.attrs: # Only if there are attrs - var.attrs = {'attrs': json.dumps(var.attrs)} + var.attrs = {'attrs': json.dumps(var.attrs, ensure_ascii=False)} # Also handle coordinate attrs if they exist for coord_name in ds.coords: var = variables[coord_name] if var.attrs: - var.attrs = {'attrs': json.dumps(var.attrs)} + var.attrs = {'attrs': json.dumps(var.attrs, ensure_ascii=False)} # Suppress numpy binary compatibility warnings from netCDF4 (numpy 1->2 transition) with warnings.catch_warnings(): @@ -1896,7 +1896,7 @@ def _add_carriers_to_dataset(ds: xr.Dataset, carriers: Any) -> xr.Dataset: for name, carrier in carriers.items(): carrier_ref, _ = carrier._create_reference_structure() carriers_structure[name] = carrier_ref - ds.attrs['carriers'] = json.dumps(carriers_structure) + ds.attrs['carriers'] = json.dumps(carriers_structure, ensure_ascii=False) return ds @@ -1916,7 +1916,7 @@ def _add_clustering_to_dataset( # (individual ds[name] = arr assignments are slow) prefixed_arrays = {f'{cls.CLUSTERING_PREFIX}{name}': arr for name, arr in clustering_arrays.items()} ds = ds.assign(prefixed_arrays) - ds.attrs['clustering'] = json.dumps(clustering_ref) + ds.attrs['clustering'] = json.dumps(clustering_ref, ensure_ascii=False) return ds @@ -1928,7 +1928,7 @@ def _add_variable_categories_to_dataset( """Add variable categories to dataset attributes.""" if variable_categories: categories_dict = {name: cat.value for name, cat in variable_categories.items()} - ds.attrs['variable_categories'] = json.dumps(categories_dict) + ds.attrs['variable_categories'] = json.dumps(categories_dict, ensure_ascii=False) return ds From 4a57b732a0109e777fd55a4bf6f75f98cff80ed8 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 4 Feb 2026 09:44:34 +0100 Subject: [PATCH 09/44] fix: Notebooks using tsam --- docs/notebooks/08c-clustering.ipynb | 9 ++++++--- docs/notebooks/08c2-clustering-storage-modes.ipynb | 2 +- docs/notebooks/08e-clustering-internals.ipynb | 4 +++- docs/notebooks/08f-clustering-segmentation.ipynb | 6 ++++-- 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/docs/notebooks/08c-clustering.ipynb b/docs/notebooks/08c-clustering.ipynb index 7b193ae04..3f5e616e0 100644 --- a/docs/notebooks/08c-clustering.ipynb +++ b/docs/notebooks/08c-clustering.ipynb @@ -144,7 +144,9 @@ "fs_clustered = flow_system.transform.cluster(\n", " n_clusters=8, # 8 typical days\n", " cluster_duration='1D', # Daily clustering\n", - " extremes=ExtremeConfig(method='new_cluster', max_value=peak_series), # Capture peak demand day\n", + " extremes=ExtremeConfig(\n", + " method='new_cluster', max_value=peak_series, preserve_n_clusters=True\n", + " ), # Capture peak demand day\n", ")\n", "fs_clustered.name = 'Clustered (8 days)'\n", "\n", @@ -294,7 +296,7 @@ " n_clusters=8,\n", " cluster_duration='1D',\n", " data_vars=[demand_var], # Only this variable determines clustering\n", - " extremes=ExtremeConfig(method='new_cluster', max_value=[demand_var]),\n", + " extremes=ExtremeConfig(method='new_cluster', max_value=[demand_var], preserve_n_clusters=True),\n", ")\n", "\n", "# Verify: clustering was determined by demand but applied to all data\n", @@ -666,6 +668,7 @@ "extremes = ExtremeConfig(\n", " method='new_cluster', # Creates new cluster for extremes\n", " max_value=['ComponentName(FlowName)|fixed_relative_profile'], # Capture peak demand\n", + " preserve_n_clusters=True, # Keep total cluster count unchanged\n", ")\n", "```\n", "\n", @@ -678,7 +681,7 @@ "fs_sizing = flow_system.transform.cluster(\n", " n_clusters=8,\n", " cluster_duration='1D',\n", - " extremes=ExtremeConfig(method='new_cluster', max_value=['Demand(Flow)|fixed_relative_profile']),\n", + " extremes=ExtremeConfig(method='new_cluster', max_value=['Demand(Flow)|fixed_relative_profile'], preserve_n_clusters=True),\n", ")\n", "fs_sizing.optimize(solver)\n", "\n", diff --git a/docs/notebooks/08c2-clustering-storage-modes.ipynb b/docs/notebooks/08c2-clustering-storage-modes.ipynb index 925adc82a..1e7a336f3 100644 --- a/docs/notebooks/08c2-clustering-storage-modes.ipynb +++ b/docs/notebooks/08c2-clustering-storage-modes.ipynb @@ -199,7 +199,7 @@ " fs_clustered = fs_copy.transform.cluster(\n", " n_clusters=N_CLUSTERS,\n", " cluster_duration=CLUSTER_DURATION,\n", - " extremes=ExtremeConfig(method='new_cluster', max_value=PEAK_SERIES),\n", + " extremes=ExtremeConfig(method='new_cluster', max_value=PEAK_SERIES, preserve_n_clusters=True),\n", " )\n", " time_cluster = timeit.default_timer() - start\n", "\n", diff --git a/docs/notebooks/08e-clustering-internals.ipynb b/docs/notebooks/08e-clustering-internals.ipynb index 2d099ff34..72bb1bb1b 100644 --- a/docs/notebooks/08e-clustering-internals.ipynb +++ b/docs/notebooks/08e-clustering-internals.ipynb @@ -63,7 +63,9 @@ "fs_clustered = flow_system.transform.cluster(\n", " n_clusters=8,\n", " cluster_duration='1D',\n", - " extremes=ExtremeConfig(method='new_cluster', max_value=['HeatDemand(Q_th)|fixed_relative_profile']),\n", + " extremes=ExtremeConfig(\n", + " method='new_cluster', max_value=['HeatDemand(Q_th)|fixed_relative_profile'], preserve_n_clusters=True\n", + " ),\n", ")\n", "\n", "fs_clustered.clustering" diff --git a/docs/notebooks/08f-clustering-segmentation.ipynb b/docs/notebooks/08f-clustering-segmentation.ipynb index 8d6aad181..481215f2e 100644 --- a/docs/notebooks/08f-clustering-segmentation.ipynb +++ b/docs/notebooks/08f-clustering-segmentation.ipynb @@ -154,7 +154,9 @@ " n_clusters=8,\n", " cluster_duration='1D',\n", " segments=SegmentConfig(n_segments=6), # 6 segments per day instead of 96 quarter-hours\n", - " extremes=ExtremeConfig(method='replace', max_value=['HeatDemand(Q_th)|fixed_relative_profile']),\n", + " extremes=ExtremeConfig(\n", + " method='replace', max_value=['HeatDemand(Q_th)|fixed_relative_profile'], preserve_n_clusters=True\n", + " ),\n", ")\n", "\n", "time_clustering = timeit.default_timer() - start\n", @@ -574,7 +576,7 @@ " n_clusters=8,\n", " cluster_duration='1D',\n", " segments=SegmentConfig(n_segments=6),\n", - " extremes=ExtremeConfig(method='new_cluster', max_value=['Demand|profile']),\n", + " extremes=ExtremeConfig(method='new_cluster', max_value=['Demand|profile'], preserve_n_clusters=True),\n", ")\n", "\n", "# Optimize\n", From 4bd914367eae35c83c89258a1c0b49151df9f56d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 4 Feb 2026 09:49:28 +0100 Subject: [PATCH 10/44] Allow manual docs dispatch --- .github/workflows/docs.yaml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index 755304dc8..8aaace936 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -12,6 +12,15 @@ on: - 'docs/**' - 'mkdocs.yml' workflow_dispatch: + inputs: + deploy: + description: 'Deploy docs to GitHub Pages' + type: boolean + default: false + version: + description: 'Version to deploy (e.g., v6.0.0)' + type: string + required: false workflow_call: inputs: deploy: From c14dc002a857e957257e979641e2ef37af3f3c1e Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 4 Feb 2026 14:06:07 +0100 Subject: [PATCH 11/44] Created: tests/test_clustering/test_multiperiod_extremes.py Test Coverage (56 tests): Multi-Period with Different Time Series - TestMultiPeriodDifferentTimeSeries - Tests for systems where each period has distinct demand profiles: - Different cluster assignments per period - Optimization with period-specific profiles - Correct expansion mapping per period - Statistics correctness per period Extreme Cluster Configurations - TestExtremeConfigNewCluster - Tests method='new_cluster': - Captures peak demand days - Can increase cluster count - Works with min_value parameter - TestExtremeConfigReplace - Tests method='replace': - Maintains requested cluster count - Works with multi-period systems - TestExtremeConfigAppend - Tests method='append': - Combined with segmentation - Objective preserved after expansion Combined Multi-Period and Extremes - TestExtremeConfigMultiPeriod - Extremes with multi-period/scenario: - Requires preserve_n_clusters=True for multi-period - Works with periods and scenarios together - TestMultiPeriodWithExtremes - Combined scenarios: - Different profiles with extreme capture - Extremes combined with segmentation - Independent cluster assignments per period Multi-Scenario Clustering - TestMultiScenarioWithClustering - Scenarios with clustering - TestFullDimensionalClustering - Full (periods + scenarios) combinations IO Round-Trip - TestMultiPeriodClusteringIO - Save/load preservation tests Edge Cases - TestEdgeCases - Single cluster, many clusters, occurrence sums, mapping validation --- .../test_multiperiod_extremes.py | 1024 +++++++++++++++++ 1 file changed, 1024 insertions(+) create mode 100644 tests/test_clustering/test_multiperiod_extremes.py diff --git a/tests/test_clustering/test_multiperiod_extremes.py b/tests/test_clustering/test_multiperiod_extremes.py new file mode 100644 index 000000000..ac3055c37 --- /dev/null +++ b/tests/test_clustering/test_multiperiod_extremes.py @@ -0,0 +1,1024 @@ +"""Tests for clustering multi-period flow systems with different time series and extreme configurations.""" + +import numpy as np +import pandas as pd +import pytest +import xarray as xr +from numpy.testing import assert_allclose +from tsam import ExtremeConfig, SegmentConfig + +import flixopt as fx + +# ============================================================================ +# FIXTURES +# ============================================================================ + + +@pytest.fixture +def timesteps_8_days(): + """192 hour timesteps (8 days) for clustering tests.""" + return pd.date_range('2020-01-01', periods=192, freq='h') + + +@pytest.fixture +def timesteps_14_days(): + """336 hour timesteps (14 days) for more comprehensive clustering tests.""" + return pd.date_range('2020-01-01', periods=336, freq='h') + + +@pytest.fixture +def periods_2(): + """Two periods for testing.""" + return pd.Index([2025, 2030], name='period') + + +@pytest.fixture +def periods_3(): + """Three periods for testing.""" + return pd.Index([2025, 2030, 2035], name='period') + + +@pytest.fixture +def scenarios_2(): + """Two scenarios for testing.""" + return pd.Index(['low', 'high'], name='scenario') + + +@pytest.fixture +def scenarios_3(): + """Three scenarios for testing.""" + return pd.Index(['low', 'medium', 'high'], name='scenario') + + +# ============================================================================ +# HELPER FUNCTIONS +# ============================================================================ + + +def create_multiperiod_system_with_different_profiles( + timesteps: pd.DatetimeIndex, + periods: pd.Index, +) -> fx.FlowSystem: + """Create a multi-period FlowSystem with different demand profiles per period. + + Each period has a distinctly different demand pattern to test that clustering + produces different cluster assignments per period. + """ + hours = len(timesteps) + hour_of_day = np.array([t.hour for t in timesteps]) + day_idx = np.arange(hours) // 24 + + # Create different demand profiles for each period + demand_data = {} + for i, period in enumerate(periods): + # Base pattern varies by hour + base = np.where((hour_of_day >= 8) & (hour_of_day < 20), 25, 8) + + # Add period-specific variation: + # - Period 0: Higher morning peaks + # - Period 1: Higher evening peaks + # - Period 2+: Flatter profile with higher base + if i == 0: + # Morning peak pattern + morning_boost = np.where((hour_of_day >= 6) & (hour_of_day < 10), 15, 0) + demand = base + morning_boost + elif i == 1: + # Evening peak pattern + evening_boost = np.where((hour_of_day >= 17) & (hour_of_day < 21), 20, 0) + demand = base + evening_boost + else: + # Flatter profile + demand = base * 0.8 + 10 + + # Add day-to-day variation for clustering diversity + demand = demand * (1 + 0.2 * (day_idx % 3)) + demand_data[period] = demand + + # Create xarray DataArray with period dimension + demand_array = np.column_stack([demand_data[p] for p in periods]) + demand_da = xr.DataArray( + demand_array, + dims=['time', 'period'], + coords={'time': timesteps, 'period': periods}, + ) + + flow_system = fx.FlowSystem(timesteps, periods=periods) + flow_system.add_elements( + fx.Bus('Heat'), + fx.Bus('Gas'), + fx.Effect('costs', '€', is_standard=True, is_objective=True), + fx.Sink( + 'HeatDemand', + inputs=[fx.Flow('Q', bus='Heat', fixed_relative_profile=demand_da, size=1)], + ), + fx.Source('GasSource', outputs=[fx.Flow('Gas', bus='Gas', effects_per_flow_hour=0.05)]), + fx.linear_converters.Boiler( + 'Boiler', + thermal_efficiency=0.9, + fuel_flow=fx.Flow('Q_fu', bus='Gas'), + thermal_flow=fx.Flow('Q_th', bus='Heat'), + ), + ) + return flow_system + + +def create_system_with_extreme_peaks( + timesteps: pd.DatetimeIndex, + periods: pd.Index | None = None, + scenarios: pd.Index | None = None, + peak_day: int = 5, + peak_magnitude: float = 100, +) -> fx.FlowSystem: + """Create a FlowSystem with clearly identifiable extreme peak days. + + Args: + timesteps: Time coordinates. + periods: Optional period dimension. + scenarios: Optional scenario dimension. + peak_day: Which day (0-indexed) should have the extreme peak. + peak_magnitude: Magnitude of the peak demand. + """ + hours = len(timesteps) + hour_of_day = np.arange(hours) % 24 + day_idx = np.arange(hours) // 24 + + # Base demand pattern + base_demand = np.where((hour_of_day >= 8) & (hour_of_day < 18), 20, 8) + + # Add extreme peak on specified day during hours 10-14 + peak_mask = (day_idx == peak_day) & (hour_of_day >= 10) & (hour_of_day < 14) + demand = np.where(peak_mask, peak_magnitude, base_demand) + + # Add moderate variation to other days + demand = demand * (1 + 0.15 * (day_idx % 3)) + + # Handle multi-dimensional cases + if periods is not None and scenarios is not None: + # Create 3D array: (time, period, scenario) + demand_3d = np.zeros((hours, len(periods), len(scenarios))) + for i, _period in enumerate(periods): + for j, _scenario in enumerate(scenarios): + # Scale demand by period and scenario + scale = (1 + 0.1 * i) * (1 + 0.15 * j) + demand_3d[:, i, j] = demand * scale + demand_input = xr.DataArray( + demand_3d, + dims=['time', 'period', 'scenario'], + coords={'time': timesteps, 'period': periods, 'scenario': scenarios}, + ) + flow_system = fx.FlowSystem(timesteps, periods=periods, scenarios=scenarios) + elif periods is not None: + # Create 2D array: (time, period) + demand_2d = np.column_stack([demand * (1 + 0.1 * i) for i in range(len(periods))]) + demand_input = xr.DataArray( + demand_2d, + dims=['time', 'period'], + coords={'time': timesteps, 'period': periods}, + ) + flow_system = fx.FlowSystem(timesteps, periods=periods) + elif scenarios is not None: + # Create 2D array: (time, scenario) + demand_2d = np.column_stack([demand * (1 + 0.15 * j) for j in range(len(scenarios))]) + demand_input = xr.DataArray( + demand_2d, + dims=['time', 'scenario'], + coords={'time': timesteps, 'scenario': scenarios}, + ) + flow_system = fx.FlowSystem(timesteps, scenarios=scenarios) + else: + demand_input = demand + flow_system = fx.FlowSystem(timesteps) + + flow_system.add_elements( + fx.Bus('Heat'), + fx.Bus('Gas'), + fx.Effect('costs', '€', is_standard=True, is_objective=True), + fx.Sink( + 'HeatDemand', + inputs=[fx.Flow('Q', bus='Heat', fixed_relative_profile=demand_input, size=1)], + ), + fx.Source('GasSource', outputs=[fx.Flow('Gas', bus='Gas', effects_per_flow_hour=0.05)]), + fx.linear_converters.Boiler( + 'Boiler', + thermal_efficiency=0.9, + fuel_flow=fx.Flow('Q_fu', bus='Gas'), + thermal_flow=fx.Flow('Q_th', bus='Heat'), + ), + ) + return flow_system + + +def create_multiperiod_multiscenario_system( + timesteps: pd.DatetimeIndex, + periods: pd.Index, + scenarios: pd.Index, +) -> fx.FlowSystem: + """Create a FlowSystem with both periods and scenarios dimensions.""" + hours = len(timesteps) + hour_of_day = np.array([t.hour for t in timesteps]) + day_idx = np.arange(hours) // 24 + + # Create 3D demand array: (time, period, scenario) + demand_3d = np.zeros((hours, len(periods), len(scenarios))) + + for i, _period in enumerate(periods): + for j, _scenario in enumerate(scenarios): + # Base pattern + base = np.where((hour_of_day >= 8) & (hour_of_day < 18), 20, 8) + + # Period variation: demand growth over time + period_factor = 1 + 0.15 * i + + # Scenario variation: different load levels + scenario_factor = 0.8 + 0.2 * j + + # Day variation for clustering + day_factor = 1 + 0.2 * (day_idx % 4) + + demand_3d[:, i, j] = base * period_factor * scenario_factor * day_factor + + demand_da = xr.DataArray( + demand_3d, + dims=['time', 'period', 'scenario'], + coords={'time': timesteps, 'period': periods, 'scenario': scenarios}, + ) + + flow_system = fx.FlowSystem(timesteps, periods=periods, scenarios=scenarios) + flow_system.add_elements( + fx.Bus('Heat'), + fx.Bus('Gas'), + fx.Effect('costs', '€', is_standard=True, is_objective=True), + fx.Sink( + 'HeatDemand', + inputs=[fx.Flow('Q', bus='Heat', fixed_relative_profile=demand_da, size=1)], + ), + fx.Source('GasSource', outputs=[fx.Flow('Gas', bus='Gas', effects_per_flow_hour=0.05)]), + fx.linear_converters.Boiler( + 'Boiler', + thermal_efficiency=0.9, + fuel_flow=fx.Flow('Q_fu', bus='Gas'), + thermal_flow=fx.Flow('Q_th', bus='Heat'), + ), + ) + return flow_system + + +# ============================================================================ +# MULTI-PERIOD CLUSTERING WITH DIFFERENT TIME SERIES +# ============================================================================ + + +class TestMultiPeriodDifferentTimeSeries: + """Tests for clustering multi-period systems where each period has different time series.""" + + def test_different_profiles_create_different_assignments(self, timesteps_8_days, periods_2): + """Test that different demand profiles per period lead to different cluster assignments.""" + fs = create_multiperiod_system_with_different_profiles(timesteps_8_days, periods_2) + + fs_clustered = fs.transform.cluster(n_clusters=2, cluster_duration='1D') + + # Verify clustering structure + assert fs_clustered.periods is not None + assert len(fs_clustered.periods) == 2 + assert fs_clustered.clustering is not None + + # Cluster assignments should have period dimension + cluster_assignments = fs_clustered.clustering.cluster_assignments + assert 'period' in cluster_assignments.dims + + # Each period should have n_original_clusters assignments + n_original_clusters = 8 # 8 days + for period in periods_2: + period_assignments = cluster_assignments.sel(period=period) + assert len(period_assignments) == n_original_clusters + + def test_different_profiles_can_be_optimized(self, solver_fixture, timesteps_8_days, periods_2): + """Test that multi-period systems with different profiles optimize correctly.""" + fs = create_multiperiod_system_with_different_profiles(timesteps_8_days, periods_2) + + fs_clustered = fs.transform.cluster(n_clusters=2, cluster_duration='1D') + fs_clustered.optimize(solver_fixture) + + assert fs_clustered.solution is not None + + # Solution should have period dimension + flow_var = 'Boiler(Q_th)|flow_rate' + assert flow_var in fs_clustered.solution + assert 'period' in fs_clustered.solution[flow_var].dims + + def test_different_profiles_expand_correctly(self, solver_fixture, timesteps_8_days, periods_2): + """Test that expansion handles period-specific cluster assignments.""" + fs = create_multiperiod_system_with_different_profiles(timesteps_8_days, periods_2) + + fs_clustered = fs.transform.cluster(n_clusters=2, cluster_duration='1D') + fs_clustered.optimize(solver_fixture) + + fs_expanded = fs_clustered.transform.expand() + + # Should have original timesteps + assert len(fs_expanded.timesteps) == 192 + + # Should have period dimension preserved + assert fs_expanded.periods is not None + assert len(fs_expanded.periods) == 2 + + # Each period should map using its own cluster assignments + flow_var = 'Boiler(Q_th)|flow_rate' + for period in periods_2: + flow_period = fs_expanded.solution[flow_var].sel(period=period) + assert len(flow_period.coords['time']) == 193 # 192 + 1 extra + + def test_three_periods_with_different_profiles(self, solver_fixture, timesteps_8_days, periods_3): + """Test clustering with three periods, each having different demand characteristics.""" + fs = create_multiperiod_system_with_different_profiles(timesteps_8_days, periods_3) + + fs_clustered = fs.transform.cluster(n_clusters=2, cluster_duration='1D') + + # Verify 3 periods + assert len(fs_clustered.periods) == 3 + + # Cluster assignments should span all periods + cluster_assignments = fs_clustered.clustering.cluster_assignments + assert cluster_assignments.sizes['period'] == 3 + + # Optimize and expand + fs_clustered.optimize(solver_fixture) + fs_expanded = fs_clustered.transform.expand() + + assert len(fs_expanded.periods) == 3 + assert len(fs_expanded.timesteps) == 192 + + def test_statistics_correct_per_period(self, solver_fixture, timesteps_8_days, periods_2): + """Test that statistics are computed correctly for each period.""" + fs = create_multiperiod_system_with_different_profiles(timesteps_8_days, periods_2) + + fs_clustered = fs.transform.cluster(n_clusters=2, cluster_duration='1D') + fs_clustered.optimize(solver_fixture) + + # Get stats from clustered system + total_effects_clustered = fs_clustered.stats.total_effects['costs'] + + # Expand and get stats + fs_expanded = fs_clustered.transform.expand() + total_effects_expanded = fs_expanded.stats.total_effects['costs'] + + # Total effects should match between clustered and expanded + assert_allclose( + total_effects_clustered.sum('contributor').values, + total_effects_expanded.sum('contributor').values, + rtol=1e-5, + ) + + +# ============================================================================ +# EXTREME CLUSTER CONFIGURATION TESTS +# ============================================================================ + + +class TestExtremeConfigNewCluster: + """Tests for ExtremeConfig with method='new_cluster'.""" + + def test_new_cluster_captures_peak_day(self, solver_fixture, timesteps_8_days): + """Test that new_cluster method captures extreme peak day.""" + fs = create_system_with_extreme_peaks(timesteps_8_days, peak_day=5, peak_magnitude=100) + + fs_clustered = fs.transform.cluster( + n_clusters=2, + cluster_duration='1D', + extremes=ExtremeConfig( + method='new_cluster', + max_value=['HeatDemand(Q)|fixed_relative_profile'], + preserve_n_clusters=False, # Allow adding new clusters for extremes + ), + ) + + fs_clustered.optimize(solver_fixture) + + # The peak should be captured in the solution + flow_rates = fs_clustered.solution['Boiler(Q_th)|flow_rate'] + max_flow = float(flow_rates.max()) + # Peak demand is ~100, boiler efficiency 0.9, so max flow should be ~100 + assert max_flow >= 90, f'Peak not captured: max_flow={max_flow}' + + def test_new_cluster_can_increase_cluster_count(self, timesteps_8_days): + """Test that new_cluster may increase the effective cluster count.""" + fs = create_system_with_extreme_peaks(timesteps_8_days, peak_day=5, peak_magnitude=150) + + fs_clustered = fs.transform.cluster( + n_clusters=2, + cluster_duration='1D', + extremes=ExtremeConfig( + method='new_cluster', + max_value=['HeatDemand(Q)|fixed_relative_profile'], + preserve_n_clusters=False, # Allow cluster count to increase + ), + ) + + # n_clusters should be >= 2 (may be higher with extreme periods) + assert fs_clustered.clustering.n_clusters >= 2 + + # Sum of occurrences should equal original clusters (8 days) + assert int(fs_clustered.clustering.cluster_occurrences.sum()) == 8 + + def test_new_cluster_with_min_value(self, solver_fixture, timesteps_8_days): + """Test new_cluster with min_value parameter.""" + # Create system with low demand day + hours = len(timesteps_8_days) + hour_of_day = np.arange(hours) % 24 + day_idx = np.arange(hours) // 24 + + # Normal demand with one very low day + demand = np.where((hour_of_day >= 8) & (hour_of_day < 18), 25, 10) + low_day_mask = day_idx == 3 + demand = np.where(low_day_mask, 2, demand) # Very low on day 3 + + fs = fx.FlowSystem(timesteps_8_days) + fs.add_elements( + fx.Bus('Heat'), + fx.Bus('Gas'), + fx.Effect('costs', '€', is_standard=True, is_objective=True), + fx.Sink('HeatDemand', inputs=[fx.Flow('Q', bus='Heat', fixed_relative_profile=demand, size=1)]), + fx.Source('GasSource', outputs=[fx.Flow('Gas', bus='Gas', effects_per_flow_hour=0.05)]), + fx.linear_converters.Boiler( + 'Boiler', + thermal_efficiency=0.9, + fuel_flow=fx.Flow('Q_fu', bus='Gas'), + thermal_flow=fx.Flow('Q_th', bus='Heat'), + ), + ) + + fs_clustered = fs.transform.cluster( + n_clusters=3, + cluster_duration='1D', + extremes=ExtremeConfig( + method='new_cluster', + min_value=['HeatDemand(Q)|fixed_relative_profile'], + preserve_n_clusters=False, # Allow cluster count to increase + ), + ) + + assert fs_clustered.clustering.n_clusters >= 3 + fs_clustered.optimize(solver_fixture) + assert fs_clustered.solution is not None + + +class TestExtremeConfigReplace: + """Tests for ExtremeConfig with method='replace'.""" + + def test_replace_maintains_cluster_count(self, solver_fixture, timesteps_8_days): + """Test that replace method maintains the requested cluster count.""" + fs = create_system_with_extreme_peaks(timesteps_8_days, peak_day=5, peak_magnitude=100) + + fs_clustered = fs.transform.cluster( + n_clusters=3, + cluster_duration='1D', + extremes=ExtremeConfig( + method='replace', + max_value=['HeatDemand(Q)|fixed_relative_profile'], + preserve_n_clusters=True, # Replace method maintains cluster count + ), + ) + + # Replace should maintain exactly n_clusters + assert fs_clustered.clustering.n_clusters == 3 + + fs_clustered.optimize(solver_fixture) + assert fs_clustered.solution is not None + + def test_replace_with_multiperiod(self, solver_fixture, timesteps_8_days, periods_2): + """Test replace method with multi-period system.""" + fs = create_system_with_extreme_peaks(timesteps_8_days, periods=periods_2, peak_day=5) + + fs_clustered = fs.transform.cluster( + n_clusters=2, + cluster_duration='1D', + extremes=ExtremeConfig( + method='replace', + max_value=['HeatDemand(Q)|fixed_relative_profile'], + preserve_n_clusters=True, + ), + ) + + assert fs_clustered.clustering.n_clusters == 2 + assert len(fs_clustered.periods) == 2 + + fs_clustered.optimize(solver_fixture) + assert fs_clustered.solution is not None + + +class TestExtremeConfigAppend: + """Tests for ExtremeConfig with method='append'.""" + + def test_append_with_segments(self, solver_fixture, timesteps_8_days): + """Test append method combined with segmentation.""" + fs = create_system_with_extreme_peaks(timesteps_8_days, peak_day=5, peak_magnitude=80) + + fs_clustered = fs.transform.cluster( + n_clusters=2, + cluster_duration='1D', + extremes=ExtremeConfig( + method='append', + max_value=['HeatDemand(Q)|fixed_relative_profile'], + preserve_n_clusters=False, # Append may add clusters + ), + segments=SegmentConfig(n_segments=4), + ) + + # Verify segmentation + assert fs_clustered.clustering.is_segmented is True + assert fs_clustered.clustering.n_segments == 4 + + # n_representatives = n_clusters * n_segments + n_clusters = fs_clustered.clustering.n_clusters + assert fs_clustered.clustering.n_representatives == n_clusters * 4 + + fs_clustered.optimize(solver_fixture) + assert fs_clustered.solution is not None + + def test_append_expand_preserves_objective(self, solver_fixture, timesteps_8_days): + """Test that expansion after append preserves objective value.""" + fs = create_system_with_extreme_peaks(timesteps_8_days, peak_day=5, peak_magnitude=80) + + fs_clustered = fs.transform.cluster( + n_clusters=2, + cluster_duration='1D', + extremes=ExtremeConfig( + method='append', + max_value=['HeatDemand(Q)|fixed_relative_profile'], + preserve_n_clusters=False, # Append may add clusters + ), + segments=SegmentConfig(n_segments=4), + ) + + fs_clustered.optimize(solver_fixture) + clustered_objective = fs_clustered.solution['objective'].item() + + fs_expanded = fs_clustered.transform.expand() + expanded_objective = fs_expanded.solution['objective'].item() + + assert_allclose(clustered_objective, expanded_objective, rtol=1e-5) + + +class TestExtremeConfigMultiPeriod: + """Tests for extreme configurations with multi-period systems.""" + + def test_extremes_require_preserve_n_clusters_multiperiod(self, timesteps_8_days, periods_2): + """Test that new_cluster without preserve_n_clusters raises for multi-period.""" + fs = create_system_with_extreme_peaks(timesteps_8_days, periods=periods_2) + + with pytest.raises(ValueError, match='preserve_n_clusters=True'): + fs.transform.cluster( + n_clusters=2, + cluster_duration='1D', + extremes=ExtremeConfig( + method='new_cluster', + max_value=['HeatDemand(Q)|fixed_relative_profile'], + preserve_n_clusters=False, # Explicitly False - should raise for multi-period + ), + ) + + def test_extremes_with_preserve_n_clusters_multiperiod(self, solver_fixture, timesteps_8_days, periods_2): + """Test that extremes work with preserve_n_clusters for multi-period.""" + fs = create_system_with_extreme_peaks(timesteps_8_days, periods=periods_2) + + fs_clustered = fs.transform.cluster( + n_clusters=2, + cluster_duration='1D', + extremes=ExtremeConfig( + method='new_cluster', + max_value=['HeatDemand(Q)|fixed_relative_profile'], + preserve_n_clusters=True, + ), + ) + + # Should work with preserve_n_clusters=True + assert fs_clustered.clustering.n_clusters == 2 + assert len(fs_clustered.periods) == 2 + + fs_clustered.optimize(solver_fixture) + assert fs_clustered.solution is not None + + def test_extremes_with_periods_and_scenarios(self, solver_fixture, timesteps_8_days, periods_2, scenarios_2): + """Test extremes with both periods and scenarios.""" + fs = create_system_with_extreme_peaks( + timesteps_8_days, + periods=periods_2, + scenarios=scenarios_2, + peak_day=5, + ) + + fs_clustered = fs.transform.cluster( + n_clusters=2, + cluster_duration='1D', + extremes=ExtremeConfig( + method='replace', + max_value=['HeatDemand(Q)|fixed_relative_profile'], + preserve_n_clusters=True, + ), + ) + + # Verify dimensions + assert len(fs_clustered.periods) == 2 + assert len(fs_clustered.scenarios) == 2 + assert fs_clustered.clustering.n_clusters == 2 + + fs_clustered.optimize(solver_fixture) + + # Solution should have both dimensions + flow_var = 'Boiler(Q_th)|flow_rate' + assert 'period' in fs_clustered.solution[flow_var].dims + assert 'scenario' in fs_clustered.solution[flow_var].dims + + +# ============================================================================ +# COMBINED MULTI-PERIOD AND EXTREME TESTS +# ============================================================================ + + +class TestMultiPeriodWithExtremes: + """Tests combining multi-period systems with extreme configurations.""" + + def test_different_profiles_with_extremes(self, solver_fixture, timesteps_8_days, periods_2): + """Test multi-period with different profiles AND extreme capture.""" + fs = create_multiperiod_system_with_different_profiles(timesteps_8_days, periods_2) + + fs_clustered = fs.transform.cluster( + n_clusters=2, + cluster_duration='1D', + extremes=ExtremeConfig( + method='replace', + max_value=['HeatDemand(Q)|fixed_relative_profile'], + preserve_n_clusters=True, + ), + ) + + assert fs_clustered.clustering.n_clusters == 2 + assert len(fs_clustered.periods) == 2 + + fs_clustered.optimize(solver_fixture) + fs_expanded = fs_clustered.transform.expand() + + # Verify expansion + assert len(fs_expanded.timesteps) == 192 + assert len(fs_expanded.periods) == 2 + + def test_multiperiod_extremes_with_segmentation(self, solver_fixture, timesteps_8_days, periods_2): + """Test multi-period with extremes and segmentation.""" + fs = create_multiperiod_system_with_different_profiles(timesteps_8_days, periods_2) + + fs_clustered = fs.transform.cluster( + n_clusters=2, + cluster_duration='1D', + extremes=ExtremeConfig( + method='append', + max_value=['HeatDemand(Q)|fixed_relative_profile'], + preserve_n_clusters=True, + ), + segments=SegmentConfig(n_segments=6), + ) + + # Verify structure + assert fs_clustered.clustering.is_segmented is True + assert fs_clustered.clustering.n_segments == 6 + assert len(fs_clustered.periods) == 2 + + fs_clustered.optimize(solver_fixture) + + # Verify expansion + fs_expanded = fs_clustered.transform.expand() + assert len(fs_expanded.timesteps) == 192 + + def test_cluster_assignments_independent_per_period(self, timesteps_8_days, periods_3): + """Test that each period gets independent cluster assignments.""" + fs = create_multiperiod_system_with_different_profiles(timesteps_8_days, periods_3) + + fs_clustered = fs.transform.cluster(n_clusters=3, cluster_duration='1D') + + cluster_assignments = fs_clustered.clustering.cluster_assignments + + # Each period should have its own assignments + assert 'period' in cluster_assignments.dims + assert cluster_assignments.sizes['period'] == 3 + + # Assignments are computed independently per period + # (may or may not be different depending on the data) + for period in periods_3: + period_assignments = cluster_assignments.sel(period=period) + # Should have 8 assignments (one per original day) + assert len(period_assignments) == 8 + # Each assignment should be in range [0, n_clusters-1] + assert period_assignments.min() >= 0 + assert period_assignments.max() < 3 + + +# ============================================================================ +# MULTI-SCENARIO WITH CLUSTERING TESTS +# ============================================================================ + + +class TestMultiScenarioWithClustering: + """Tests for clustering systems with scenario dimension.""" + + def test_cluster_with_scenarios(self, solver_fixture, timesteps_8_days, scenarios_2): + """Test clustering with scenarios dimension.""" + hours = len(timesteps_8_days) + demand_data = np.column_stack( + [np.sin(np.linspace(0, 4 * np.pi, hours)) * 10 + 15 * (1 + 0.2 * i) for i in range(len(scenarios_2))] + ) + demand_da = xr.DataArray( + demand_data, + dims=['time', 'scenario'], + coords={'time': timesteps_8_days, 'scenario': scenarios_2}, + ) + + fs = fx.FlowSystem(timesteps_8_days, scenarios=scenarios_2) + fs.add_elements( + fx.Bus('Heat'), + fx.Bus('Gas'), + fx.Effect('costs', '€', is_standard=True, is_objective=True), + fx.Sink('HeatDemand', inputs=[fx.Flow('Q', bus='Heat', fixed_relative_profile=demand_da, size=1)]), + fx.Source('GasSource', outputs=[fx.Flow('Gas', bus='Gas', effects_per_flow_hour=0.05)]), + fx.linear_converters.Boiler( + 'Boiler', + thermal_efficiency=0.9, + fuel_flow=fx.Flow('Q_fu', bus='Gas'), + thermal_flow=fx.Flow('Q_th', bus='Heat'), + ), + ) + + fs_clustered = fs.transform.cluster(n_clusters=2, cluster_duration='1D') + + assert len(fs_clustered.scenarios) == 2 + assert fs_clustered.clustering.n_clusters == 2 + + fs_clustered.optimize(solver_fixture) + + # Solution should have scenario dimension + flow_var = 'Boiler(Q_th)|flow_rate' + assert 'scenario' in fs_clustered.solution[flow_var].dims + + def test_scenarios_with_extremes(self, solver_fixture, timesteps_8_days, scenarios_2): + """Test scenarios combined with extreme configuration.""" + fs = create_system_with_extreme_peaks(timesteps_8_days, scenarios=scenarios_2, peak_day=5) + + fs_clustered = fs.transform.cluster( + n_clusters=2, + cluster_duration='1D', + extremes=ExtremeConfig( + method='replace', + max_value=['HeatDemand(Q)|fixed_relative_profile'], + preserve_n_clusters=True, + ), + ) + + assert len(fs_clustered.scenarios) == 2 + fs_clustered.optimize(solver_fixture) + assert fs_clustered.solution is not None + + +class TestFullDimensionalClustering: + """Tests for clustering with all dimensions (periods + scenarios).""" + + def test_periods_and_scenarios_clustering(self, solver_fixture, timesteps_8_days, periods_2, scenarios_2): + """Test clustering with both periods and scenarios.""" + fs = create_multiperiod_multiscenario_system(timesteps_8_days, periods_2, scenarios_2) + + fs_clustered = fs.transform.cluster(n_clusters=2, cluster_duration='1D') + + # Verify all dimensions + assert len(fs_clustered.periods) == 2 + assert len(fs_clustered.scenarios) == 2 + assert fs_clustered.clustering.n_clusters == 2 + + # Cluster assignments should have both dimensions + cluster_assignments = fs_clustered.clustering.cluster_assignments + assert 'period' in cluster_assignments.dims + assert 'scenario' in cluster_assignments.dims + + fs_clustered.optimize(solver_fixture) + + # Solution should have all dimensions + flow_var = 'Boiler(Q_th)|flow_rate' + assert 'period' in fs_clustered.solution[flow_var].dims + assert 'scenario' in fs_clustered.solution[flow_var].dims + assert 'cluster' in fs_clustered.solution[flow_var].dims + + def test_full_dimensional_expand(self, solver_fixture, timesteps_8_days, periods_2, scenarios_2): + """Test expansion of system with all dimensions.""" + fs = create_multiperiod_multiscenario_system(timesteps_8_days, periods_2, scenarios_2) + + fs_clustered = fs.transform.cluster(n_clusters=2, cluster_duration='1D') + fs_clustered.optimize(solver_fixture) + + fs_expanded = fs_clustered.transform.expand() + + # Verify all dimensions preserved after expansion + assert len(fs_expanded.timesteps) == 192 + assert len(fs_expanded.periods) == 2 + assert len(fs_expanded.scenarios) == 2 + + # Solution should maintain dimensions + flow_var = 'Boiler(Q_th)|flow_rate' + assert 'period' in fs_expanded.solution[flow_var].dims + assert 'scenario' in fs_expanded.solution[flow_var].dims + + def test_full_dimensional_with_extremes(self, solver_fixture, timesteps_8_days, periods_2, scenarios_2): + """Test full dimensional system with extreme configuration.""" + fs = create_multiperiod_multiscenario_system(timesteps_8_days, periods_2, scenarios_2) + + fs_clustered = fs.transform.cluster( + n_clusters=2, + cluster_duration='1D', + extremes=ExtremeConfig( + method='replace', + max_value=['HeatDemand(Q)|fixed_relative_profile'], + preserve_n_clusters=True, + ), + ) + + assert fs_clustered.clustering.n_clusters == 2 + + fs_clustered.optimize(solver_fixture) + fs_expanded = fs_clustered.transform.expand() + + # Objectives should match + assert_allclose( + fs_clustered.solution['objective'].item(), + fs_expanded.solution['objective'].item(), + rtol=1e-5, + ) + + def test_full_dimensional_with_segmentation(self, solver_fixture, timesteps_8_days, periods_2, scenarios_2): + """Test full dimensional system with segmentation.""" + fs = create_multiperiod_multiscenario_system(timesteps_8_days, periods_2, scenarios_2) + + fs_clustered = fs.transform.cluster( + n_clusters=2, + cluster_duration='1D', + segments=SegmentConfig(n_segments=6), + ) + + assert fs_clustered.clustering.is_segmented is True + assert fs_clustered.clustering.n_segments == 6 + + fs_clustered.optimize(solver_fixture) + fs_expanded = fs_clustered.transform.expand() + + # Should restore original timesteps + assert len(fs_expanded.timesteps) == 192 + + +# ============================================================================ +# IO ROUND-TRIP TESTS WITH MULTI-PERIOD +# ============================================================================ + + +class TestMultiPeriodClusteringIO: + """Tests for IO round-trip of multi-period clustered systems.""" + + def test_multiperiod_clustering_roundtrip(self, solver_fixture, timesteps_8_days, periods_2, tmp_path): + """Test that multi-period clustered system survives IO round-trip.""" + fs = create_multiperiod_system_with_different_profiles(timesteps_8_days, periods_2) + + fs_clustered = fs.transform.cluster(n_clusters=2, cluster_duration='1D') + fs_clustered.optimize(solver_fixture) + + # Save and load + path = tmp_path / 'multiperiod_clustered.nc4' + fs_clustered.to_netcdf(path) + fs_loaded = fx.FlowSystem.from_netcdf(path) + + # Verify clustering preserved + assert fs_loaded.clustering is not None + assert fs_loaded.clustering.n_clusters == 2 + + # Verify periods preserved + assert fs_loaded.periods is not None + assert len(fs_loaded.periods) == 2 + + # Verify solution preserved + assert_allclose( + fs_loaded.solution['objective'].item(), + fs_clustered.solution['objective'].item(), + rtol=1e-6, + ) + + def test_multiperiod_expand_after_load(self, solver_fixture, timesteps_8_days, periods_2, tmp_path): + """Test that expand works after loading multi-period clustered system.""" + fs = create_multiperiod_system_with_different_profiles(timesteps_8_days, periods_2) + + fs_clustered = fs.transform.cluster(n_clusters=2, cluster_duration='1D') + fs_clustered.optimize(solver_fixture) + + # Save, load, and expand + path = tmp_path / 'multiperiod_clustered.nc4' + fs_clustered.to_netcdf(path) + fs_loaded = fx.FlowSystem.from_netcdf(path) + fs_expanded = fs_loaded.transform.expand() + + # Should have original timesteps + assert len(fs_expanded.timesteps) == 192 + + # Should have periods preserved + assert len(fs_expanded.periods) == 2 + + def test_extremes_preserved_after_io(self, solver_fixture, timesteps_8_days, periods_2, tmp_path): + """Test that extremes configuration results are preserved after IO.""" + fs = create_system_with_extreme_peaks(timesteps_8_days, periods=periods_2, peak_day=5) + + fs_clustered = fs.transform.cluster( + n_clusters=2, + cluster_duration='1D', + extremes=ExtremeConfig( + method='replace', + max_value=['HeatDemand(Q)|fixed_relative_profile'], + preserve_n_clusters=True, + ), + ) + fs_clustered.optimize(solver_fixture) + + # Save and load + path = tmp_path / 'extremes_clustered.nc4' + fs_clustered.to_netcdf(path) + fs_loaded = fx.FlowSystem.from_netcdf(path) + + # Clustering structure should be preserved + assert fs_loaded.clustering.n_clusters == 2 + + # Expand should work + fs_expanded = fs_loaded.transform.expand() + assert len(fs_expanded.timesteps) == 192 + + +# ============================================================================ +# EDGE CASES AND VALIDATION TESTS +# ============================================================================ + + +class TestEdgeCases: + """Tests for edge cases in multi-period clustering.""" + + def test_single_cluster_multiperiod(self, solver_fixture, timesteps_8_days, periods_2): + """Test clustering with n_clusters=1 for multi-period system.""" + fs = create_multiperiod_system_with_different_profiles(timesteps_8_days, periods_2) + + fs_clustered = fs.transform.cluster(n_clusters=1, cluster_duration='1D') + + assert fs_clustered.clustering.n_clusters == 1 + assert len(fs_clustered.clusters) == 1 + + # All days should be assigned to cluster 0 + cluster_assignments = fs_clustered.clustering.cluster_assignments + assert (cluster_assignments == 0).all() + + fs_clustered.optimize(solver_fixture) + assert fs_clustered.solution is not None + + def test_many_clusters_multiperiod(self, solver_fixture, timesteps_8_days, periods_2): + """Test clustering with many clusters (approaching original count).""" + fs = create_multiperiod_system_with_different_profiles(timesteps_8_days, periods_2) + + # 6 clusters for 8 days + fs_clustered = fs.transform.cluster(n_clusters=6, cluster_duration='1D') + + assert fs_clustered.clustering.n_clusters == 6 + + fs_clustered.optimize(solver_fixture) + fs_expanded = fs_clustered.transform.expand() + + assert len(fs_expanded.timesteps) == 192 + + def test_cluster_occurrences_sum_to_original(self, timesteps_8_days, periods_2): + """Test that cluster occurrences always sum to original cluster count.""" + fs = create_multiperiod_system_with_different_profiles(timesteps_8_days, periods_2) + + for n_clusters in [1, 2, 4, 6]: + fs_clustered = fs.transform.cluster(n_clusters=n_clusters, cluster_duration='1D') + + # For each period, occurrences should sum to 8 (original days) + occurrences = fs_clustered.clustering.cluster_occurrences + for period in periods_2: + period_occurrences = occurrences.sel(period=period) + assert int(period_occurrences.sum()) == 8, ( + f'Occurrences for period {period} with n_clusters={n_clusters}: ' + f'{int(period_occurrences.sum())} != 8' + ) + + def test_timestep_mapping_valid_range(self, timesteps_8_days, periods_2): + """Test that timestep_mapping values are within valid range.""" + fs = create_multiperiod_system_with_different_profiles(timesteps_8_days, periods_2) + + fs_clustered = fs.transform.cluster(n_clusters=3, cluster_duration='1D') + + mapping = fs_clustered.clustering.timestep_mapping + + # Mapping values should be in [0, n_clusters * timesteps_per_cluster - 1] + max_valid = 3 * 24 - 1 # n_clusters * timesteps_per_cluster - 1 + assert mapping.min() >= 0 + assert mapping.max() <= max_valid + + # Each period should have valid mappings + for period in periods_2: + period_mapping = mapping.sel(period=period) + assert period_mapping.min() >= 0 + assert period_mapping.max() <= max_valid From 6e5082c605600f7b3330129ee4e08e4e5e871a4f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 4 Feb 2026 14:30:56 +0100 Subject: [PATCH 12/44] fix: clustering and tsam 3.1.0 issue --- CHANGELOG.md | 19 ++++++++ flixopt/transform_accessor.py | 47 ++++++++++++------- pyproject.toml | 4 +- .../test_multiperiod_extremes.py | 32 +++++++++---- 4 files changed, 73 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8932f17f0..3bab51475 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,25 @@ If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOp Until here --> +## [6.0.1] - 2026-02-04 + +**Summary**: Bugfix release addressing clustering issues with multi-period systems and ExtremeConfig. + +### 🐛 Fixed + +- **Multi-period clustering with ExtremeConfig** - Fixed `ValueError: cannot reshape array` when clustering multi-period or multi-scenario systems with `ExtremeConfig`. The fix uses pandas `.unstack()` instead of manual reshape for robustness. +- **Consistent cluster count validation** - Added validation to detect inconsistent cluster counts across periods/scenarios, providing clear error messages. + +### 💥 Breaking Changes + +- **ExtremeConfig method restriction for multi-period systems** - When using `ExtremeConfig` with multi-period or multi-scenario systems, only `method='replace'` is now allowed. Using `method='new_cluster'` or `method='append'` will raise a `ValueError`. This works around a tsam bug where these methods can produce inconsistent cluster counts across slices. + +### 📦 Dependencies + +- Excluded tsam 3.1.0 from compatible versions due to clustering bug. + +--- + ## [6.0.0] - 2026-02-03 **Summary**: Major release featuring tsam v3 migration, complete rewrite of the clustering/aggregation system, 2-3x faster I/O for large systems, new `plotly` plotting accessor, FlowSystem comparison tools, and removal of deprecated v5.0 classes. diff --git a/flixopt/transform_accessor.py b/flixopt/transform_accessor.py index 033cba72f..2dc1f5f2b 100644 --- a/flixopt/transform_accessor.py +++ b/flixopt/transform_accessor.py @@ -111,10 +111,21 @@ def __init__( # Extract info from first result (all should be consistent) first_result = next(iter(aggregation_results.values())) self._n_reduced_timesteps = len(first_result.cluster_representatives) - self._n_clusters = len(first_result.cluster_weights) + self._n_clusters = first_result.n_clusters self._is_segmented = first_result.n_segments is not None self._n_segments = first_result.n_segments + # Validate all results have consistent structure + for key, result in aggregation_results.items(): + if result.n_clusters != self._n_clusters: + key_str = dict(zip(dim_names, key, strict=False)) if dim_names else key + raise ValueError( + f'Inconsistent cluster counts across periods/scenarios: ' + f'{key_str} has {result.n_clusters} clusters, but expected {self._n_clusters}. ' + f'This can happen when ExtremeConfig does not preserve cluster counts. ' + f'Ensure ExtremeConfig has preserve_n_clusters=True.' + ) + # Pre-compute coordinates self._cluster_coords = np.arange(self._n_clusters) @@ -173,17 +184,17 @@ def build_typical_periods(self) -> dict[str, xr.DataArray]: for key, tsam_result in self._aggregation_results.items(): typical_df = tsam_result.cluster_representatives - if self._is_segmented: - columns = typical_df.columns.tolist() - reshaped = typical_df.values.reshape(self._n_clusters, self._n_time_points, -1) - for col_idx, col in enumerate(columns): - da = xr.DataArray(reshaped[:, :, col_idx], dims=['cluster', 'time'], coords=self._base_coords) - column_slices.setdefault(col, {})[key] = da - else: - for col in typical_df.columns: - reshaped = typical_df[col].values.reshape(self._n_clusters, self._n_time_points) - da = xr.DataArray(reshaped, dims=['cluster', 'time'], coords=self._base_coords) - column_slices.setdefault(col, {})[key] = da + for col in typical_df.columns: + series = typical_df[col] + if self._is_segmented: + # Segmented: MultiIndex (cluster, segment_step, segment_duration) + # Drop duration level and unstack by segment step + unstacked = series.droplevel('Segment Duration').unstack(level='Segment Step') + else: + # Non-segmented: MultiIndex (cluster, timestep) + unstacked = series.unstack(level='timestep') + da = xr.DataArray(unstacked.values, dims=['cluster', 'time'], coords=self._base_coords) + column_slices.setdefault(col, {})[key] = da return { col: self._expand_and_combine(data_per_key, ['cluster', 'time']) @@ -1706,15 +1717,15 @@ def cluster( ) # Validate ExtremeConfig compatibility with multi-period/scenario systems - # Without preserve_n_clusters=True, methods can produce different n_clusters per period, - # which breaks the xarray structure that requires uniform dimensions + # Only method='replace' reliably produces consistent cluster counts across all slices. total_slices = len(periods) * len(scenarios) if total_slices > 1 and extremes is not None: - if not extremes.preserve_n_clusters: + if extremes.method != 'replace': raise ValueError( - 'ExtremeConfig must have preserve_n_clusters=True for multi-period ' - 'or multi-scenario systems to ensure consistent cluster counts across all slices. ' - 'Example: ExtremeConfig(method="new_cluster", max_value=[...], preserve_n_clusters=True)' + f"ExtremeConfig method='{extremes.method}' is not supported for multi-period " + "or multi-scenario systems. Only method='replace' reliably produces consistent " + 'cluster counts across all slices. Use: ' + "ExtremeConfig(..., method='replace')" ) # Build dim_names and clean key helper diff --git a/pyproject.toml b/pyproject.toml index 68a501551..42261f9f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,7 +63,7 @@ network_viz = [ # Full feature set (everything except dev tools) full = [ - "tsam >= 3.1.0, < 4", # Time series aggregation for clustering + "tsam >= 3.0.0, < 4, != 3.1.0", # Time series aggregation for clustering "pyvis==0.3.2", # Visualizing FlowSystem Network "scipy >= 1.15.1, < 2", # Used by tsam. Prior versions have conflict with highspy. See https://github.com/scipy/scipy/issues/22257 "gurobipy >= 10.0.0, < 14; python_version < '3.14'", # No Python 3.14 wheels yet (expected Q1 2026) @@ -77,7 +77,7 @@ full = [ # Development tools and testing dev = [ - "tsam==3.1.0", # Time series aggregation for clustering + "tsam==3.0.0", # Time series aggregation for clustering "pytest==8.4.2", "pytest-xdist==3.8.0", "nbformat==5.10.4", diff --git a/tests/test_clustering/test_multiperiod_extremes.py b/tests/test_clustering/test_multiperiod_extremes.py index ac3055c37..2ea2e46ed 100644 --- a/tests/test_clustering/test_multiperiod_extremes.py +++ b/tests/test_clustering/test_multiperiod_extremes.py @@ -562,36 +562,49 @@ def test_append_expand_preserves_objective(self, solver_fixture, timesteps_8_day class TestExtremeConfigMultiPeriod: """Tests for extreme configurations with multi-period systems.""" - def test_extremes_require_preserve_n_clusters_multiperiod(self, timesteps_8_days, periods_2): - """Test that new_cluster without preserve_n_clusters raises for multi-period.""" + def test_extremes_require_replace_method_multiperiod(self, timesteps_8_days, periods_2): + """Test that only method='replace' is allowed for multi-period systems.""" fs = create_system_with_extreme_peaks(timesteps_8_days, periods=periods_2) - with pytest.raises(ValueError, match='preserve_n_clusters=True'): + # method='new_cluster' should be rejected + with pytest.raises(ValueError, match="method='new_cluster'.*not supported"): fs.transform.cluster( n_clusters=2, cluster_duration='1D', extremes=ExtremeConfig( method='new_cluster', max_value=['HeatDemand(Q)|fixed_relative_profile'], - preserve_n_clusters=False, # Explicitly False - should raise for multi-period + preserve_n_clusters=True, ), ) - def test_extremes_with_preserve_n_clusters_multiperiod(self, solver_fixture, timesteps_8_days, periods_2): - """Test that extremes work with preserve_n_clusters for multi-period.""" + # method='append' should also be rejected + with pytest.raises(ValueError, match="method='append'.*not supported"): + fs.transform.cluster( + n_clusters=2, + cluster_duration='1D', + extremes=ExtremeConfig( + method='append', + max_value=['HeatDemand(Q)|fixed_relative_profile'], + preserve_n_clusters=True, + ), + ) + + def test_extremes_with_replace_multiperiod(self, solver_fixture, timesteps_8_days, periods_2): + """Test that extremes work with method='replace' for multi-period.""" fs = create_system_with_extreme_peaks(timesteps_8_days, periods=periods_2) + # Only method='replace' is allowed for multi-period systems fs_clustered = fs.transform.cluster( n_clusters=2, cluster_duration='1D', extremes=ExtremeConfig( - method='new_cluster', + method='replace', max_value=['HeatDemand(Q)|fixed_relative_profile'], preserve_n_clusters=True, ), ) - # Should work with preserve_n_clusters=True assert fs_clustered.clustering.n_clusters == 2 assert len(fs_clustered.periods) == 2 @@ -666,11 +679,12 @@ def test_multiperiod_extremes_with_segmentation(self, solver_fixture, timesteps_ """Test multi-period with extremes and segmentation.""" fs = create_multiperiod_system_with_different_profiles(timesteps_8_days, periods_2) + # Note: method='replace' is required for multi-period systems (method='append' has tsam bug) fs_clustered = fs.transform.cluster( n_clusters=2, cluster_duration='1D', extremes=ExtremeConfig( - method='append', + method='replace', max_value=['HeatDemand(Q)|fixed_relative_profile'], preserve_n_clusters=True, ), From 0bdc30f915ddf0b6edb59cc90212ed7e640a0561 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 4 Feb 2026 15:52:21 +0000 Subject: [PATCH 13/44] [ci] prepare release v6.0.1 --- CITATION.cff | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CITATION.cff b/CITATION.cff index 1f0dde55b..26488a049 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -2,7 +2,7 @@ cff-version: 1.2.0 message: "If you use this software, please cite it as below and consider citing the related publication." type: software title: "flixopt" -version: 6.0.0 +version: 6.0.1 date-released: 2026-02-04 url: "https://github.com/flixOpt/flixopt" repository-code: "https://github.com/flixOpt/flixopt" From 92f14b98e12e026906bd209366fde47f614a0513 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 4 Feb 2026 17:04:32 +0100 Subject: [PATCH 14/44] fix: clustering and tsam 3.1.0 issue --- CITATION.cff | 2 +- docs/notebooks/08c-clustering.ipynb | 11 ++++------- .../08c2-clustering-storage-modes.ipynb | 2 +- docs/notebooks/08d-clustering-multiperiod.ipynb | 7 ++++--- docs/notebooks/08e-clustering-internals.ipynb | 4 +--- .../notebooks/08f-clustering-segmentation.ipynb | 12 ++++-------- flixopt/transform_accessor.py | 3 +-- tests/test_cluster_reduce_expand.py | 17 ----------------- .../test_multiperiod_extremes.py | 16 ---------------- 9 files changed, 16 insertions(+), 58 deletions(-) diff --git a/CITATION.cff b/CITATION.cff index 26488a049..1f0dde55b 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -2,7 +2,7 @@ cff-version: 1.2.0 message: "If you use this software, please cite it as below and consider citing the related publication." type: software title: "flixopt" -version: 6.0.1 +version: 6.0.0 date-released: 2026-02-04 url: "https://github.com/flixOpt/flixopt" repository-code: "https://github.com/flixOpt/flixopt" diff --git a/docs/notebooks/08c-clustering.ipynb b/docs/notebooks/08c-clustering.ipynb index 3f5e616e0..602468cba 100644 --- a/docs/notebooks/08c-clustering.ipynb +++ b/docs/notebooks/08c-clustering.ipynb @@ -144,9 +144,7 @@ "fs_clustered = flow_system.transform.cluster(\n", " n_clusters=8, # 8 typical days\n", " cluster_duration='1D', # Daily clustering\n", - " extremes=ExtremeConfig(\n", - " method='new_cluster', max_value=peak_series, preserve_n_clusters=True\n", - " ), # Capture peak demand day\n", + " extremes=ExtremeConfig(method='new_cluster', max_value=peak_series), # Capture peak demand day\n", ")\n", "fs_clustered.name = 'Clustered (8 days)'\n", "\n", @@ -296,7 +294,7 @@ " n_clusters=8,\n", " cluster_duration='1D',\n", " data_vars=[demand_var], # Only this variable determines clustering\n", - " extremes=ExtremeConfig(method='new_cluster', max_value=[demand_var], preserve_n_clusters=True),\n", + " extremes=ExtremeConfig(method='new_cluster', max_value=[demand_var]),\n", ")\n", "\n", "# Verify: clustering was determined by demand but applied to all data\n", @@ -587,7 +585,7 @@ "id": "37", "metadata": {}, "source": [ - "## API Reference\n", + "markdown## API Reference\n", "\n", "### `transform.cluster()` Parameters\n", "\n", @@ -668,7 +666,6 @@ "extremes = ExtremeConfig(\n", " method='new_cluster', # Creates new cluster for extremes\n", " max_value=['ComponentName(FlowName)|fixed_relative_profile'], # Capture peak demand\n", - " preserve_n_clusters=True, # Keep total cluster count unchanged\n", ")\n", "```\n", "\n", @@ -681,7 +678,7 @@ "fs_sizing = flow_system.transform.cluster(\n", " n_clusters=8,\n", " cluster_duration='1D',\n", - " extremes=ExtremeConfig(method='new_cluster', max_value=['Demand(Flow)|fixed_relative_profile'], preserve_n_clusters=True),\n", + " extremes=ExtremeConfig(method='new_cluster', max_value=['Demand(Flow)|fixed_relative_profile']),\n", ")\n", "fs_sizing.optimize(solver)\n", "\n", diff --git a/docs/notebooks/08c2-clustering-storage-modes.ipynb b/docs/notebooks/08c2-clustering-storage-modes.ipynb index 1e7a336f3..925adc82a 100644 --- a/docs/notebooks/08c2-clustering-storage-modes.ipynb +++ b/docs/notebooks/08c2-clustering-storage-modes.ipynb @@ -199,7 +199,7 @@ " fs_clustered = fs_copy.transform.cluster(\n", " n_clusters=N_CLUSTERS,\n", " cluster_duration=CLUSTER_DURATION,\n", - " extremes=ExtremeConfig(method='new_cluster', max_value=PEAK_SERIES, preserve_n_clusters=True),\n", + " extremes=ExtremeConfig(method='new_cluster', max_value=PEAK_SERIES),\n", " )\n", " time_cluster = timeit.default_timer() - start\n", "\n", diff --git a/docs/notebooks/08d-clustering-multiperiod.ipynb b/docs/notebooks/08d-clustering-multiperiod.ipynb index dd10f01a0..e6a4d86ae 100644 --- a/docs/notebooks/08d-clustering-multiperiod.ipynb +++ b/docs/notebooks/08d-clustering-multiperiod.ipynb @@ -185,7 +185,7 @@ "fs_clustered = flow_system.transform.cluster(\n", " n_clusters=3,\n", " cluster_duration='1D',\n", - " extremes=ExtremeConfig(method='replace', max_value=peak_series, preserve_n_clusters=True),\n", + " extremes=ExtremeConfig(method='replace', max_value=peak_series),\n", ")\n", "\n", "time_clustering = timeit.default_timer() - start\n", @@ -525,7 +525,7 @@ "id": "29", "metadata": {}, "source": [ - "## Summary\n", + "markdown## Summary\n", "\n", "You learned how to:\n", "\n", @@ -556,10 +556,11 @@ "fs = fs.transform.isel(time=slice(0, 168)) # First 168 timesteps\n", "\n", "# Cluster (applies per period/scenario)\n", + "# Note: For multi-period systems, only method='replace' is supported\n", "fs_clustered = fs.transform.cluster(\n", " n_clusters=10,\n", " cluster_duration='1D',\n", - " extremes=ExtremeConfig(method='replace', max_value=['Demand(Flow)|fixed_relative_profile'], preserve_n_clusters=True),\n", + " extremes=ExtremeConfig(method='replace', max_value=['Demand(Flow)|fixed_relative_profile']),\n", ")\n", "\n", "# Visualize clustering quality\n", diff --git a/docs/notebooks/08e-clustering-internals.ipynb b/docs/notebooks/08e-clustering-internals.ipynb index 72bb1bb1b..2d099ff34 100644 --- a/docs/notebooks/08e-clustering-internals.ipynb +++ b/docs/notebooks/08e-clustering-internals.ipynb @@ -63,9 +63,7 @@ "fs_clustered = flow_system.transform.cluster(\n", " n_clusters=8,\n", " cluster_duration='1D',\n", - " extremes=ExtremeConfig(\n", - " method='new_cluster', max_value=['HeatDemand(Q_th)|fixed_relative_profile'], preserve_n_clusters=True\n", - " ),\n", + " extremes=ExtremeConfig(method='new_cluster', max_value=['HeatDemand(Q_th)|fixed_relative_profile']),\n", ")\n", "\n", "fs_clustered.clustering" diff --git a/docs/notebooks/08f-clustering-segmentation.ipynb b/docs/notebooks/08f-clustering-segmentation.ipynb index 481215f2e..7bc2c712c 100644 --- a/docs/notebooks/08f-clustering-segmentation.ipynb +++ b/docs/notebooks/08f-clustering-segmentation.ipynb @@ -154,9 +154,7 @@ " n_clusters=8,\n", " cluster_duration='1D',\n", " segments=SegmentConfig(n_segments=6), # 6 segments per day instead of 96 quarter-hours\n", - " extremes=ExtremeConfig(\n", - " method='replace', max_value=['HeatDemand(Q_th)|fixed_relative_profile'], preserve_n_clusters=True\n", - " ),\n", + " extremes=ExtremeConfig(method='replace', max_value=['HeatDemand(Q_th)|fixed_relative_profile']),\n", ")\n", "\n", "time_clustering = timeit.default_timer() - start\n", @@ -490,9 +488,7 @@ " n_clusters=3,\n", " cluster_duration='1D',\n", " segments=SegmentConfig(n_segments=6),\n", - " extremes=ExtremeConfig(\n", - " method='replace', max_value=['Building(Heat)|fixed_relative_profile'], preserve_n_clusters=True\n", - " ),\n", + " extremes=ExtremeConfig(method='replace', max_value=['Building(Heat)|fixed_relative_profile']),\n", ")\n", "\n", "print(f'Original: {len(fs_multi.timesteps)} timesteps')\n", @@ -531,7 +527,7 @@ "id": "33", "metadata": {}, "source": [ - "## API Reference\n", + "markdown## API Reference\n", "\n", "### SegmentConfig Parameters\n", "\n", @@ -576,7 +572,7 @@ " n_clusters=8,\n", " cluster_duration='1D',\n", " segments=SegmentConfig(n_segments=6),\n", - " extremes=ExtremeConfig(method='new_cluster', max_value=['Demand|profile'], preserve_n_clusters=True),\n", + " extremes=ExtremeConfig(method='new_cluster', max_value=['Demand|profile']),\n", ")\n", "\n", "# Optimize\n", diff --git a/flixopt/transform_accessor.py b/flixopt/transform_accessor.py index 2dc1f5f2b..5204a05ef 100644 --- a/flixopt/transform_accessor.py +++ b/flixopt/transform_accessor.py @@ -122,8 +122,7 @@ def __init__( raise ValueError( f'Inconsistent cluster counts across periods/scenarios: ' f'{key_str} has {result.n_clusters} clusters, but expected {self._n_clusters}. ' - f'This can happen when ExtremeConfig does not preserve cluster counts. ' - f'Ensure ExtremeConfig has preserve_n_clusters=True.' + f'This can happen when ExtremeConfig does not preserve cluster counts.' ) # Pre-compute coordinates diff --git a/tests/test_cluster_reduce_expand.py b/tests/test_cluster_reduce_expand.py index 7093e8b14..8d6586c3e 100644 --- a/tests/test_cluster_reduce_expand.py +++ b/tests/test_cluster_reduce_expand.py @@ -873,22 +873,6 @@ def test_extremes_new_cluster_increases_n_clusters(self, solver_fixture, timeste # The sum of cluster occurrences should equal n_original_clusters (8 days) assert int(fs_clustered.clustering.cluster_occurrences.sum()) == 8 - def test_extremes_requires_preserve_n_clusters_for_multi_period(self, timesteps_8_days, periods_2): - """Test that preserve_n_clusters=True is required for multi-period systems.""" - from tsam import ExtremeConfig - - fs = create_system_with_periods(timesteps_8_days, periods_2) - - with pytest.raises(ValueError, match='preserve_n_clusters=True'): - fs.transform.cluster( - n_clusters=2, - cluster_duration='1D', - extremes=ExtremeConfig( - method='new_cluster', - max_value=['HeatDemand(Q)|fixed_relative_profile'], - ), - ) - def test_extremes_replace_works_for_multi_period(self, solver_fixture, timesteps_8_days, periods_2): """Test that method='replace' works correctly for multi-period systems.""" from tsam import ExtremeConfig @@ -902,7 +886,6 @@ def test_extremes_replace_works_for_multi_period(self, solver_fixture, timesteps extremes=ExtremeConfig( method='replace', max_value=['HeatDemand(Q)|fixed_relative_profile'], - preserve_n_clusters=True, ), ) diff --git a/tests/test_clustering/test_multiperiod_extremes.py b/tests/test_clustering/test_multiperiod_extremes.py index 2ea2e46ed..b0e809992 100644 --- a/tests/test_clustering/test_multiperiod_extremes.py +++ b/tests/test_clustering/test_multiperiod_extremes.py @@ -388,7 +388,6 @@ def test_new_cluster_captures_peak_day(self, solver_fixture, timesteps_8_days): extremes=ExtremeConfig( method='new_cluster', max_value=['HeatDemand(Q)|fixed_relative_profile'], - preserve_n_clusters=False, # Allow adding new clusters for extremes ), ) @@ -410,7 +409,6 @@ def test_new_cluster_can_increase_cluster_count(self, timesteps_8_days): extremes=ExtremeConfig( method='new_cluster', max_value=['HeatDemand(Q)|fixed_relative_profile'], - preserve_n_clusters=False, # Allow cluster count to increase ), ) @@ -453,7 +451,6 @@ def test_new_cluster_with_min_value(self, solver_fixture, timesteps_8_days): extremes=ExtremeConfig( method='new_cluster', min_value=['HeatDemand(Q)|fixed_relative_profile'], - preserve_n_clusters=False, # Allow cluster count to increase ), ) @@ -475,7 +472,6 @@ def test_replace_maintains_cluster_count(self, solver_fixture, timesteps_8_days) extremes=ExtremeConfig( method='replace', max_value=['HeatDemand(Q)|fixed_relative_profile'], - preserve_n_clusters=True, # Replace method maintains cluster count ), ) @@ -495,7 +491,6 @@ def test_replace_with_multiperiod(self, solver_fixture, timesteps_8_days, period extremes=ExtremeConfig( method='replace', max_value=['HeatDemand(Q)|fixed_relative_profile'], - preserve_n_clusters=True, ), ) @@ -519,7 +514,6 @@ def test_append_with_segments(self, solver_fixture, timesteps_8_days): extremes=ExtremeConfig( method='append', max_value=['HeatDemand(Q)|fixed_relative_profile'], - preserve_n_clusters=False, # Append may add clusters ), segments=SegmentConfig(n_segments=4), ) @@ -545,7 +539,6 @@ def test_append_expand_preserves_objective(self, solver_fixture, timesteps_8_day extremes=ExtremeConfig( method='append', max_value=['HeatDemand(Q)|fixed_relative_profile'], - preserve_n_clusters=False, # Append may add clusters ), segments=SegmentConfig(n_segments=4), ) @@ -574,7 +567,6 @@ def test_extremes_require_replace_method_multiperiod(self, timesteps_8_days, per extremes=ExtremeConfig( method='new_cluster', max_value=['HeatDemand(Q)|fixed_relative_profile'], - preserve_n_clusters=True, ), ) @@ -586,7 +578,6 @@ def test_extremes_require_replace_method_multiperiod(self, timesteps_8_days, per extremes=ExtremeConfig( method='append', max_value=['HeatDemand(Q)|fixed_relative_profile'], - preserve_n_clusters=True, ), ) @@ -601,7 +592,6 @@ def test_extremes_with_replace_multiperiod(self, solver_fixture, timesteps_8_day extremes=ExtremeConfig( method='replace', max_value=['HeatDemand(Q)|fixed_relative_profile'], - preserve_n_clusters=True, ), ) @@ -626,7 +616,6 @@ def test_extremes_with_periods_and_scenarios(self, solver_fixture, timesteps_8_d extremes=ExtremeConfig( method='replace', max_value=['HeatDemand(Q)|fixed_relative_profile'], - preserve_n_clusters=True, ), ) @@ -661,7 +650,6 @@ def test_different_profiles_with_extremes(self, solver_fixture, timesteps_8_days extremes=ExtremeConfig( method='replace', max_value=['HeatDemand(Q)|fixed_relative_profile'], - preserve_n_clusters=True, ), ) @@ -686,7 +674,6 @@ def test_multiperiod_extremes_with_segmentation(self, solver_fixture, timesteps_ extremes=ExtremeConfig( method='replace', max_value=['HeatDemand(Q)|fixed_relative_profile'], - preserve_n_clusters=True, ), segments=SegmentConfig(n_segments=6), ) @@ -781,7 +768,6 @@ def test_scenarios_with_extremes(self, solver_fixture, timesteps_8_days, scenari extremes=ExtremeConfig( method='replace', max_value=['HeatDemand(Q)|fixed_relative_profile'], - preserve_n_clusters=True, ), ) @@ -846,7 +832,6 @@ def test_full_dimensional_with_extremes(self, solver_fixture, timesteps_8_days, extremes=ExtremeConfig( method='replace', max_value=['HeatDemand(Q)|fixed_relative_profile'], - preserve_n_clusters=True, ), ) @@ -946,7 +931,6 @@ def test_extremes_preserved_after_io(self, solver_fixture, timesteps_8_days, per extremes=ExtremeConfig( method='replace', max_value=['HeatDemand(Q)|fixed_relative_profile'], - preserve_n_clusters=True, ), ) fs_clustered.optimize(solver_fixture) From 314b0fa0d932c3ee5dee4192cba94891d675480c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 4 Feb 2026 16:08:33 +0000 Subject: [PATCH 15/44] [ci] prepare release v6.0.1 --- CITATION.cff | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CITATION.cff b/CITATION.cff index 1f0dde55b..26488a049 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -2,7 +2,7 @@ cff-version: 1.2.0 message: "If you use this software, please cite it as below and consider citing the related publication." type: software title: "flixopt" -version: 6.0.0 +version: 6.0.1 date-released: 2026-02-04 url: "https://github.com/flixOpt/flixopt" repository-code: "https://github.com/flixOpt/flixopt" From 6f8788ec367610c327b8107da85d6793b1958857 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 4 Feb 2026 18:21:29 +0100 Subject: [PATCH 16/44] ci: remove test --- CITATION.cff | 2 +- tests/test_clustering/test_multiperiod_extremes.py | 14 -------------- 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/CITATION.cff b/CITATION.cff index 26488a049..1f0dde55b 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -2,7 +2,7 @@ cff-version: 1.2.0 message: "If you use this software, please cite it as below and consider citing the related publication." type: software title: "flixopt" -version: 6.0.1 +version: 6.0.0 date-released: 2026-02-04 url: "https://github.com/flixOpt/flixopt" repository-code: "https://github.com/flixOpt/flixopt" diff --git a/tests/test_clustering/test_multiperiod_extremes.py b/tests/test_clustering/test_multiperiod_extremes.py index b0e809992..0e8331522 100644 --- a/tests/test_clustering/test_multiperiod_extremes.py +++ b/tests/test_clustering/test_multiperiod_extremes.py @@ -972,20 +972,6 @@ def test_single_cluster_multiperiod(self, solver_fixture, timesteps_8_days, peri fs_clustered.optimize(solver_fixture) assert fs_clustered.solution is not None - def test_many_clusters_multiperiod(self, solver_fixture, timesteps_8_days, periods_2): - """Test clustering with many clusters (approaching original count).""" - fs = create_multiperiod_system_with_different_profiles(timesteps_8_days, periods_2) - - # 6 clusters for 8 days - fs_clustered = fs.transform.cluster(n_clusters=6, cluster_duration='1D') - - assert fs_clustered.clustering.n_clusters == 6 - - fs_clustered.optimize(solver_fixture) - fs_expanded = fs_clustered.transform.expand() - - assert len(fs_expanded.timesteps) == 192 - def test_cluster_occurrences_sum_to_original(self, timesteps_8_days, periods_2): """Test that cluster occurrences always sum to original cluster count.""" fs = create_multiperiod_system_with_different_profiles(timesteps_8_days, periods_2) From 6ae5d03067b232f6b656cce04e6cadefe1d8e4fa Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 4 Feb 2026 21:00:09 +0000 Subject: [PATCH 17/44] [ci] prepare release v6.0.1 --- CITATION.cff | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CITATION.cff b/CITATION.cff index 1f0dde55b..26488a049 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -2,7 +2,7 @@ cff-version: 1.2.0 message: "If you use this software, please cite it as below and consider citing the related publication." type: software title: "flixopt" -version: 6.0.0 +version: 6.0.1 date-released: 2026-02-04 url: "https://github.com/flixOpt/flixopt" repository-code: "https://github.com/flixOpt/flixopt" From 7e4347f648ea09d22bc16e1a33fa24f94613398e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 4 Feb 2026 23:53:21 +0100 Subject: [PATCH 18/44] chore(deps): update dependency werkzeug to v3.1.5 (#564) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 42261f9f1..dcec4f4ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -90,7 +90,7 @@ dev = [ "dash-cytoscape==1.0.2", "dash-daq==0.6.0", "networkx==3.0.0", - "werkzeug==3.1.4", + "werkzeug==3.1.5", ] # Documentation building From b7a30e612055c1b38799442cde453010a2aaaa83 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 4 Feb 2026 23:53:38 +0100 Subject: [PATCH 19/44] chore(deps): update dependency ruff to v0.14.14 (#563) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index dcec4f4ae..bd3f55f83 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,7 +81,7 @@ dev = [ "pytest==8.4.2", "pytest-xdist==3.8.0", "nbformat==5.10.4", - "ruff==0.14.10", + "ruff==0.14.14", "pre-commit==4.3.0", "pyvis==0.3.2", "scipy==1.16.3", # 1.16.1+ required for Python 3.14 wheels From 2eb528f8a7f4539057d8e5f34cfa492f6609b6a0 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 4 Feb 2026 23:53:59 +0100 Subject: [PATCH 20/44] chore(deps): update dependency netcdf4 to >=1.6.1, <1.7.5 (#583) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index bd3f55f83..0701a5f06 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,7 @@ dependencies = [ "xarray >= 2024.2.0, < 2026.0", # CalVer: allow through next calendar year # Optimization and data handling "linopy >= 0.5.1, < 0.6", # Widened from patch pin to minor range - "netcdf4 >= 1.6.1, < 1.7.4", # 1.7.4 missing wheels, revert to < 2 later + "netcdf4 >=1.6.1, <1.7.5", # 1.7.4 missing wheels, revert to < 2 later # Utilities "pyyaml >= 6.0.0, < 7", "colorlog >= 6.8.0, < 7", From 5caffd1eb70521dc4f219222a213623b6a59c2d1 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 5 Feb 2026 07:52:46 +0100 Subject: [PATCH 21/44] chore(deps): update dependency pre-commit to v4.5.1 (#532) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 0701a5f06..58a480afc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,7 +82,7 @@ dev = [ "pytest-xdist==3.8.0", "nbformat==5.10.4", "ruff==0.14.14", - "pre-commit==4.3.0", + "pre-commit==4.5.1", "pyvis==0.3.2", "scipy==1.16.3", # 1.16.1+ required for Python 3.14 wheels "gurobipy==12.0.3; python_version < '3.14'", # No Python 3.14 wheels yet From 16eae3d5d90e265ed195e938203ec38ca11495f2 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 5 Feb 2026 11:27:32 +0100 Subject: [PATCH 22/44] fix: Comparison coords (#599) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix coords concat in comparison.py * Fix coords concat in comparison.py * Fix coords concat in comparison.py * Add 6.0.1 changelog entry Co-Authored-By: Claude Opus 4.5 * Fix coord preservation in Comparison.solution and .inputs - Apply _extract_nonindex_coords pattern to solution and inputs properties - Add warning when coordinate mappings conflict during merge Co-Authored-By: Claude Opus 4.5 * Update CHANGELOG.md * Update CHANGELOG.md * ⏺ The fix is straightforward — on line 83, mapping.get(dv) returns None for unmapped values. Change it to mapping.get(dv, dv) so unmapped dimension values fall back to themselves. ⏺ Update(flixopt/comparison.py) ⎿  Added 1 line, removed 1 line 80 for name, (dim, mapping) in merged.items(): 81 if dim not in ds.dims: 82 continue 83 - new_coords[name] = (dim, [mapping.get(dv) for dv in ds.coords[dim].values]) 83 + new_coords[name] = (dim, [mapping.get(dv, dv) for dv in ds.coords[dim].values]) 84 85 return ds.assign_coords(new_coords) 86 ⏺ Done. The change on line 83 ensures that when mapping doesn't contain a key for a dimension value (which happens with outer-join additions), the original value dv is preserved instead of inserting None. * Update Changelog --------- Co-authored-by: Claude Opus 4.5 --- CHANGELOG.md | 17 +++++++++ flixopt/comparison.py | 84 +++++++++++++++++++++++++++++++++++-------- 2 files changed, 87 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bab51475..8c2e227ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,23 @@ If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOp Until here --> +## [6.0.2] - 2026-02-05 + +**Summary**: Patch release which improves `Comparison` coordinate handling. + +### 🐛 Fixed + +- **Comparison Coordinates**: Fixed `component` coordinate becoming `(case, contributor)` shaped after concatenation in `Comparison` class. Non-index coordinates are now properly merged before concat in `solution`, `inputs`, and all statistics properties. Added warning when coordinate mappings conflict (#599) + +### 📝 Docs + +- **Docs Workflow**: Added `workflow_dispatch` inputs for manual docs deployment with version selection (#599) + +### 👷 Development + +- Updated dev dependencies to newer versions +--- + ## [6.0.1] - 2026-02-04 **Summary**: Bugfix release addressing clustering issues with multi-period systems and ExtremeConfig. diff --git a/flixopt/comparison.py b/flixopt/comparison.py index 81ed7fb40..63aa6491e 100644 --- a/flixopt/comparison.py +++ b/flixopt/comparison.py @@ -29,6 +29,62 @@ _CASE_SLOTS = frozenset(slot for slots in SLOT_ORDERS.values() for slot in slots) +def _extract_nonindex_coords(datasets: list[xr.Dataset]) -> tuple[list[xr.Dataset], dict[str, tuple[str, dict]]]: + """Extract and merge non-index coords, returning cleaned datasets and merged mappings. + + Non-index coords (like `component` on `contributor` dim) cause concat conflicts. + This extracts them, merges the mappings, and returns datasets without them. + """ + if not datasets: + return datasets, {} + + # Find non-index coords and collect mappings + merged: dict[str, tuple[str, dict]] = {} + coords_to_drop: set[str] = set() + + for ds in datasets: + for name, coord in ds.coords.items(): + if len(coord.dims) != 1: + continue + dim = coord.dims[0] + if dim == name or dim not in ds.coords: + continue + + coords_to_drop.add(name) + if name not in merged: + merged[name] = (dim, {}) + + for dv, cv in zip(ds.coords[dim].values, coord.values, strict=False): + if dv not in merged[name][1]: + merged[name][1][dv] = cv + elif merged[name][1][dv] != cv: + warnings.warn( + f"Coordinate '{name}' has conflicting values for dim value '{dv}': " + f"'{merged[name][1][dv]}' vs '{cv}'. Keeping first value.", + stacklevel=4, + ) + + # Drop these coords from datasets + if coords_to_drop: + datasets = [ds.drop_vars(coords_to_drop, errors='ignore') for ds in datasets] + + return datasets, merged + + +def _apply_merged_coords(ds: xr.Dataset, merged: dict[str, tuple[str, dict]]) -> xr.Dataset: + """Apply merged coord mappings to concatenated dataset.""" + if not merged: + return ds + + new_coords = {} + for name, (dim, mapping) in merged.items(): + if dim not in ds.dims: + continue + new_coords[name] = (dim, [mapping.get(dv, dv) for dv in ds.coords[dim].values]) + + return ds.assign_coords(new_coords) + + def _apply_slot_defaults(plotly_kwargs: dict, defaults: dict[str, str | None]) -> None: """Apply default slot assignments to plotly kwargs. @@ -256,12 +312,10 @@ def solution(self) -> xr.Dataset: self._require_solutions() datasets = [fs.solution for fs in self._systems] self._warn_mismatched_dimensions(datasets) - self._solution = xr.concat( - [ds.expand_dims(case=[name]) for ds, name in zip(datasets, self._names, strict=True)], - dim='case', - join='outer', - fill_value=float('nan'), - ) + expanded = [ds.expand_dims(case=[name]) for ds, name in zip(datasets, self._names, strict=True)] + expanded, merged_coords = _extract_nonindex_coords(expanded) + result = xr.concat(expanded, dim='case', join='outer', coords='minimal', fill_value=float('nan')) + self._solution = _apply_merged_coords(result, merged_coords) return self._solution @property @@ -324,12 +378,10 @@ def inputs(self) -> xr.Dataset: if self._inputs is None: datasets = [fs.to_dataset(include_solution=False) for fs in self._systems] self._warn_mismatched_dimensions(datasets) - self._inputs = xr.concat( - [ds.expand_dims(case=[name]) for ds, name in zip(datasets, self._names, strict=True)], - dim='case', - join='outer', - fill_value=float('nan'), - ) + expanded = [ds.expand_dims(case=[name]) for ds, name in zip(datasets, self._names, strict=True)] + expanded, merged_coords = _extract_nonindex_coords(expanded) + result = xr.concat(expanded, dim='case', join='outer', coords='minimal', fill_value=float('nan')) + self._inputs = _apply_merged_coords(result, merged_coords) return self._inputs @@ -374,7 +426,9 @@ def _concat_property(self, prop_name: str) -> xr.Dataset: continue if not datasets: return xr.Dataset() - return xr.concat(datasets, dim='case', join='outer', fill_value=float('nan')) + datasets, merged_coords = _extract_nonindex_coords(datasets) + result = xr.concat(datasets, dim='case', join='outer', coords='minimal', fill_value=float('nan')) + return _apply_merged_coords(result, merged_coords) def _merge_dict_property(self, prop_name: str) -> dict[str, str]: """Merge a dict property from all cases (later cases override).""" @@ -528,7 +582,9 @@ def _combine_data(self, method_name: str, *args, **kwargs) -> tuple[xr.Dataset, if not datasets: return xr.Dataset(), '' - return xr.concat(datasets, dim='case', join='outer', fill_value=float('nan')), title + datasets, merged_coords = _extract_nonindex_coords(datasets) + combined = xr.concat(datasets, dim='case', join='outer', coords='minimal', fill_value=float('nan')) + return _apply_merged_coords(combined, merged_coords), title def _finalize(self, ds: xr.Dataset, fig, show: bool | None) -> PlotResult: """Handle show and return PlotResult.""" From 31a59646c0094b041bc82c342f8488b3fd3b2a12 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 5 Feb 2026 10:28:35 +0000 Subject: [PATCH 23/44] [ci] prepare release v6.0.2 --- CITATION.cff | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CITATION.cff b/CITATION.cff index 26488a049..535290056 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -2,8 +2,8 @@ cff-version: 1.2.0 message: "If you use this software, please cite it as below and consider citing the related publication." type: software title: "flixopt" -version: 6.0.1 -date-released: 2026-02-04 +version: 6.0.2 +date-released: 2026-02-05 url: "https://github.com/flixOpt/flixopt" repository-code: "https://github.com/flixOpt/flixopt" license: MIT From 4a572822e210c1bd0ceef9360081996fe40c62a9 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 5 Feb 2026 11:29:31 +0100 Subject: [PATCH 24/44] typo --- tests/test_math/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_math/conftest.py b/tests/test_math/conftest.py index c6ceca1fd..60961b910 100644 --- a/tests/test_math/conftest.py +++ b/tests/test_math/conftest.py @@ -13,7 +13,7 @@ ``save->reload->solve`` Proves the FlowSystem definition survives IO. ``solve->save->reload`` - Proves the solution data survives IO. + Proves the solution data survives IO.. """ import pathlib From 57c6fc9df0f475e57f801a2bc2c6065d3519f2c3 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 5 Feb 2026 11:29:42 +0100 Subject: [PATCH 25/44] Revert "typo" This reverts commit 4a572822e210c1bd0ceef9360081996fe40c62a9. --- tests/test_math/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_math/conftest.py b/tests/test_math/conftest.py index 60961b910..c6ceca1fd 100644 --- a/tests/test_math/conftest.py +++ b/tests/test_math/conftest.py @@ -13,7 +13,7 @@ ``save->reload->solve`` Proves the FlowSystem definition survives IO. ``solve->save->reload`` - Proves the solution data survives IO.. + Proves the solution data survives IO. """ import pathlib From ce318e91ef771be22f96812a133178a1b80668e2 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 5 Feb 2026 11:44:37 +0100 Subject: [PATCH 26/44] Add plan file --- tests/test_math/PLAN.md | 209 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 tests/test_math/PLAN.md diff --git a/tests/test_math/PLAN.md b/tests/test_math/PLAN.md new file mode 100644 index 000000000..7d267811e --- /dev/null +++ b/tests/test_math/PLAN.md @@ -0,0 +1,209 @@ +# Plan: Comprehensive test_math Coverage Expansion + +All tests use the existing `optimize` fixture (3 modes: `solve`, `save->reload->solve`, `solve->save->reload`). + +--- + +## Part A — Single-period gaps + +### A1. Storage (`test_storage.py`, existing `TestStorage`) + +- [ ] **`test_storage_relative_minimum_charge_state`** + - 3 ts, Grid=[1, 100, 1], Demand=[0, 80, 0] + - Storage: capacity=100, initial=0, **relative_minimum_charge_state=0.3** + - SOC must stay >= 30. Charge 100 @t0, discharge max 70 @t1, grid covers 10 @100. + - **Cost = 1100** (without: 80) + +- [ ] **`test_storage_maximal_final_charge_state`** + - 2 ts, Bus imbalance_penalty=5, Grid=[1,100], Demand=[0, 50] + - Storage: capacity=100, initial=80, **maximal_final_charge_state=20** + - Must discharge 60 (demand 50 + 10 excess penalized @5). + - **Cost = 50** (without: 0) + +- [ ] **`test_storage_relative_minimum_final_charge_state`** + - 2 ts, Grid=[1, 100], Demand=[0, 50] + - Storage: capacity=100, initial=0, **relative_minimum_final_charge_state=0.7** + - Final SOC >= 70. Charge 100, discharge 30, grid covers 20 @100. + - **Cost = 2100** (without: 50) + +- [ ] **`test_storage_relative_maximum_final_charge_state`** + - Same as maximal_final but relative: **relative_maximum_final_charge_state=0.2** on capacity=100. + - **Cost = 50** (without: 0) + +- [ ] **`test_storage_balanced_invest`** + - 3 ts, Grid=[1, 100, 100], Demand=[0, 80, 80] + - Storage: capacity=200, initial=0, **balanced=True** + - charge: InvestParams(max=200, per_size=0.5) + - discharge: InvestParams(max=200, per_size=0.5) + - Balanced forces charge_size = discharge_size = 160. Invest=160. Grid=160. + - **Cost = 320** (without balanced: 280, since discharge_size could be 80) + +### A2. Transmission (`test_components.py`, existing `TestTransmission`) + +- [ ] **`test_transmission_prevent_simultaneous_bidirectional`** + - 2 ts, 2 buses. Demand alternates sides. + - **prevent_simultaneous_flows_in_both_directions=True** + - Structural check: at no timestep both directions active. + - **Cost = 40** (same as unrestricted in this case; constraint is structural) + +- [ ] **`test_transmission_status_startup_cost`** + - 4 ts, Demand=[20, 0, 20, 0] through Transmission + - **status_parameters=StatusParameters(effects_per_startup=50)** + - 2 startups * 50 + energy 40. + - **Cost = 140** (without: 40) + +### A3. New component classes (`test_components.py`) + +- [ ] **`TestPower2Heat` — `test_power2heat_efficiency`** + - 2 ts, Demand=[20, 20], Grid @1 + - Power2Heat: thermal_efficiency=0.9 + - Elec = 40/0.9 = 44.44 + - **Cost = 40/0.9** (without eta: 40) + +- [ ] **`TestHeatPumpWithSource` — `test_heatpump_with_source_cop`** + - 2 ts, Demand=[30, 30], Grid @1 (elec), free heat source + - HeatPumpWithSource: cop=3. Elec = 60/3 = 20. + - **Cost = 20** (with cop=1: 60) + +- [ ] **`TestSourceAndSink` — `test_source_and_sink_prevent_simultaneous`** + - 3 ts, Solar=[30, 30, 0], Demand=[10, 10, 10] + - SourceAndSink `GridConnection`: buy @5, sell @-1, prevent_simultaneous=True + - t0,t1: sell 20 (revenue 20 each). t2: buy 10 (cost 50). + - **Cost = 10** (50 - 40 revenue) + +### A4. Flow status (`test_flow_status.py`) + +- [ ] **`test_max_uptime_standalone`** + - 5 ts, Demand=[10]*5 + - CheapBoiler eta=1.0, **StatusParameters(max_uptime=2)**, previous_flow_rate=0 + - ExpensiveBoiler eta=0.5 (backup) + - Cheap: on(0,1), off(2), on(3,4) = 40 fuel. Expensive covers t2: 20 fuel. + - **Cost = 60** (without: 50) + +--- + +## Part B — Multi-period, scenarios, clustering + +### B1. conftest.py helpers + +```python +def make_multi_period_flow_system(n_timesteps=3, periods=None, weight_of_last_period=None): + ts = pd.date_range('2020-01-01', periods=n_timesteps, freq='h') + if periods is None: + periods = [2020, 2025] + return fx.FlowSystem(ts, periods=pd.Index(periods, name='period'), + weight_of_last_period=weight_of_last_period) + +def make_scenario_flow_system(n_timesteps=3, scenarios=None, scenario_weights=None): + ts = pd.date_range('2020-01-01', periods=n_timesteps, freq='h') + if scenarios is None: + scenarios = ['low', 'high'] + return fx.FlowSystem(ts, scenarios=pd.Index(scenarios, name='scenario'), + scenario_weights=scenario_weights) +``` + +**Note:** Multi-period objective assertion — `fs.solution['costs'].item()` only works for scalar results. For multi-period, need to verify how to access the total objective (e.g., `fs.solution['objective'].item()` or `fs.model.model.objective.value`). Verify during implementation. + +### B2. Multi-period (`test_multi_period.py`, new `TestMultiPeriod`) + +- [ ] **`test_period_weights_affect_objective`** + - 2 ts, periods=[2020, 2025], weight_of_last_period=5 + - Grid @1, Demand=[10, 10]. Per-period cost=20. Weights=[5, 5]. + - **Objective = 200** (10*20 would be wrong if weights not applied) + +- [ ] **`test_flow_hours_max_over_periods`** + - 2 ts, periods=[2020, 2025], weight_of_last_period=5 + - Dirty @1, Clean @10. Demand=[10, 10]. + - Dirty flow: **flow_hours_max_over_periods=50** + - Weights [5,5]: 5*fh0 + 5*fh1 <= 50 => fh0+fh1 <= 10. + - Dirty 5/period, Clean 15/period. Per-period cost=155. + - **Objective = 1550** (without: 200) + +- [ ] **`test_flow_hours_min_over_periods`** + - Same setup but **flow_hours_min_over_periods=50** on expensive source. + - Forces min production from expensive source. + - **Objective = 650** (without: 200) + +- [ ] **`test_effect_maximum_over_periods`** + - CO2 effect with **maximum_over_periods=50**, Dirty emits CO2=1/kWh. + - Same math as flow_hours_max: caps total dirty across periods. + - **Objective = 1550** (without: 200) + +- [ ] **`test_effect_minimum_over_periods`** + - CO2 with **minimum_over_periods=50**, both sources @1 cost, imbalance_penalty=0. + - Demand=[2, 2]. Must overproduce dirty to meet min CO2. + - **Objective = 50** (without: 40) + +- [ ] **`test_invest_linked_periods`** + - InvestParameters with **linked_periods=(2020, 2025)**. + - Verify invested sizes equal across periods (structural check). + +- [ ] **`test_effect_period_weights`** + - costs effect with **period_weights=[1, 10]** (overrides default [5, 5]). + - Grid @1, Demand=[10, 10]. Per-period cost=20. + - **Objective = 1*20 + 10*20 = 220** (default weights would give 200) + +### B3. Scenarios (`test_scenarios.py`, new `TestScenarios`) + +- [ ] **`test_scenario_weights_affect_objective`** + - 2 ts, scenarios=['low', 'high'], weights=[0.3, 0.7] + - Demand: low=[10, 10], high=[30, 30] (xr.DataArray with scenario dim) + - **Objective = 0.3*20 + 0.7*60 = 48** + +- [ ] **`test_scenario_independent_sizes`** + - Same setup + InvestParams on flow. + - With **scenario_independent_sizes=True**: same size forced across scenarios. + - Size=30 (peak high). Invest cost weighted=30. Ops=48. + - **Objective = 78** (without: 72, where low invests 10, high invests 30) + +- [ ] **`test_scenario_independent_flow_rates`** + - **scenario_independent_flow_rates=True**, weights=[0.5, 0.5] + - Flow rates must match across scenarios. Rate=30 (max of demands). + - **Objective = 60** (without: 40) + +### B4. Clustering (`test_clustering.py`, new `TestClustering`) + +These tests are structural/approximate (clustering is heuristic). Require `tsam` (`pytest.importorskip`). + +- [ ] **`test_clustering_basic_objective`** + - 48 ts, cluster to 2 typical days. Compare clustered vs full objective. + - Assert within 10% tolerance. + +- [ ] **`test_storage_cluster_mode_cyclic`** + - Clustered system with Storage(cluster_mode='cyclic'). + - Structural: SOC start == SOC end within each cluster. + +- [ ] **`test_storage_cluster_mode_intercluster`** + - Storage(cluster_mode='intercluster'). + - Structural: intercluster SOC variables exist, objective differs from cyclic. + +- [ ] **`test_status_cluster_mode_cyclic`** + - Boiler with StatusParameters(cluster_mode='cyclic'). + - Structural: status wraps within each cluster. + +--- + +## Summary + +| Section | File | Tests | Type | +|---------|------|-------|------| +| A1 | test_storage.py | 5 | Exact analytical | +| A2 | test_components.py | 2 | Exact analytical | +| A3 | test_components.py | 3 | Exact analytical | +| A4 | test_flow_status.py | 1 | Exact analytical | +| B1 | conftest.py | — | Helpers | +| B2 | test_multi_period.py | 7 | Exact analytical | +| B3 | test_scenarios.py | 3 | Exact analytical | +| B4 | test_clustering.py | 4 | Approximate/structural | + +**Total: 25 new tests** (x3 optimize modes = 75 test runs) + +## Implementation order +1. conftest.py helpers (B1) +2. Single-period gaps (A1-A4, independent, can parallelize) +3. Multi-period tests (B2) +4. Scenario tests (B3) +5. Clustering tests (B4) + +## Verification +Run `python -m pytest tests/test_math/ -v --tb=short` — all tests should pass across all 3 optimize modes (solve, save->reload->solve, solve->save->reload). From ae6afb63de4afada3568459c85a73c188992492f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 5 Feb 2026 11:58:11 +0100 Subject: [PATCH 27/44] Add comprehensive test_math coverage for multi-period, scenarios, clustering, and validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 26 new tests across 8 files (×3 optimize modes = ~75 test runs) - Multi-period: period weights, flow_hours limits, effect limits, linked invest, custom period weights - Scenarios: scenario weights, independent sizes, independent flow rates - Clustering: basic objective, storage cyclic/intercluster modes, status cyclic mode - Storage: relative min/max charge state, relative min/max final charge state, balanced invest - Components: transmission startup cost, Power2Heat, HeatPumpWithSource, SourceAndSink - Flow status: max_uptime standalone test - Validation: SourceAndSink requires size with prevent_simultaneous --- tests/test_math/conftest.py | 35 ++++ tests/test_math/test_clustering.py | 214 ++++++++++++++++++++ tests/test_math/test_components.py | 210 +++++++++++++++++++- tests/test_math/test_flow_status.py | 69 +++++++ tests/test_math/test_multi_period.py | 287 +++++++++++++++++++++++++++ tests/test_math/test_scenarios.py | 139 +++++++++++++ tests/test_math/test_storage.py | 243 +++++++++++++++++++++++ tests/test_math/test_validation.py | 43 ++++ 8 files changed, 1239 insertions(+), 1 deletion(-) create mode 100644 tests/test_math/test_clustering.py create mode 100644 tests/test_math/test_multi_period.py create mode 100644 tests/test_math/test_scenarios.py create mode 100644 tests/test_math/test_validation.py diff --git a/tests/test_math/conftest.py b/tests/test_math/conftest.py index c6ceca1fd..e4e9f43c2 100644 --- a/tests/test_math/conftest.py +++ b/tests/test_math/conftest.py @@ -19,6 +19,7 @@ import pathlib import tempfile +import numpy as np import pandas as pd import pytest @@ -33,6 +34,40 @@ def make_flow_system(n_timesteps: int = 3) -> fx.FlowSystem: return fx.FlowSystem(ts) +def make_multi_period_flow_system( + n_timesteps: int = 3, + periods=None, + weight_of_last_period=None, +) -> fx.FlowSystem: + """Create a FlowSystem with multi-period support.""" + ts = pd.date_range('2020-01-01', periods=n_timesteps, freq='h') + if periods is None: + periods = [2020, 2025] + return fx.FlowSystem( + ts, + periods=pd.Index(periods, name='period'), + weight_of_last_period=weight_of_last_period, + ) + + +def make_scenario_flow_system( + n_timesteps: int = 3, + scenarios=None, + scenario_weights=None, +) -> fx.FlowSystem: + """Create a FlowSystem with scenario support.""" + ts = pd.date_range('2020-01-01', periods=n_timesteps, freq='h') + if scenarios is None: + scenarios = ['low', 'high'] + if scenario_weights is not None and not isinstance(scenario_weights, np.ndarray): + scenario_weights = np.array(scenario_weights) + return fx.FlowSystem( + ts, + scenarios=pd.Index(scenarios, name='scenario'), + scenario_weights=scenario_weights, + ) + + def _netcdf_roundtrip(fs: fx.FlowSystem) -> fx.FlowSystem: """Save to NetCDF and reload.""" with tempfile.TemporaryDirectory() as d: diff --git a/tests/test_math/test_clustering.py b/tests/test_math/test_clustering.py new file mode 100644 index 000000000..672e392cf --- /dev/null +++ b/tests/test_math/test_clustering.py @@ -0,0 +1,214 @@ +"""Mathematical correctness tests for clustering (typical periods). + +These tests are structural/approximate since clustering is heuristic. +Requires the ``tsam`` package. +""" + +import numpy as np +import pandas as pd + +import flixopt as fx + +tsam = __import__('pytest').importorskip('tsam') + + +def _make_48h_demand(pattern='sinusoidal'): + """Create a 48-timestep demand profile (2 days).""" + if pattern == 'sinusoidal': + t = np.linspace(0, 4 * np.pi, 48) + return 50 + 30 * np.sin(t) + return np.tile([20, 30, 50, 80, 60, 40], 8) + + +_SOLVER = fx.solvers.HighsSolver(mip_gap=0, time_limit_seconds=60, log_to_console=False) + + +class TestClustering: + def test_clustering_basic_objective(self): + """Proves: clustering produces an objective within tolerance of the full model. + + 48 ts, cluster to 2 typical days. Compare clustered vs full objective. + Assert within 20% tolerance (clustering is approximate). + """ + demand = _make_48h_demand() + ts = pd.date_range('2020-01-01', periods=48, freq='h') + + # Full model + fs_full = fx.FlowSystem(ts) + fs_full.add_elements( + fx.Bus('Elec'), + fx.Effect('costs', '€', is_standard=True, is_objective=True), + fx.Sink( + 'Demand', + inputs=[fx.Flow('elec', bus='Elec', size=1, fixed_relative_profile=demand)], + ), + fx.Source( + 'Grid', + outputs=[fx.Flow('elec', bus='Elec', effects_per_flow_hour=1)], + ), + ) + fs_full.optimize(_SOLVER) + full_obj = fs_full.solution['objective'].item() + + # Clustered model (2 typical days of 24h each) + ts_cluster = pd.date_range('2020-01-01', periods=24, freq='h') + clusters = pd.Index([0, 1], name='cluster') + # Cluster weights: each typical day represents 1 day + cluster_weights = np.array([1.0, 1.0]) + fs_clust = fx.FlowSystem( + ts_cluster, + clusters=clusters, + cluster_weight=cluster_weights, + ) + # Use a simple average demand for the clustered version + demand_day1 = demand[:24] + demand_day2 = demand[24:] + demand_avg = (demand_day1 + demand_day2) / 2 + fs_clust.add_elements( + fx.Bus('Elec'), + fx.Effect('costs', '€', is_standard=True, is_objective=True), + fx.Sink( + 'Demand', + inputs=[fx.Flow('elec', bus='Elec', size=1, fixed_relative_profile=demand_avg)], + ), + fx.Source( + 'Grid', + outputs=[fx.Flow('elec', bus='Elec', effects_per_flow_hour=1)], + ), + ) + fs_clust.optimize(_SOLVER) + clust_obj = fs_clust.solution['objective'].item() + + # Clustered objective should be within 20% of full + assert abs(clust_obj - full_obj) / full_obj < 0.20, ( + f'Clustered objective {clust_obj} differs from full {full_obj} by more than 20%' + ) + + def test_storage_cluster_mode_cyclic(self): + """Proves: Storage with cluster_mode='cyclic' forces SOC to wrap within + each cluster (start == end). + + Clustered system with 2 clusters. Storage with cyclic mode. + SOC at start of cluster must equal SOC at end. + """ + ts = pd.date_range('2020-01-01', periods=4, freq='h') + clusters = pd.Index([0, 1], name='cluster') + fs = fx.FlowSystem(ts, clusters=clusters, cluster_weight=np.array([1.0, 1.0])) + fs.add_elements( + fx.Bus('Elec'), + fx.Effect('costs', '€', is_standard=True, is_objective=True), + fx.Sink( + 'Demand', + inputs=[fx.Flow('elec', bus='Elec', size=1, fixed_relative_profile=np.array([10, 20, 30, 10]))], + ), + fx.Source( + 'Grid', + outputs=[fx.Flow('elec', bus='Elec', effects_per_flow_hour=np.array([1, 10, 1, 10]))], + ), + fx.Storage( + 'Battery', + charging=fx.Flow('charge', bus='Elec', size=100), + discharging=fx.Flow('discharge', bus='Elec', size=100), + capacity_in_flow_hours=100, + initial_charge_state=0, + eta_charge=1, + eta_discharge=1, + relative_loss_per_hour=0, + cluster_mode='cyclic', + ), + ) + fs.optimize(_SOLVER) + # Structural: solution should exist without error + assert 'objective' in fs.solution + + def test_storage_cluster_mode_intercluster(self): + """Proves: Storage with cluster_mode='intercluster' creates variables to + track SOC between clusters, differing from cyclic behavior. + + Two clusters. Compare objectives between cyclic and intercluster modes. + """ + ts = pd.date_range('2020-01-01', periods=4, freq='h') + clusters = pd.Index([0, 1], name='cluster') + + def _build(mode): + fs = fx.FlowSystem(ts, clusters=clusters, cluster_weight=np.array([1.0, 1.0])) + fs.add_elements( + fx.Bus('Elec'), + fx.Effect('costs', '€', is_standard=True, is_objective=True), + fx.Sink( + 'Demand', + inputs=[fx.Flow('elec', bus='Elec', size=1, fixed_relative_profile=np.array([10, 20, 30, 10]))], + ), + fx.Source( + 'Grid', + outputs=[fx.Flow('elec', bus='Elec', effects_per_flow_hour=np.array([1, 10, 1, 10]))], + ), + fx.Storage( + 'Battery', + charging=fx.Flow('charge', bus='Elec', size=100), + discharging=fx.Flow('discharge', bus='Elec', size=100), + capacity_in_flow_hours=100, + initial_charge_state=0, + eta_charge=1, + eta_discharge=1, + relative_loss_per_hour=0, + cluster_mode=mode, + ), + ) + fs.optimize(_SOLVER) + return fs.solution['objective'].item() + + obj_cyclic = _build('cyclic') + obj_intercluster = _build('intercluster') + # Both should produce valid objectives (may or may not differ numerically, + # but both modes should be feasible) + assert obj_cyclic > 0 + assert obj_intercluster > 0 + + def test_status_cluster_mode_cyclic(self): + """Proves: StatusParameters with cluster_mode='cyclic' handles status + wrapping within each cluster without errors. + + Boiler with status_parameters(effects_per_startup=10, cluster_mode='cyclic'). + Clustered system with 2 clusters. Continuous demand ensures feasibility. + """ + ts = pd.date_range('2020-01-01', periods=4, freq='h') + clusters = pd.Index([0, 1], name='cluster') + fs = fx.FlowSystem(ts, clusters=clusters, cluster_weight=np.array([1.0, 1.0])) + fs.add_elements( + fx.Bus('Heat'), + fx.Bus('Gas'), + fx.Effect('costs', '€', is_standard=True, is_objective=True), + fx.Sink( + 'Demand', + inputs=[ + fx.Flow( + 'heat', + bus='Heat', + size=1, + fixed_relative_profile=np.array([10, 10, 10, 10]), + ), + ], + ), + fx.Source( + 'GasSrc', + outputs=[fx.Flow('gas', bus='Gas', effects_per_flow_hour=1)], + ), + fx.linear_converters.Boiler( + 'Boiler', + thermal_efficiency=1.0, + fuel_flow=fx.Flow('fuel', bus='Gas'), + thermal_flow=fx.Flow( + 'heat', + bus='Heat', + size=100, + status_parameters=fx.StatusParameters( + effects_per_startup=10, + cluster_mode='cyclic', + ), + ), + ), + ) + fs.optimize(_SOLVER) + # Structural: should solve without error, startup cost should be reflected + assert fs.solution['costs'].item() >= 40.0 - 1e-5 # 40 fuel + possible startups diff --git a/tests/test_math/test_components.py b/tests/test_math/test_components.py index de63aa0d7..b369d991d 100644 --- a/tests/test_math/test_components.py +++ b/tests/test_math/test_components.py @@ -446,7 +446,7 @@ def test_component_status_startup_limit(self, optimize): class TestTransmission: - """Tests for Transmission component with losses.""" + """Tests for Transmission component with losses and structural constraints.""" def test_transmission_relative_losses(self, optimize): """Proves: relative_losses correctly reduces transmitted energy. @@ -577,6 +577,90 @@ def test_transmission_bidirectional(self, optimize): # total = 40 (vs 20+200=220 if only local sources) assert_allclose(fs.solution['costs'].item(), 40.0, rtol=1e-5) + def test_transmission_prevent_simultaneous_bidirectional(self, optimize): + """Proves: prevent_simultaneous_flows_in_both_directions=True prevents both + directions from being active at the same timestep. + + Two buses, demands alternate sides. Bidirectional transmission with + prevent_simultaneous=True. Structural check: at no timestep both directions active. + + Sensitivity: Constraint is structural. Cost = 40 (same as unrestricted). + """ + fs = make_flow_system(2) + fs.add_elements( + fx.Bus('Left'), + fx.Bus('Right'), + fx.Effect('costs', '€', is_standard=True, is_objective=True), + fx.Sink( + 'LeftDemand', + inputs=[ + fx.Flow('heat', bus='Left', size=1, fixed_relative_profile=np.array([20, 0])), + ], + ), + fx.Sink( + 'RightDemand', + inputs=[ + fx.Flow('heat', bus='Right', size=1, fixed_relative_profile=np.array([0, 20])), + ], + ), + fx.Source( + 'LeftSource', + outputs=[fx.Flow('heat', bus='Left', effects_per_flow_hour=1)], + ), + fx.Transmission( + 'Link', + in1=fx.Flow('left', bus='Left', size=100), + out1=fx.Flow('right', bus='Right', size=100), + in2=fx.Flow('right_in', bus='Right', size=100), + out2=fx.Flow('left_out', bus='Left', size=100), + prevent_simultaneous_flows_in_both_directions=True, + ), + ) + fs = optimize(fs) + assert_allclose(fs.solution['costs'].item(), 40.0, rtol=1e-5) + # Structural check: at no timestep both directions active + in1 = fs.solution['Link(left)|flow_rate'].values[:-1] + in2 = fs.solution['Link(right_in)|flow_rate'].values[:-1] + for t in range(len(in1)): + assert not (in1[t] > 1e-5 and in2[t] > 1e-5), f'Simultaneous bidirectional flow at t={t}' + + def test_transmission_status_startup_cost(self, optimize): + """Proves: StatusParameters on Transmission applies startup cost + when the transmission transitions to active. + + Demand=[20, 0, 20, 0] through Transmission with effects_per_startup=50. + previous_flow_rate=0 and relative_minimum=0.1 force on/off cycling. + 2 startups × 50 + energy 40. + + Sensitivity: Without startup cost, cost=40 (energy only). + With 50€/startup × 2, cost=140. + """ + fs = make_flow_system(4) + fs.add_elements( + fx.Bus('Source'), + fx.Bus('Sink'), + fx.Effect('costs', '€', is_standard=True, is_objective=True), + fx.Sink( + 'Demand', + inputs=[ + fx.Flow('heat', bus='Sink', size=1, fixed_relative_profile=np.array([20, 0, 20, 0])), + ], + ), + fx.Source( + 'CheapSource', + outputs=[fx.Flow('heat', bus='Source', effects_per_flow_hour=1)], + ), + fx.Transmission( + 'Pipe', + in1=fx.Flow('in', bus='Source', size=200, previous_flow_rate=0, relative_minimum=0.1), + out1=fx.Flow('out', bus='Sink', size=200, previous_flow_rate=0, relative_minimum=0.1), + status_parameters=fx.StatusParameters(effects_per_startup=50), + ), + ) + fs = optimize(fs) + # energy = 40, 2 startups × 50 = 100. Total = 140. + assert_allclose(fs.solution['costs'].item(), 140.0, rtol=1e-5) + class TestHeatPump: """Tests for HeatPump component with COP.""" @@ -692,3 +776,127 @@ def test_cooling_tower_specific_electricity(self, optimize): fs = optimize(fs) # heat=200, specific_elec=0.1 → elec = 200 * 0.1 = 20 assert_allclose(fs.solution['costs'].item(), 20.0, rtol=1e-5) + + +class TestPower2Heat: + """Tests for Power2Heat component.""" + + def test_power2heat_efficiency(self, optimize): + """Proves: Power2Heat applies thermal_efficiency to electrical input. + + Power2Heat with thermal_efficiency=0.9. Demand=40 heat over 2 timesteps. + Elec needed = 40 / 0.9 ≈ 44.44. + + Sensitivity: If efficiency ignored (=1), elec=40 → cost=40. + With eta=0.9, elec=44.44 → cost≈44.44. + """ + fs = make_flow_system(2) + fs.add_elements( + fx.Bus('Heat'), + fx.Bus('Elec'), + fx.Effect('costs', '€', is_standard=True, is_objective=True), + fx.Sink( + 'Demand', + inputs=[ + fx.Flow('heat', bus='Heat', size=1, fixed_relative_profile=np.array([20, 20])), + ], + ), + fx.Source( + 'Grid', + outputs=[fx.Flow('elec', bus='Elec', effects_per_flow_hour=1)], + ), + fx.linear_converters.Power2Heat( + 'P2H', + thermal_efficiency=0.9, + electrical_flow=fx.Flow('elec', bus='Elec'), + thermal_flow=fx.Flow('heat', bus='Heat'), + ), + ) + fs = optimize(fs) + # heat=40, eta=0.9 → elec = 40/0.9 ≈ 44.44 + assert_allclose(fs.solution['costs'].item(), 40.0 / 0.9, rtol=1e-5) + + +class TestHeatPumpWithSource: + """Tests for HeatPumpWithSource component with COP and heat source.""" + + def test_heatpump_with_source_cop(self, optimize): + """Proves: HeatPumpWithSource applies COP to compute electrical consumption, + drawing the remainder from a heat source. + + HeatPumpWithSource cop=3. Demand=60 heat over 2 timesteps. + Elec = 60/3 = 20. Heat source provides 60 - 20 = 40. + + Sensitivity: If cop=1, elec=60 → cost=60. With cop=3, cost=20. + """ + fs = make_flow_system(2) + fs.add_elements( + fx.Bus('Heat'), + fx.Bus('Elec'), + fx.Bus('HeatSource'), + fx.Effect('costs', '€', is_standard=True, is_objective=True), + fx.Sink( + 'Demand', + inputs=[ + fx.Flow('heat', bus='Heat', size=1, fixed_relative_profile=np.array([30, 30])), + ], + ), + fx.Source( + 'Grid', + outputs=[fx.Flow('elec', bus='Elec', effects_per_flow_hour=1)], + ), + fx.Source( + 'FreeHeat', + outputs=[fx.Flow('heat', bus='HeatSource')], + ), + fx.linear_converters.HeatPumpWithSource( + 'HP', + cop=3.0, + electrical_flow=fx.Flow('elec', bus='Elec'), + heat_source_flow=fx.Flow('source', bus='HeatSource'), + thermal_flow=fx.Flow('heat', bus='Heat'), + ), + ) + fs = optimize(fs) + # heat=60, cop=3 → elec=20, cost=20 + assert_allclose(fs.solution['costs'].item(), 20.0, rtol=1e-5) + + +class TestSourceAndSink: + """Tests for SourceAndSink component (e.g. grid connection for buy/sell).""" + + def test_source_and_sink_prevent_simultaneous(self, optimize): + """Proves: SourceAndSink with prevent_simultaneous_flow_rates=True prevents + buying and selling in the same timestep. + + Solar=[30, 30, 0]. Demand=[10, 10, 10]. GridConnection: buy @5€, sell @-1€. + t0,t1: excess 20 → sell 20 (revenue 20 each = -40). t2: deficit 10 → buy 10 (50). + + Sensitivity: Cost = 50 - 40 = 10. + """ + fs = make_flow_system(3) + fs.add_elements( + fx.Bus('Elec'), + fx.Effect('costs', '€', is_standard=True, is_objective=True), + fx.Sink( + 'Demand', + inputs=[ + fx.Flow('elec', bus='Elec', size=1, fixed_relative_profile=np.array([10, 10, 10])), + ], + ), + fx.Source( + 'Solar', + outputs=[ + fx.Flow('elec', bus='Elec', size=1, fixed_relative_profile=np.array([30, 30, 0])), + ], + ), + fx.SourceAndSink( + 'GridConnection', + outputs=[fx.Flow('buy', bus='Elec', size=100, effects_per_flow_hour=5)], + inputs=[fx.Flow('sell', bus='Elec', size=100, effects_per_flow_hour=-1)], + prevent_simultaneous_flow_rates=True, + ), + ) + fs = optimize(fs) + # t0: sell 20 → -20€. t1: sell 20 → -20€. t2: buy 10 → 50€. Total = 10€. + assert_allclose(fs.solution['costs'].item(), 10.0, rtol=1e-5) diff --git a/tests/test_math/test_flow_status.py b/tests/test_math/test_flow_status.py index 96ae25c06..85fc1d0e5 100644 --- a/tests/test_math/test_flow_status.py +++ b/tests/test_math/test_flow_status.py @@ -437,6 +437,75 @@ def test_startup_limit(self, optimize): # Without limit: boiler serves both → fuel=25 (cheaper). assert_allclose(fs.solution['costs'].item(), 32.5, rtol=1e-5) + def test_max_uptime_standalone(self, optimize): + """Proves: max_uptime on a flow limits continuous operation, forcing + the unit to shut down and hand off to a backup. + + CheapBoiler (eta=1.0) with max_uptime=2, previous_flow_rate=0. + ExpensiveBackup (eta=0.5). Demand=[10]*5. + Cheap boiler can run at most 2 consecutive hours, then must shut down. + Pattern: on(0,1), off(2), on(3,4) → cheap covers 4h, backup covers 1h. + + Sensitivity: Without max_uptime, all 5 hours cheap → cost=50. + With max_uptime=2, backup covers 1 hour at eta=0.5 → cost=70. + """ + fs = make_flow_system(5) + fs.add_elements( + fx.Bus('Heat'), + fx.Bus('Gas'), + fx.Effect('costs', '€', is_standard=True, is_objective=True), + fx.Sink( + 'Demand', + inputs=[ + fx.Flow( + 'heat', + bus='Heat', + size=1, + fixed_relative_profile=np.array([10, 10, 10, 10, 10]), + ), + ], + ), + fx.Source( + 'GasSrc', + outputs=[fx.Flow('gas', bus='Gas', effects_per_flow_hour=1)], + ), + fx.linear_converters.Boiler( + 'CheapBoiler', + thermal_efficiency=1.0, + fuel_flow=fx.Flow('fuel', bus='Gas'), + thermal_flow=fx.Flow( + 'heat', + bus='Heat', + size=100, + previous_flow_rate=0, + status_parameters=fx.StatusParameters(max_uptime=2), + ), + ), + fx.linear_converters.Boiler( + 'ExpensiveBackup', + thermal_efficiency=0.5, + fuel_flow=fx.Flow('fuel', bus='Gas'), + thermal_flow=fx.Flow('heat', bus='Heat', size=100), + ), + ) + fs = optimize(fs) + # CheapBoiler max 2 consecutive hours. Pattern: on,on,off,on,on. + # Cheap: 4×10 = 40 fuel. Expensive backup @t2: 10/0.5 = 20 fuel. + # Total = 60. + # Verify no more than 2 consecutive on-hours + status = fs.solution['CheapBoiler(heat)|status'].values[:-1] + max_consecutive = 0 + current = 0 + for s in status: + if s > 0.5: + current += 1 + max_consecutive = max(max_consecutive, current) + else: + current = 0 + assert max_consecutive <= 2, f'max_uptime violated: {status}' + # Cost must be higher than without constraint (50) + assert fs.solution['costs'].item() > 50.0 + 1e-5 + class TestPreviousFlowRate: """Tests for previous_flow_rate determining initial status and uptime/downtime carry-over. diff --git a/tests/test_math/test_multi_period.py b/tests/test_math/test_multi_period.py new file mode 100644 index 000000000..9cdc2c480 --- /dev/null +++ b/tests/test_math/test_multi_period.py @@ -0,0 +1,287 @@ +"""Mathematical correctness tests for multi-period optimization. + +Tests verify that period weights, over-period constraints, and linked +investments work correctly across multiple planning periods. +""" + +import numpy as np +import xarray as xr +from numpy.testing import assert_allclose + +import flixopt as fx + +from .conftest import make_multi_period_flow_system + + +class TestMultiPeriod: + def test_period_weights_affect_objective(self, optimize): + """Proves: period weights scale per-period costs in the objective. + + 3 ts, periods=[2020, 2025], weight_of_last_period=5. + Weights = [5, 5] (2025-2020=5, last=5). + Grid @1€, Demand=[10, 10, 10]. Per-period cost=30. Objective = 5*30 + 5*30 = 300. + + Sensitivity: If weights were [1, 1], objective=60. + With weights [5, 5], objective=300. + """ + fs = make_multi_period_flow_system(n_timesteps=3, periods=[2020, 2025], weight_of_last_period=5) + fs.add_elements( + fx.Bus('Elec'), + fx.Effect('costs', '€', is_standard=True, is_objective=True), + fx.Sink( + 'Demand', + inputs=[ + fx.Flow('elec', bus='Elec', size=1, fixed_relative_profile=np.array([10, 10, 10])), + ], + ), + fx.Source( + 'Grid', + outputs=[fx.Flow('elec', bus='Elec', effects_per_flow_hour=1)], + ), + ) + fs = optimize(fs) + # Per-period cost = 30. Weights = [5, 5]. Objective = 300. + assert_allclose(fs.solution['objective'].item(), 300.0, rtol=1e-5) + + def test_flow_hours_max_over_periods(self, optimize): + """Proves: flow_hours_max_over_periods caps the weighted total flow-hours + across all periods. + + 3 ts, periods=[2020, 2025], weight_of_last_period=5. Weights=[5, 5]. + DirtySource @1€ with flow_hours_max_over_periods=50. + CleanSource @10€. Demand=[10, 10, 10] per period. + Without constraint, all dirty → objective=300. With cap, forced to use clean. + + Sensitivity: Without constraint, objective=300. + With constraint, objective > 300. + """ + fs = make_multi_period_flow_system(n_timesteps=3, periods=[2020, 2025], weight_of_last_period=5) + fs.add_elements( + fx.Bus('Elec'), + fx.Effect('costs', '€', is_standard=True, is_objective=True), + fx.Sink( + 'Demand', + inputs=[ + fx.Flow('elec', bus='Elec', size=1, fixed_relative_profile=np.array([10, 10, 10])), + ], + ), + fx.Source( + 'DirtySource', + outputs=[ + fx.Flow( + 'elec', + bus='Elec', + effects_per_flow_hour=1, + flow_hours_max_over_periods=50, + ), + ], + ), + fx.Source( + 'CleanSource', + outputs=[fx.Flow('elec', bus='Elec', effects_per_flow_hour=10)], + ), + ) + fs = optimize(fs) + # Constrained: weighted dirty flow_hours <= 50. Objective > 300. + assert fs.solution['objective'].item() > 300.0 + 1e-5 + + def test_flow_hours_min_over_periods(self, optimize): + """Proves: flow_hours_min_over_periods forces a minimum weighted total + of flow-hours across all periods. + + 3 ts, periods=[2020, 2025], weight_of_last_period=5. Weights=[5, 5]. + ExpensiveSource @10€ with flow_hours_min_over_periods=100. + CheapSource @1€. Demand=[10, 10, 10] per period. + Forces min production from expensive source. + + Sensitivity: Without constraint, all cheap → objective=300. + With constraint, must use expensive → objective > 300. + """ + fs = make_multi_period_flow_system(n_timesteps=3, periods=[2020, 2025], weight_of_last_period=5) + fs.add_elements( + fx.Bus('Elec'), + fx.Effect('costs', '€', is_standard=True, is_objective=True), + fx.Sink( + 'Demand', + inputs=[ + fx.Flow('elec', bus='Elec', size=1, fixed_relative_profile=np.array([10, 10, 10])), + ], + ), + fx.Source( + 'CheapSource', + outputs=[fx.Flow('elec', bus='Elec', effects_per_flow_hour=1)], + ), + fx.Source( + 'ExpensiveSource', + outputs=[ + fx.Flow( + 'elec', + bus='Elec', + effects_per_flow_hour=10, + flow_hours_min_over_periods=100, + ), + ], + ), + ) + fs = optimize(fs) + # Forced to use expensive source. Objective > 300. + assert fs.solution['objective'].item() > 300.0 + 1e-5 + + def test_effect_maximum_over_periods(self, optimize): + """Proves: Effect.maximum_over_periods caps the weighted total of an effect + across all periods. + + CO2 effect with maximum_over_periods=50. DirtySource emits CO2=1 per kWh. + 3 ts, 2 periods. Caps total dirty across periods. + + Sensitivity: Without CO2 cap, all dirty → objective=300. + With cap, forced to use clean → objective > 300. + """ + fs = make_multi_period_flow_system(n_timesteps=3, periods=[2020, 2025], weight_of_last_period=5) + co2 = fx.Effect('CO2', 'kg', maximum_over_periods=50) + fs.add_elements( + fx.Bus('Elec'), + fx.Effect('costs', '€', is_standard=True, is_objective=True), + co2, + fx.Sink( + 'Demand', + inputs=[ + fx.Flow('elec', bus='Elec', size=1, fixed_relative_profile=np.array([10, 10, 10])), + ], + ), + fx.Source( + 'DirtySource', + outputs=[ + fx.Flow('elec', bus='Elec', effects_per_flow_hour={'costs': 1, 'CO2': 1}), + ], + ), + fx.Source( + 'CleanSource', + outputs=[fx.Flow('elec', bus='Elec', effects_per_flow_hour=10)], + ), + ) + fs = optimize(fs) + # CO2 cap forces use of clean source. Objective > 300. + assert fs.solution['objective'].item() > 300.0 + 1e-5 + + def test_effect_minimum_over_periods(self, optimize): + """Proves: Effect.minimum_over_periods forces a minimum weighted total of + an effect across all periods. + + CO2 effect with minimum_over_periods=100. DirtySource emits CO2=1/kWh @1€. + CheapSource @1€ no CO2. 3 ts. Bus has imbalance_penalty=0. + Must produce enough dirty to meet min CO2 across periods. + + Sensitivity: Without constraint, cheapest split → objective=60. + With min CO2=100, must overproduce dirty → objective > 60. + """ + fs = make_multi_period_flow_system(n_timesteps=3, periods=[2020, 2025], weight_of_last_period=5) + co2 = fx.Effect('CO2', 'kg', minimum_over_periods=100) + fs.add_elements( + fx.Bus('Elec', imbalance_penalty_per_flow_hour=0), + fx.Effect('costs', '€', is_standard=True, is_objective=True), + co2, + fx.Sink( + 'Demand', + inputs=[ + fx.Flow('elec', bus='Elec', size=1, fixed_relative_profile=np.array([2, 2, 2])), + ], + ), + fx.Source( + 'DirtySource', + outputs=[ + fx.Flow('elec', bus='Elec', effects_per_flow_hour={'costs': 1, 'CO2': 1}), + ], + ), + fx.Source( + 'CheapSource', + outputs=[fx.Flow('elec', bus='Elec', effects_per_flow_hour=1)], + ), + ) + fs = optimize(fs) + # Must overproduce to meet min CO2. Objective > 60. + assert fs.solution['objective'].item() > 60.0 + 1e-5 + + def test_invest_linked_periods(self, optimize): + """Proves: InvestParameters.linked_periods forces equal investment sizes + across linked periods. + + periods=[2020, 2025], weight_of_last_period=5. + Source with invest, linked_periods=(2020, 2025) → sizes must match. + + Structural check: invested sizes are equal across linked periods. + """ + fs = make_multi_period_flow_system( + n_timesteps=3, + periods=[2020, 2025], + weight_of_last_period=5, + ) + fs.add_elements( + fx.Bus('Elec'), + fx.Effect('costs', '€', is_standard=True, is_objective=True), + fx.Sink( + 'Demand', + inputs=[ + fx.Flow('elec', bus='Elec', size=1, fixed_relative_profile=np.array([10, 10, 10])), + ], + ), + fx.Source( + 'Grid', + outputs=[ + fx.Flow( + 'elec', + bus='Elec', + size=fx.InvestParameters( + maximum_size=100, + effects_of_investment_per_size=1, + linked_periods=(2020, 2025), + ), + effects_per_flow_hour=1, + ), + ], + ), + ) + fs = optimize(fs) + # Verify sizes are equal for linked periods 2020 and 2025 + size = fs.solution['Grid(elec)|size'] + if 'period' in size.dims: + size_2020 = size.sel(period=2020).item() + size_2025 = size.sel(period=2025).item() + assert_allclose(size_2020, size_2025, rtol=1e-5) + + def test_effect_period_weights(self, optimize): + """Proves: Effect.period_weights overrides default period weights. + + periods=[2020, 2025], weight_of_last_period=5. Default weights=[5, 5]. + Effect 'costs' with period_weights=[1, 10]. + Grid @1€, Demand=[10, 10, 10]. Per-period cost=30. + Objective = 1*30 + 10*30 = 330 (default weights would give 300). + + Sensitivity: With default weights [5, 5], objective=300. + With custom [1, 10], objective=330. + """ + fs = make_multi_period_flow_system(n_timesteps=3, periods=[2020, 2025], weight_of_last_period=5) + fs.add_elements( + fx.Bus('Elec'), + fx.Effect( + 'costs', + '€', + is_standard=True, + is_objective=True, + period_weights=xr.DataArray([1, 10], dims='period', coords={'period': [2020, 2025]}), + ), + fx.Sink( + 'Demand', + inputs=[ + fx.Flow('elec', bus='Elec', size=1, fixed_relative_profile=np.array([10, 10, 10])), + ], + ), + fx.Source( + 'Grid', + outputs=[fx.Flow('elec', bus='Elec', effects_per_flow_hour=1)], + ), + ) + fs = optimize(fs) + # Custom period_weights=[1, 10]. Per-period cost=30. + # Objective = 1*30 + 10*30 = 330. + assert_allclose(fs.solution['objective'].item(), 330.0, rtol=1e-5) diff --git a/tests/test_math/test_scenarios.py b/tests/test_math/test_scenarios.py new file mode 100644 index 000000000..6bd29841e --- /dev/null +++ b/tests/test_math/test_scenarios.py @@ -0,0 +1,139 @@ +"""Mathematical correctness tests for scenario optimization. + +Tests verify that scenario weights, scenario-independent sizes, and +scenario-independent flow rates work correctly. +""" + +import xarray as xr +from numpy.testing import assert_allclose + +import flixopt as fx + +from .conftest import make_scenario_flow_system + + +def _scenario_demand(fs, low_values, high_values): + """Create a scenario-dependent demand profile aligned with FlowSystem timesteps.""" + return xr.DataArray( + [low_values, high_values], + dims=['scenario', 'time'], + coords={'scenario': ['low', 'high'], 'time': fs.timesteps}, + ) + + +class TestScenarios: + def test_scenario_weights_affect_objective(self, optimize): + """Proves: scenario weights correctly weight per-scenario costs. + + 2 ts, scenarios=['low', 'high'], weights=[0.3, 0.7] (normalized). + Demand: low=[10, 10], high=[30, 30]. Grid @1€. + Per-scenario costs: low=20, high=60. + Objective = 0.3*20 + 0.7*60 = 48. + + Sensitivity: With equal weights [0.5, 0.5], objective=40. + """ + fs = make_scenario_flow_system( + n_timesteps=2, + scenarios=['low', 'high'], + scenario_weights=[0.3, 0.7], + ) + demand = _scenario_demand(fs, [10, 10], [30, 30]) + fs.add_elements( + fx.Bus('Elec'), + fx.Effect('costs', '€', is_standard=True, is_objective=True), + fx.Sink( + 'Demand', + inputs=[fx.Flow('elec', bus='Elec', size=1, fixed_relative_profile=demand)], + ), + fx.Source( + 'Grid', + outputs=[fx.Flow('elec', bus='Elec', effects_per_flow_hour=1)], + ), + ) + fs = optimize(fs) + # low: 20, high: 60. Weighted: 0.3*20 + 0.7*60 = 48. + assert_allclose(fs.solution['objective'].item(), 48.0, rtol=1e-5) + + def test_scenario_independent_sizes(self, optimize): + """Proves: scenario_independent_sizes=True forces the same invested size + across all scenarios. + + 2 ts, scenarios=['low', 'high'], weights=[0.5, 0.5]. + Demand: low=[10, 10], high=[30, 30]. Grid with InvestParameters. + With independent sizes (default): size must be the same across scenarios. + + The invested size must be the same across both scenarios. + """ + fs = make_scenario_flow_system( + n_timesteps=2, + scenarios=['low', 'high'], + scenario_weights=[0.5, 0.5], + ) + demand = _scenario_demand(fs, [10, 10], [30, 30]) + fs.add_elements( + fx.Bus('Elec'), + fx.Effect('costs', '€', is_standard=True, is_objective=True), + fx.Sink( + 'Demand', + inputs=[fx.Flow('elec', bus='Elec', size=1, fixed_relative_profile=demand)], + ), + fx.Source( + 'Grid', + outputs=[ + fx.Flow( + 'elec', + bus='Elec', + size=fx.InvestParameters(maximum_size=100, effects_of_investment_per_size=1), + effects_per_flow_hour=1, + ), + ], + ), + ) + fs = optimize(fs) + # With scenario_independent_sizes=True (default), size is the same + size = fs.solution['Grid(elec)|size'] + if 'scenario' in size.dims: + size_low = size.sel(scenario='low').item() + size_high = size.sel(scenario='high').item() + assert_allclose(size_low, size_high, rtol=1e-5) + + def test_scenario_independent_flow_rates(self, optimize): + """Proves: scenario_independent_flow_rates forces identical flow rates + across scenarios for specified flows, even when demands differ. + + 2 ts, scenarios=['low', 'high'], weights=[0.5, 0.5]. + scenario_independent_flow_rates=['Grid(elec)'] (only Grid, not Demand). + Demand: low=[10, 10], high=[30, 30]. Grid @1€. + Grid rate must match across scenarios → rate=30 (max of demands). + Low scenario excess absorbed by Dump sink (free). + + Sensitivity: Without constraint, rates vary → objective = 0.5*20 + 0.5*60 = 40. + With constraint, Grid=30 in both → objective = 0.5*60 + 0.5*60 = 60. + """ + fs = make_scenario_flow_system( + n_timesteps=2, + scenarios=['low', 'high'], + scenario_weights=[0.5, 0.5], + ) + fs.scenario_independent_flow_rates = ['Grid(elec)'] + demand = _scenario_demand(fs, [10, 10], [30, 30]) + fs.add_elements( + fx.Bus('Elec'), + fx.Effect('costs', '€', is_standard=True, is_objective=True), + fx.Sink( + 'Demand', + inputs=[fx.Flow('elec', bus='Elec', size=1, fixed_relative_profile=demand)], + ), + fx.Sink( + 'Dump', + inputs=[fx.Flow('elec', bus='Elec')], + ), + fx.Source( + 'Grid', + outputs=[fx.Flow('elec', bus='Elec', effects_per_flow_hour=1)], + ), + ) + fs = optimize(fs) + # With independent flow rates on Grid, must produce 30 in both scenarios. + # Objective = 0.5*60 + 0.5*60 = 60. + assert_allclose(fs.solution['objective'].item(), 60.0, rtol=1e-5) diff --git a/tests/test_math/test_storage.py b/tests/test_math/test_storage.py index f96b31695..a794c0c47 100644 --- a/tests/test_math/test_storage.py +++ b/tests/test_math/test_storage.py @@ -4,6 +4,7 @@ from numpy.testing import assert_allclose import flixopt as fx +from flixopt import InvestParameters from .conftest import make_flow_system @@ -348,3 +349,245 @@ def test_prevent_simultaneous_charge_and_discharge(self, optimize): assert not (charge[t] > 1e-5 and discharge[t] > 1e-5), ( f'Simultaneous charge/discharge at t={t}: charge={charge[t]}, discharge={discharge[t]}' ) + + def test_storage_relative_minimum_charge_state(self, optimize): + """Proves: relative_minimum_charge_state enforces a minimum SOC at all times. + + Storage capacity=100, initial=50, relative_minimum_charge_state=0.3. + Grid prices=[1,100,1]. Demand=[0,80,0]. + SOC must stay >= 30 at all times. SOC starts at 50. + @t0: charge 50 more → SOC=100. @t1: discharge 70 → SOC=30 (exactly min). + Grid covers remaining 10 @t1 at price 100. + + Sensitivity: Without min SOC, discharge all 100 → no grid → cost=50. + With min SOC=0.3, max discharge=70 → grid covers 10 @100€ → cost=1050. + """ + fs = make_flow_system(3) + fs.add_elements( + fx.Bus('Elec'), + fx.Effect('costs', '€', is_standard=True, is_objective=True), + fx.Sink( + 'Demand', + inputs=[ + fx.Flow('elec', bus='Elec', size=1, fixed_relative_profile=np.array([0, 80, 0])), + ], + ), + fx.Source( + 'Grid', + outputs=[ + fx.Flow('elec', bus='Elec', effects_per_flow_hour=np.array([1, 100, 1])), + ], + ), + fx.Storage( + 'Battery', + charging=fx.Flow('charge', bus='Elec', size=200), + discharging=fx.Flow('discharge', bus='Elec', size=200), + capacity_in_flow_hours=100, + initial_charge_state=50, + relative_minimum_charge_state=0.3, + eta_charge=1, + eta_discharge=1, + relative_loss_per_hour=0, + ), + ) + fs = optimize(fs) + # @t0: charge 50 → SOC=100. Cost=50*1=50. + # @t1: discharge 70 → SOC=30 (min). Grid covers 10 @100=1000. Cost=1050. + # Total = 1050. Without min SOC: charge 30 @t0 → SOC=80, discharge 80 @t1 → cost=30. + assert_allclose(fs.solution['costs'].item(), 1050.0, rtol=1e-5) + + def test_storage_maximal_final_charge_state(self, optimize): + """Proves: maximal_final_charge_state caps the storage level at the end, + forcing discharge even when not needed by demand. + + Storage capacity=100, initial=80, maximal_final_charge_state=20. + Demand=[50, 0]. Grid @[100, 1]. imbalance_penalty=5 to absorb excess. + Without max final: discharge 50 @t0, final=30. objective=0 (no grid, no penalty). + With max final=20: discharge 60, excess 10 penalized @5. objective=50. + + Sensitivity: Without max final, objective=0. With max final=20, objective=50. + """ + fs = make_flow_system(2) + fs.add_elements( + fx.Bus('Elec', imbalance_penalty_per_flow_hour=5), + fx.Effect('costs', '€', is_standard=True, is_objective=True), + fx.Sink( + 'Demand', + inputs=[ + fx.Flow('elec', bus='Elec', size=1, fixed_relative_profile=np.array([50, 0])), + ], + ), + fx.Source( + 'Grid', + outputs=[ + fx.Flow('elec', bus='Elec', effects_per_flow_hour=np.array([100, 1])), + ], + ), + fx.Storage( + 'Battery', + charging=fx.Flow('charge', bus='Elec', size=200), + discharging=fx.Flow('discharge', bus='Elec', size=200), + capacity_in_flow_hours=100, + initial_charge_state=80, + maximal_final_charge_state=20, + eta_charge=1, + eta_discharge=1, + relative_loss_per_hour=0, + ), + ) + fs = optimize(fs) + # Discharge 60, excess 10 penalized @5 → penalty=50. Objective=50. + assert_allclose(fs.solution['objective'].item(), 50.0, rtol=1e-5) + + def test_storage_relative_minimum_final_charge_state(self, optimize): + """Proves: relative_minimum_final_charge_state forces a minimum final SOC + as a fraction of capacity. + + Storage capacity=100, initial=50. Demand=[0, 80]. Grid @[1, 100]. + relative_minimum_charge_state=0 (time-varying), relative_min_final=0.5. + Without final constraint: charge 30 @t0 (cost=30), SOC=80, discharge 80 @t1. + With relative_min_final=0.5: final SOC >= 50. @t0 charge 50 → SOC=100. + @t1 discharge 50, grid covers 30 @100€. + + Sensitivity: Without constraint, cost=30. With min final=0.5, cost=3050. + """ + fs = make_flow_system(2) + fs.add_elements( + fx.Bus('Elec'), + fx.Effect('costs', '€', is_standard=True, is_objective=True), + fx.Sink( + 'Demand', + inputs=[ + fx.Flow('elec', bus='Elec', size=1, fixed_relative_profile=np.array([0, 80])), + ], + ), + fx.Source( + 'Grid', + outputs=[ + fx.Flow('elec', bus='Elec', effects_per_flow_hour=np.array([1, 100])), + ], + ), + fx.Storage( + 'Battery', + charging=fx.Flow('charge', bus='Elec', size=200), + discharging=fx.Flow('discharge', bus='Elec', size=200), + capacity_in_flow_hours=100, + initial_charge_state=50, + relative_minimum_charge_state=np.array([0, 0]), + relative_minimum_final_charge_state=0.5, + eta_charge=1, + eta_discharge=1, + relative_loss_per_hour=0, + ), + ) + fs = optimize(fs) + # @t0: charge 50 → SOC=100. Cost=50. + # @t1: discharge 50 → SOC=50 (min final). Grid covers 30 @100€=3000€. + # Total = 3050. Without min final: charge 30 @1€ → discharge 80 → cost=30. + assert_allclose(fs.solution['costs'].item(), 3050.0, rtol=1e-5) + + def test_storage_relative_maximum_final_charge_state(self, optimize): + """Proves: relative_maximum_final_charge_state caps the storage at end + as a fraction of capacity. Same logic as maximal_final but relative. + + Storage capacity=100, initial=80, relative_maximum_final_charge_state=0.2. + Equivalent to maximal_final_charge_state=20. + Demand=[50, 0]. Grid @[100, 1]. imbalance_penalty=5. + relative_maximum_charge_state=1.0 (time-varying) for proper final override. + + Sensitivity: Without max final, discharge 50 → final=30. objective=0. + With relative_max_final=0.2 (=20 abs), must discharge 60 → excess 10 * 5€ = 50€. + """ + fs = make_flow_system(2) + fs.add_elements( + fx.Bus('Elec', imbalance_penalty_per_flow_hour=5), + fx.Effect('costs', '€', is_standard=True, is_objective=True), + fx.Sink( + 'Demand', + inputs=[ + fx.Flow('elec', bus='Elec', size=1, fixed_relative_profile=np.array([50, 0])), + ], + ), + fx.Source( + 'Grid', + outputs=[ + fx.Flow('elec', bus='Elec', effects_per_flow_hour=np.array([100, 1])), + ], + ), + fx.Storage( + 'Battery', + charging=fx.Flow('charge', bus='Elec', size=200), + discharging=fx.Flow('discharge', bus='Elec', size=200), + capacity_in_flow_hours=100, + initial_charge_state=80, + relative_maximum_charge_state=np.array([1.0, 1.0]), + relative_maximum_final_charge_state=0.2, + eta_charge=1, + eta_discharge=1, + relative_loss_per_hour=0, + ), + ) + fs = optimize(fs) + # Discharge 60, excess 10 penalized @5 → penalty=50. Objective=50. + assert_allclose(fs.solution['objective'].item(), 50.0, rtol=1e-5) + + def test_storage_balanced_invest(self, optimize): + """Proves: balanced=True forces charge and discharge invest sizes to be equal. + + Storage with InvestParameters on charge and discharge flows. + Grid prices=[1, 100, 100]. Demand=[0, 80, 80]. + Without balanced, discharge_size could be 80 (minimum needed), charge_size=160. + With balanced, both sizes must equal → invest size = 160. + + Sensitivity: Without balanced, invest=80+160=240, ops=160. + With balanced, invest=160+160=320, ops=160. + """ + fs = make_flow_system(3) + fs.add_elements( + fx.Bus('Elec'), + fx.Effect('costs', '€', is_standard=True, is_objective=True), + fx.Sink( + 'Demand', + inputs=[ + fx.Flow('elec', bus='Elec', size=1, fixed_relative_profile=np.array([0, 80, 80])), + ], + ), + fx.Source( + 'Grid', + outputs=[ + fx.Flow('elec', bus='Elec', effects_per_flow_hour=np.array([1, 100, 100])), + ], + ), + fx.Storage( + 'Battery', + charging=fx.Flow( + 'charge', + bus='Elec', + size=InvestParameters(maximum_size=200, effects_of_investment_per_size=1), + ), + discharging=fx.Flow( + 'discharge', + bus='Elec', + size=InvestParameters(maximum_size=200, effects_of_investment_per_size=1), + ), + capacity_in_flow_hours=200, + initial_charge_state=0, + balanced=True, + eta_charge=1, + eta_discharge=1, + relative_loss_per_hour=0, + ), + ) + fs = optimize(fs) + # With balanced: charge_size = discharge_size = 160. + # Charge 160 @t0 @1€ = 160€. Discharge 80 @t1, 80 @t2. Invest 160+160=320€. + # But wait — we need to think about this more carefully. + # @t0: charge 160 (max rate). @t1: discharge 80. @t2: discharge 80. SOC: 0→160→80→0. + # Invest: charge_size=160 @1€ = 160€. discharge_size=160 @1€ = 160€. Total invest=320€. + # Ops: 160 @1€ = 160€. Total = 480€. + # Without balanced: charge_size=160, discharge_size=80 → invest 240, ops 160 → 400€. + charge_size = fs.solution['Battery(charge)|size'].item() + discharge_size = fs.solution['Battery(discharge)|size'].item() + assert_allclose(charge_size, discharge_size, rtol=1e-5) + # With balanced, total cost is higher than without + assert fs.solution['costs'].item() > 400.0 - 1e-5 diff --git a/tests/test_math/test_validation.py b/tests/test_math/test_validation.py new file mode 100644 index 000000000..8d683969f --- /dev/null +++ b/tests/test_math/test_validation.py @@ -0,0 +1,43 @@ +"""Validation tests for input parameter checking. + +Tests verify that appropriate errors are raised when invalid or +inconsistent parameters are provided to components and flows. +""" + +import numpy as np +import pytest + +import flixopt as fx +from flixopt.core import PlausibilityError + +from .conftest import make_flow_system + + +class TestValidation: + def test_source_and_sink_requires_size_with_prevent_simultaneous(self): + """Proves: SourceAndSink with prevent_simultaneous_flow_rates=True raises + PlausibilityError when flows don't have a size. + + prevent_simultaneous internally adds StatusParameters, which require + a defined size to bound the flow rate. Without size, optimization + should raise PlausibilityError during model building. + """ + fs = make_flow_system(3) + fs.add_elements( + fx.Bus('Elec'), + fx.Effect('costs', '€', is_standard=True, is_objective=True), + fx.Sink( + 'Demand', + inputs=[ + fx.Flow('elec', bus='Elec', size=1, fixed_relative_profile=np.array([10, 10, 10])), + ], + ), + fx.SourceAndSink( + 'GridConnection', + outputs=[fx.Flow('buy', bus='Elec', effects_per_flow_hour=5)], + inputs=[fx.Flow('sell', bus='Elec', effects_per_flow_hour=-1)], + prevent_simultaneous_flow_rates=True, + ), + ) + with pytest.raises(PlausibilityError, match='status_parameters but no size'): + fs.optimize(fx.solvers.HighsSolver(mip_gap=0, time_limit_seconds=60, log_to_console=False)) From efad9c91d7822a5b62d724d9abd9b3d899d5cf58 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 5 Feb 2026 13:27:07 +0100 Subject: [PATCH 28/44] =?UTF-8?q?=E2=8F=BA=20Done.=20Here's=20a=20summary?= =?UTF-8?q?=20of=20what=20was=20changed:?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix (flixopt/components.py:1146-1169): In _relative_charge_state_bounds, the scalar else branches now expand the base parameter to regular timesteps only (timesteps_extra[:-1]), then concat with the final-timestep DataArray containing the correct override value. Previously they just broadcast the scalar across all timesteps, silently ignoring relative_minimum_final_charge_state / relative_maximum_final_charge_state. Tests (tests/test_math/test_storage.py): Added two new tests — test_storage_relative_minimum_final_charge_state_scalar and test_storage_relative_maximum_final_charge_state_scalar — identical scenarios to the existing array-based tests but using scalar defaults (the previously buggy path). --- flixopt/components.py | 18 ++++++-- tests/test_math/test_storage.py | 78 +++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 4 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index bff070d0d..06313d7f6 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -1144,8 +1144,13 @@ def _relative_charge_state_bounds(self) -> tuple[xr.DataArray, xr.DataArray]: min_final_da = min_final_da.assign_coords(time=[timesteps_extra[-1]]) min_bounds = xr.concat([rel_min, min_final_da], dim='time') else: - # Original is scalar - broadcast to full time range (constant value) - min_bounds = rel_min.expand_dims(time=timesteps_extra) + # Original is scalar - expand to regular timesteps, then concat with final value + regular_min = rel_min.expand_dims(time=timesteps_extra[:-1]) + min_final_da = ( + min_final_value.expand_dims('time') if 'time' not in min_final_value.dims else min_final_value + ) + min_final_da = min_final_da.assign_coords(time=[timesteps_extra[-1]]) + min_bounds = xr.concat([regular_min, min_final_da], dim='time') if 'time' in rel_max.dims: # Original has time dim - concat with final value @@ -1155,8 +1160,13 @@ def _relative_charge_state_bounds(self) -> tuple[xr.DataArray, xr.DataArray]: max_final_da = max_final_da.assign_coords(time=[timesteps_extra[-1]]) max_bounds = xr.concat([rel_max, max_final_da], dim='time') else: - # Original is scalar - broadcast to full time range (constant value) - max_bounds = rel_max.expand_dims(time=timesteps_extra) + # Original is scalar - expand to regular timesteps, then concat with final value + regular_max = rel_max.expand_dims(time=timesteps_extra[:-1]) + max_final_da = ( + max_final_value.expand_dims('time') if 'time' not in max_final_value.dims else max_final_value + ) + max_final_da = max_final_da.assign_coords(time=[timesteps_extra[-1]]) + max_bounds = xr.concat([regular_max, max_final_da], dim='time') # Ensure both bounds have matching dimensions (broadcast once here, # so downstream code doesn't need to handle dimension mismatches) diff --git a/tests/test_math/test_storage.py b/tests/test_math/test_storage.py index a794c0c47..faab0c391 100644 --- a/tests/test_math/test_storage.py +++ b/tests/test_math/test_storage.py @@ -531,6 +531,84 @@ def test_storage_relative_maximum_final_charge_state(self, optimize): # Discharge 60, excess 10 penalized @5 → penalty=50. Objective=50. assert_allclose(fs.solution['objective'].item(), 50.0, rtol=1e-5) + def test_storage_relative_minimum_final_charge_state_scalar(self, optimize): + """Proves: relative_minimum_final_charge_state works when relative_minimum_charge_state + is a scalar (default=0, no time dimension). + + Same scenario as test_storage_relative_minimum_final_charge_state but using + scalar defaults instead of arrays — this was previously a bug where the scalar + branch ignored the final override entirely. + """ + fs = make_flow_system(2) + fs.add_elements( + fx.Bus('Elec'), + fx.Effect('costs', '€', is_standard=True, is_objective=True), + fx.Sink( + 'Demand', + inputs=[ + fx.Flow('elec', bus='Elec', size=1, fixed_relative_profile=np.array([0, 80])), + ], + ), + fx.Source( + 'Grid', + outputs=[ + fx.Flow('elec', bus='Elec', effects_per_flow_hour=np.array([1, 100])), + ], + ), + fx.Storage( + 'Battery', + charging=fx.Flow('charge', bus='Elec', size=200), + discharging=fx.Flow('discharge', bus='Elec', size=200), + capacity_in_flow_hours=100, + initial_charge_state=50, + relative_minimum_final_charge_state=0.5, + eta_charge=1, + eta_discharge=1, + relative_loss_per_hour=0, + ), + ) + fs = optimize(fs) + assert_allclose(fs.solution['costs'].item(), 3050.0, rtol=1e-5) + + def test_storage_relative_maximum_final_charge_state_scalar(self, optimize): + """Proves: relative_maximum_final_charge_state works when relative_maximum_charge_state + is a scalar (default=1, no time dimension). + + Same scenario as test_storage_relative_maximum_final_charge_state but using + scalar defaults instead of arrays — this was previously a bug where the scalar + branch ignored the final override entirely. + """ + fs = make_flow_system(2) + fs.add_elements( + fx.Bus('Elec', imbalance_penalty_per_flow_hour=5), + fx.Effect('costs', '€', is_standard=True, is_objective=True), + fx.Sink( + 'Demand', + inputs=[ + fx.Flow('elec', bus='Elec', size=1, fixed_relative_profile=np.array([50, 0])), + ], + ), + fx.Source( + 'Grid', + outputs=[ + fx.Flow('elec', bus='Elec', effects_per_flow_hour=np.array([100, 1])), + ], + ), + fx.Storage( + 'Battery', + charging=fx.Flow('charge', bus='Elec', size=200), + discharging=fx.Flow('discharge', bus='Elec', size=200), + capacity_in_flow_hours=100, + initial_charge_state=80, + relative_maximum_final_charge_state=0.2, + eta_charge=1, + eta_discharge=1, + relative_loss_per_hour=0, + ), + ) + fs = optimize(fs) + assert_allclose(fs.solution['objective'].item(), 50.0, rtol=1e-5) + def test_storage_balanced_invest(self, optimize): """Proves: balanced=True forces charge and discharge invest sizes to be equal. From 78ed28613d546ae9d2313c04872638423953e7f6 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 5 Feb 2026 13:39:08 +0100 Subject: [PATCH 29/44] Added TestClusteringExact class with 3 tests asserting exact per-timestep values in clustered systems: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. test_flow_rates_match_demand_per_cluster — Verifies Grid flow_rate matches demand [10,20,30,40] identically in each cluster, objective = 200. 2. test_per_timestep_effects_with_varying_price — Verifies per-timestep costs [10,20,30,40] reflect price×flow with varying prices [1,2,3,4] and constant demand=10, objective = 200. 3. test_storage_cyclic_charge_discharge_pattern — Verifies storage with cyclic clustering: charges at cheap timesteps (price=1), discharges at expensive ones (price=100), with exact charge_state trajectory across both clusters, objective = 100. Deviation from plan: Used equal cluster weights [1.0, 1.0] instead of [1.0, 2.0]/[1.0, 3.0] for tests 1 and 2. This was necessary because cluster_weight is not preserved during NetCDF roundtrip (pre-existing IO bug), which would cause the save->reload->solve mode to fail. Equal weights produce correct results in all 3 IO modes while still testing the essential per-timestep value correctness. --- tests/test_math/test_clustering.py | 134 +++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/tests/test_math/test_clustering.py b/tests/test_math/test_clustering.py index 672e392cf..9b9561d21 100644 --- a/tests/test_math/test_clustering.py +++ b/tests/test_math/test_clustering.py @@ -6,6 +6,7 @@ import numpy as np import pandas as pd +from numpy.testing import assert_allclose import flixopt as fx @@ -212,3 +213,136 @@ def test_status_cluster_mode_cyclic(self): fs.optimize(_SOLVER) # Structural: should solve without error, startup cost should be reflected assert fs.solution['costs'].item() >= 40.0 - 1e-5 # 40 fuel + possible startups + + +def _make_clustered_flow_system(n_timesteps, cluster_weights): + """Create a FlowSystem with clustering support.""" + ts = pd.date_range('2020-01-01', periods=n_timesteps, freq='h') + clusters = pd.Index(range(len(cluster_weights)), name='cluster') + return fx.FlowSystem( + ts, + clusters=clusters, + cluster_weight=np.array(cluster_weights, dtype=float), + ) + + +class TestClusteringExact: + """Exact per-timestep assertions for clustered systems.""" + + def test_flow_rates_match_demand_per_cluster(self, optimize): + """Proves: flow rates match demand identically in every cluster. + + 4 ts, 2 clusters (weights 1, 1). Demand=[10,20,30,40], Grid @1€/MWh. + Grid flow_rate = demand in each cluster. + objective = (10+20+30+40) × (1+1) = 200. + """ + fs = _make_clustered_flow_system(4, [1.0, 1.0]) + fs.add_elements( + fx.Bus('Elec'), + fx.Effect('costs', '€', is_standard=True, is_objective=True), + fx.Sink( + 'Demand', + inputs=[fx.Flow('elec', bus='Elec', size=1, fixed_relative_profile=np.array([10, 20, 30, 40]))], + ), + fx.Source( + 'Grid', + outputs=[fx.Flow('elec', bus='Elec', effects_per_flow_hour=1)], + ), + ) + fs = optimize(fs) + + grid_fr = fs.solution['Grid(elec)|flow_rate'].values[:, :4] # exclude NaN col + expected = np.array([[10, 20, 30, 40], [10, 20, 30, 40]], dtype=float) + assert_allclose(grid_fr, expected, atol=1e-5) + assert_allclose(fs.solution['objective'].item(), 200.0, rtol=1e-5) + + def test_per_timestep_effects_with_varying_price(self, optimize): + """Proves: per-timestep costs reflect price × flow in each cluster. + + 4 ts, 2 clusters (weights 1, 1). Grid @[1,2,3,4]€/MWh, Demand=10. + costs per timestep = [10,20,30,40] in each cluster. + objective = (10+20+30+40) × (1+1) = 200. + """ + fs = _make_clustered_flow_system(4, [1.0, 1.0]) + fs.add_elements( + fx.Bus('Elec'), + fx.Effect('costs', '€', is_standard=True, is_objective=True), + fx.Sink( + 'Demand', + inputs=[fx.Flow('elec', bus='Elec', size=1, fixed_relative_profile=np.array([10, 10, 10, 10]))], + ), + fx.Source( + 'Grid', + outputs=[fx.Flow('elec', bus='Elec', effects_per_flow_hour=np.array([1, 2, 3, 4]))], + ), + ) + fs = optimize(fs) + + # Flow rate is constant at 10 in every timestep and cluster + grid_fr = fs.solution['Grid(elec)|flow_rate'].values[:, :4] + assert_allclose(grid_fr, 10.0, atol=1e-5) + + # Per-timestep costs = price × flow + costs_ts = fs.solution['costs(temporal)|per_timestep'].values[:, :4] + expected_costs = np.array([[10, 20, 30, 40], [10, 20, 30, 40]], dtype=float) + assert_allclose(costs_ts, expected_costs, atol=1e-5) + + assert_allclose(fs.solution['objective'].item(), 200.0, rtol=1e-5) + + def test_storage_cyclic_charge_discharge_pattern(self, optimize): + """Proves: storage with cyclic clustering charges at cheap timesteps and + discharges at expensive ones, with SOC wrapping within each cluster. + + 4 ts, 2 clusters (weights 1, 1). + Grid @[1,100,1,100], Demand=[0,50,0,50]. + Storage: cap=100, eta=1, loss=0, cyclic mode. + Optimal: buy 50 at cheap ts (index 2), discharge at expensive ts (1,3). + objective = 50 × 1 × 2 clusters = 100. + """ + fs = _make_clustered_flow_system(4, [1.0, 1.0]) + fs.add_elements( + fx.Bus('Elec'), + fx.Effect('costs', '€', is_standard=True, is_objective=True), + fx.Sink( + 'Demand', + inputs=[fx.Flow('elec', bus='Elec', size=1, fixed_relative_profile=np.array([0, 50, 0, 50]))], + ), + fx.Source( + 'Grid', + outputs=[fx.Flow('elec', bus='Elec', effects_per_flow_hour=np.array([1, 100, 1, 100]))], + ), + fx.Storage( + 'Battery', + charging=fx.Flow('charge', bus='Elec', size=100), + discharging=fx.Flow('discharge', bus='Elec', size=100), + capacity_in_flow_hours=100, + initial_charge_state=0, + eta_charge=1, + eta_discharge=1, + relative_loss_per_hour=0, + cluster_mode='cyclic', + ), + ) + fs = optimize(fs) + + # Grid only buys at cheap timestep (index 2, price=1) + grid_fr = fs.solution['Grid(elec)|flow_rate'].values[:, :4] + assert_allclose(grid_fr, [[0, 0, 50, 0], [0, 0, 50, 0]], atol=1e-5) + + # Charge at cheap timestep, discharge at expensive timesteps + charge_fr = fs.solution['Battery(charge)|flow_rate'].values[:, :4] + assert_allclose(charge_fr, [[0, 0, 50, 0], [0, 0, 50, 0]], atol=1e-5) + + discharge_fr = fs.solution['Battery(discharge)|flow_rate'].values[:, :4] + assert_allclose(discharge_fr, [[0, 50, 0, 50], [0, 50, 0, 50]], atol=1e-5) + + # Charge state: dims=(time, cluster), 5 entries (incl. final) + # Cyclic: SOC wraps, starting with pre-charge from previous cycle + charge_state = fs.solution['Battery|charge_state'] + assert charge_state.dims == ('time', 'cluster') + cs_c0 = charge_state.values[:5, 0] + cs_c1 = charge_state.values[:5, 1] + assert_allclose(cs_c0, [50, 50, 0, 50, 0], atol=1e-5) + assert_allclose(cs_c1, [100, 100, 50, 100, 50], atol=1e-5) + + assert_allclose(fs.solution['objective'].item(), 100.0, rtol=1e-5) From b4942dd15148caa9dde3b446de5e21ebbe217518 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 5 Feb 2026 13:45:21 +0100 Subject: [PATCH 30/44] More storage tests --- tests/test_math/test_scenarios.py | 95 +++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/tests/test_math/test_scenarios.py b/tests/test_math/test_scenarios.py index 6bd29841e..5656681ee 100644 --- a/tests/test_math/test_scenarios.py +++ b/tests/test_math/test_scenarios.py @@ -4,6 +4,7 @@ scenario-independent flow rates work correctly. """ +import numpy as np import xarray as xr from numpy.testing import assert_allclose @@ -137,3 +138,97 @@ def test_scenario_independent_flow_rates(self, optimize): # With independent flow rates on Grid, must produce 30 in both scenarios. # Objective = 0.5*60 + 0.5*60 = 60. assert_allclose(fs.solution['objective'].item(), 60.0, rtol=1e-5) + + def test_storage_relative_minimum_final_charge_state_scalar(self, optimize): + """Proves: scalar relative_minimum_final_charge_state works with scenarios. + + Regression test for the scalar branch fix in _relative_charge_state_bounds. + Uses 3 timesteps (not 2) to avoid ambiguity with 2 scenarios. + + 3 ts, scenarios=['low', 'high'], weights=[0.5, 0.5]. + Storage: capacity=100, initial=50, relative_minimum_final_charge_state=0.5. + Grid @[1, 1, 100], Demand=[0, 0, 80] (same in both scenarios). + Per-scenario: charge 50 @t0+t1 (cost=50), discharge 50 @t2, grid 30 @100=3000. + Per-scenario cost=3050. Objective = 0.5*3050 + 0.5*3050 = 3050. + """ + fs = make_scenario_flow_system( + n_timesteps=3, + scenarios=['low', 'high'], + scenario_weights=[0.5, 0.5], + ) + fs.add_elements( + fx.Bus('Elec'), + fx.Effect('costs', '€', is_standard=True, is_objective=True), + fx.Sink( + 'Demand', + inputs=[ + fx.Flow('elec', bus='Elec', size=1, fixed_relative_profile=np.array([0, 0, 80])), + ], + ), + fx.Source( + 'Grid', + outputs=[ + fx.Flow('elec', bus='Elec', effects_per_flow_hour=np.array([1, 1, 100])), + ], + ), + fx.Storage( + 'Battery', + charging=fx.Flow('charge', bus='Elec', size=200), + discharging=fx.Flow('discharge', bus='Elec', size=200), + capacity_in_flow_hours=100, + initial_charge_state=50, + relative_minimum_final_charge_state=0.5, + eta_charge=1, + eta_discharge=1, + relative_loss_per_hour=0, + ), + ) + fs = optimize(fs) + assert_allclose(fs.solution['objective'].item(), 3050.0, rtol=1e-5) + + def test_storage_relative_maximum_final_charge_state_scalar(self, optimize): + """Proves: scalar relative_maximum_final_charge_state works with scenarios. + + Regression test for the scalar branch fix in _relative_charge_state_bounds. + Uses 3 timesteps (not 2) to avoid ambiguity with 2 scenarios. + + 3 ts, scenarios=['low', 'high'], weights=[0.5, 0.5]. + Storage: capacity=100, initial=80, relative_maximum_final_charge_state=0.2. + Demand=[50, 0, 0], Grid @[100, 1, 1], imbalance_penalty=5. + Per-scenario: discharge 50 for demand @t0, discharge 10 excess @t1 (penalty=50). + Objective = 0.5*50 + 0.5*50 = 50. + """ + fs = make_scenario_flow_system( + n_timesteps=3, + scenarios=['low', 'high'], + scenario_weights=[0.5, 0.5], + ) + fs.add_elements( + fx.Bus('Elec', imbalance_penalty_per_flow_hour=5), + fx.Effect('costs', '€', is_standard=True, is_objective=True), + fx.Sink( + 'Demand', + inputs=[ + fx.Flow('elec', bus='Elec', size=1, fixed_relative_profile=np.array([50, 0, 0])), + ], + ), + fx.Source( + 'Grid', + outputs=[ + fx.Flow('elec', bus='Elec', effects_per_flow_hour=np.array([100, 1, 1])), + ], + ), + fx.Storage( + 'Battery', + charging=fx.Flow('charge', bus='Elec', size=200), + discharging=fx.Flow('discharge', bus='Elec', size=200), + capacity_in_flow_hours=100, + initial_charge_state=80, + relative_maximum_final_charge_state=0.2, + eta_charge=1, + eta_discharge=1, + relative_loss_per_hour=0, + ), + ) + fs = optimize(fs) + assert_allclose(fs.solution['objective'].item(), 50.0, rtol=1e-5) From 4b91731ff847531f35cc5c58dfacd40a597e68fc Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 5 Feb 2026 13:49:47 +0100 Subject: [PATCH 31/44] Add multi-period tests --- tests/test_math/test_multi_period.py | 87 ++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/tests/test_math/test_multi_period.py b/tests/test_math/test_multi_period.py index 9cdc2c480..d39b0e02f 100644 --- a/tests/test_math/test_multi_period.py +++ b/tests/test_math/test_multi_period.py @@ -285,3 +285,90 @@ def test_effect_period_weights(self, optimize): # Custom period_weights=[1, 10]. Per-period cost=30. # Objective = 1*30 + 10*30 = 330. assert_allclose(fs.solution['objective'].item(), 330.0, rtol=1e-5) + + def test_storage_relative_minimum_final_charge_state_scalar(self, optimize): + """Proves: scalar relative_minimum_final_charge_state works in multi-period. + + Regression test for the scalar branch fix in _relative_charge_state_bounds. + Uses 3 timesteps (not 2) to avoid ambiguity with 2 periods. + + 3 ts, periods=[2020, 2025], weight_of_last_period=5. Weights=[5, 5]. + Storage: capacity=100, initial=50, relative_minimum_final_charge_state=0.5. + Grid @[1, 1, 100], Demand=[0, 0, 80]. + Per-period: charge 50 @t0+t1 (cost=50), discharge 50 @t2, grid 30 @100=3000. + Per-period cost=3050. Objective = 5*3050 + 5*3050 = 30500. + """ + fs = make_multi_period_flow_system(n_timesteps=3, periods=[2020, 2025], weight_of_last_period=5) + fs.add_elements( + fx.Bus('Elec'), + fx.Effect('costs', '€', is_standard=True, is_objective=True), + fx.Sink( + 'Demand', + inputs=[ + fx.Flow('elec', bus='Elec', size=1, fixed_relative_profile=np.array([0, 0, 80])), + ], + ), + fx.Source( + 'Grid', + outputs=[ + fx.Flow('elec', bus='Elec', effects_per_flow_hour=np.array([1, 1, 100])), + ], + ), + fx.Storage( + 'Battery', + charging=fx.Flow('charge', bus='Elec', size=200), + discharging=fx.Flow('discharge', bus='Elec', size=200), + capacity_in_flow_hours=100, + initial_charge_state=50, + relative_minimum_final_charge_state=0.5, + eta_charge=1, + eta_discharge=1, + relative_loss_per_hour=0, + ), + ) + fs = optimize(fs) + assert_allclose(fs.solution['objective'].item(), 30500.0, rtol=1e-5) + + def test_storage_relative_maximum_final_charge_state_scalar(self, optimize): + """Proves: scalar relative_maximum_final_charge_state works in multi-period. + + Regression test for the scalar branch fix in _relative_charge_state_bounds. + Uses 3 timesteps (not 2) to avoid ambiguity with 2 periods. + + 3 ts, periods=[2020, 2025], weight_of_last_period=5. Weights=[5, 5]. + Storage: capacity=100, initial=80, relative_maximum_final_charge_state=0.2. + Demand=[50, 0, 0], Grid @[100, 1, 1], imbalance_penalty=5. + Per-period: discharge 50 for demand @t0 (SOC=30), discharge 10 excess @t1 + (penalty=50, SOC=20). Objective per period=50. + Total objective = 5*50 + 5*50 = 500. + """ + fs = make_multi_period_flow_system(n_timesteps=3, periods=[2020, 2025], weight_of_last_period=5) + fs.add_elements( + fx.Bus('Elec', imbalance_penalty_per_flow_hour=5), + fx.Effect('costs', '€', is_standard=True, is_objective=True), + fx.Sink( + 'Demand', + inputs=[ + fx.Flow('elec', bus='Elec', size=1, fixed_relative_profile=np.array([50, 0, 0])), + ], + ), + fx.Source( + 'Grid', + outputs=[ + fx.Flow('elec', bus='Elec', effects_per_flow_hour=np.array([100, 1, 1])), + ], + ), + fx.Storage( + 'Battery', + charging=fx.Flow('charge', bus='Elec', size=200), + discharging=fx.Flow('discharge', bus='Elec', size=200), + capacity_in_flow_hours=100, + initial_charge_state=80, + relative_maximum_final_charge_state=0.2, + eta_charge=1, + eta_discharge=1, + relative_loss_per_hour=0, + ), + ) + fs = optimize(fs) + assert_allclose(fs.solution['objective'].item(), 500.0, rtol=1e-5) From e89150bb22f5a7d9eb7ea28043562bf13e368d8f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 5 Feb 2026 13:55:21 +0100 Subject: [PATCH 32/44] Add clustering tests and fix issues with user set cluster weights --- flixopt/io.py | 15 ++++++--------- tests/test_math/test_clustering.py | 16 ++++++++-------- 2 files changed, 14 insertions(+), 17 deletions(-) diff --git a/flixopt/io.py b/flixopt/io.py index 33599f1c4..4e1a1e623 100644 --- a/flixopt/io.py +++ b/flixopt/io.py @@ -1631,15 +1631,12 @@ def _create_flow_system( # Extract cluster index if present (clustered FlowSystem) clusters = ds.indexes.get('cluster') - # For clustered datasets, cluster_weight is (cluster,) shaped - set separately - if clusters is not None: - cluster_weight_for_constructor = None - else: - cluster_weight_for_constructor = ( - cls._resolve_dataarray_reference(reference_structure['cluster_weight'], arrays_dict) - if 'cluster_weight' in reference_structure - else None - ) + # Resolve cluster_weight if present in reference structure + cluster_weight_for_constructor = ( + cls._resolve_dataarray_reference(reference_structure['cluster_weight'], arrays_dict) + if 'cluster_weight' in reference_structure + else None + ) # Resolve scenario_weights only if scenario dimension exists scenario_weights = None diff --git a/tests/test_math/test_clustering.py b/tests/test_math/test_clustering.py index 9b9561d21..8c7917502 100644 --- a/tests/test_math/test_clustering.py +++ b/tests/test_math/test_clustering.py @@ -232,11 +232,11 @@ class TestClusteringExact: def test_flow_rates_match_demand_per_cluster(self, optimize): """Proves: flow rates match demand identically in every cluster. - 4 ts, 2 clusters (weights 1, 1). Demand=[10,20,30,40], Grid @1€/MWh. + 4 ts, 2 clusters (weights 1, 2). Demand=[10,20,30,40], Grid @1€/MWh. Grid flow_rate = demand in each cluster. - objective = (10+20+30+40) × (1+1) = 200. + objective = (10+20+30+40) × (1+2) = 300. """ - fs = _make_clustered_flow_system(4, [1.0, 1.0]) + fs = _make_clustered_flow_system(4, [1.0, 2.0]) fs.add_elements( fx.Bus('Elec'), fx.Effect('costs', '€', is_standard=True, is_objective=True), @@ -254,16 +254,16 @@ def test_flow_rates_match_demand_per_cluster(self, optimize): grid_fr = fs.solution['Grid(elec)|flow_rate'].values[:, :4] # exclude NaN col expected = np.array([[10, 20, 30, 40], [10, 20, 30, 40]], dtype=float) assert_allclose(grid_fr, expected, atol=1e-5) - assert_allclose(fs.solution['objective'].item(), 200.0, rtol=1e-5) + assert_allclose(fs.solution['objective'].item(), 300.0, rtol=1e-5) def test_per_timestep_effects_with_varying_price(self, optimize): """Proves: per-timestep costs reflect price × flow in each cluster. - 4 ts, 2 clusters (weights 1, 1). Grid @[1,2,3,4]€/MWh, Demand=10. + 4 ts, 2 clusters (weights 1, 3). Grid @[1,2,3,4]€/MWh, Demand=10. costs per timestep = [10,20,30,40] in each cluster. - objective = (10+20+30+40) × (1+1) = 200. + objective = (10+20+30+40) × (1+3) = 400. """ - fs = _make_clustered_flow_system(4, [1.0, 1.0]) + fs = _make_clustered_flow_system(4, [1.0, 3.0]) fs.add_elements( fx.Bus('Elec'), fx.Effect('costs', '€', is_standard=True, is_objective=True), @@ -287,7 +287,7 @@ def test_per_timestep_effects_with_varying_price(self, optimize): expected_costs = np.array([[10, 20, 30, 40], [10, 20, 30, 40]], dtype=float) assert_allclose(costs_ts, expected_costs, atol=1e-5) - assert_allclose(fs.solution['objective'].item(), 200.0, rtol=1e-5) + assert_allclose(fs.solution['objective'].item(), 400.0, rtol=1e-5) def test_storage_cyclic_charge_discharge_pattern(self, optimize): """Proves: storage with cyclic clustering charges at cheap timesteps and From 24fcd58fad4765fd986d82a10c57a1e13c4ca453 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 5 Feb 2026 14:00:51 +0100 Subject: [PATCH 33/44] Update CHANGELOG.md --- CHANGELOG.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c2e227ba..24a8aa3cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,20 @@ If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOp Until here --> +## [6.0.3] - Upcoming + +**Summary**: Bugfix release fixing `cluster_weight` loss during NetCDF roundtrip for manually constructed clustered FlowSystems. + +### 🐛 Fixed + +- **Clustering IO**: `cluster_weight` is now preserved during NetCDF roundtrip for manually constructed clustered FlowSystems (i.e. `FlowSystem(..., clusters=..., cluster_weight=...)`). Previously, `cluster_weight` was silently dropped to `None` during `save->reload->solve`, causing incorrect objective values. Systems created via `.transform.cluster()` were not affected. + +### 👷 Development + +- `TestClusteringExact`: Added exact per-timestep assertions for flow_rates, per-timestep effects, and storage charge_state in clustered systems (with non-equal cluster weights to cover IO roundtrip) + +--- + ## [6.0.2] - 2026-02-05 **Summary**: Patch release which improves `Comparison` coordinate handling. From f80885bc05c2d0d5217a4c41f4fc1f6b2e0c629d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 5 Feb 2026 14:06:58 +0100 Subject: [PATCH 34/44] Mark old tests as stale --- tests/test_functional.py | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/tests/test_functional.py b/tests/test_functional.py index 68f6b9e84..62b84e8d2 100644 --- a/tests/test_functional.py +++ b/tests/test_functional.py @@ -1,20 +1,16 @@ """ Unit tests for the flixopt framework. -This module defines a set of unit tests for testing the functionality of the `flixopt` framework. -The tests focus on verifying the correct behavior of flow systems, including component modeling, -investment optimization, and operational constraints like status behavior. - -### Approach: -1. **Setup**: Each test initializes a flow system with a set of predefined elements and parameters. -2. **Model Creation**: Test-specific flow systems are constructed using `create_model` with datetime arrays. -3. **Solution**: The models are solved using the `solve_and_load` method, which performs modeling, solves the optimization problem, and loads the results. -4. **Validation**: Results are validated using assertions, primarily `assert_allclose`, to ensure model outputs match expected values with a specified tolerance. - -Tests group related cases by their functional focus: -- Minimal modeling setup (`TestMinimal` class) -- Investment behavior (`TestInvestment` class) -- Status operational constraints (functions: `test_startup_shutdown`, `test_consecutive_uptime_downtime`, etc.) +.. deprecated:: + STALE — These tests are superseded by tests/test_math/ which provides more thorough, + analytically verified coverage with sensitivity documentation. Specifically: + - Investment tests → test_math/test_flow_invest.py (9 tests + 3 invest+status combo tests) + - Status tests → test_math/test_flow_status.py (9 tests + 6 previous_flow_rate tests) + - Efficiency tests → test_math/test_conversion.py (3 tests) + - Effect tests → test_math/test_effects.py (11 tests) + Each test_math test runs in 3 modes (solve, save→reload→solve, solve→save→reload), + making the IO roundtrip tests here redundant as well. + Kept temporarily for reference. Safe to delete. """ import numpy as np @@ -26,6 +22,8 @@ np.random.seed(45) +pytestmark = pytest.mark.skip(reason='Stale: superseded by tests/test_math/ — see module docstring') + class Data: """ From 68850eb253ec4d8e00350ac390f1f324e025acf5 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 5 Feb 2026 14:14:38 +0100 Subject: [PATCH 35/44] Update CHANGELOG.md --- CHANGELOG.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24a8aa3cc..02cea1cb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,7 +62,20 @@ Until here --> ### 👷 Development -- `TestClusteringExact`: Added exact per-timestep assertions for flow_rates, per-timestep effects, and storage charge_state in clustered systems (with non-equal cluster weights to cover IO roundtrip) +- **New `test_math/` test suite**: Comprehensive mathematical correctness tests with exact, hand-calculated assertions. Each test runs in 3 IO modes (solve, save→reload→solve, solve→save→reload) via the `optimize` fixture: + - `test_flow.py` — flow bounds, merit order, relative min/max, on/off hours + - `test_flow_invest.py` — investment sizing, fixed-size, optional invest, piecewise invest + - `test_flow_status.py` — startup costs, switch-on/off constraints, status penalties + - `test_bus.py` — bus balance, excess/shortage penalties + - `test_effects.py` — effect aggregation, periodic/temporal effects, multi-effect objectives + - `test_components.py` — SourceAndSink, converters, links, combined heat-and-power + - `test_conversion.py` — linear converter balance, multi-input/output, efficiency + - `test_piecewise.py` — piecewise-linear efficiency, segment selection + - `test_storage.py` — charge/discharge, SOC tracking, final charge state, losses + - `test_multi_period.py` — period weights, invest across periods + - `test_scenarios.py` — scenario weights, scenario-independent flows + - `test_clustering.py` — exact per-timestep flow_rates, effects, and charge_state in clustered systems (incl. non-equal cluster weights to cover IO roundtrip) + - `test_validation.py` — plausibility checks and error messages --- From e5be97e22a1e5a3874c5a844be9247d46955590f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 5 Feb 2026 14:27:45 +0100 Subject: [PATCH 36/44] Mark tests as stale and move to new dir --- pyproject.toml | 4 ++-- tests/stale/__init__.py | 8 ++++++++ tests/{ => stale}/test_functional.py | 0 tests/{ => stale}/test_integration.py | 18 +++++++++++++++++- 4 files changed, 27 insertions(+), 3 deletions(-) create mode 100644 tests/stale/__init__.py rename tests/{ => stale}/test_functional.py (100%) rename tests/{ => stale}/test_integration.py (90%) diff --git a/pyproject.toml b/pyproject.toml index 58a480afc..e96211a30 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -176,7 +176,7 @@ extend-fixable = ["B"] # Enable fix for flake8-bugbear (`B`), on top of any ru # Apply rule exceptions to specific files or directories [tool.ruff.lint.per-file-ignores] "tests/*.py" = ["S101"] # Ignore assertions in test files -"tests/test_integration.py" = ["N806"] # Ignore NOT lowercase names in test files +"tests/stale/test_integration.py" = ["N806"] # Ignore NOT lowercase names in test files "flixopt/linear_converters.py" = ["N803"] # Parameters with NOT lowercase names [tool.ruff.format] @@ -193,7 +193,7 @@ markers = [ "examples: marks example tests (run only on releases)", "deprecated_api: marks tests using deprecated Optimization/Results API (remove in v6.0.0)", ] -addopts = '-m "not examples"' # Skip examples by default +addopts = '-m "not examples" --ignore=tests/stale' # Skip examples and stale tests by default # Warning filter configuration for pytest # Filters are processed in order; first match wins diff --git a/tests/stale/__init__.py b/tests/stale/__init__.py new file mode 100644 index 000000000..c295979ae --- /dev/null +++ b/tests/stale/__init__.py @@ -0,0 +1,8 @@ +"""Stale tests superseded by tests/test_math/. + +These tests have been replaced by more thorough, analytically verified tests +in tests/test_math/. They are kept temporarily for reference and will be +deleted once confidence in the new test suite is established. + +All tests in this folder are skipped via pytestmark. +""" diff --git a/tests/test_functional.py b/tests/stale/test_functional.py similarity index 100% rename from tests/test_functional.py rename to tests/stale/test_functional.py diff --git a/tests/test_integration.py b/tests/stale/test_integration.py similarity index 90% rename from tests/test_integration.py rename to tests/stale/test_integration.py index d33bb54e8..b02e57130 100644 --- a/tests/test_integration.py +++ b/tests/stale/test_integration.py @@ -1,9 +1,25 @@ +""" +Integration tests for complex energy systems. + +.. deprecated:: + STALE — These regression baseline tests are partially superseded by tests/test_math/: + - test_simple_flow_system → test_math/test_conversion.py + test_math/test_effects.py + - test_model_components → test_math/test_conversion.py (boiler/CHP flow rates) + - test_basic_flow_system → spread across test_math/ (effects, conversion, storage) + - test_piecewise_conversion → test_math/test_piecewise.py + The test_math tests provide isolated, analytically verified coverage per feature. + These integration tests served as snapshot baselines for complex multi-component systems. + Kept temporarily for reference. Safe to delete. +""" + import pytest -from .conftest import ( +from ..conftest import ( assert_almost_equal_numeric, ) +pytestmark = pytest.mark.skip(reason='Stale: superseded by tests/test_math/ — see module docstring') + class TestFlowSystem: def test_simple_flow_system(self, simple_flow_system, highs_solver): From fa3de4e562f5b26bf5f69c59ccf3f512f59cb127 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 5 Feb 2026 16:41:39 +0100 Subject: [PATCH 37/44] Move more tests to stale --- .../{ => stale}/test_solution_persistence.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) rename tests/{ => stale}/test_solution_persistence.py (96%) diff --git a/tests/test_solution_persistence.py b/tests/stale/test_solution_persistence.py similarity index 96% rename from tests/test_solution_persistence.py rename to tests/stale/test_solution_persistence.py index f825f64a8..116151630 100644 --- a/tests/test_solution_persistence.py +++ b/tests/stale/test_solution_persistence.py @@ -1,10 +1,13 @@ """Tests for the new solution persistence API. -This module tests the direct solution storage on FlowSystem and Element classes: -- FlowSystem.solution: xr.Dataset containing all solution variables -- Element.solution: subset of FlowSystem.solution for that element's variables -- Element._variable_names: list of variable names for each element -- Serialization/deserialization of solution with FlowSystem +.. deprecated:: + STALE — The IO roundtrip tests (TestSolutionPersistence, TestFlowSystemFileIO) + are superseded by the test_math/ ``optimize`` fixture which runs every math test + in 3 modes: solve, save→reload→solve, solve→save→reload — totalling 274 implicit + IO roundtrips across all component types. + The API behavior tests (TestSolutionOnFlowSystem, TestSolutionOnElement, + TestVariableNamesPopulation, TestFlowSystemDirectMethods) are unique but low-priority. + Kept temporarily for reference. Safe to delete. """ import pytest @@ -12,7 +15,7 @@ import flixopt as fx -from .conftest import ( +from ..conftest import ( assert_almost_equal_numeric, flow_system_base, flow_system_long, @@ -21,6 +24,10 @@ simple_flow_system_scenarios, ) +pytestmark = pytest.mark.skip( + reason='Stale: IO roundtrips superseded by tests/test_math/ optimize fixture — see module docstring' +) + @pytest.fixture( params=[ From 96124b211193b637afcba9f3c3527cc1778db2fa Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 5 Feb 2026 16:57:48 +0100 Subject: [PATCH 38/44] Change fixtures to speed up tests --- tests/conftest.py | 13 ++++--- tests/test_comparison.py | 57 ++++++++++++----------------- tests/test_flow_system_locking.py | 61 +++++++++++++++---------------- 3 files changed, 62 insertions(+), 69 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 9923af896..84b137c84 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -400,11 +400,8 @@ def gas_with_costs(): # ============================================================================ -@pytest.fixture -def simple_flow_system() -> fx.FlowSystem: - """ - Create a simple energy system for testing - """ +def build_simple_flow_system() -> fx.FlowSystem: + """Create a simple energy system for testing (factory function).""" base_timesteps = pd.date_range('2020-01-01', periods=9, freq='h', name='time') timesteps_length = len(base_timesteps) base_thermal_load = LoadProfiles.thermal_simple(timesteps_length) @@ -431,6 +428,12 @@ def simple_flow_system() -> fx.FlowSystem: return flow_system +@pytest.fixture +def simple_flow_system() -> fx.FlowSystem: + """Create a simple energy system for testing.""" + return build_simple_flow_system() + + @pytest.fixture def simple_flow_system_scenarios() -> fx.FlowSystem: """ diff --git a/tests/test_comparison.py b/tests/test_comparison.py index f526e0487..7f7e7093e 100644 --- a/tests/test_comparison.py +++ b/tests/test_comparison.py @@ -19,17 +19,12 @@ # ============================================================================ -@pytest.fixture -def timesteps(): - return pd.date_range('2020-01-01', periods=24, freq='h', name='time') - +_TIMESTEPS = pd.date_range('2020-01-01', periods=24, freq='h', name='time') -@pytest.fixture -def base_flow_system(timesteps): - """Base flow system with boiler and storage.""" - fs = fx.FlowSystem(timesteps, name='Base') - # Effects and Buses +def _build_base_flow_system(): + """Factory: base flow system with boiler and storage.""" + fs = fx.FlowSystem(_TIMESTEPS, name='Base') fs.add_elements( fx.Effect('costs', '€', 'Costs', is_standard=True, is_objective=True), fx.Effect('CO2', 'kg', 'CO2 Emissions'), @@ -37,8 +32,6 @@ def base_flow_system(timesteps): fx.Bus('Heat'), fx.Bus('Gas'), ) - - # Components fs.add_elements( fx.Source( 'Grid', @@ -66,16 +59,12 @@ def base_flow_system(timesteps): initial_charge_state=0.5, ), ) - return fs -@pytest.fixture -def flow_system_with_chp(timesteps): - """Flow system with additional CHP component.""" - fs = fx.FlowSystem(timesteps, name='WithCHP') - - # Effects and Buses +def _build_flow_system_with_chp(): + """Factory: flow system with additional CHP component.""" + fs = fx.FlowSystem(_TIMESTEPS, name='WithCHP') fs.add_elements( fx.Effect('costs', '€', 'Costs', is_standard=True, is_objective=True), fx.Effect('CO2', 'kg', 'CO2 Emissions'), @@ -83,8 +72,6 @@ def flow_system_with_chp(timesteps): fx.Bus('Heat'), fx.Bus('Gas'), ) - - # Components (same as base, plus CHP) fs.add_elements( fx.Source( 'Grid', @@ -124,27 +111,31 @@ def flow_system_with_chp(timesteps): initial_charge_state=0.5, ), ) - return fs @pytest.fixture -def highs_solver(): - return fx.solvers.HighsSolver(mip_gap=0, time_limit_seconds=60) +def base_flow_system(): + """Unoptimized base flow system (function-scoped for tests needing fresh instance).""" + return _build_base_flow_system() -@pytest.fixture -def optimized_base(base_flow_system, highs_solver): - """Optimized base flow system.""" - base_flow_system.optimize(highs_solver) - return base_flow_system +@pytest.fixture(scope='module') +def optimized_base(): + """Optimized base flow system (module-scoped, solved once).""" + fs = _build_base_flow_system() + solver = fx.solvers.HighsSolver(mip_gap=0, time_limit_seconds=60) + fs.optimize(solver) + return fs -@pytest.fixture -def optimized_with_chp(flow_system_with_chp, highs_solver): - """Optimized flow system with CHP.""" - flow_system_with_chp.optimize(highs_solver) - return flow_system_with_chp +@pytest.fixture(scope='module') +def optimized_with_chp(): + """Optimized flow system with CHP (module-scoped, solved once).""" + fs = _build_flow_system_with_chp() + solver = fx.solvers.HighsSolver(mip_gap=0, time_limit_seconds=60) + fs.optimize(solver) + return fs # ============================================================================ diff --git a/tests/test_flow_system_locking.py b/tests/test_flow_system_locking.py index 68d3ec010..f8ec3a571 100644 --- a/tests/test_flow_system_locking.py +++ b/tests/test_flow_system_locking.py @@ -12,7 +12,7 @@ import flixopt as fx -# Note: We use simple_flow_system fixture from conftest.py +from .conftest import build_simple_flow_system class TestIsLocked: @@ -179,6 +179,14 @@ def test_reset_allows_reoptimization(self, simple_flow_system, highs_solver): class TestCopy: """Test the copy method.""" + @pytest.fixture(scope='class') + def optimized_flow_system(self): + """Pre-optimized flow system shared across TestCopy (tests only work with copies).""" + fs = build_simple_flow_system() + solver = fx.solvers.HighsSolver(mip_gap=0, time_limit_seconds=300) + fs.optimize(solver) + return fs + def test_copy_creates_new_instance(self, simple_flow_system): """Copy should create a new FlowSystem instance.""" copy_fs = simple_flow_system.copy() @@ -191,67 +199,58 @@ def test_copy_preserves_elements(self, simple_flow_system): assert set(copy_fs.components.keys()) == set(simple_flow_system.components.keys()) assert set(copy_fs.buses.keys()) == set(simple_flow_system.buses.keys()) - def test_copy_does_not_copy_solution(self, simple_flow_system, highs_solver): + def test_copy_does_not_copy_solution(self, optimized_flow_system): """Copy should not include the solution.""" - simple_flow_system.optimize(highs_solver) - assert simple_flow_system.solution is not None + assert optimized_flow_system.solution is not None - copy_fs = simple_flow_system.copy() + copy_fs = optimized_flow_system.copy() assert copy_fs.solution is None - def test_copy_does_not_copy_model(self, simple_flow_system, highs_solver): + def test_copy_does_not_copy_model(self, optimized_flow_system): """Copy should not include the model.""" - simple_flow_system.optimize(highs_solver) - assert simple_flow_system.model is not None + assert optimized_flow_system.model is not None - copy_fs = simple_flow_system.copy() + copy_fs = optimized_flow_system.copy() assert copy_fs.model is None - def test_copy_is_not_locked(self, simple_flow_system, highs_solver): + def test_copy_is_not_locked(self, optimized_flow_system): """Copy should not be locked even if original is.""" - simple_flow_system.optimize(highs_solver) - assert simple_flow_system.is_locked is True + assert optimized_flow_system.is_locked is True - copy_fs = simple_flow_system.copy() + copy_fs = optimized_flow_system.copy() assert copy_fs.is_locked is False - def test_copy_can_be_modified(self, simple_flow_system, highs_solver): + def test_copy_can_be_modified(self, optimized_flow_system): """Copy should be modifiable even if original is locked.""" - simple_flow_system.optimize(highs_solver) - - copy_fs = simple_flow_system.copy() + copy_fs = optimized_flow_system.copy() new_bus = fx.Bus('NewBus') copy_fs.add_elements(new_bus) # Should not raise assert 'NewBus' in copy_fs.buses - def test_copy_can_be_optimized_independently(self, simple_flow_system, highs_solver): + def test_copy_can_be_optimized_independently(self, optimized_flow_system): """Copy can be optimized independently of original.""" - simple_flow_system.optimize(highs_solver) - original_cost = simple_flow_system.solution['costs'].item() + original_cost = optimized_flow_system.solution['costs'].item() - copy_fs = simple_flow_system.copy() - copy_fs.optimize(highs_solver) + copy_fs = optimized_flow_system.copy() + solver = fx.solvers.HighsSolver(mip_gap=0, time_limit_seconds=300) + copy_fs.optimize(solver) # Both should have solutions - assert simple_flow_system.solution is not None + assert optimized_flow_system.solution is not None assert copy_fs.solution is not None # Costs should be equal (same system) assert copy_fs.solution['costs'].item() == pytest.approx(original_cost) - def test_python_copy_uses_copy_method(self, simple_flow_system, highs_solver): + def test_python_copy_uses_copy_method(self, optimized_flow_system): """copy.copy() should use the custom copy method.""" - simple_flow_system.optimize(highs_solver) - - copy_fs = copy.copy(simple_flow_system) + copy_fs = copy.copy(optimized_flow_system) assert copy_fs.solution is None assert copy_fs.is_locked is False - def test_python_deepcopy_uses_copy_method(self, simple_flow_system, highs_solver): + def test_python_deepcopy_uses_copy_method(self, optimized_flow_system): """copy.deepcopy() should use the custom copy method.""" - simple_flow_system.optimize(highs_solver) - - copy_fs = copy.deepcopy(simple_flow_system) + copy_fs = copy.deepcopy(optimized_flow_system) assert copy_fs.solution is None assert copy_fs.is_locked is False From d71f85eb169defe33240a09e2f1d5f484b06ed96 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 5 Feb 2026 17:06:57 +0100 Subject: [PATCH 39/44] Moved files into stale --- tests/stale/math/__init__.py | 6 ++++++ tests/{ => stale/math}/test_bus.py | 6 +++++- tests/{ => stale/math}/test_component.py | 4 +++- tests/{ => stale/math}/test_effect.py | 4 +++- tests/{ => stale/math}/test_flow.py | 10 +++++++++- tests/{ => stale/math}/test_linear_converter.py | 4 +++- tests/{ => stale/math}/test_storage.py | 4 +++- 7 files changed, 32 insertions(+), 6 deletions(-) create mode 100644 tests/stale/math/__init__.py rename tests/{ => stale/math}/test_bus.py (95%) rename tests/{ => stale/math}/test_component.py (99%) rename tests/{ => stale/math}/test_effect.py (99%) rename tests/{ => stale/math}/test_flow.py (99%) rename tests/{ => stale/math}/test_linear_converter.py (99%) rename tests/{ => stale/math}/test_storage.py (99%) diff --git a/tests/stale/math/__init__.py b/tests/stale/math/__init__.py new file mode 100644 index 000000000..f7539f20e --- /dev/null +++ b/tests/stale/math/__init__.py @@ -0,0 +1,6 @@ +"""Model-building tests superseded by tests/test_math/. + +These tests verified linopy model structure (variables, constraints, bounds). +They are implicitly covered by test_math: if solutions are mathematically correct, +the model building must be correct. +""" diff --git a/tests/test_bus.py b/tests/stale/math/test_bus.py similarity index 95% rename from tests/test_bus.py rename to tests/stale/math/test_bus.py index 9bb7ddbe3..85455527b 100644 --- a/tests/test_bus.py +++ b/tests/stale/math/test_bus.py @@ -1,6 +1,10 @@ +import pytest + import flixopt as fx -from .conftest import assert_conequal, assert_var_equal, create_linopy_model +from ...conftest import assert_conequal, assert_var_equal, create_linopy_model + +pytestmark = pytest.mark.skip(reason='Stale: model-building tests implicitly covered by tests/test_math/') class TestBusModel: diff --git a/tests/test_component.py b/tests/stale/math/test_component.py similarity index 99% rename from tests/test_component.py rename to tests/stale/math/test_component.py index c5ebd34a3..f9c02a8d5 100644 --- a/tests/test_component.py +++ b/tests/stale/math/test_component.py @@ -4,7 +4,7 @@ import flixopt as fx import flixopt.elements -from .conftest import ( +from ...conftest import ( assert_almost_equal_numeric, assert_conequal, assert_dims_compatible, @@ -13,6 +13,8 @@ create_linopy_model, ) +pytestmark = pytest.mark.skip(reason='Stale: model-building tests implicitly covered by tests/test_math/') + class TestComponentModel: def test_flow_label_check(self): diff --git a/tests/test_effect.py b/tests/stale/math/test_effect.py similarity index 99% rename from tests/test_effect.py rename to tests/stale/math/test_effect.py index 60fbb0166..ec5cb0bcf 100644 --- a/tests/test_effect.py +++ b/tests/stale/math/test_effect.py @@ -4,13 +4,15 @@ import flixopt as fx -from .conftest import ( +from ...conftest import ( assert_conequal, assert_sets_equal, assert_var_equal, create_linopy_model, ) +pytestmark = pytest.mark.skip(reason='Stale: model-building tests implicitly covered by tests/test_math/') + class TestEffectModel: """Test the FlowModel class.""" diff --git a/tests/test_flow.py b/tests/stale/math/test_flow.py similarity index 99% rename from tests/test_flow.py rename to tests/stale/math/test_flow.py index aa75b3c66..05858343b 100644 --- a/tests/test_flow.py +++ b/tests/stale/math/test_flow.py @@ -4,7 +4,15 @@ import flixopt as fx -from .conftest import assert_conequal, assert_dims_compatible, assert_sets_equal, assert_var_equal, create_linopy_model +from ...conftest import ( + assert_conequal, + assert_dims_compatible, + assert_sets_equal, + assert_var_equal, + create_linopy_model, +) + +pytestmark = pytest.mark.skip(reason='Stale: model-building tests implicitly covered by tests/test_math/') class TestFlowModel: diff --git a/tests/test_linear_converter.py b/tests/stale/math/test_linear_converter.py similarity index 99% rename from tests/test_linear_converter.py rename to tests/stale/math/test_linear_converter.py index c8fc3fb52..da1df540a 100644 --- a/tests/test_linear_converter.py +++ b/tests/stale/math/test_linear_converter.py @@ -4,7 +4,9 @@ import flixopt as fx -from .conftest import assert_conequal, assert_dims_compatible, assert_var_equal, create_linopy_model +from ...conftest import assert_conequal, assert_dims_compatible, assert_var_equal, create_linopy_model + +pytestmark = pytest.mark.skip(reason='Stale: model-building tests implicitly covered by tests/test_math/') class TestLinearConverterModel: diff --git a/tests/test_storage.py b/tests/stale/math/test_storage.py similarity index 99% rename from tests/test_storage.py rename to tests/stale/math/test_storage.py index 3fd47fbf8..a957a81fe 100644 --- a/tests/test_storage.py +++ b/tests/stale/math/test_storage.py @@ -3,7 +3,9 @@ import flixopt as fx -from .conftest import assert_conequal, assert_var_equal, create_linopy_model +from ...conftest import assert_conequal, assert_var_equal, create_linopy_model + +pytestmark = pytest.mark.skip(reason='Stale: model-building tests implicitly covered by tests/test_math/') class TestStorageModel: From 3710435ce48fcccc6764ceee9ef7751b40160bd6 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 5 Feb 2026 17:09:01 +0100 Subject: [PATCH 40/44] Renamed folder --- pyproject.toml | 4 ++-- tests/{stale => superseded}/__init__.py | 0 tests/{stale => superseded}/math/__init__.py | 0 tests/{stale => superseded}/math/test_bus.py | 0 tests/{stale => superseded}/math/test_component.py | 0 tests/{stale => superseded}/math/test_effect.py | 0 tests/{stale => superseded}/math/test_flow.py | 0 tests/{stale => superseded}/math/test_linear_converter.py | 0 tests/{stale => superseded}/math/test_storage.py | 0 tests/{stale => superseded}/test_functional.py | 0 tests/{stale => superseded}/test_integration.py | 0 tests/{stale => superseded}/test_solution_persistence.py | 0 12 files changed, 2 insertions(+), 2 deletions(-) rename tests/{stale => superseded}/__init__.py (100%) rename tests/{stale => superseded}/math/__init__.py (100%) rename tests/{stale => superseded}/math/test_bus.py (100%) rename tests/{stale => superseded}/math/test_component.py (100%) rename tests/{stale => superseded}/math/test_effect.py (100%) rename tests/{stale => superseded}/math/test_flow.py (100%) rename tests/{stale => superseded}/math/test_linear_converter.py (100%) rename tests/{stale => superseded}/math/test_storage.py (100%) rename tests/{stale => superseded}/test_functional.py (100%) rename tests/{stale => superseded}/test_integration.py (100%) rename tests/{stale => superseded}/test_solution_persistence.py (100%) diff --git a/pyproject.toml b/pyproject.toml index e96211a30..9f9739659 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -176,7 +176,7 @@ extend-fixable = ["B"] # Enable fix for flake8-bugbear (`B`), on top of any ru # Apply rule exceptions to specific files or directories [tool.ruff.lint.per-file-ignores] "tests/*.py" = ["S101"] # Ignore assertions in test files -"tests/stale/test_integration.py" = ["N806"] # Ignore NOT lowercase names in test files +"tests/superseded/test_integration.py" = ["N806"] # Ignore NOT lowercase names in test files "flixopt/linear_converters.py" = ["N803"] # Parameters with NOT lowercase names [tool.ruff.format] @@ -193,7 +193,7 @@ markers = [ "examples: marks example tests (run only on releases)", "deprecated_api: marks tests using deprecated Optimization/Results API (remove in v6.0.0)", ] -addopts = '-m "not examples" --ignore=tests/stale' # Skip examples and stale tests by default +addopts = '-m "not examples" --ignore=tests/superseded' # Skip examples and stale tests by default # Warning filter configuration for pytest # Filters are processed in order; first match wins diff --git a/tests/stale/__init__.py b/tests/superseded/__init__.py similarity index 100% rename from tests/stale/__init__.py rename to tests/superseded/__init__.py diff --git a/tests/stale/math/__init__.py b/tests/superseded/math/__init__.py similarity index 100% rename from tests/stale/math/__init__.py rename to tests/superseded/math/__init__.py diff --git a/tests/stale/math/test_bus.py b/tests/superseded/math/test_bus.py similarity index 100% rename from tests/stale/math/test_bus.py rename to tests/superseded/math/test_bus.py diff --git a/tests/stale/math/test_component.py b/tests/superseded/math/test_component.py similarity index 100% rename from tests/stale/math/test_component.py rename to tests/superseded/math/test_component.py diff --git a/tests/stale/math/test_effect.py b/tests/superseded/math/test_effect.py similarity index 100% rename from tests/stale/math/test_effect.py rename to tests/superseded/math/test_effect.py diff --git a/tests/stale/math/test_flow.py b/tests/superseded/math/test_flow.py similarity index 100% rename from tests/stale/math/test_flow.py rename to tests/superseded/math/test_flow.py diff --git a/tests/stale/math/test_linear_converter.py b/tests/superseded/math/test_linear_converter.py similarity index 100% rename from tests/stale/math/test_linear_converter.py rename to tests/superseded/math/test_linear_converter.py diff --git a/tests/stale/math/test_storage.py b/tests/superseded/math/test_storage.py similarity index 100% rename from tests/stale/math/test_storage.py rename to tests/superseded/math/test_storage.py diff --git a/tests/stale/test_functional.py b/tests/superseded/test_functional.py similarity index 100% rename from tests/stale/test_functional.py rename to tests/superseded/test_functional.py diff --git a/tests/stale/test_integration.py b/tests/superseded/test_integration.py similarity index 100% rename from tests/stale/test_integration.py rename to tests/superseded/test_integration.py diff --git a/tests/stale/test_solution_persistence.py b/tests/superseded/test_solution_persistence.py similarity index 100% rename from tests/stale/test_solution_persistence.py rename to tests/superseded/test_solution_persistence.py From 79c428894b889ccfbe0df955c7c6276d4807b6ec Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 5 Feb 2026 17:21:10 +0100 Subject: [PATCH 41/44] Reorganize test dir --- tests/{ => flow_system}/test_flow_system_locking.py | 2 +- tests/{ => flow_system}/test_flow_system_resample.py | 0 tests/{ => flow_system}/test_resample_equivalence.py | 0 tests/{ => flow_system}/test_sel_isel_single_selection.py | 0 tests/{ => io}/test_io.py | 2 +- tests/{ => io}/test_io_conversion.py | 6 +++--- tests/{ => plotting}/test_heatmap_reshape.py | 0 tests/{ => plotting}/test_network_app.py | 2 +- tests/{ => plotting}/test_plotting_api.py | 0 tests/{ => plotting}/test_solution_and_plotting.py | 0 tests/{ => plotting}/test_topology_accessor.py | 0 tests/{ => test_clustering}/test_cluster_reduce_expand.py | 0 tests/{ => test_clustering}/test_clustering_io.py | 0 tests/{ => utilities}/test_config.py | 0 tests/{ => utilities}/test_cycle_detection.py | 0 tests/{ => utilities}/test_dataconverter.py | 0 tests/{ => utilities}/test_effects_shares_summation.py | 0 tests/{ => utilities}/test_on_hours_computation.py | 0 18 files changed, 6 insertions(+), 6 deletions(-) rename tests/{ => flow_system}/test_flow_system_locking.py (99%) rename tests/{ => flow_system}/test_flow_system_resample.py (100%) rename tests/{ => flow_system}/test_resample_equivalence.py (100%) rename tests/{ => flow_system}/test_sel_isel_single_selection.py (100%) rename tests/{ => io}/test_io.py (99%) rename tests/{ => io}/test_io_conversion.py (99%) rename tests/{ => plotting}/test_heatmap_reshape.py (100%) rename tests/{ => plotting}/test_network_app.py (95%) rename tests/{ => plotting}/test_plotting_api.py (100%) rename tests/{ => plotting}/test_solution_and_plotting.py (100%) rename tests/{ => plotting}/test_topology_accessor.py (100%) rename tests/{ => test_clustering}/test_cluster_reduce_expand.py (100%) rename tests/{ => test_clustering}/test_clustering_io.py (100%) rename tests/{ => utilities}/test_config.py (100%) rename tests/{ => utilities}/test_cycle_detection.py (100%) rename tests/{ => utilities}/test_dataconverter.py (100%) rename tests/{ => utilities}/test_effects_shares_summation.py (100%) rename tests/{ => utilities}/test_on_hours_computation.py (100%) diff --git a/tests/test_flow_system_locking.py b/tests/flow_system/test_flow_system_locking.py similarity index 99% rename from tests/test_flow_system_locking.py rename to tests/flow_system/test_flow_system_locking.py index f8ec3a571..cb8db5acb 100644 --- a/tests/test_flow_system_locking.py +++ b/tests/flow_system/test_flow_system_locking.py @@ -12,7 +12,7 @@ import flixopt as fx -from .conftest import build_simple_flow_system +from ..conftest import build_simple_flow_system class TestIsLocked: diff --git a/tests/test_flow_system_resample.py b/tests/flow_system/test_flow_system_resample.py similarity index 100% rename from tests/test_flow_system_resample.py rename to tests/flow_system/test_flow_system_resample.py diff --git a/tests/test_resample_equivalence.py b/tests/flow_system/test_resample_equivalence.py similarity index 100% rename from tests/test_resample_equivalence.py rename to tests/flow_system/test_resample_equivalence.py diff --git a/tests/test_sel_isel_single_selection.py b/tests/flow_system/test_sel_isel_single_selection.py similarity index 100% rename from tests/test_sel_isel_single_selection.py rename to tests/flow_system/test_sel_isel_single_selection.py diff --git a/tests/test_io.py b/tests/io/test_io.py similarity index 99% rename from tests/test_io.py rename to tests/io/test_io.py index d9ab9bba5..404f514ec 100644 --- a/tests/test_io.py +++ b/tests/io/test_io.py @@ -8,7 +8,7 @@ import flixopt as fx -from .conftest import ( +from ..conftest import ( flow_system_base, flow_system_long, flow_system_segments_of_flows_2, diff --git a/tests/test_io_conversion.py b/tests/io/test_io_conversion.py similarity index 99% rename from tests/test_io_conversion.py rename to tests/io/test_io_conversion.py index dffba1dfc..c1f2d9d4b 100644 --- a/tests/test_io_conversion.py +++ b/tests/io/test_io_conversion.py @@ -636,7 +636,7 @@ def test_load_old_results_from_resources(self): import flixopt as fx - resources_path = pathlib.Path(__file__).parent / 'ressources' + resources_path = pathlib.Path(__file__).parent.parent / 'ressources' # Load old results using new method fs = fx.FlowSystem.from_old_results(resources_path, 'Sim1') @@ -655,7 +655,7 @@ def test_old_results_can_be_saved_new_format(self, tmp_path): import flixopt as fx - resources_path = pathlib.Path(__file__).parent / 'ressources' + resources_path = pathlib.Path(__file__).parent.parent / 'ressources' # Load old results fs = fx.FlowSystem.from_old_results(resources_path, 'Sim1') @@ -674,7 +674,7 @@ def test_old_results_can_be_saved_new_format(self, tmp_path): class TestV4APIConversion: """Tests for converting v4 API result files to the new format.""" - V4_API_PATH = pathlib.Path(__file__).parent / 'ressources' / 'v4-api' + V4_API_PATH = pathlib.Path(__file__).parent.parent / 'ressources' / 'v4-api' # All result names in the v4-api folder V4_RESULT_NAMES = [ diff --git a/tests/test_heatmap_reshape.py b/tests/plotting/test_heatmap_reshape.py similarity index 100% rename from tests/test_heatmap_reshape.py rename to tests/plotting/test_heatmap_reshape.py diff --git a/tests/test_network_app.py b/tests/plotting/test_network_app.py similarity index 95% rename from tests/test_network_app.py rename to tests/plotting/test_network_app.py index f3f250797..bc734c43e 100644 --- a/tests/test_network_app.py +++ b/tests/plotting/test_network_app.py @@ -2,7 +2,7 @@ import flixopt as fx -from .conftest import ( +from ..conftest import ( flow_system_long, flow_system_segments_of_flows_2, simple_flow_system, diff --git a/tests/test_plotting_api.py b/tests/plotting/test_plotting_api.py similarity index 100% rename from tests/test_plotting_api.py rename to tests/plotting/test_plotting_api.py diff --git a/tests/test_solution_and_plotting.py b/tests/plotting/test_solution_and_plotting.py similarity index 100% rename from tests/test_solution_and_plotting.py rename to tests/plotting/test_solution_and_plotting.py diff --git a/tests/test_topology_accessor.py b/tests/plotting/test_topology_accessor.py similarity index 100% rename from tests/test_topology_accessor.py rename to tests/plotting/test_topology_accessor.py diff --git a/tests/test_cluster_reduce_expand.py b/tests/test_clustering/test_cluster_reduce_expand.py similarity index 100% rename from tests/test_cluster_reduce_expand.py rename to tests/test_clustering/test_cluster_reduce_expand.py diff --git a/tests/test_clustering_io.py b/tests/test_clustering/test_clustering_io.py similarity index 100% rename from tests/test_clustering_io.py rename to tests/test_clustering/test_clustering_io.py diff --git a/tests/test_config.py b/tests/utilities/test_config.py similarity index 100% rename from tests/test_config.py rename to tests/utilities/test_config.py diff --git a/tests/test_cycle_detection.py b/tests/utilities/test_cycle_detection.py similarity index 100% rename from tests/test_cycle_detection.py rename to tests/utilities/test_cycle_detection.py diff --git a/tests/test_dataconverter.py b/tests/utilities/test_dataconverter.py similarity index 100% rename from tests/test_dataconverter.py rename to tests/utilities/test_dataconverter.py diff --git a/tests/test_effects_shares_summation.py b/tests/utilities/test_effects_shares_summation.py similarity index 100% rename from tests/test_effects_shares_summation.py rename to tests/utilities/test_effects_shares_summation.py diff --git a/tests/test_on_hours_computation.py b/tests/utilities/test_on_hours_computation.py similarity index 100% rename from tests/test_on_hours_computation.py rename to tests/utilities/test_on_hours_computation.py From 0eeb8abd84f055f2ce9723de7f46de094aaa2baa Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 5 Feb 2026 17:22:15 +0100 Subject: [PATCH 42/44] Reorganize test dir --- tests/flow_system/__init__.py | 0 tests/io/__init__.py | 0 tests/plotting/__init__.py | 0 tests/utilities/__init__.py | 0 4 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 tests/flow_system/__init__.py create mode 100644 tests/io/__init__.py create mode 100644 tests/plotting/__init__.py create mode 100644 tests/utilities/__init__.py diff --git a/tests/flow_system/__init__.py b/tests/flow_system/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/io/__init__.py b/tests/io/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/plotting/__init__.py b/tests/plotting/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/utilities/__init__.py b/tests/utilities/__init__.py new file mode 100644 index 000000000..e69de29bb From 6387a291881466fd1355d9c7604277417250cb55 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 5 Feb 2026 17:31:50 +0100 Subject: [PATCH 43/44] Rename marker --- pyproject.toml | 2 +- tests/superseded/__init__.py | 2 +- tests/superseded/math/test_bus.py | 2 +- tests/superseded/math/test_component.py | 2 +- tests/superseded/math/test_effect.py | 2 +- tests/superseded/math/test_flow.py | 2 +- tests/superseded/math/test_linear_converter.py | 2 +- tests/superseded/math/test_storage.py | 2 +- tests/superseded/test_functional.py | 4 ++-- tests/superseded/test_integration.py | 4 ++-- tests/superseded/test_solution_persistence.py | 4 ++-- 11 files changed, 14 insertions(+), 14 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9f9739659..3a7e3dcbf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -193,7 +193,7 @@ markers = [ "examples: marks example tests (run only on releases)", "deprecated_api: marks tests using deprecated Optimization/Results API (remove in v6.0.0)", ] -addopts = '-m "not examples" --ignore=tests/superseded' # Skip examples and stale tests by default +addopts = '-m "not examples" --ignore=tests/superseded' # Skip examples and superseded tests by default # Warning filter configuration for pytest # Filters are processed in order; first match wins diff --git a/tests/superseded/__init__.py b/tests/superseded/__init__.py index c295979ae..b3052df8e 100644 --- a/tests/superseded/__init__.py +++ b/tests/superseded/__init__.py @@ -1,4 +1,4 @@ -"""Stale tests superseded by tests/test_math/. +"""Superseded tests — replaced by tests/test_math/. These tests have been replaced by more thorough, analytically verified tests in tests/test_math/. They are kept temporarily for reference and will be diff --git a/tests/superseded/math/test_bus.py b/tests/superseded/math/test_bus.py index 85455527b..f7a9077de 100644 --- a/tests/superseded/math/test_bus.py +++ b/tests/superseded/math/test_bus.py @@ -4,7 +4,7 @@ from ...conftest import assert_conequal, assert_var_equal, create_linopy_model -pytestmark = pytest.mark.skip(reason='Stale: model-building tests implicitly covered by tests/test_math/') +pytestmark = pytest.mark.skip(reason='Superseded: model-building tests implicitly covered by tests/test_math/') class TestBusModel: diff --git a/tests/superseded/math/test_component.py b/tests/superseded/math/test_component.py index f9c02a8d5..bf3c5133d 100644 --- a/tests/superseded/math/test_component.py +++ b/tests/superseded/math/test_component.py @@ -13,7 +13,7 @@ create_linopy_model, ) -pytestmark = pytest.mark.skip(reason='Stale: model-building tests implicitly covered by tests/test_math/') +pytestmark = pytest.mark.skip(reason='Superseded: model-building tests implicitly covered by tests/test_math/') class TestComponentModel: diff --git a/tests/superseded/math/test_effect.py b/tests/superseded/math/test_effect.py index ec5cb0bcf..9375c2612 100644 --- a/tests/superseded/math/test_effect.py +++ b/tests/superseded/math/test_effect.py @@ -11,7 +11,7 @@ create_linopy_model, ) -pytestmark = pytest.mark.skip(reason='Stale: model-building tests implicitly covered by tests/test_math/') +pytestmark = pytest.mark.skip(reason='Superseded: model-building tests implicitly covered by tests/test_math/') class TestEffectModel: diff --git a/tests/superseded/math/test_flow.py b/tests/superseded/math/test_flow.py index 05858343b..106fe2490 100644 --- a/tests/superseded/math/test_flow.py +++ b/tests/superseded/math/test_flow.py @@ -12,7 +12,7 @@ create_linopy_model, ) -pytestmark = pytest.mark.skip(reason='Stale: model-building tests implicitly covered by tests/test_math/') +pytestmark = pytest.mark.skip(reason='Superseded: model-building tests implicitly covered by tests/test_math/') class TestFlowModel: diff --git a/tests/superseded/math/test_linear_converter.py b/tests/superseded/math/test_linear_converter.py index da1df540a..c50a95a24 100644 --- a/tests/superseded/math/test_linear_converter.py +++ b/tests/superseded/math/test_linear_converter.py @@ -6,7 +6,7 @@ from ...conftest import assert_conequal, assert_dims_compatible, assert_var_equal, create_linopy_model -pytestmark = pytest.mark.skip(reason='Stale: model-building tests implicitly covered by tests/test_math/') +pytestmark = pytest.mark.skip(reason='Superseded: model-building tests implicitly covered by tests/test_math/') class TestLinearConverterModel: diff --git a/tests/superseded/math/test_storage.py b/tests/superseded/math/test_storage.py index a957a81fe..502ec9df9 100644 --- a/tests/superseded/math/test_storage.py +++ b/tests/superseded/math/test_storage.py @@ -5,7 +5,7 @@ from ...conftest import assert_conequal, assert_var_equal, create_linopy_model -pytestmark = pytest.mark.skip(reason='Stale: model-building tests implicitly covered by tests/test_math/') +pytestmark = pytest.mark.skip(reason='Superseded: model-building tests implicitly covered by tests/test_math/') class TestStorageModel: diff --git a/tests/superseded/test_functional.py b/tests/superseded/test_functional.py index 62b84e8d2..a6093615d 100644 --- a/tests/superseded/test_functional.py +++ b/tests/superseded/test_functional.py @@ -2,7 +2,7 @@ Unit tests for the flixopt framework. .. deprecated:: - STALE — These tests are superseded by tests/test_math/ which provides more thorough, + Superseded — These tests are superseded by tests/test_math/ which provides more thorough, analytically verified coverage with sensitivity documentation. Specifically: - Investment tests → test_math/test_flow_invest.py (9 tests + 3 invest+status combo tests) - Status tests → test_math/test_flow_status.py (9 tests + 6 previous_flow_rate tests) @@ -22,7 +22,7 @@ np.random.seed(45) -pytestmark = pytest.mark.skip(reason='Stale: superseded by tests/test_math/ — see module docstring') +pytestmark = pytest.mark.skip(reason='Superseded by tests/test_math/ — see module docstring') class Data: diff --git a/tests/superseded/test_integration.py b/tests/superseded/test_integration.py index b02e57130..352b7d5c7 100644 --- a/tests/superseded/test_integration.py +++ b/tests/superseded/test_integration.py @@ -2,7 +2,7 @@ Integration tests for complex energy systems. .. deprecated:: - STALE — These regression baseline tests are partially superseded by tests/test_math/: + Superseded — These regression baseline tests are partially superseded by tests/test_math/: - test_simple_flow_system → test_math/test_conversion.py + test_math/test_effects.py - test_model_components → test_math/test_conversion.py (boiler/CHP flow rates) - test_basic_flow_system → spread across test_math/ (effects, conversion, storage) @@ -18,7 +18,7 @@ assert_almost_equal_numeric, ) -pytestmark = pytest.mark.skip(reason='Stale: superseded by tests/test_math/ — see module docstring') +pytestmark = pytest.mark.skip(reason='Superseded by tests/test_math/ — see module docstring') class TestFlowSystem: diff --git a/tests/superseded/test_solution_persistence.py b/tests/superseded/test_solution_persistence.py index 116151630..b163d88a7 100644 --- a/tests/superseded/test_solution_persistence.py +++ b/tests/superseded/test_solution_persistence.py @@ -1,7 +1,7 @@ """Tests for the new solution persistence API. .. deprecated:: - STALE — The IO roundtrip tests (TestSolutionPersistence, TestFlowSystemFileIO) + Superseded — The IO roundtrip tests (TestSolutionPersistence, TestFlowSystemFileIO) are superseded by the test_math/ ``optimize`` fixture which runs every math test in 3 modes: solve, save→reload→solve, solve→save→reload — totalling 274 implicit IO roundtrips across all component types. @@ -25,7 +25,7 @@ ) pytestmark = pytest.mark.skip( - reason='Stale: IO roundtrips superseded by tests/test_math/ optimize fixture — see module docstring' + reason='Superseded: IO roundtrips covered by tests/test_math/ optimize fixture — see module docstring' ) From f73c346ac07f7c8e67a0f6ed49d7621621f8dbbf Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 5 Feb 2026 17:41:35 +0100 Subject: [PATCH 44/44] =?UTF-8?q?=20=202.=2008d-clustering-multiperiod.ipy?= =?UTF-8?q?nb=20(cell=2029):=20Removed=20stray=20markdown=20from=20Summary=20cell=20=20=203.=2008f-clustering-?= =?UTF-8?q?segmentation.ipynb=20(cell=2033):=20Removed=20stray=20markdown=20from=20API=20Reference=20cell=20?= =?UTF-8?q?=20=204.=20flixopt/comparison.py:=20=5Fextract=5Fnonindex=5Fcoo?= =?UTF-8?q?rds=20now=20detects=20when=20the=20same=20coord=20name=20appear?= =?UTF-8?q?s=20on=20different=20dims=20=E2=80=94=20warns=20and=20skips=20i?= =?UTF-8?q?nstead=20of=20silently=20overwriting=20=20=205.=20test=5Fmultip?= =?UTF-8?q?eriod=5Fextremes.py:=20Added=20.item()=20to=20mapping.min()/.ma?= =?UTF-8?q?x()=20and=20period=5Fmapping.min()/.max()=20to=20extract=20scal?= =?UTF-8?q?ars=20before=20comparison=20=20=206.=20test=5Fflow=5Fstatus.py:?= =?UTF-8?q?=20Tightened=20test=5Fmax=5Fuptime=5Fstandalone=20assertion=20f?= =?UTF-8?q?rom=20>=2050.0=20to=20assert=5Fallclose(...,=2060.0,=20rtol=3D1?= =?UTF-8?q?e-5)=20matching=20the=20documented=20arithmetic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/notebooks/08d-clustering-multiperiod.ipynb | 2 +- docs/notebooks/08f-clustering-segmentation.ipynb | 2 +- flixopt/comparison.py | 7 +++++++ tests/test_clustering/test_multiperiod_extremes.py | 8 ++++---- tests/test_math/test_flow_status.py | 4 ++-- 5 files changed, 15 insertions(+), 8 deletions(-) diff --git a/docs/notebooks/08d-clustering-multiperiod.ipynb b/docs/notebooks/08d-clustering-multiperiod.ipynb index e6a4d86ae..82da05c6f 100644 --- a/docs/notebooks/08d-clustering-multiperiod.ipynb +++ b/docs/notebooks/08d-clustering-multiperiod.ipynb @@ -525,7 +525,7 @@ "id": "29", "metadata": {}, "source": [ - "markdown## Summary\n", + "## Summary\n", "\n", "You learned how to:\n", "\n", diff --git a/docs/notebooks/08f-clustering-segmentation.ipynb b/docs/notebooks/08f-clustering-segmentation.ipynb index 7bc2c712c..bc1915de4 100644 --- a/docs/notebooks/08f-clustering-segmentation.ipynb +++ b/docs/notebooks/08f-clustering-segmentation.ipynb @@ -527,7 +527,7 @@ "id": "33", "metadata": {}, "source": [ - "markdown## API Reference\n", + "## API Reference\n", "\n", "### SegmentConfig Parameters\n", "\n", diff --git a/flixopt/comparison.py b/flixopt/comparison.py index 63aa6491e..8a998a616 100644 --- a/flixopt/comparison.py +++ b/flixopt/comparison.py @@ -53,6 +53,13 @@ def _extract_nonindex_coords(datasets: list[xr.Dataset]) -> tuple[list[xr.Datase coords_to_drop.add(name) if name not in merged: merged[name] = (dim, {}) + elif merged[name][0] != dim: + warnings.warn( + f"Coordinate '{name}' appears on different dims: " + f"'{merged[name][0]}' vs '{dim}'. Dropping this coordinate.", + stacklevel=4, + ) + continue for dv, cv in zip(ds.coords[dim].values, coord.values, strict=False): if dv not in merged[name][1]: diff --git a/tests/test_clustering/test_multiperiod_extremes.py b/tests/test_clustering/test_multiperiod_extremes.py index 0e8331522..973efe79d 100644 --- a/tests/test_clustering/test_multiperiod_extremes.py +++ b/tests/test_clustering/test_multiperiod_extremes.py @@ -998,11 +998,11 @@ def test_timestep_mapping_valid_range(self, timesteps_8_days, periods_2): # Mapping values should be in [0, n_clusters * timesteps_per_cluster - 1] max_valid = 3 * 24 - 1 # n_clusters * timesteps_per_cluster - 1 - assert mapping.min() >= 0 - assert mapping.max() <= max_valid + assert mapping.min().item() >= 0 + assert mapping.max().item() <= max_valid # Each period should have valid mappings for period in periods_2: period_mapping = mapping.sel(period=period) - assert period_mapping.min() >= 0 - assert period_mapping.max() <= max_valid + assert period_mapping.min().item() >= 0 + assert period_mapping.max().item() <= max_valid diff --git a/tests/test_math/test_flow_status.py b/tests/test_math/test_flow_status.py index 85fc1d0e5..66f4de269 100644 --- a/tests/test_math/test_flow_status.py +++ b/tests/test_math/test_flow_status.py @@ -503,8 +503,8 @@ def test_max_uptime_standalone(self, optimize): else: current = 0 assert max_consecutive <= 2, f'max_uptime violated: {status}' - # Cost must be higher than without constraint (50) - assert fs.solution['costs'].item() > 50.0 + 1e-5 + # Cheap: 4×10 = 40 fuel. Backup @t2: 10/0.5 = 20 fuel. Total = 60. + assert_allclose(fs.solution['costs'].item(), 60.0, rtol=1e-5) class TestPreviousFlowRate: