From aef3fc3841eb8d931c42a7e4c2805c971ac639f9 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 28 Dec 2025 13:50:06 +0100 Subject: [PATCH 01/12] Enable selecting a single period/scenario --- flixopt/flow_system.py | 63 ++++++- tests/test_sel_isel_single_selection.py | 221 ++++++++++++++++++++++++ 2 files changed, 279 insertions(+), 5 deletions(-) create mode 100644 tests/test_sel_isel_single_selection.py diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index c10a1defb..098500bc7 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -237,6 +237,10 @@ def __init__( # Aggregation info - populated by transform.cluster() self.clustering: Clustering | None = None + # Context about dropped dimensions from sel/isel operations + self.selected_period: int | None = None + self.selected_scenario: str | None = None + # Statistics accessor cache - lazily initialized, invalidated on new solution self._statistics: StatisticsAccessor | None = None @@ -502,6 +506,11 @@ def _update_period_metadata( period index. This ensures period metadata stays synchronized with the actual periods after operations like selection. + When the period dimension is dropped (single value selected), this method: + - Stores the selected period in attrs as 'selected_period' + - Removes the scalar coordinate and period_weights DataArray + - Cleans up period-related attributes + This is analogous to _update_time_metadata() for time-related metadata. Args: @@ -513,7 +522,19 @@ def _update_period_metadata( The same dataset with updated period-related attributes and data variables """ new_period_index = dataset.indexes.get('period') - if new_period_index is not None and len(new_period_index) >= 1: + + if new_period_index is None: + # Period dimension was dropped (single value selected) + # Store which period was selected in attrs for context + if 'period' in dataset.coords: + dataset.attrs['selected_period'] = int(dataset.coords['period'].values) + dataset = dataset.drop_vars('period') # Remove scalar coordinate + # Remove period-related data that no longer makes sense + dataset = dataset.drop_vars(['period_weights'], errors='ignore') + dataset.attrs.pop('weight_of_last_period', None) + return dataset + + if len(new_period_index) >= 1: # Reuse stored weight_of_last_period when not explicitly overridden. # This is essential for single-period subsets where it cannot be inferred from intervals. if weight_of_last_period is None: @@ -542,6 +563,11 @@ def _update_scenario_metadata(cls, dataset: xr.Dataset) -> xr.Dataset: Recomputes or removes scenario weights. This ensures scenario metadata stays synchronized with the actual scenarios after operations like selection. + When the scenario dimension is dropped (single value selected), this method: + - Stores the selected scenario in attrs as 'selected_scenario' + - Removes the scalar coordinate and scenario_weights DataArray + - Cleans up scenario-related attributes + This is analogous to _update_period_metadata() for time-related metadata. Args: @@ -551,7 +577,19 @@ def _update_scenario_metadata(cls, dataset: xr.Dataset) -> xr.Dataset: The same dataset with updated scenario-related attributes and data variables """ new_scenario_index = dataset.indexes.get('scenario') - if new_scenario_index is None or len(new_scenario_index) <= 1: + + if new_scenario_index is None: + # Scenario dimension was dropped (single value selected) + # Store which scenario was selected in attrs for context + if 'scenario' in dataset.coords: + dataset.attrs['selected_scenario'] = str(dataset.coords['scenario'].values) + dataset = dataset.drop_vars('scenario') # Remove scalar coordinate + # Remove scenario-related data that no longer makes sense + dataset = dataset.drop_vars(['scenario_weights'], errors='ignore') + dataset.attrs.pop('scenario_weights', None) + return dataset + + if len(new_scenario_index) <= 1: dataset.attrs.pop('scenario_weights', None) return dataset @@ -652,6 +690,12 @@ def to_dataset(self, include_solution: bool = True) -> xr.Dataset: ds.attrs['timesteps_per_cluster'] = len(self.timesteps) ds.attrs['timestep_duration'] = float(self.timestep_duration.mean()) + # Include context about dropped dimensions from sel/isel operations + if self.selected_period is not None: + ds.attrs['selected_period'] = self.selected_period + if self.selected_scenario is not None: + ds.attrs['selected_scenario'] = self.selected_scenario + # Add version info ds.attrs['flixopt_version'] = __version__ @@ -708,6 +752,11 @@ def from_dataset(cls, ds: xr.Dataset) -> FlowSystem: else None ) + # Resolve scenario_weights only if scenario dimension exists + scenario_weights = None + if ds.indexes.get('scenario') is not None and 'scenario_weights' in reference_structure: + scenario_weights = cls._resolve_dataarray_reference(reference_structure['scenario_weights'], arrays_dict) + # Create FlowSystem instance with constructor parameters flow_system = cls( timesteps=ds.indexes['time'], @@ -717,9 +766,7 @@ def from_dataset(cls, ds: xr.Dataset) -> FlowSystem: hours_of_last_timestep=reference_structure.get('hours_of_last_timestep'), hours_of_previous_timesteps=reference_structure.get('hours_of_previous_timesteps'), weight_of_last_period=reference_structure.get('weight_of_last_period'), - scenario_weights=cls._resolve_dataarray_reference(reference_structure['scenario_weights'], arrays_dict) - if 'scenario_weights' in reference_structure - else None, + scenario_weights=scenario_weights, cluster_weight=cluster_weight_for_constructor, scenario_independent_sizes=reference_structure.get('scenario_independent_sizes', True), scenario_independent_flow_rates=reference_structure.get('scenario_independent_flow_rates', False), @@ -765,6 +812,12 @@ def from_dataset(cls, ds: xr.Dataset) -> FlowSystem: carrier = cls._resolve_reference_structure(carrier_data, {}) flow_system._carriers.add(carrier) + # Restore context about dropped dimensions from sel/isel operations + if 'selected_period' in reference_structure: + flow_system.selected_period = reference_structure['selected_period'] + if 'selected_scenario' in reference_structure: + flow_system.selected_scenario = reference_structure['selected_scenario'] + # Reconnect network to populate bus inputs/outputs (not stored in NetCDF). flow_system.connect_and_transform() diff --git a/tests/test_sel_isel_single_selection.py b/tests/test_sel_isel_single_selection.py new file mode 100644 index 000000000..1419c8077 --- /dev/null +++ b/tests/test_sel_isel_single_selection.py @@ -0,0 +1,221 @@ +"""Tests for sel/isel with single period/scenario selection.""" + +import numpy as np +import pandas as pd +import pytest + +import flixopt as fx + + +@pytest.fixture +def fs_with_scenarios(): + """FlowSystem with scenarios for testing single selection.""" + timesteps = pd.date_range('2023-01-01', periods=24, freq='h') + scenarios = pd.Index(['A', 'B', 'C'], name='scenario') + scenario_weights = np.array([0.5, 0.3, 0.2]) + + fs = fx.FlowSystem(timesteps, scenarios=scenarios, scenario_weights=scenario_weights) + fs.add_elements( + fx.Bus('heat'), + fx.Effect('costs', unit='EUR', description='costs', is_objective=True, is_standard=True), + ) + fs.add_elements( + fx.Sink('demand', inputs=[fx.Flow('in', bus='heat', fixed_relative_profile=np.ones(24), size=10)]), + fx.Source('source', outputs=[fx.Flow('out', bus='heat', size=50, effects_per_flow_hour={'costs': 0.05})]), + ) + return fs + + +@pytest.fixture +def fs_with_periods(): + """FlowSystem with periods for testing single selection.""" + timesteps = pd.date_range('2023-01-01', periods=24, freq='h') + periods = pd.Index([2020, 2030, 2040], name='period') + + fs = fx.FlowSystem(timesteps, periods=periods, weight_of_last_period=10) + fs.add_elements( + fx.Bus('heat'), + fx.Effect('costs', unit='EUR', description='costs', is_objective=True, is_standard=True), + ) + fs.add_elements( + fx.Sink('demand', inputs=[fx.Flow('in', bus='heat', fixed_relative_profile=np.ones(24), size=10)]), + fx.Source('source', outputs=[fx.Flow('out', bus='heat', size=50, effects_per_flow_hour={'costs': 0.05})]), + ) + return fs + + +@pytest.fixture +def fs_with_periods_and_scenarios(): + """FlowSystem with both periods and scenarios.""" + timesteps = pd.date_range('2023-01-01', periods=24, freq='h') + periods = pd.Index([2020, 2030], name='period') + scenarios = pd.Index(['Low', 'High'], name='scenario') + + fs = fx.FlowSystem(timesteps, periods=periods, scenarios=scenarios, weight_of_last_period=10) + fs.add_elements( + fx.Bus('heat'), + fx.Effect('costs', unit='EUR', description='costs', is_objective=True, is_standard=True), + ) + fs.add_elements( + fx.Sink('demand', inputs=[fx.Flow('in', bus='heat', fixed_relative_profile=np.ones(24), size=10)]), + fx.Source('source', outputs=[fx.Flow('out', bus='heat', size=50, effects_per_flow_hour={'costs': 0.05})]), + ) + return fs + + +class TestIselSingleScenario: + """Test isel with single scenario selection.""" + + def test_isel_single_scenario_drops_dimension(self, fs_with_scenarios): + """Selecting a single scenario with isel should drop the scenario dimension.""" + fs_selected = fs_with_scenarios.transform.isel(scenario=0) + + assert fs_selected.scenarios is None + assert 'scenario' not in fs_selected.to_dataset().dims + + def test_isel_single_scenario_stores_selected_value(self, fs_with_scenarios): + """The selected scenario value should be stored in attrs.""" + fs_selected = fs_with_scenarios.transform.isel(scenario=1) + + ds = fs_selected.to_dataset() + assert ds.attrs.get('selected_scenario') == 'B' + + def test_isel_single_scenario_removes_scenario_weights(self, fs_with_scenarios): + """scenario_weights should be removed when scenario dimension is dropped.""" + fs_selected = fs_with_scenarios.transform.isel(scenario=0) + + ds = fs_selected.to_dataset() + assert 'scenario_weights' not in ds.data_vars + assert 'scenario_weights' not in ds.attrs + + def test_isel_single_scenario_preserves_time(self, fs_with_scenarios): + """Time dimension should be preserved.""" + fs_selected = fs_with_scenarios.transform.isel(scenario=0) + + assert len(fs_selected.timesteps) == 24 + + def test_isel_single_scenario_roundtrip(self, fs_with_scenarios): + """FlowSystem should survive to_dataset/from_dataset roundtrip after single selection.""" + fs_selected = fs_with_scenarios.transform.isel(scenario=0) + + ds = fs_selected.to_dataset() + fs_restored = fx.FlowSystem.from_dataset(ds) + + assert fs_restored.scenarios is None + assert len(fs_restored.timesteps) == 24 + + +class TestSelSingleScenario: + """Test sel with single scenario selection.""" + + def test_sel_single_scenario_drops_dimension(self, fs_with_scenarios): + """Selecting a single scenario with sel should drop the scenario dimension.""" + fs_selected = fs_with_scenarios.transform.sel(scenario='B') + + assert fs_selected.scenarios is None + + def test_sel_single_scenario_stores_selected_value(self, fs_with_scenarios): + """The selected scenario value should be stored in attrs.""" + fs_selected = fs_with_scenarios.transform.sel(scenario='C') + + ds = fs_selected.to_dataset() + assert ds.attrs.get('selected_scenario') == 'C' + + +class TestIselSinglePeriod: + """Test isel with single period selection.""" + + def test_isel_single_period_drops_dimension(self, fs_with_periods): + """Selecting a single period with isel should drop the period dimension.""" + fs_selected = fs_with_periods.transform.isel(period=0) + + assert fs_selected.periods is None + assert 'period' not in fs_selected.to_dataset().dims + + def test_isel_single_period_stores_selected_value(self, fs_with_periods): + """The selected period value should be stored in attrs.""" + fs_selected = fs_with_periods.transform.isel(period=1) + + ds = fs_selected.to_dataset() + assert ds.attrs.get('selected_period') == 2030 + + def test_isel_single_period_removes_period_weights(self, fs_with_periods): + """period_weights should be removed when period dimension is dropped.""" + fs_selected = fs_with_periods.transform.isel(period=0) + + ds = fs_selected.to_dataset() + assert 'period_weights' not in ds.data_vars + assert 'weight_of_last_period' not in ds.attrs + + def test_isel_single_period_roundtrip(self, fs_with_periods): + """FlowSystem should survive roundtrip after single period selection.""" + fs_selected = fs_with_periods.transform.isel(period=0) + + ds = fs_selected.to_dataset() + fs_restored = fx.FlowSystem.from_dataset(ds) + + assert fs_restored.periods is None + + +class TestSelSinglePeriod: + """Test sel with single period selection.""" + + def test_sel_single_period_drops_dimension(self, fs_with_periods): + """Selecting a single period with sel should drop the period dimension.""" + fs_selected = fs_with_periods.transform.sel(period=2030) + + assert fs_selected.periods is None + + def test_sel_single_period_stores_selected_value(self, fs_with_periods): + """The selected period value should be stored in attrs.""" + fs_selected = fs_with_periods.transform.sel(period=2040) + + ds = fs_selected.to_dataset() + assert ds.attrs.get('selected_period') == 2040 + + +class TestMixedSelection: + """Test mixed selections (single + multiple).""" + + def test_single_period_multiple_scenarios(self, fs_with_periods_and_scenarios): + """Single period but multiple scenarios should only drop period.""" + fs_selected = fs_with_periods_and_scenarios.transform.isel(period=0) + + assert fs_selected.periods is None + assert fs_selected.scenarios is not None + assert len(fs_selected.scenarios) == 2 + + def test_multiple_periods_single_scenario(self, fs_with_periods_and_scenarios): + """Multiple periods but single scenario should only drop scenario.""" + fs_selected = fs_with_periods_and_scenarios.transform.isel(scenario=0) + + assert fs_selected.periods is not None + assert len(fs_selected.periods) == 2 + assert fs_selected.scenarios is None + + def test_single_period_single_scenario(self, fs_with_periods_and_scenarios): + """Single period and single scenario should drop both.""" + fs_selected = fs_with_periods_and_scenarios.transform.isel(period=0, scenario=0) + + assert fs_selected.periods is None + assert fs_selected.scenarios is None + + +class TestSliceSelection: + """Test that slice selection preserves dimensions.""" + + def test_slice_scenarios_preserves_dimension(self, fs_with_scenarios): + """Slice selection should preserve dimension even with 1 element.""" + # Select a slice that results in 2 elements + fs_selected = fs_with_scenarios.transform.isel(scenario=slice(0, 2)) + + assert fs_selected.scenarios is not None + assert len(fs_selected.scenarios) == 2 + + def test_list_selection_preserves_dimension(self, fs_with_scenarios): + """List selection should preserve dimension even with 1 element.""" + fs_selected = fs_with_scenarios.transform.isel(scenario=[0]) + + # List selection should preserve dimension + assert fs_selected.scenarios is not None + assert len(fs_selected.scenarios) == 1 From 61a451f866d0f2df2593444bcc8e78fbbae61232 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 28 Dec 2025 14:27:07 +0100 Subject: [PATCH 02/12] No selected_* tracking --- flixopt/flow_system.py | 38 ++++--------------------- tests/test_sel_isel_single_selection.py | 28 ------------------ 2 files changed, 6 insertions(+), 60 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 098500bc7..5d48acf1e 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -237,10 +237,6 @@ def __init__( # Aggregation info - populated by transform.cluster() self.clustering: Clustering | None = None - # Context about dropped dimensions from sel/isel operations - self.selected_period: int | None = None - self.selected_scenario: str | None = None - # Statistics accessor cache - lazily initialized, invalidated on new solution self._statistics: StatisticsAccessor | None = None @@ -506,10 +502,8 @@ def _update_period_metadata( period index. This ensures period metadata stays synchronized with the actual periods after operations like selection. - When the period dimension is dropped (single value selected), this method: - - Stores the selected period in attrs as 'selected_period' - - Removes the scalar coordinate and period_weights DataArray - - Cleans up period-related attributes + When the period dimension is dropped (single value selected), this method + removes the scalar coordinate, period_weights DataArray, and cleans up attributes. This is analogous to _update_time_metadata() for time-related metadata. @@ -525,11 +519,8 @@ def _update_period_metadata( if new_period_index is None: # Period dimension was dropped (single value selected) - # Store which period was selected in attrs for context if 'period' in dataset.coords: - dataset.attrs['selected_period'] = int(dataset.coords['period'].values) - dataset = dataset.drop_vars('period') # Remove scalar coordinate - # Remove period-related data that no longer makes sense + dataset = dataset.drop_vars('period') dataset = dataset.drop_vars(['period_weights'], errors='ignore') dataset.attrs.pop('weight_of_last_period', None) return dataset @@ -563,10 +554,8 @@ def _update_scenario_metadata(cls, dataset: xr.Dataset) -> xr.Dataset: Recomputes or removes scenario weights. This ensures scenario metadata stays synchronized with the actual scenarios after operations like selection. - When the scenario dimension is dropped (single value selected), this method: - - Stores the selected scenario in attrs as 'selected_scenario' - - Removes the scalar coordinate and scenario_weights DataArray - - Cleans up scenario-related attributes + When the scenario dimension is dropped (single value selected), this method + removes the scalar coordinate, scenario_weights DataArray, and cleans up attributes. This is analogous to _update_period_metadata() for time-related metadata. @@ -580,11 +569,8 @@ def _update_scenario_metadata(cls, dataset: xr.Dataset) -> xr.Dataset: if new_scenario_index is None: # Scenario dimension was dropped (single value selected) - # Store which scenario was selected in attrs for context if 'scenario' in dataset.coords: - dataset.attrs['selected_scenario'] = str(dataset.coords['scenario'].values) - dataset = dataset.drop_vars('scenario') # Remove scalar coordinate - # Remove scenario-related data that no longer makes sense + dataset = dataset.drop_vars('scenario') dataset = dataset.drop_vars(['scenario_weights'], errors='ignore') dataset.attrs.pop('scenario_weights', None) return dataset @@ -690,12 +676,6 @@ def to_dataset(self, include_solution: bool = True) -> xr.Dataset: ds.attrs['timesteps_per_cluster'] = len(self.timesteps) ds.attrs['timestep_duration'] = float(self.timestep_duration.mean()) - # Include context about dropped dimensions from sel/isel operations - if self.selected_period is not None: - ds.attrs['selected_period'] = self.selected_period - if self.selected_scenario is not None: - ds.attrs['selected_scenario'] = self.selected_scenario - # Add version info ds.attrs['flixopt_version'] = __version__ @@ -812,12 +792,6 @@ def from_dataset(cls, ds: xr.Dataset) -> FlowSystem: carrier = cls._resolve_reference_structure(carrier_data, {}) flow_system._carriers.add(carrier) - # Restore context about dropped dimensions from sel/isel operations - if 'selected_period' in reference_structure: - flow_system.selected_period = reference_structure['selected_period'] - if 'selected_scenario' in reference_structure: - flow_system.selected_scenario = reference_structure['selected_scenario'] - # Reconnect network to populate bus inputs/outputs (not stored in NetCDF). flow_system.connect_and_transform() diff --git a/tests/test_sel_isel_single_selection.py b/tests/test_sel_isel_single_selection.py index 1419c8077..4d84ced51 100644 --- a/tests/test_sel_isel_single_selection.py +++ b/tests/test_sel_isel_single_selection.py @@ -73,13 +73,6 @@ def test_isel_single_scenario_drops_dimension(self, fs_with_scenarios): assert fs_selected.scenarios is None assert 'scenario' not in fs_selected.to_dataset().dims - def test_isel_single_scenario_stores_selected_value(self, fs_with_scenarios): - """The selected scenario value should be stored in attrs.""" - fs_selected = fs_with_scenarios.transform.isel(scenario=1) - - ds = fs_selected.to_dataset() - assert ds.attrs.get('selected_scenario') == 'B' - def test_isel_single_scenario_removes_scenario_weights(self, fs_with_scenarios): """scenario_weights should be removed when scenario dimension is dropped.""" fs_selected = fs_with_scenarios.transform.isel(scenario=0) @@ -114,13 +107,6 @@ def test_sel_single_scenario_drops_dimension(self, fs_with_scenarios): assert fs_selected.scenarios is None - def test_sel_single_scenario_stores_selected_value(self, fs_with_scenarios): - """The selected scenario value should be stored in attrs.""" - fs_selected = fs_with_scenarios.transform.sel(scenario='C') - - ds = fs_selected.to_dataset() - assert ds.attrs.get('selected_scenario') == 'C' - class TestIselSinglePeriod: """Test isel with single period selection.""" @@ -132,13 +118,6 @@ def test_isel_single_period_drops_dimension(self, fs_with_periods): assert fs_selected.periods is None assert 'period' not in fs_selected.to_dataset().dims - def test_isel_single_period_stores_selected_value(self, fs_with_periods): - """The selected period value should be stored in attrs.""" - fs_selected = fs_with_periods.transform.isel(period=1) - - ds = fs_selected.to_dataset() - assert ds.attrs.get('selected_period') == 2030 - def test_isel_single_period_removes_period_weights(self, fs_with_periods): """period_weights should be removed when period dimension is dropped.""" fs_selected = fs_with_periods.transform.isel(period=0) @@ -166,13 +145,6 @@ def test_sel_single_period_drops_dimension(self, fs_with_periods): assert fs_selected.periods is None - def test_sel_single_period_stores_selected_value(self, fs_with_periods): - """The selected period value should be stored in attrs.""" - fs_selected = fs_with_periods.transform.sel(period=2040) - - ds = fs_selected.to_dataset() - assert ds.attrs.get('selected_period') == 2040 - class TestMixedSelection: """Test mixed selections (single + multiple).""" From 4e90a5953d8492d3c2d3e1f47241c65356232fa5 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 28 Dec 2025 15:13:59 +0100 Subject: [PATCH 03/12] Clustering IO --- flixopt/clustering/base.py | 115 ++++++++++++- flixopt/flow_system.py | 33 +++- flixopt/transform_accessor.py | 56 +++++-- tests/test_cluster_reduce_expand.py | 2 +- tests/test_clustering_io.py | 246 ++++++++++++++++++++++++++++ 5 files changed, 431 insertions(+), 21 deletions(-) create mode 100644 tests/test_clustering_io.py diff --git a/flixopt/clustering/base.py b/flixopt/clustering/base.py index 2c442e3d5..52ad720db 100644 --- a/flixopt/clustering/base.py +++ b/flixopt/clustering/base.py @@ -21,11 +21,11 @@ from typing import TYPE_CHECKING, Any import numpy as np +import pandas as pd import xarray as xr if TYPE_CHECKING: from ..color_processing import ColorType - from ..flow_system import FlowSystem from ..plot_result import PlotResult from ..statistics_accessor import SelectType @@ -98,6 +98,29 @@ def __repr__(self) -> str: f')' ) + def _create_reference_structure(self) -> tuple[dict, dict[str, xr.DataArray]]: + """Create reference structure for serialization.""" + ref = {'__class__': self.__class__.__name__} + arrays = {} + + # Store DataArrays with references + arrays[str(self.cluster_order.name)] = self.cluster_order + ref['cluster_order'] = f':::{self.cluster_order.name}' + + arrays[str(self.cluster_occurrences.name)] = self.cluster_occurrences + ref['cluster_occurrences'] = f':::{self.cluster_occurrences.name}' + + # Store scalar values + if isinstance(self.n_clusters, xr.DataArray): + arrays[str(self.n_clusters.name)] = self.n_clusters + ref['n_clusters'] = f':::{self.n_clusters.name}' + else: + ref['n_clusters'] = int(self.n_clusters) + + ref['timesteps_per_cluster'] = self.timesteps_per_cluster + + return ref, arrays + @property def n_original_periods(self) -> int: """Number of original periods (before clustering).""" @@ -304,6 +327,37 @@ def __repr__(self) -> str: f')' ) + def _create_reference_structure(self) -> tuple[dict, dict[str, xr.DataArray]]: + """Create reference structure for serialization.""" + ref = {'__class__': self.__class__.__name__} + arrays = {} + + # Store DataArrays with references + arrays[str(self.timestep_mapping.name)] = self.timestep_mapping + ref['timestep_mapping'] = f':::{self.timestep_mapping.name}' + + arrays[str(self.representative_weights.name)] = self.representative_weights + ref['representative_weights'] = f':::{self.representative_weights.name}' + + # Store scalar values + if isinstance(self.n_representatives, xr.DataArray): + n_rep_name = self.n_representatives.name or 'n_representatives' + self.n_representatives = self.n_representatives.rename(n_rep_name) + arrays[n_rep_name] = self.n_representatives + ref['n_representatives'] = f':::{n_rep_name}' + else: + ref['n_representatives'] = int(self.n_representatives) + + # Store nested ClusterStructure if present + if self.cluster_structure is not None: + cs_ref, cs_arrays = self.cluster_structure._create_reference_structure() + ref['cluster_structure'] = cs_ref + arrays.update(cs_arrays) + + # Skip aggregated_data and original_data - not needed for serialization + + return ref, arrays + @property def n_original_timesteps(self) -> int: """Number of original timesteps (before aggregation).""" @@ -911,7 +965,6 @@ class Clustering: Attributes: result: The ClusterResult from the aggregation backend. - original_flow_system: Reference to the FlowSystem before aggregation. backend_name: Name of the aggregation backend used (e.g., 'tsam', 'manual'). Example: @@ -923,9 +976,23 @@ class Clustering: """ result: ClusterResult - original_flow_system: FlowSystem # FlowSystem - avoid circular import backend_name: str = 'unknown' + def _create_reference_structure(self) -> tuple[dict, dict[str, xr.DataArray]]: + """Create reference structure for serialization.""" + ref = {'__class__': self.__class__.__name__} + arrays = {} + + # Store nested ClusterResult + result_ref, result_arrays = self.result._create_reference_structure() + ref['result'] = result_ref + arrays.update(result_arrays) + + # Store scalar values + ref['backend_name'] = self.backend_name + + return ref, arrays + def __repr__(self) -> str: cs = self.result.cluster_structure if cs is not None: @@ -1024,6 +1091,36 @@ def cluster_start_positions(self) -> np.ndarray: n_timesteps = self.n_clusters * self.timesteps_per_period return np.arange(0, n_timesteps, self.timesteps_per_period) + # Properties to derive original coordinates from existing DataArrays + + @property + def original_timesteps(self) -> pd.DatetimeIndex: + """Original timesteps before clustering. + + Derived from the 'original_time' coordinate of timestep_mapping. + """ + return pd.DatetimeIndex(self.result.timestep_mapping.coords['original_time'].values) + + @property + def original_periods(self) -> pd.Index | None: + """Original periods before clustering (if multi-period system). + + Returns None if the FlowSystem had no period dimension. + """ + if 'period' in self.result.timestep_mapping.dims: + return pd.Index(self.result.timestep_mapping.coords['period'].values) + return None + + @property + def original_scenarios(self) -> pd.Index | None: + """Original scenarios before clustering (if multi-scenario system). + + Returns None if the FlowSystem had no scenario dimension. + """ + if 'scenario' in self.result.timestep_mapping.dims: + return pd.Index(self.result.timestep_mapping.coords['scenario'].values) + return None + def create_cluster_structure_from_mapping( timestep_mapping: xr.DataArray, @@ -1073,3 +1170,15 @@ def create_cluster_structure_from_mapping( n_clusters=n_clusters, timesteps_per_cluster=timesteps_per_cluster, ) + + +def _register_clustering_classes(): + """Register clustering classes for IO. + + Called from flow_system.py after all imports are complete to avoid circular imports. + """ + from ..structure import CLASS_REGISTRY + + CLASS_REGISTRY['ClusterStructure'] = ClusterStructure + CLASS_REGISTRY['ClusterResult'] = ClusterResult + CLASS_REGISTRY['Clustering'] = Clustering diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 5d48acf1e..49268db44 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -45,6 +45,11 @@ from .carrier import Carrier, CarrierContainer +# Register clustering classes for IO (deferred to avoid circular imports) +from .clustering.base import _register_clustering_classes + +_register_clustering_classes() + logger = logging.getLogger('flixopt') @@ -669,13 +674,24 @@ def to_dataset(self, include_solution: bool = True) -> xr.Dataset: carriers_structure[name] = carrier_ref ds.attrs['carriers'] = json.dumps(carriers_structure) - # Include cluster info for clustered FlowSystems + # Include cluster info for clustered FlowSystems (old structure) if self.clusters is not None: ds.attrs['is_clustered'] = True ds.attrs['n_clusters'] = len(self.clusters) ds.attrs['timesteps_per_cluster'] = len(self.timesteps) ds.attrs['timestep_duration'] = float(self.timestep_duration.mean()) + # Serialize Clustering object if present (new structure) + if self.clustering is not None: + clustering_ref, clustering_arrays = self.clustering._create_reference_structure() + # Add clustering arrays with prefix and rename conflicting dimensions + for name, arr in clustering_arrays.items(): + # Rename 'time' dimension to 'clustering_time' to avoid conflicts + if 'time' in arr.dims: + arr = arr.rename({'time': 'clustering_time'}) + ds[f'clustering|{name}'] = arr + ds.attrs['clustering'] = json.dumps(clustering_ref) + # Add version info ds.attrs['flixopt_version'] = __version__ @@ -792,6 +808,21 @@ def from_dataset(cls, ds: xr.Dataset) -> FlowSystem: carrier = cls._resolve_reference_structure(carrier_data, {}) flow_system._carriers.add(carrier) + # Restore Clustering object if present + if 'clustering' in reference_structure: + clustering_structure = json.loads(reference_structure['clustering']) + # Collect clustering arrays (prefixed with 'clustering|') + clustering_arrays = {} + for name, arr in ds.data_vars.items(): + if name.startswith('clustering|'): + # Remove 'clustering|' prefix (11 chars) and rename dimension back + arr_name = name[11:] + if 'clustering_time' in arr.dims: + arr = arr.rename({'clustering_time': 'time'}) + clustering_arrays[arr_name] = arr + clustering = cls._resolve_reference_structure(clustering_structure, clustering_arrays) + flow_system.clustering = clustering + # Reconnect network to populate bus inputs/outputs (not stored in NetCDF). flow_system.connect_and_transform() diff --git a/flixopt/transform_accessor.py b/flixopt/transform_accessor.py index 9a4d6e804..e68806f86 100644 --- a/flixopt/transform_accessor.py +++ b/flixopt/transform_accessor.py @@ -851,6 +851,9 @@ def _build_cluster_occurrences_for_key(key: tuple) -> np.ndarray: timestep_mapping_slices = {} cluster_occurrences_slices = {} + # Use renamed timesteps as coordinates for multi-dimensional case + original_timesteps_coord = self._fs.timesteps.rename('original_time') + for p in periods: for s in scenarios: key = (p, s) @@ -858,7 +861,10 @@ def _build_cluster_occurrences_for_key(key: tuple) -> np.ndarray: cluster_orders[key], dims=['original_period'], name='cluster_order' ) timestep_mapping_slices[key] = xr.DataArray( - _build_timestep_mapping_for_key(key), dims=['original_time'], name='timestep_mapping' + _build_timestep_mapping_for_key(key), + dims=['original_time'], + coords={'original_time': original_timesteps_coord}, + name='timestep_mapping', ) cluster_occurrences_slices[key] = xr.DataArray( _build_cluster_occurrences_for_key(key), dims=['cluster'], name='cluster_occurrences' @@ -877,8 +883,13 @@ def _build_cluster_occurrences_for_key(key: tuple) -> np.ndarray: else: # Simple case: single (None, None) slice cluster_order_da = xr.DataArray(cluster_orders[first_key], dims=['original_period'], name='cluster_order') + # Use renamed timesteps as coordinates + original_timesteps_coord = self._fs.timesteps.rename('original_time') timestep_mapping_da = xr.DataArray( - _build_timestep_mapping_for_key(first_key), dims=['original_time'], name='timestep_mapping' + _build_timestep_mapping_for_key(first_key), + dims=['original_time'], + coords={'original_time': original_timesteps_coord}, + name='timestep_mapping', ) cluster_occurrences_da = xr.DataArray( _build_cluster_occurrences_for_key(first_key), dims=['cluster'], name='cluster_occurrences' @@ -914,7 +925,6 @@ def _build_flat_weights_for_key(key: tuple) -> xr.DataArray: reduced_fs.clustering = Clustering( result=aggregation_result, - original_flow_system=self._fs, backend_name='tsam', ) @@ -1130,19 +1140,21 @@ def expand_solution(self) -> FlowSystem: raise ValueError('No cluster structure available for expansion.') timesteps_per_cluster = cluster_structure.timesteps_per_cluster - original_fs: FlowSystem = info.original_flow_system n_clusters = ( int(cluster_structure.n_clusters) if isinstance(cluster_structure.n_clusters, (int, np.integer)) else int(cluster_structure.n_clusters.values) ) - has_periods = original_fs.periods is not None - has_scenarios = original_fs.scenarios is not None - periods = list(original_fs.periods) if has_periods else [None] - scenarios = list(original_fs.scenarios) if has_scenarios else [None] + # Use derived properties from Clustering instead of original_flow_system + original_timesteps = info.original_timesteps + original_periods = info.original_periods + original_scenarios = info.original_scenarios + has_periods = original_periods is not None + has_scenarios = original_scenarios is not None - original_timesteps = original_fs.timesteps + periods = list(original_periods) if has_periods else [None] + scenarios = list(original_scenarios) if has_scenarios else [None] n_original_timesteps = len(original_timesteps) n_reduced_timesteps = n_clusters * timesteps_per_cluster @@ -1154,11 +1166,23 @@ def expand_da(da: xr.DataArray) -> xr.DataArray: # 1. Expand FlowSystem data (with cluster_weight set to 1.0 for all timesteps) reduced_ds = self._fs.to_dataset(include_solution=False) - expanded_ds = xr.Dataset( - {name: expand_da(da) for name, da in reduced_ds.data_vars.items() if name != 'cluster_weight'}, - attrs=reduced_ds.attrs, - ) - expanded_ds.attrs['timestep_duration'] = original_fs.timestep_duration.values.tolist() + # Filter out cluster-related variables and copy attrs without clustering info + data_vars = { + name: expand_da(da) + for name, da in reduced_ds.data_vars.items() + if name != 'cluster_weight' and not name.startswith('clustering|') + } + attrs = { + k: v + for k, v in reduced_ds.attrs.items() + if k not in ('is_clustered', 'n_clusters', 'timesteps_per_cluster', 'clustering') + } + expanded_ds = xr.Dataset(data_vars, attrs=attrs) + # Compute timestep_duration from original timesteps + # Add extra timestep for duration calculation (assume same interval as last) + original_timesteps_extra = FlowSystem._create_timesteps_with_extra(original_timesteps, None) + timestep_duration = FlowSystem.calculate_timestep_duration(original_timesteps_extra) + expanded_ds.attrs['timestep_duration'] = timestep_duration.values.tolist() # Create cluster_weight with value 1.0 for all timesteps (no weighting needed for expanded) # Use _combine_slices_to_dataarray for consistent multi-dim handling @@ -1204,8 +1228,8 @@ def expand_da(da: xr.DataArray) -> xr.DataArray: soc_boundary_per_timestep = soc_boundary_per_timestep.assign_coords(time=original_timesteps) # Apply self-discharge decay to SOC_boundary based on time within period - # Get the storage's relative_loss_per_hour from original flow system - storage = original_fs.storages[storage_name] + # Get the storage's relative_loss_per_hour from the clustered flow system + storage = self._fs.storages.get(storage_name) if storage is not None: # Time within period for each timestep (0, 1, 2, ..., timesteps_per_cluster-1, 0, 1, ...) time_within_period = np.arange(n_original_timesteps) % timesteps_per_cluster diff --git a/tests/test_cluster_reduce_expand.py b/tests/test_cluster_reduce_expand.py index a25fd9eaa..a5f540b32 100644 --- a/tests/test_cluster_reduce_expand.py +++ b/tests/test_cluster_reduce_expand.py @@ -293,7 +293,7 @@ def test_cluster_with_scenarios(timesteps_8_days, scenarios_2): assert info.result.cluster_structure is not None assert info.result.cluster_structure.n_clusters == 2 # Original FlowSystem had scenarios - assert info.original_flow_system.scenarios is not None + assert info.original_scenarios is not None def test_cluster_and_expand_with_scenarios(solver_fixture, timesteps_8_days, scenarios_2): diff --git a/tests/test_clustering_io.py b/tests/test_clustering_io.py new file mode 100644 index 000000000..8b9e38390 --- /dev/null +++ b/tests/test_clustering_io.py @@ -0,0 +1,246 @@ +"""Tests for clustering serialization and deserialization.""" + +import numpy as np +import pandas as pd +import pytest + +import flixopt as fx + + +@pytest.fixture +def simple_system_24h(): + """Create a simple flow system with 24 hourly timesteps.""" + timesteps = pd.date_range('2023-01-01', periods=24, freq='h') + + fs = fx.FlowSystem(timesteps) + fs.add_elements( + fx.Bus('heat'), + fx.Effect('costs', unit='EUR', description='costs', is_objective=True, is_standard=True), + ) + fs.add_elements( + fx.Sink('demand', inputs=[fx.Flow('in', bus='heat', fixed_relative_profile=np.ones(24), size=10)]), + fx.Source('source', outputs=[fx.Flow('out', bus='heat', size=50, effects_per_flow_hour={'costs': 0.05})]), + ) + return fs + + +@pytest.fixture +def simple_system_8_days(): + """Create a simple flow system with 8 days of hourly timesteps.""" + timesteps = pd.date_range('2023-01-01', periods=8 * 24, freq='h') + + # Create varying demand profile with different patterns for different days + # 4 "weekdays" with high demand, 4 "weekend" days with low demand + hourly_pattern = np.sin(np.linspace(0, 2 * np.pi, 24)) * 0.5 + 0.5 + weekday_profile = hourly_pattern * 1.5 # Higher demand + weekend_profile = hourly_pattern * 0.5 # Lower demand + demand_profile = np.concatenate( + [ + weekday_profile, + weekday_profile, + weekday_profile, + weekday_profile, + weekend_profile, + weekend_profile, + weekend_profile, + weekend_profile, + ] + ) + + fs = fx.FlowSystem(timesteps) + fs.add_elements( + fx.Bus('heat'), + fx.Effect('costs', unit='EUR', description='costs', is_objective=True, is_standard=True), + ) + fs.add_elements( + fx.Sink('demand', inputs=[fx.Flow('in', bus='heat', fixed_relative_profile=demand_profile, size=10)]), + fx.Source('source', outputs=[fx.Flow('out', bus='heat', size=50, effects_per_flow_hour={'costs': 0.05})]), + ) + return fs + + +class TestClusteringRoundtrip: + """Test that clustering survives dataset roundtrip.""" + + def test_clustering_to_dataset_has_clustering_attrs(self, simple_system_8_days): + """Clustered FlowSystem dataset should have clustering info.""" + fs = simple_system_8_days + fs_clustered = fs.transform.cluster(n_clusters=2, cluster_duration='1D') + + ds = fs_clustered.to_dataset(include_solution=False) + + # Check that clustering attrs are present + assert 'clustering' in ds.attrs + + # Check that clustering arrays are present with prefix + clustering_vars = [name for name in ds.data_vars if name.startswith('clustering|')] + assert len(clustering_vars) > 0 + + def test_clustering_roundtrip_preserves_clustering_object(self, simple_system_8_days): + """Clustering object should be restored after roundtrip.""" + fs = simple_system_8_days + fs_clustered = fs.transform.cluster(n_clusters=2, cluster_duration='1D') + + # Roundtrip + ds = fs_clustered.to_dataset(include_solution=False) + fs_restored = fx.FlowSystem.from_dataset(ds) + + # Clustering should be restored + assert fs_restored.clustering is not None + assert fs_restored.clustering.backend_name == 'tsam' + + def test_clustering_roundtrip_preserves_n_clusters(self, simple_system_8_days): + """Number of clusters should be preserved after roundtrip.""" + fs = simple_system_8_days + fs_clustered = fs.transform.cluster(n_clusters=2, cluster_duration='1D') + + ds = fs_clustered.to_dataset(include_solution=False) + fs_restored = fx.FlowSystem.from_dataset(ds) + + assert fs_restored.clustering.n_clusters == 2 + + def test_clustering_roundtrip_preserves_timesteps_per_cluster(self, simple_system_8_days): + """Timesteps per cluster should be preserved after roundtrip.""" + fs = simple_system_8_days + fs_clustered = fs.transform.cluster(n_clusters=2, cluster_duration='1D') + + ds = fs_clustered.to_dataset(include_solution=False) + fs_restored = fx.FlowSystem.from_dataset(ds) + + assert fs_restored.clustering.timesteps_per_cluster == 24 + + def test_clustering_roundtrip_preserves_original_timesteps(self, simple_system_8_days): + """Original timesteps should be preserved after roundtrip.""" + fs = simple_system_8_days + fs_clustered = fs.transform.cluster(n_clusters=2, cluster_duration='1D') + original_timesteps = fs_clustered.clustering.original_timesteps + + ds = fs_clustered.to_dataset(include_solution=False) + fs_restored = fx.FlowSystem.from_dataset(ds) + + pd.testing.assert_index_equal(fs_restored.clustering.original_timesteps, original_timesteps) + + def test_clustering_roundtrip_preserves_timestep_mapping(self, simple_system_8_days): + """Timestep mapping should be preserved after roundtrip.""" + fs = simple_system_8_days + fs_clustered = fs.transform.cluster(n_clusters=2, cluster_duration='1D') + original_mapping = fs_clustered.clustering.timestep_mapping.values.copy() + + ds = fs_clustered.to_dataset(include_solution=False) + fs_restored = fx.FlowSystem.from_dataset(ds) + + np.testing.assert_array_equal(fs_restored.clustering.timestep_mapping.values, original_mapping) + + +class TestClusteringWithSolutionRoundtrip: + """Test that clustering with solution survives roundtrip.""" + + def test_expand_solution_after_roundtrip(self, simple_system_8_days, solver_fixture): + """expand_solution should work after loading from dataset.""" + fs = simple_system_8_days + fs_clustered = fs.transform.cluster(n_clusters=2, cluster_duration='1D') + + # Solve + fs_clustered.optimize(solver_fixture) + + # Roundtrip + ds = fs_clustered.to_dataset(include_solution=True) + fs_restored = fx.FlowSystem.from_dataset(ds) + + # expand_solution should work + fs_expanded = fs_restored.transform.expand_solution() + + # Check expanded FlowSystem has correct number of timesteps + assert len(fs_expanded.timesteps) == 8 * 24 + + def test_expand_solution_after_netcdf_roundtrip(self, simple_system_8_days, tmp_path, solver_fixture): + """expand_solution should work after loading from NetCDF file.""" + fs = simple_system_8_days + fs_clustered = fs.transform.cluster(n_clusters=2, cluster_duration='1D') + + # Solve + fs_clustered.optimize(solver_fixture) + + # Save to NetCDF + nc_path = tmp_path / 'clustered.nc' + fs_clustered.to_netcdf(nc_path) + + # Load from NetCDF + fs_restored = fx.FlowSystem.from_netcdf(nc_path) + + # expand_solution should work + fs_expanded = fs_restored.transform.expand_solution() + + # Check expanded FlowSystem has correct number of timesteps + assert len(fs_expanded.timesteps) == 8 * 24 + + +class TestClusteringDerivedProperties: + """Test derived properties on Clustering object.""" + + def test_original_timesteps_property(self, simple_system_8_days): + """original_timesteps property should return correct DatetimeIndex.""" + fs = simple_system_8_days + original_timesteps = fs.timesteps + + fs_clustered = fs.transform.cluster(n_clusters=2, cluster_duration='1D') + + # Check values are equal (name attribute may differ) + pd.testing.assert_index_equal( + fs_clustered.clustering.original_timesteps, + original_timesteps, + check_names=False, + ) + + def test_original_periods_none_for_simple_system(self, simple_system_8_days): + """original_periods should be None for system without periods.""" + fs = simple_system_8_days + fs_clustered = fs.transform.cluster(n_clusters=2, cluster_duration='1D') + + assert fs_clustered.clustering.original_periods is None + + def test_original_scenarios_none_for_simple_system(self, simple_system_8_days): + """original_scenarios should be None for system without scenarios.""" + fs = simple_system_8_days + fs_clustered = fs.transform.cluster(n_clusters=2, cluster_duration='1D') + + assert fs_clustered.clustering.original_scenarios is None + + +class TestClusteringWithScenarios: + """Test clustering IO with scenarios.""" + + @pytest.fixture + def system_with_scenarios(self): + """Create a flow system with scenarios.""" + timesteps = pd.date_range('2023-01-01', periods=4 * 24, freq='h') + scenarios = pd.Index(['Low', 'High'], name='scenario') + + # Create varying demand profile for clustering + demand_profile = np.tile(np.sin(np.linspace(0, 2 * np.pi, 24)) * 0.5 + 0.5, 4) + + fs = fx.FlowSystem(timesteps, scenarios=scenarios) + fs.add_elements( + fx.Bus('heat'), + fx.Effect('costs', unit='EUR', description='costs', is_objective=True, is_standard=True), + ) + fs.add_elements( + fx.Sink('demand', inputs=[fx.Flow('in', bus='heat', fixed_relative_profile=demand_profile, size=10)]), + fx.Source('source', outputs=[fx.Flow('out', bus='heat', size=50, effects_per_flow_hour={'costs': 0.05})]), + ) + return fs + + def test_clustering_roundtrip_preserves_original_scenarios(self, system_with_scenarios): + """original_scenarios should be preserved after roundtrip.""" + fs = system_with_scenarios + fs_clustered = fs.transform.cluster(n_clusters=2, cluster_duration='1D') + + ds = fs_clustered.to_dataset(include_solution=False) + fs_restored = fx.FlowSystem.from_dataset(ds) + + # Original scenarios should be preserved (check values, name attribute may differ) + pd.testing.assert_index_equal( + fs_restored.clustering.original_scenarios, + pd.Index(['Low', 'High'], name='scenario'), + check_names=False, + ) From c7b6afe7b1de697404f506cbd84b9470d0357108 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 28 Dec 2025 15:23:15 +0100 Subject: [PATCH 04/12] Clustering IO --- flixopt/clustering/base.py | 22 ---------------------- flixopt/flow_system.py | 10 ++++++---- flixopt/transform_accessor.py | 13 ++++++------- tests/test_cluster_reduce_expand.py | 5 +++-- tests/test_clustering_io.py | 23 +++++++++-------------- 5 files changed, 24 insertions(+), 49 deletions(-) diff --git a/flixopt/clustering/base.py b/flixopt/clustering/base.py index 52ad720db..e8e7a162c 100644 --- a/flixopt/clustering/base.py +++ b/flixopt/clustering/base.py @@ -1091,8 +1091,6 @@ def cluster_start_positions(self) -> np.ndarray: n_timesteps = self.n_clusters * self.timesteps_per_period return np.arange(0, n_timesteps, self.timesteps_per_period) - # Properties to derive original coordinates from existing DataArrays - @property def original_timesteps(self) -> pd.DatetimeIndex: """Original timesteps before clustering. @@ -1101,26 +1099,6 @@ def original_timesteps(self) -> pd.DatetimeIndex: """ return pd.DatetimeIndex(self.result.timestep_mapping.coords['original_time'].values) - @property - def original_periods(self) -> pd.Index | None: - """Original periods before clustering (if multi-period system). - - Returns None if the FlowSystem had no period dimension. - """ - if 'period' in self.result.timestep_mapping.dims: - return pd.Index(self.result.timestep_mapping.coords['period'].values) - return None - - @property - def original_scenarios(self) -> pd.Index | None: - """Original scenarios before clustering (if multi-scenario system). - - Returns None if the FlowSystem had no scenario dimension. - """ - if 'scenario' in self.result.timestep_mapping.dims: - return pd.Index(self.result.timestep_mapping.coords['scenario'].values) - return None - def create_cluster_structure_from_mapping( timestep_mapping: xr.DataArray, diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 49268db44..e0b754065 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -686,9 +686,11 @@ def to_dataset(self, include_solution: bool = True) -> xr.Dataset: clustering_ref, clustering_arrays = self.clustering._create_reference_structure() # Add clustering arrays with prefix and rename conflicting dimensions for name, arr in clustering_arrays.items(): - # Rename 'time' dimension to 'clustering_time' to avoid conflicts + # Rename 'time' dimension to 'representative_time' to avoid conflicts + # (representative_weights has n_clusters * timesteps_per_cluster elements, + # different from FlowSystem's time dimension) if 'time' in arr.dims: - arr = arr.rename({'time': 'clustering_time'}) + arr = arr.rename({'time': 'representative_time'}) ds[f'clustering|{name}'] = arr ds.attrs['clustering'] = json.dumps(clustering_ref) @@ -817,8 +819,8 @@ def from_dataset(cls, ds: xr.Dataset) -> FlowSystem: if name.startswith('clustering|'): # Remove 'clustering|' prefix (11 chars) and rename dimension back arr_name = name[11:] - if 'clustering_time' in arr.dims: - arr = arr.rename({'clustering_time': 'time'}) + if 'representative_time' in arr.dims: + arr = arr.rename({'representative_time': 'time'}) clustering_arrays[arr_name] = arr clustering = cls._resolve_reference_structure(clustering_structure, clustering_arrays) flow_system.clustering = clustering diff --git a/flixopt/transform_accessor.py b/flixopt/transform_accessor.py index e68806f86..01b16d0f4 100644 --- a/flixopt/transform_accessor.py +++ b/flixopt/transform_accessor.py @@ -1146,15 +1146,14 @@ def expand_solution(self) -> FlowSystem: else int(cluster_structure.n_clusters.values) ) - # Use derived properties from Clustering instead of original_flow_system + # Get original timesteps from clustering, but periods/scenarios from the FlowSystem + # (the clustered FlowSystem preserves the same periods/scenarios) original_timesteps = info.original_timesteps - original_periods = info.original_periods - original_scenarios = info.original_scenarios - has_periods = original_periods is not None - has_scenarios = original_scenarios is not None + has_periods = self._fs.periods is not None + has_scenarios = self._fs.scenarios is not None - periods = list(original_periods) if has_periods else [None] - scenarios = list(original_scenarios) if has_scenarios else [None] + periods = list(self._fs.periods) if has_periods else [None] + scenarios = list(self._fs.scenarios) if has_scenarios else [None] n_original_timesteps = len(original_timesteps) n_reduced_timesteps = n_clusters * timesteps_per_cluster diff --git a/tests/test_cluster_reduce_expand.py b/tests/test_cluster_reduce_expand.py index a5f540b32..6d4be5c41 100644 --- a/tests/test_cluster_reduce_expand.py +++ b/tests/test_cluster_reduce_expand.py @@ -292,8 +292,9 @@ def test_cluster_with_scenarios(timesteps_8_days, scenarios_2): assert info is not None assert info.result.cluster_structure is not None assert info.result.cluster_structure.n_clusters == 2 - # Original FlowSystem had scenarios - assert info.original_scenarios is not None + # Clustered FlowSystem preserves scenarios + assert fs_reduced.scenarios is not None + assert len(fs_reduced.scenarios) == 2 def test_cluster_and_expand_with_scenarios(solver_fixture, timesteps_8_days, scenarios_2): diff --git a/tests/test_clustering_io.py b/tests/test_clustering_io.py index 8b9e38390..483cdc447 100644 --- a/tests/test_clustering_io.py +++ b/tests/test_clustering_io.py @@ -192,19 +192,14 @@ def test_original_timesteps_property(self, simple_system_8_days): check_names=False, ) - def test_original_periods_none_for_simple_system(self, simple_system_8_days): - """original_periods should be None for system without periods.""" + def test_simple_system_has_no_periods_or_scenarios(self, simple_system_8_days): + """Clustered simple system should preserve that it has no periods/scenarios.""" fs = simple_system_8_days fs_clustered = fs.transform.cluster(n_clusters=2, cluster_duration='1D') - assert fs_clustered.clustering.original_periods is None - - def test_original_scenarios_none_for_simple_system(self, simple_system_8_days): - """original_scenarios should be None for system without scenarios.""" - fs = simple_system_8_days - fs_clustered = fs.transform.cluster(n_clusters=2, cluster_duration='1D') - - assert fs_clustered.clustering.original_scenarios is None + # FlowSystem without periods/scenarios should remain so after clustering + assert fs_clustered.periods is None + assert fs_clustered.scenarios is None class TestClusteringWithScenarios: @@ -230,17 +225,17 @@ def system_with_scenarios(self): ) return fs - def test_clustering_roundtrip_preserves_original_scenarios(self, system_with_scenarios): - """original_scenarios should be preserved after roundtrip.""" + def test_clustering_roundtrip_preserves_scenarios(self, system_with_scenarios): + """Scenarios should be preserved after clustering and roundtrip.""" fs = system_with_scenarios fs_clustered = fs.transform.cluster(n_clusters=2, cluster_duration='1D') ds = fs_clustered.to_dataset(include_solution=False) fs_restored = fx.FlowSystem.from_dataset(ds) - # Original scenarios should be preserved (check values, name attribute may differ) + # Scenarios should be preserved in the FlowSystem itself pd.testing.assert_index_equal( - fs_restored.clustering.original_scenarios, + fs_restored.scenarios, pd.Index(['Low', 'High'], name='scenario'), check_names=False, ) From 120d544b167ed3dbdbc78380ba03e30aac3dfeaa Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 28 Dec 2025 16:19:11 +0100 Subject: [PATCH 05/12] Improve IO --- flixopt/clustering/base.py | 16 +++++++--------- flixopt/flow_system.py | 11 ++--------- flixopt/transform_accessor.py | 15 ++++++++------- tests/test_clustering/test_base.py | 1 - 4 files changed, 17 insertions(+), 26 deletions(-) diff --git a/flixopt/clustering/base.py b/flixopt/clustering/base.py index e8e7a162c..42622afeb 100644 --- a/flixopt/clustering/base.py +++ b/flixopt/clustering/base.py @@ -304,10 +304,9 @@ def __post_init__(self): self.timestep_mapping = self.timestep_mapping.rename('timestep_mapping') # Ensure representative_weights is a DataArray + # Can be (cluster, time) for 2D structure or (time,) for flat structure if not isinstance(self.representative_weights, xr.DataArray): - self.representative_weights = xr.DataArray( - self.representative_weights, dims=['time'], name='representative_weights' - ) + self.representative_weights = xr.DataArray(self.representative_weights, name='representative_weights') elif self.representative_weights.name is None: self.representative_weights = self.representative_weights.rename('representative_weights') @@ -514,12 +513,11 @@ def validate(self) -> None: if max_idx >= n_rep: raise ValueError(f'timestep_mapping contains index {max_idx} but n_representatives is {n_rep}') - # Check weights length matches n_representatives - if len(self.representative_weights) != n_rep: - raise ValueError( - f'representative_weights has {len(self.representative_weights)} elements ' - f'but n_representatives is {n_rep}' - ) + # Check weights size matches n_representatives + # representative_weights can be (cluster, time) or flat (time,) + weights_size = self.representative_weights.size + if weights_size != n_rep: + raise ValueError(f'representative_weights has {weights_size} elements but n_representatives is {n_rep}') # Check weights sum roughly equals original timesteps weight_sum = float(self.representative_weights.sum().values) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index e0b754065..5d1733cba 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -684,13 +684,8 @@ def to_dataset(self, include_solution: bool = True) -> xr.Dataset: # Serialize Clustering object if present (new structure) if self.clustering is not None: clustering_ref, clustering_arrays = self.clustering._create_reference_structure() - # Add clustering arrays with prefix and rename conflicting dimensions + # Add clustering arrays with prefix for name, arr in clustering_arrays.items(): - # Rename 'time' dimension to 'representative_time' to avoid conflicts - # (representative_weights has n_clusters * timesteps_per_cluster elements, - # different from FlowSystem's time dimension) - if 'time' in arr.dims: - arr = arr.rename({'time': 'representative_time'}) ds[f'clustering|{name}'] = arr ds.attrs['clustering'] = json.dumps(clustering_ref) @@ -817,10 +812,8 @@ def from_dataset(cls, ds: xr.Dataset) -> FlowSystem: clustering_arrays = {} for name, arr in ds.data_vars.items(): if name.startswith('clustering|'): - # Remove 'clustering|' prefix (11 chars) and rename dimension back + # Remove 'clustering|' prefix (11 chars) arr_name = name[11:] - if 'representative_time' in arr.dims: - arr = arr.rename({'representative_time': 'time'}) clustering_arrays[arr_name] = arr clustering = cls._resolve_reference_structure(clustering_structure, clustering_arrays) flow_system.clustering = clustering diff --git a/flixopt/transform_accessor.py b/flixopt/transform_accessor.py index 01b16d0f4..84969534d 100644 --- a/flixopt/transform_accessor.py +++ b/flixopt/transform_accessor.py @@ -902,16 +902,17 @@ def _build_cluster_occurrences_for_key(key: tuple) -> np.ndarray: timesteps_per_cluster=timesteps_per_cluster, ) - # Create representative_weights in flat format for ClusterResult compatibility - # This repeats each cluster's weight for all timesteps within that cluster - def _build_flat_weights_for_key(key: tuple) -> xr.DataArray: + # Create representative_weights with (cluster,) dimension only + # Each cluster has one weight (same for all timesteps within it) + def _build_cluster_weights_for_key(key: tuple) -> xr.DataArray: occurrences = cluster_occurrences_all[key] - weights = np.repeat([occurrences.get(c, 1) for c in range(actual_n_clusters)], timesteps_per_cluster) - return xr.DataArray(weights, dims=['time'], name='representative_weights') + # Shape: (n_clusters,) - one weight per cluster + weights = np.array([occurrences.get(c, 1) for c in range(actual_n_clusters)]) + return xr.DataArray(weights, dims=['cluster'], name='representative_weights') - flat_weights_slices = {key: _build_flat_weights_for_key(key) for key in cluster_occurrences_all} + weights_slices = {key: _build_cluster_weights_for_key(key) for key in cluster_occurrences_all} representative_weights = self._combine_slices_to_dataarray_generic( - flat_weights_slices, ['time'], periods, scenarios, 'representative_weights' + weights_slices, ['cluster'], periods, scenarios, 'representative_weights' ) aggregation_result = ClusterResult( diff --git a/tests/test_clustering/test_base.py b/tests/test_clustering/test_base.py index a6c4d8cc7..9c63f25f6 100644 --- a/tests/test_clustering/test_base.py +++ b/tests/test_clustering/test_base.py @@ -152,7 +152,6 @@ def test_creation(self): info = Clustering( result=result, - original_flow_system=None, # Would be FlowSystem in practice backend_name='tsam', ) From f67edb9910a6446709f44e960b7413924bdbb1b9 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 28 Dec 2025 17:03:14 +0100 Subject: [PATCH 06/12] Improve validation --- flixopt/clustering/base.py | 61 +++++++++++++++++++++++++++----------- 1 file changed, 44 insertions(+), 17 deletions(-) diff --git a/flixopt/clustering/base.py b/flixopt/clustering/base.py index 42622afeb..c60d309aa 100644 --- a/flixopt/clustering/base.py +++ b/flixopt/clustering/base.py @@ -513,23 +513,50 @@ def validate(self) -> None: if max_idx >= n_rep: raise ValueError(f'timestep_mapping contains index {max_idx} but n_representatives is {n_rep}') - # Check weights size matches n_representatives - # representative_weights can be (cluster, time) or flat (time,) - weights_size = self.representative_weights.size - if weights_size != n_rep: - raise ValueError(f'representative_weights has {weights_size} elements but n_representatives is {n_rep}') - - # Check weights sum roughly equals original timesteps - weight_sum = float(self.representative_weights.sum().values) - n_original = self.n_original_timesteps - if abs(weight_sum - n_original) > 1e-6: - # Warning only - some aggregation methods may not preserve this exactly - import warnings - - warnings.warn( - f'representative_weights sum ({weight_sum}) does not match n_original_timesteps ({n_original})', - stacklevel=2, - ) + # Check weights dimensions + # representative_weights should have (cluster,) dimension with n_clusters elements + # (plus optional period/scenario dimensions) + if self.cluster_structure is not None: + n_clusters = self.cluster_structure.n_clusters + if 'cluster' in self.representative_weights.dims: + weights_n_clusters = self.representative_weights.sizes['cluster'] + if weights_n_clusters != n_clusters: + raise ValueError( + f'representative_weights has {weights_n_clusters} clusters ' + f'but cluster_structure has {n_clusters}' + ) + + # Check weights sum roughly equals number of original periods + # (each weight is how many original periods that cluster represents) + # Sum should be checked per period/scenario slice, not across all dimensions + if self.cluster_structure is not None: + n_original_periods = self.cluster_structure.n_original_periods + # Sum over cluster dimension only (keep period/scenario if present) + weight_sum_per_slice = self.representative_weights.sum(dim='cluster') + # Check each slice + if weight_sum_per_slice.size == 1: + # Simple case: no period/scenario + weight_sum = float(weight_sum_per_slice.values) + if abs(weight_sum - n_original_periods) > 1e-6: + import warnings + + warnings.warn( + f'representative_weights sum ({weight_sum}) does not match ' + f'n_original_periods ({n_original_periods})', + stacklevel=2, + ) + else: + # Multi-dimensional: check each slice + for val in weight_sum_per_slice.values.flat: + if abs(float(val) - n_original_periods) > 1e-6: + import warnings + + warnings.warn( + f'representative_weights sum per slice ({float(val)}) does not match ' + f'n_original_periods ({n_original_periods})', + stacklevel=2, + ) + break # Only warn once class ClusteringPlotAccessor: From ca5397264e1b1ad507263bca6e257a9ca9f4f063 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 1 Jan 2026 21:47:53 +0100 Subject: [PATCH 07/12] Fix cluster weight IO --- flixopt/flow_system.py | 23 +++++++++++++++-------- flixopt/structure.py | 4 +++- flixopt/transform_accessor.py | 6 +++++- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 5d1733cba..5583e1c39 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -74,8 +74,8 @@ class FlowSystem(Interface, CompositeContainerMixin[Element]): scenario_weights: The weights of each scenario. If None, all scenarios have the same weight (normalized to 1). Period weights are always computed internally from the period index (like timestep_duration for time). The final `weights` array (accessible via `flow_system.model.objective_weights`) is computed as period_weights × normalized_scenario_weights, with normalization applied to the scenario weights by default. - cluster_weight: Weight for each timestep representing cluster representation count. - If None (default), all timesteps have weight 1.0. Used by cluster() to specify + cluster_weight: Weight for each cluster. + If None (default), all clusters have weight 1.0. Used by cluster() to specify how many original timesteps each cluster represents. Combined with timestep_duration via aggregation_weight for proper time aggregation in clustered models. scenario_independent_sizes: Controls whether investment sizes are equalized across scenarios. @@ -206,10 +206,13 @@ def __init__( # Cluster weight for cluster() optimization (default 1.0) # Represents how many original timesteps each cluster represents # May have period/scenario dimensions if cluster() was used with those - self.cluster_weight = self.fit_to_model_coords( - 'cluster_weight', - np.ones(len(self.timesteps)) if cluster_weight is None else cluster_weight, - dims=['time', 'period', 'scenario'], # Gracefully ignores dims not present + self.cluster_weight = ( + self.fit_to_model_coords( + 'cluster_weight', + cluster_weight, + ) + if cluster_weight is not None + else None ) self.scenario_weights = scenario_weights # Use setter @@ -2024,8 +2027,10 @@ def weights(self) -> TimeSeriesWeights: """ from .structure import TimeSeriesWeights + # cluster_weight defaults to 1.0 when not set + cluster_weight = self.cluster_weight if self.cluster_weight is not None else 1.0 return TimeSeriesWeights( - temporal=self.timestep_duration * self.cluster_weight, + temporal=self.timestep_duration * cluster_weight, period=self.period_weights, scenario=self._scenario_weights, ) @@ -2041,7 +2046,9 @@ def aggregation_weight(self) -> xr.DataArray: This is equivalent to `weights.temporal`. The unified TimeSeriesWeights interface (via `flow_system.weights`) is recommended for new code. """ - return self.timestep_duration * self.cluster_weight + # cluster_weight defaults to 1.0 when not set + cluster_weight = self.cluster_weight if self.cluster_weight is not None else 1.0 + return self.timestep_duration * cluster_weight @property def is_clustered(self) -> bool: diff --git a/flixopt/structure.py b/flixopt/structure.py index 7996565e8..50346c21b 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -314,12 +314,14 @@ def hours_of_previous_timesteps(self): return self.flow_system.hours_of_previous_timesteps @property - def cluster_weight(self) -> xr.DataArray: + def cluster_weight(self) -> xr.DataArray | float: """Cluster weight for cluster() optimization. Represents how many original timesteps each cluster represents. Default is 1.0 for all timesteps. """ + if self.flow_system.cluster_weight is None: + return 1.0 return self.flow_system.cluster_weight @property diff --git a/flixopt/transform_accessor.py b/flixopt/transform_accessor.py index 7e8273ab7..dc63dcacb 100644 --- a/flixopt/transform_accessor.py +++ b/flixopt/transform_accessor.py @@ -801,7 +801,11 @@ def _build_cluster_weight_for_key(key: tuple) -> xr.DataArray: da = TimeSeriesData.from_dataarray(da.assign_attrs(original_da.attrs)) ds_new_vars[name] = da - ds_new = xr.Dataset(ds_new_vars, attrs=ds.attrs) + # Copy attrs but remove cluster_weight - the clustered FlowSystem gets its own + # cluster_weight set after from_dataset (original reference has wrong shape) + new_attrs = dict(ds.attrs) + new_attrs.pop('cluster_weight', None) + ds_new = xr.Dataset(ds_new_vars, attrs=new_attrs) ds_new.attrs['timesteps_per_cluster'] = timesteps_per_cluster ds_new.attrs['timestep_duration'] = dt ds_new.attrs['n_clusters'] = actual_n_clusters From df0f6a06a869a76541bba3108d52688b2cc4958d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 2 Jan 2026 10:30:51 +0100 Subject: [PATCH 08/12] Fix cluster weights stuff --- flixopt/elements.py | 18 +++++++----------- flixopt/features.py | 17 ++++++++--------- flixopt/flow_system.py | 21 ++++++++++----------- flixopt/structure.py | 34 ++++++++++++++++++++++++++-------- 4 files changed, 51 insertions(+), 39 deletions(-) diff --git a/flixopt/elements.py b/flixopt/elements.py index ba2b72f80..424a9fab5 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -677,14 +677,13 @@ def _do_modeling(self): self._constraint_flow_rate() # Total flow hours tracking (per period) - # Sum over all temporal dimensions (time, and cluster if present) - weighted_flow = self.flow_rate * self._model.aggregation_weight - # Get temporal_dims from aggregation_weight (not weighted_flow which has linopy's _term dim) - temporal_dims = [d for d in self._model.aggregation_weight.dims if d not in ('period', 'scenario')] + # Sum over temporal dimensions, weighted by cluster_weight + flow_hours = self.flow_rate * self._model.timestep_duration + weighted_flow_hours = flow_hours * self._model.cluster_weight ModelingPrimitives.expression_tracking_variable( model=self, name=f'{self.label_full}|total_flow_hours', - tracked_expression=weighted_flow.sum(temporal_dims), + tracked_expression=weighted_flow_hours.sum(self._model.temporal_dims), bounds=( self.element.flow_hours_min if self.element.flow_hours_min is not None else 0, self.element.flow_hours_max if self.element.flow_hours_max is not None else None, @@ -841,9 +840,8 @@ def _create_bounds_for_load_factor(self): # Get the size (either from element or investment) size = self.investment.size if self.with_investment else self.element.size - # Sum over all temporal dimensions (time, and cluster if present) - temporal_dims = [d for d in self._model.aggregation_weight.dims if d not in ('period', 'scenario')] - total_hours = self._model.aggregation_weight.sum(temporal_dims) + # Sum over temporal dimensions, weighted by cluster_weight + total_hours = (self._model.timestep_duration * self._model.cluster_weight).sum(self._model.temporal_dims) # Maximum load factor constraint if self.element.load_factor_max is not None: @@ -959,9 +957,7 @@ def _do_modeling(self): # Add virtual supply/demand to balance and penalty if needed if self.element.allows_imbalance: - imbalance_penalty = np.multiply( - self._model.aggregation_weight, self.element.imbalance_penalty_per_flow_hour - ) + imbalance_penalty = self.element.imbalance_penalty_per_flow_hour * self._model.timestep_duration self.virtual_supply = self.add_variables( lower=0, coords=self._model.get_coords(), short_name='virtual_supply' diff --git a/flixopt/features.py b/flixopt/features.py index e0a018a7f..95c6e9f87 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -197,21 +197,20 @@ def _do_modeling(self): self.add_constraints(self.status + inactive == 1, short_name='complementary') # 3. Total duration tracking using existing pattern - # Sum over all temporal dimensions (time, and cluster if present) - weighted_status = self.status * self._model.aggregation_weight - # Get temporal_dims from aggregation_weight (not weighted_status which has linopy's _term dim) - temporal_dims = [d for d in self._model.aggregation_weight.dims if d not in ('period', 'scenario')] - agg_weight_sum = self._model.aggregation_weight.sum(temporal_dims) + # Sum over temporal dimensions, weighted by cluster_weight + active_hours = self.status * self._model.timestep_duration + weighted_active_hours = active_hours * self._model.cluster_weight + total_hours = (self._model.timestep_duration * self._model.cluster_weight).sum(self._model.temporal_dims) ModelingPrimitives.expression_tracking_variable( self, - tracked_expression=weighted_status.sum(temporal_dims), + tracked_expression=weighted_active_hours.sum(self._model.temporal_dims), bounds=( self.parameters.active_hours_min if self.parameters.active_hours_min is not None else 0, self.parameters.active_hours_max if self.parameters.active_hours_max is not None - else agg_weight_sum.max().item() - if hasattr(agg_weight_sum, 'max') - else agg_weight_sum, + else total_hours.max().item() + if hasattr(total_hours, 'max') + else total_hours, ), short_name='active_hours', coords=['period', 'scenario'], diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 5583e1c39..bd88af681 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -76,8 +76,8 @@ class FlowSystem(Interface, CompositeContainerMixin[Element]): The final `weights` array (accessible via `flow_system.model.objective_weights`) is computed as period_weights × normalized_scenario_weights, with normalization applied to the scenario weights by default. cluster_weight: Weight for each cluster. If None (default), all clusters have weight 1.0. Used by cluster() to specify - how many original timesteps each cluster represents. Combined with timestep_duration - via aggregation_weight for proper time aggregation in clustered models. + how many original timesteps each cluster represents. Multiply with timestep_duration + for proper time aggregation in clustered models. scenario_independent_sizes: Controls whether investment sizes are equalized across scenarios. - True: All sizes are shared/equalized across scenarios - False: All sizes are optimized separately per scenario @@ -206,7 +206,7 @@ def __init__( # Cluster weight for cluster() optimization (default 1.0) # Represents how many original timesteps each cluster represents # May have period/scenario dimensions if cluster() was used with those - self.cluster_weight = ( + self.cluster_weight: xr.DataArray | None = ( self.fit_to_model_coords( 'cluster_weight', cluster_weight, @@ -2027,7 +2027,6 @@ def weights(self) -> TimeSeriesWeights: """ from .structure import TimeSeriesWeights - # cluster_weight defaults to 1.0 when not set cluster_weight = self.cluster_weight if self.cluster_weight is not None else 1.0 return TimeSeriesWeights( temporal=self.timestep_duration * cluster_weight, @@ -2039,14 +2038,14 @@ def weights(self) -> TimeSeriesWeights: def aggregation_weight(self) -> xr.DataArray: """Combined weight for time aggregation. - Combines timestep_duration (physical duration) and cluster_weight (cluster representation). - Use this for proper time aggregation in clustered models. - - Note: - This is equivalent to `weights.temporal`. The unified TimeSeriesWeights - interface (via `flow_system.weights`) is recommended for new code. + .. deprecated:: + Use ``timestep_duration * cluster_weight`` instead. """ - # cluster_weight defaults to 1.0 when not set + warnings.warn( + 'aggregation_weight is deprecated. Use `timestep_duration * cluster_weight` instead.', + DeprecationWarning, + stacklevel=2, + ) cluster_weight = self.cluster_weight if self.cluster_weight is not None else 1.0 return self.timestep_duration * cluster_weight diff --git a/flixopt/structure.py b/flixopt/structure.py index 50346c21b..67fc1433a 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -71,9 +71,9 @@ class TimeSeriesWeights: >>> total = weighted_sum * weights.period * weights.scenario Note: - For backwards compatibility, the existing properties (cluster_weight, - timestep_duration, aggregation_weight) are still available on FlowSystem - and FlowSystemModel. + For backwards compatibility, cluster_weight and timestep_duration are + still available on FlowSystem and FlowSystemModel. The aggregation_weight + property is deprecated; use ``timestep_duration * cluster_weight`` instead. """ temporal: xr.DataArray @@ -315,22 +315,40 @@ def hours_of_previous_timesteps(self): @property def cluster_weight(self) -> xr.DataArray | float: - """Cluster weight for cluster() optimization. + """Cluster weight for time aggregation. - Represents how many original timesteps each cluster represents. - Default is 1.0 for all timesteps. + Represents how many original time periods each cluster represents. + Returns 1.0 for non-clustered systems. """ if self.flow_system.cluster_weight is None: return 1.0 return self.flow_system.cluster_weight + @property + def temporal_dims(self) -> list[str]: + """Temporal dimensions for summing over time. + + Returns ['time', 'cluster'] for clustered systems, ['time'] otherwise. + Use this when summing temporal values to totals. + """ + if self.flow_system.clusters is not None: + return ['time', 'cluster'] + return ['time'] + @property def aggregation_weight(self) -> xr.DataArray: """Combined weight for time aggregation. - Combines timestep_duration (physical duration) and cluster_weight (cluster representation). - Use this for proper time aggregation in clustered models. + .. deprecated:: + Use ``timestep_duration * cluster_weight`` instead. """ + import warnings + + warnings.warn( + 'aggregation_weight is deprecated. Use `timestep_duration * cluster_weight` instead.', + DeprecationWarning, + stacklevel=2, + ) return self.timestep_duration * self.cluster_weight @property From 0e38cc2fcff83a8cb85be173ff7be231f691ba30 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 2 Jan 2026 10:36:31 +0100 Subject: [PATCH 09/12] Fix cluster weights stuff --- flixopt/flow_system.py | 15 --------------- flixopt/structure.py | 21 ++------------------- 2 files changed, 2 insertions(+), 34 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index bd88af681..b9a6a50e0 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -2034,21 +2034,6 @@ def weights(self) -> TimeSeriesWeights: scenario=self._scenario_weights, ) - @property - def aggregation_weight(self) -> xr.DataArray: - """Combined weight for time aggregation. - - .. deprecated:: - Use ``timestep_duration * cluster_weight`` instead. - """ - warnings.warn( - 'aggregation_weight is deprecated. Use `timestep_duration * cluster_weight` instead.', - DeprecationWarning, - stacklevel=2, - ) - cluster_weight = self.cluster_weight if self.cluster_weight is not None else 1.0 - return self.timestep_duration * cluster_weight - @property def is_clustered(self) -> bool: """Check if this FlowSystem uses time series clustering. diff --git a/flixopt/structure.py b/flixopt/structure.py index 67fc1433a..9c211c509 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -71,9 +71,8 @@ class TimeSeriesWeights: >>> total = weighted_sum * weights.period * weights.scenario Note: - For backwards compatibility, cluster_weight and timestep_duration are - still available on FlowSystem and FlowSystemModel. The aggregation_weight - property is deprecated; use ``timestep_duration * cluster_weight`` instead. + The individual properties ``cluster_weight``, ``timestep_duration``, and + ``temporal_dims`` are available on FlowSystemModel for direct access. """ temporal: xr.DataArray @@ -335,22 +334,6 @@ def temporal_dims(self) -> list[str]: return ['time', 'cluster'] return ['time'] - @property - def aggregation_weight(self) -> xr.DataArray: - """Combined weight for time aggregation. - - .. deprecated:: - Use ``timestep_duration * cluster_weight`` instead. - """ - import warnings - - warnings.warn( - 'aggregation_weight is deprecated. Use `timestep_duration * cluster_weight` instead.', - DeprecationWarning, - stacklevel=2, - ) - return self.timestep_duration * self.cluster_weight - @property def scenario_weights(self) -> xr.DataArray: """ From cb013452ea90a0b89d813636e107723969fdbf4b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 3 Jan 2026 18:46:26 +0100 Subject: [PATCH 10/12] Refactor weights API: always normalize scenario weights (#547) * Add weights class * Add weights class * The Weights API is now used in the modeling equations: Changes made: 1. elements.py - Flow tracking: # Before: flow_hours = self.flow_rate * self._model.timestep_duration weighted_flow_hours = flow_hours * self._model.cluster_weight tracked_expression=weighted_flow_hours.sum(self._model.temporal_dims) # After: tracked_expression=self._model.weights.sum_temporal(self.flow_rate) 2. elements.py - Load factor total hours: # Before: total_hours = (self._model.timestep_duration * self._model.cluster_weight).sum(self._model.temporal_dims) # After: total_hours = self._model.weights.temporal.sum(self._model.weights.temporal_dims) 3. features.py - Status tracking: # Before: active_hours = self.status * self._model.timestep_duration weighted_active_hours = active_hours * self._model.cluster_weight tracked_expression=weighted_active_hours.sum(self._model.temporal_dims) # After: tracked_expression=self._model.weights.sum_temporal(self.status) 4. features.py - Temporal effects summing (only needs cluster weight since already per-timestep): # Before: weighted_per_timestep = self.total_per_timestep * self._model.cluster_weight temporal_dims = [d for d in self.total_per_timestep.dims if d not in ('period', 'scenario')] # After: weighted_per_timestep = self.total_per_timestep * self._model.weights.cluster self._eq_total.lhs -= weighted_per_timestep.sum(dim=self._model.weights.temporal_dims) * The Weights API is now used in the modeling equations: Changes made: 1. elements.py - Flow tracking: # Before: flow_hours = self.flow_rate * self._model.timestep_duration weighted_flow_hours = flow_hours * self._model.cluster_weight tracked_expression=weighted_flow_hours.sum(self._model.temporal_dims) # After: tracked_expression=self._model.weights.sum_temporal(self.flow_rate) 2. elements.py - Load factor total hours: # Before: total_hours = (self._model.timestep_duration * self._model.cluster_weight).sum(self._model.temporal_dims) # After: total_hours = self._model.weights.temporal.sum(self._model.weights.temporal_dims) 3. features.py - Status tracking: # Before: active_hours = self.status * self._model.timestep_duration weighted_active_hours = active_hours * self._model.cluster_weight tracked_expression=weighted_active_hours.sum(self._model.temporal_dims) # After: tracked_expression=self._model.weights.sum_temporal(self.status) 4. features.py - Temporal effects summing (only needs cluster weight since already per-timestep): # Before: weighted_per_timestep = self.total_per_timestep * self._model.cluster_weight temporal_dims = [d for d in self.total_per_timestep.dims if d not in ('period', 'scenario')] # After: weighted_per_timestep = self.total_per_timestep * self._model.weights.cluster self._eq_total.lhs -= weighted_per_timestep.sum(dim=self._model.weights.temporal_dims) * Minor fixes in test * Improve weighting system and normalization of scenrio weights --- flixopt/__init__.py | 2 - flixopt/components.py | 6 +- flixopt/core.py | 2 +- flixopt/elements.py | 9 +- flixopt/features.py | 15 +- flixopt/flow_system.py | 188 +++++++++++++++++----- flixopt/optimization.py | 9 +- flixopt/optimize_accessor.py | 17 +- flixopt/statistics_accessor.py | 2 +- flixopt/structure.py | 151 ++++------------- tests/deprecated/test_scenarios.py | 13 +- tests/test_cluster_reduce_expand.py | 10 +- tests/test_clustering/test_integration.py | 132 +++++++++------ tests/test_io_conversion.py | 5 + tests/test_scenarios.py | 13 +- 15 files changed, 325 insertions(+), 249 deletions(-) diff --git a/flixopt/__init__.py b/flixopt/__init__.py index 73784f2cd..474668b6f 100644 --- a/flixopt/__init__.py +++ b/flixopt/__init__.py @@ -31,7 +31,6 @@ from .interface import InvestParameters, Piece, Piecewise, PiecewiseConversion, PiecewiseEffects, StatusParameters from .optimization import Optimization, SegmentedOptimization from .plot_result import PlotResult -from .structure import TimeSeriesWeights __all__ = [ 'TimeSeriesData', @@ -58,7 +57,6 @@ 'PiecewiseConversion', 'PiecewiseEffects', 'PlotResult', - 'TimeSeriesWeights', 'clustering', 'plotting', 'results', diff --git a/flixopt/components.py b/flixopt/components.py index 0f2a6077e..390fc6f02 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -1257,10 +1257,12 @@ def _absolute_charge_state_bounds(self) -> tuple[xr.DataArray, xr.DataArray]: return -np.inf, np.inf elif isinstance(self.element.capacity_in_flow_hours, InvestParameters): cap_max = self.element.capacity_in_flow_hours.maximum_or_fixed_size * relative_upper_bound - return -cap_max, cap_max + # Adding 0.0 converts -0.0 to 0.0 (linopy LP writer bug workaround) + return -cap_max + 0.0, cap_max + 0.0 else: cap = self.element.capacity_in_flow_hours * relative_upper_bound - return -cap, cap + # Adding 0.0 converts -0.0 to 0.0 (linopy LP writer bug workaround) + return -cap + 0.0, cap + 0.0 def _do_modeling(self): """Create storage model with inter-cluster linking constraints. diff --git a/flixopt/core.py b/flixopt/core.py index fdcab029b..3d456fff1 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -15,7 +15,7 @@ logger = logging.getLogger('flixopt') -FlowSystemDimensions = Literal['time', 'period', 'scenario'] +FlowSystemDimensions = Literal['time', 'cluster', 'period', 'scenario'] """Possible dimensions of a FlowSystem.""" diff --git a/flixopt/elements.py b/flixopt/elements.py index 424a9fab5..0cee53738 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -677,13 +677,10 @@ def _do_modeling(self): self._constraint_flow_rate() # Total flow hours tracking (per period) - # Sum over temporal dimensions, weighted by cluster_weight - flow_hours = self.flow_rate * self._model.timestep_duration - weighted_flow_hours = flow_hours * self._model.cluster_weight ModelingPrimitives.expression_tracking_variable( model=self, name=f'{self.label_full}|total_flow_hours', - tracked_expression=weighted_flow_hours.sum(self._model.temporal_dims), + tracked_expression=self._model.sum_temporal(self.flow_rate), bounds=( self.element.flow_hours_min if self.element.flow_hours_min is not None else 0, self.element.flow_hours_max if self.element.flow_hours_max is not None else None, @@ -840,8 +837,8 @@ def _create_bounds_for_load_factor(self): # Get the size (either from element or investment) size = self.investment.size if self.with_investment else self.element.size - # Sum over temporal dimensions, weighted by cluster_weight - total_hours = (self._model.timestep_duration * self._model.cluster_weight).sum(self._model.temporal_dims) + # Total hours in the period (sum of temporal weights) + total_hours = self._model.temporal_weight.sum(self._model.temporal_dims) # Maximum load factor constraint if self.element.load_factor_max is not None: diff --git a/flixopt/features.py b/flixopt/features.py index 95c6e9f87..e60545c71 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -196,14 +196,11 @@ def _do_modeling(self): inactive = self.add_variables(binary=True, short_name='inactive', coords=self._model.get_coords()) self.add_constraints(self.status + inactive == 1, short_name='complementary') - # 3. Total duration tracking using existing pattern - # Sum over temporal dimensions, weighted by cluster_weight - active_hours = self.status * self._model.timestep_duration - weighted_active_hours = active_hours * self._model.cluster_weight - total_hours = (self._model.timestep_duration * self._model.cluster_weight).sum(self._model.temporal_dims) + # 3. Total duration tracking + total_hours = self._model.temporal_weight.sum(self._model.temporal_dims) ModelingPrimitives.expression_tracking_variable( self, - tracked_expression=weighted_active_hours.sum(self._model.temporal_dims), + tracked_expression=self._model.sum_temporal(self.status), bounds=( self.parameters.active_hours_min if self.parameters.active_hours_min is not None else 0, self.parameters.active_hours_max @@ -630,10 +627,8 @@ def _do_modeling(self): # Add it to the total (cluster_weight handles cluster representation, defaults to 1.0) # Sum over all temporal dimensions (time, and cluster if present) - weighted_per_timestep = self.total_per_timestep * self._model.cluster_weight - # Get temporal_dims from total_per_timestep (linopy Variable) - its coords are the actual dims - temporal_dims = [d for d in self.total_per_timestep.dims if d not in ('period', 'scenario')] - self._eq_total.lhs -= weighted_per_timestep.sum(dim=temporal_dims) + weighted_per_timestep = self.total_per_timestep * self._model.weights.get('cluster', 1.0) + self._eq_total.lhs -= weighted_per_timestep.sum(dim=self._model.temporal_dims) def add_share( self, diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index b9a6a50e0..7e12029ed 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -40,7 +40,6 @@ from .clustering import Clustering from .solvers import _Solver - from .structure import TimeSeriesWeights from .types import Effect_TPS, Numeric_S, Numeric_TPS, NumericOrBool from .carrier import Carrier, CarrierContainer @@ -1117,6 +1116,7 @@ def connect_and_transform(self): self._connect_network() self._register_missing_carriers() self._assign_element_colors() + for element in chain(self.components.values(), self.effects.values(), self.buses.values()): element.transform_data() @@ -1330,22 +1330,29 @@ def flow_carriers(self) -> dict[str, str]: return self._flow_carriers - def create_model(self, normalize_weights: bool = True) -> FlowSystemModel: + def create_model(self, normalize_weights: bool | None = None) -> FlowSystemModel: """ Create a linopy model from the FlowSystem. Args: - normalize_weights: Whether to automatically normalize the weights (periods and scenarios) to sum up to 1 when solving. + normalize_weights: Deprecated. Scenario weights are now always normalized in FlowSystem. """ + if normalize_weights is not None: + warnings.warn( + f'\n\nnormalize_weights parameter is deprecated and will be removed in {DEPRECATION_REMOVAL_VERSION}. ' + 'Scenario weights are now always normalized when set on FlowSystem.\n', + DeprecationWarning, + stacklevel=2, + ) if not self.connected_and_transformed: raise RuntimeError( 'FlowSystem is not connected_and_transformed. Call FlowSystem.connect_and_transform() first.' ) # System integrity was already validated in connect_and_transform() - self.model = FlowSystemModel(self, normalize_weights) + self.model = FlowSystemModel(self) return self.model - def build_model(self, normalize_weights: bool = True) -> FlowSystem: + def build_model(self, normalize_weights: bool | None = None) -> FlowSystem: """ Build the optimization model for this FlowSystem. @@ -1359,7 +1366,7 @@ def build_model(self, normalize_weights: bool = True) -> FlowSystem: before solving. Args: - normalize_weights: Whether to normalize scenario/period weights to sum to 1. + normalize_weights: Deprecated. Scenario weights are now always normalized in FlowSystem. Returns: Self, for method chaining. @@ -1369,8 +1376,15 @@ def build_model(self, normalize_weights: bool = True) -> FlowSystem: >>> print(flow_system.model.variables) # Inspect variables before solving >>> flow_system.solve(solver) """ + if normalize_weights is not None: + warnings.warn( + f'\n\nnormalize_weights parameter is deprecated and will be removed in {DEPRECATION_REMOVAL_VERSION}. ' + 'Scenario weights are now always normalized when set on FlowSystem.\n', + DeprecationWarning, + stacklevel=2, + ) self.connect_and_transform() - self.create_model(normalize_weights) + self.create_model() self.model.do_modeling() @@ -1918,27 +1932,85 @@ def storages(self) -> ElementContainer[Storage]: self._storages_cache = ElementContainer(storages, element_type_name='storages', truncate_repr=10) return self._storages_cache + @property + def dims(self) -> list[str]: + """Active dimension names. + + Returns: + List of active dimension names in order. + + Example: + >>> fs.dims + ['time'] # simple case + >>> fs_clustered.dims + ['cluster', 'time', 'period', 'scenario'] # full case + """ + result = [] + if self.clusters is not None: + result.append('cluster') + result.append('time') + if self.periods is not None: + result.append('period') + if self.scenarios is not None: + result.append('scenario') + return result + + @property + def indexes(self) -> dict[str, pd.Index]: + """Indexes for active dimensions. + + Returns: + Dict mapping dimension names to pandas Index objects. + + Example: + >>> fs.indexes['time'] + DatetimeIndex(['2024-01-01', ...], dtype='datetime64[ns]', name='time') + """ + result: dict[str, pd.Index] = {} + if self.clusters is not None: + result['cluster'] = self.clusters + result['time'] = self.timesteps + if self.periods is not None: + result['period'] = self.periods + if self.scenarios is not None: + result['scenario'] = self.scenarios + return result + + @property + def temporal_dims(self) -> list[str]: + """Temporal dimensions for summing over time. + + Returns ['time', 'cluster'] for clustered systems, ['time'] otherwise. + """ + if self.clusters is not None: + return ['time', 'cluster'] + return ['time'] + + @property + def temporal_weight(self) -> xr.DataArray: + """Combined temporal weight (timestep_duration × cluster_weight). + + Use for converting rates to totals before summing. + Note: cluster_weight is used even without a clusters dimension. + """ + # Use cluster_weight directly if set, otherwise check weights dict, fallback to 1.0 + cluster_weight = self.weights.get('cluster', self.cluster_weight if self.cluster_weight is not None else 1.0) + return self.weights['time'] * cluster_weight + @property def coords(self) -> dict[FlowSystemDimensions, pd.Index]: """Active coordinates for variable creation. + .. deprecated:: + Use :attr:`indexes` instead. + Returns a dict of dimension names to coordinate arrays. When clustered, includes 'cluster' dimension before 'time'. Returns: Dict mapping dimension names to coordinate arrays. """ - active_coords: dict[str, pd.Index] = {} - - if self.clusters is not None: - active_coords['cluster'] = self.clusters - active_coords['time'] = self.timesteps - - if self.periods is not None: - active_coords['period'] = self.periods - if self.scenarios is not None: - active_coords['scenario'] = self.scenarios - return active_coords + return self.indexes @property def _use_true_cluster_dims(self) -> bool: @@ -1984,14 +2056,15 @@ def scenario_weights(self) -> xr.DataArray | None: @scenario_weights.setter def scenario_weights(self, value: Numeric_S | None) -> None: """ - Set scenario weights. + Set scenario weights (always normalized to sum to 1). Args: - value: Scenario weights to set (will be converted to DataArray with 'scenario' dimension) - or None to clear weights. + value: Scenario weights to set (will be converted to DataArray with 'scenario' dimension + and normalized to sum to 1), or None to clear weights. Raises: ValueError: If value is not None and no scenarios are defined in the FlowSystem. + ValueError: If weights sum to zero (cannot normalize). """ if value is None: self._scenario_weights = None @@ -2003,36 +2076,65 @@ def scenario_weights(self, value: Numeric_S | None) -> None: 'Either define scenarios in FlowSystem(scenarios=...) or set scenario_weights to None.' ) - self._scenario_weights = self.fit_to_model_coords('scenario_weights', value, dims=['scenario']) + weights = self.fit_to_model_coords('scenario_weights', value, dims=['scenario']) + + # Normalize to sum to 1 + norm = weights.sum('scenario') + if np.isclose(norm, 0.0).any(): + raise ValueError('scenario_weights sum to 0; cannot normalize.') + self._scenario_weights = weights / norm + + def _unit_weight(self, dim: str) -> xr.DataArray: + """Create a unit weight DataArray (all 1.0) for a dimension.""" + index = self.indexes[dim] + return xr.DataArray( + np.ones(len(index), dtype=float), + coords={dim: index}, + dims=[dim], + name=f'{dim}_weight', + ) @property - def weights(self) -> TimeSeriesWeights: - """Unified weighting system for time series aggregation. + def weights(self) -> dict[str, xr.DataArray]: + """Weights for active dimensions (unit weights if not explicitly set). - Returns a TimeSeriesWeights object providing a clean, unified interface - for all weight types used in flixopt. This is the recommended way to - access weights for new code (PyPSA-inspired design). + Returns: + Dict mapping dimension names to weight DataArrays. + Keys match :attr:`dims` and :attr:`indexes`. - The temporal weight combines timestep_duration and cluster_weight, - which is the proper weight for summing over time. + Example: + >>> fs.weights['time'] # timestep durations + >>> fs.weights['cluster'] # cluster weights (unit if not set) + """ + result: dict[str, xr.DataArray] = {'time': self.timestep_duration} + if self.clusters is not None: + result['cluster'] = self.cluster_weight if self.cluster_weight is not None else self._unit_weight('cluster') + if self.periods is not None: + result['period'] = self.period_weights if self.period_weights is not None else self._unit_weight('period') + if self.scenarios is not None: + result['scenario'] = ( + self.scenario_weights if self.scenario_weights is not None else self._unit_weight('scenario') + ) + return result + + def sum_temporal(self, data: xr.DataArray) -> xr.DataArray: + """Sum data over temporal dimensions with full temporal weighting. + + Applies both timestep_duration and cluster_weight, then sums over temporal dimensions. + Use this to convert rates to totals (e.g., flow_rate → total_energy). + + Args: + data: Data with time dimension (and optionally cluster). + Typically a rate (e.g., flow_rate in MW, status as 0/1). Returns: - TimeSeriesWeights with temporal, period, and scenario weights. + Data summed over temporal dims with full temporal weighting applied. Example: - >>> weights = flow_system.weights - >>> weighted_total = (flow_rate * weights.temporal).sum('time') - >>> # Or use the convenience method: - >>> weighted_total = weights.sum_over_time(flow_rate) - """ - from .structure import TimeSeriesWeights - - cluster_weight = self.cluster_weight if self.cluster_weight is not None else 1.0 - return TimeSeriesWeights( - temporal=self.timestep_duration * cluster_weight, - period=self.period_weights, - scenario=self._scenario_weights, - ) + >>> total_energy = fs.sum_temporal(flow_rate) # MW → MWh total + >>> active_hours = fs.sum_temporal(status) # count → hours + """ + return (data * self.temporal_weight).sum(self.temporal_dims) @property def is_clustered(self) -> bool: diff --git a/flixopt/optimization.py b/flixopt/optimization.py index 6a1a87ce1..0b567387f 100644 --- a/flixopt/optimization.py +++ b/flixopt/optimization.py @@ -82,7 +82,7 @@ def _initialize_optimization_common( name: str, flow_system: FlowSystem, folder: pathlib.Path | None = None, - normalize_weights: bool = True, + normalize_weights: bool | None = None, ) -> None: """ Shared initialization logic for all optimization types. @@ -95,7 +95,7 @@ def _initialize_optimization_common( name: Name of the optimization flow_system: FlowSystem to optimize folder: Directory for saving results - normalize_weights: Whether to normalize scenario weights + normalize_weights: Deprecated. Scenario weights are now always normalized in FlowSystem. """ obj.name = name @@ -106,7 +106,8 @@ def _initialize_optimization_common( ) flow_system = flow_system.copy() - obj.normalize_weights = normalize_weights + # normalize_weights is deprecated but kept for backwards compatibility + obj.normalize_weights = True # Always True now flow_system._used_in_optimization = True @@ -186,7 +187,7 @@ def do_modeling(self) -> Optimization: t_start = timeit.default_timer() self.flow_system.connect_and_transform() - self.model = self.flow_system.create_model(self.normalize_weights) + self.model = self.flow_system.create_model() self.model.do_modeling() self.durations['modeling'] = round(timeit.default_timer() - t_start, 2) diff --git a/flixopt/optimize_accessor.py b/flixopt/optimize_accessor.py index f88cdf982..7aee930a4 100644 --- a/flixopt/optimize_accessor.py +++ b/flixopt/optimize_accessor.py @@ -53,7 +53,7 @@ def __init__(self, flow_system: FlowSystem) -> None: """ self._fs = flow_system - def __call__(self, solver: _Solver, normalize_weights: bool = True) -> FlowSystem: + def __call__(self, solver: _Solver, normalize_weights: bool | None = None) -> FlowSystem: """ Build and solve the optimization model in one step. @@ -64,7 +64,7 @@ def __call__(self, solver: _Solver, normalize_weights: bool = True) -> FlowSyste Args: solver: The solver to use (e.g., HighsSolver, GurobiSolver). - normalize_weights: Whether to normalize scenario/period weights to sum to 1. + normalize_weights: Deprecated. Scenario weights are now always normalized in FlowSystem. Returns: The FlowSystem, for method chaining. @@ -85,7 +85,18 @@ def __call__(self, solver: _Solver, normalize_weights: bool = True) -> FlowSyste >>> solution = flow_system.optimize(solver).solution """ - self._fs.build_model(normalize_weights) + if normalize_weights is not None: + import warnings + + from .config import DEPRECATION_REMOVAL_VERSION + + warnings.warn( + f'\n\nnormalize_weights parameter is deprecated and will be removed in {DEPRECATION_REMOVAL_VERSION}. ' + 'Scenario weights are now always normalized when set on FlowSystem.\n', + DeprecationWarning, + stacklevel=2, + ) + self._fs.build_model() self._fs.solve(solver) return self._fs diff --git a/flixopt/statistics_accessor.py b/flixopt/statistics_accessor.py index bee26a0e2..73d115df0 100644 --- a/flixopt/statistics_accessor.py +++ b/flixopt/statistics_accessor.py @@ -847,7 +847,7 @@ def get_contributor_type(contributor: str) -> str: # For total mode, sum temporal over time (apply cluster_weight for proper weighting) # Sum over all temporal dimensions (time, and cluster if present) if mode == 'total' and current_mode == 'temporal' and 'time' in da.dims: - weighted = da * self._fs.cluster_weight + weighted = da * self._fs.weights.get('cluster', 1.0) temporal_dims = [d for d in weighted.dims if d not in ('period', 'scenario')] da = weighted.sum(temporal_dims) if share_total is None: diff --git a/flixopt/structure.py b/flixopt/structure.py index 9c211c509..4b7734199 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -43,91 +43,6 @@ CLASS_REGISTRY = {} -@dataclass -class TimeSeriesWeights: - """Unified weighting system for time series aggregation (PyPSA-inspired). - - This class provides a clean, unified interface for time series weights, - combining the various weight types used in flixopt into a single object. - - Attributes: - temporal: Combined weight for temporal operations (timestep_duration × cluster_weight). - Applied to all time-summing operations. dims: [time] or [time, period, scenario] - period: Weight for each period in multi-period optimization. - dims: [period] or None - scenario: Weight for each scenario in stochastic optimization. - dims: [scenario] or None - objective: Optional override weight for objective function calculations. - If None, uses temporal weight. dims: [time] or [time, period, scenario] - storage: Optional override weight for storage balance equations. - If None, uses temporal weight. dims: [time] or [time, period, scenario] - - Example: - >>> # Access via FlowSystem - >>> weights = flow_system.weights - >>> weighted_sum = (flow_rate * weights.temporal).sum('time') - >>> - >>> # With period/scenario weighting - >>> total = weighted_sum * weights.period * weights.scenario - - Note: - The individual properties ``cluster_weight``, ``timestep_duration``, and - ``temporal_dims`` are available on FlowSystemModel for direct access. - """ - - temporal: xr.DataArray - period: xr.DataArray | None = None - scenario: xr.DataArray | None = None - objective: xr.DataArray | None = None - storage: xr.DataArray | None = None - - def __post_init__(self): - """Validate weights.""" - if not isinstance(self.temporal, xr.DataArray): - raise TypeError('temporal must be an xarray DataArray') - if 'time' not in self.temporal.dims: - raise ValueError("temporal must have 'time' dimension") - - @property - def effective_objective(self) -> xr.DataArray: - """Get effective objective weight (override or temporal).""" - return self.objective if self.objective is not None else self.temporal - - @property - def effective_storage(self) -> xr.DataArray: - """Get effective storage weight (override or temporal).""" - return self.storage if self.storage is not None else self.temporal - - def sum_over_time(self, data: xr.DataArray) -> xr.DataArray: - """Sum data over time dimension with proper weighting. - - Args: - data: DataArray with 'time' dimension. - - Returns: - Data summed over time with temporal weighting applied. - """ - if 'time' not in data.dims: - return data - return (data * self.temporal).sum('time') - - def apply_period_scenario_weights(self, data: xr.DataArray) -> xr.DataArray: - """Apply period and scenario weights to data. - - Args: - data: DataArray, optionally with 'period' and/or 'scenario' dims. - - Returns: - Data with period and scenario weights applied. - """ - result = data - if self.period is not None and 'period' in data.dims: - result = result * self.period - if self.scenario is not None and 'scenario' in data.dims: - result = result * self.scenario - return result - - def register_class_for_io(cls): """Register a class for serialization/deserialization.""" name = cls.__name__ @@ -175,13 +90,11 @@ class FlowSystemModel(linopy.Model, SubmodelsMixin): Args: flow_system: The flow_system that is used to create the model. - normalize_weights: Whether to automatically normalize the weights to sum up to 1 when solving. """ - def __init__(self, flow_system: FlowSystem, normalize_weights: bool): + def __init__(self, flow_system: FlowSystem): super().__init__(force_dim_names=True) self.flow_system = flow_system - self.normalize_weights = normalize_weights self.effects: EffectCollectionModel | None = None self.submodels: Submodels = Submodels({}) @@ -313,57 +226,63 @@ def hours_of_previous_timesteps(self): return self.flow_system.hours_of_previous_timesteps @property - def cluster_weight(self) -> xr.DataArray | float: - """Cluster weight for time aggregation. + def dims(self) -> list[str]: + """Active dimension names.""" + return self.flow_system.dims - Represents how many original time periods each cluster represents. - Returns 1.0 for non-clustered systems. + @property + def indexes(self) -> dict[str, pd.Index]: + """Indexes for active dimensions.""" + return self.flow_system.indexes + + @property + def weights(self) -> dict[str, xr.DataArray]: + """Weights for active dimensions (unit weights if not set). + + Scenario weights are always normalized (handled by FlowSystem). """ - if self.flow_system.cluster_weight is None: - return 1.0 - return self.flow_system.cluster_weight + return self.flow_system.weights @property def temporal_dims(self) -> list[str]: """Temporal dimensions for summing over time. Returns ['time', 'cluster'] for clustered systems, ['time'] otherwise. - Use this when summing temporal values to totals. """ - if self.flow_system.clusters is not None: - return ['time', 'cluster'] - return ['time'] + return self.flow_system.temporal_dims + + @property + def temporal_weight(self) -> xr.DataArray: + """Combined temporal weight (timestep_duration × cluster_weight).""" + return self.flow_system.temporal_weight + + def sum_temporal(self, data: xr.DataArray) -> xr.DataArray: + """Sum data over temporal dimensions with full temporal weighting. + + Example: + >>> total_energy = model.sum_temporal(flow_rate) + """ + return self.flow_system.sum_temporal(data) @property def scenario_weights(self) -> xr.DataArray: """ - Scenario weights of model. With optional normalization. + Scenario weights of model (always normalized, via FlowSystem). + + Returns unit weights if no scenarios defined or no explicit weights set. """ if self.flow_system.scenarios is None: return xr.DataArray(1) if self.flow_system.scenario_weights is None: - scenario_weights = xr.DataArray( - np.ones(self.flow_system.scenarios.size, dtype=float), - coords={'scenario': self.flow_system.scenarios}, - dims=['scenario'], - name='scenario_weights', - ) - else: - scenario_weights = self.flow_system.scenario_weights - - if not self.normalize_weights: - return scenario_weights + return self.flow_system._unit_weight('scenario') - norm = scenario_weights.sum('scenario') - if np.isclose(norm, 0.0).any(): - raise ValueError('FlowSystemModel.scenario_weights: weights sum to 0; cannot normalize.') - return scenario_weights / norm + return self.flow_system.scenario_weights @property def objective_weights(self) -> xr.DataArray: """ - Objective weights of model. With optional normalization of scenario weights. + Objective weights of model (period_weights × scenario_weights). """ period_weights = self.flow_system.effects.objective_effect.submodel.period_weights scenario_weights = self.scenario_weights diff --git a/tests/deprecated/test_scenarios.py b/tests/deprecated/test_scenarios.py index 65ea62d81..2699647ad 100644 --- a/tests/deprecated/test_scenarios.py +++ b/tests/deprecated/test_scenarios.py @@ -341,12 +341,14 @@ def test_scenarios_selection(flow_system_piecewise_conversion_scenarios): assert flow_system.scenarios.equals(flow_system_full.scenarios[0:2]) - np.testing.assert_allclose(flow_system.scenario_weights.values, flow_system_full.scenario_weights[0:2]) + # Scenario weights are always normalized - subset is re-normalized to sum to 1 + subset_weights = flow_system_full.scenario_weights[0:2] + expected_normalized = subset_weights / subset_weights.sum() + np.testing.assert_allclose(flow_system.scenario_weights.values, expected_normalized.values) - # Optimize using new API with normalize_weights=False + # Optimize using new API flow_system.optimize( fx.solvers.GurobiSolver(mip_gap=0.01, time_limit_seconds=60), - normalize_weights=False, ) # Penalty has same structure as other effects: 'Penalty' is the total, 'Penalty(temporal)' and 'Penalty(periodic)' are components @@ -769,7 +771,10 @@ def test_weights_selection(): # Verify weights are correctly sliced assert fs_subset.scenarios.equals(pd.Index(['base', 'high'], name='scenario')) - np.testing.assert_allclose(fs_subset.scenario_weights.values, custom_scenario_weights[[0, 2]]) + # Scenario weights are always normalized - subset is re-normalized to sum to 1 + subset_weights = np.array([0.3, 0.2]) # Original weights for selected scenarios + expected_normalized = subset_weights / subset_weights.sum() + np.testing.assert_allclose(fs_subset.scenario_weights.values, expected_normalized) # Verify weights are 1D with just scenario dimension (no period dimension) assert fs_subset.scenario_weights.dims == ('scenario',) diff --git a/tests/test_cluster_reduce_expand.py b/tests/test_cluster_reduce_expand.py index 1d4e2d15c..7072fe22e 100644 --- a/tests/test_cluster_reduce_expand.py +++ b/tests/test_cluster_reduce_expand.py @@ -466,8 +466,8 @@ def test_storage_cluster_mode_intercluster_cyclic(self, solver_fixture, timestep assert 'cluster_boundary' in soc_boundary.dims # First and last SOC_boundary values should be equal (cyclic constraint) - first_soc = float(soc_boundary.isel(cluster_boundary=0).values) - last_soc = float(soc_boundary.isel(cluster_boundary=-1).values) + first_soc = soc_boundary.isel(cluster_boundary=0).item() + last_soc = soc_boundary.isel(cluster_boundary=-1).item() assert_allclose(first_soc, last_soc, rtol=1e-6) @@ -544,15 +544,15 @@ def test_expanded_charge_state_matches_manual_calculation(self, solver_fixture, # Manual verification for first few timesteps of first period p = 0 # First period cluster = int(cluster_order[p]) - soc_b = float(soc_boundary.isel(cluster_boundary=p).values) + soc_b = soc_boundary.isel(cluster_boundary=p).item() for t in [0, 5, 12, 23]: global_t = p * timesteps_per_cluster + t - delta_e = float(cs_clustered.isel(cluster=cluster, time=t).values) + delta_e = cs_clustered.isel(cluster=cluster, time=t).item() decay = (1 - loss_rate) ** t expected = soc_b * decay + delta_e expected_clipped = max(0.0, expected) - actual = float(cs_expanded.isel(time=global_t).values) + actual = cs_expanded.isel(time=global_t).item() assert_allclose( actual, diff --git a/tests/test_clustering/test_integration.py b/tests/test_clustering/test_integration.py index 587e39160..2d04a51c1 100644 --- a/tests/test_clustering/test_integration.py +++ b/tests/test_clustering/test_integration.py @@ -5,85 +5,121 @@ import pytest import xarray as xr -from flixopt import FlowSystem, TimeSeriesWeights +from flixopt import FlowSystem -class TestTimeSeriesWeights: - """Tests for TimeSeriesWeights class.""" +class TestWeights: + """Tests for FlowSystem.weights dict property.""" - def test_creation(self): - """Test TimeSeriesWeights creation.""" - temporal = xr.DataArray([1.0, 1.0, 1.0], dims=['time']) - weights = TimeSeriesWeights(temporal=temporal) + def test_weights_is_dict(self): + """Test weights returns a dict.""" + fs = FlowSystem(timesteps=pd.date_range('2024-01-01', periods=24, freq='h')) + weights = fs.weights + + assert isinstance(weights, dict) + assert 'time' in weights + + def test_time_weight(self): + """Test weights['time'] returns timestep_duration.""" + fs = FlowSystem(timesteps=pd.date_range('2024-01-01', periods=24, freq='h')) + weights = fs.weights + + # For hourly data, timestep_duration is 1.0 + assert float(weights['time'].mean()) == 1.0 + + def test_cluster_not_in_weights_when_non_clustered(self): + """Test weights doesn't have 'cluster' key for non-clustered systems.""" + fs = FlowSystem(timesteps=pd.date_range('2024-01-01', periods=24, freq='h')) + weights = fs.weights + + # Non-clustered: 'cluster' not in weights + assert 'cluster' not in weights - assert 'time' in weights.temporal.dims - assert float(weights.temporal.sum().values) == 3.0 + def test_temporal_dims_non_clustered(self): + """Test temporal_dims is ['time'] for non-clustered systems.""" + fs = FlowSystem(timesteps=pd.date_range('2024-01-01', periods=24, freq='h')) + + assert fs.temporal_dims == ['time'] - def test_invalid_no_time_dim(self): - """Test error when temporal has no time dimension.""" - temporal = xr.DataArray([1.0, 1.0], dims=['other']) + def test_temporal_weight(self): + """Test temporal_weight returns time * cluster.""" + fs = FlowSystem(timesteps=pd.date_range('2024-01-01', periods=24, freq='h')) - with pytest.raises(ValueError, match='time'): - TimeSeriesWeights(temporal=temporal) + expected = fs.weights['time'] * fs.weights.get('cluster', 1.0) + xr.testing.assert_equal(fs.temporal_weight, expected) - def test_sum_over_time(self): - """Test sum_over_time convenience method.""" - temporal = xr.DataArray([2.0, 3.0, 1.0], dims=['time'], coords={'time': [0, 1, 2]}) - weights = TimeSeriesWeights(temporal=temporal) + def test_sum_temporal(self): + """Test sum_temporal applies full temporal weighting (time * cluster) and sums.""" + fs = FlowSystem(timesteps=pd.date_range('2024-01-01', periods=3, freq='h')) - data = xr.DataArray([10.0, 20.0, 30.0], dims=['time'], coords={'time': [0, 1, 2]}) - result = weights.sum_over_time(data) + # Input is a rate (e.g., flow_rate in MW) + data = xr.DataArray([10.0, 20.0, 30.0], dims=['time'], coords={'time': fs.timesteps}) - # 10*2 + 20*3 + 30*1 = 20 + 60 + 30 = 110 - assert float(result.values) == 110.0 + result = fs.sum_temporal(data) - def test_effective_objective(self): - """Test effective_objective property.""" - temporal = xr.DataArray([1.0, 1.0], dims=['time']) - objective = xr.DataArray([2.0, 2.0], dims=['time']) + # For hourly non-clustered: temporal = time * cluster = 1.0 * 1.0 = 1.0 + # result = sum(data * temporal) = sum(data) = 60 + assert float(result.values) == 60.0 - # Without override - weights1 = TimeSeriesWeights(temporal=temporal) - assert np.array_equal(weights1.effective_objective.values, temporal.values) - # With override - weights2 = TimeSeriesWeights(temporal=temporal, objective=objective) - assert np.array_equal(weights2.effective_objective.values, objective.values) +class TestFlowSystemDimsIndexesWeights: + """Tests for FlowSystem.dims, .indexes, .weights properties.""" + def test_dims_property(self): + """Test that FlowSystem.dims returns active dimension names.""" + fs = FlowSystem(timesteps=pd.date_range('2024-01-01', periods=24, freq='h')) -class TestFlowSystemWeightsProperty: - """Tests for FlowSystem.weights property.""" + dims = fs.dims + assert dims == ['time'] - def test_weights_property_exists(self): - """Test that FlowSystem has weights property.""" + def test_indexes_property(self): + """Test that FlowSystem.indexes returns active indexes.""" fs = FlowSystem(timesteps=pd.date_range('2024-01-01', periods=24, freq='h')) - weights = fs.weights - assert isinstance(weights, TimeSeriesWeights) + indexes = fs.indexes + assert isinstance(indexes, dict) + assert 'time' in indexes + assert len(indexes['time']) == 24 - def test_weights_temporal_equals_aggregation_weight(self): - """Test that weights.temporal equals aggregation_weight.""" + def test_weights_keys_match_dims(self): + """Test that weights.keys() is subset of dims (only 'time' for simple case).""" fs = FlowSystem(timesteps=pd.date_range('2024-01-01', periods=24, freq='h')) - weights = fs.weights - aggregation_weight = fs.aggregation_weight + # For non-clustered, weights only has 'time' + assert set(fs.weights.keys()) == {'time'} - np.testing.assert_array_almost_equal(weights.temporal.values, aggregation_weight.values) + def test_temporal_weight_calculation(self): + """Test that temporal_weight = timestep_duration * cluster_weight.""" + fs = FlowSystem(timesteps=pd.date_range('2024-01-01', periods=24, freq='h')) + + expected = fs.timestep_duration * 1.0 # cluster is 1.0 for non-clustered + + np.testing.assert_array_almost_equal(fs.temporal_weight.values, expected.values) def test_weights_with_cluster_weight(self): - """Test weights property includes cluster_weight.""" + """Test weights property includes cluster_weight when provided.""" # Create FlowSystem with custom cluster_weight timesteps = pd.date_range('2024-01-01', periods=24, freq='h') - cluster_weight = np.array([2.0] * 12 + [1.0] * 12) # First 12h weighted 2x + cluster_weight = xr.DataArray( + np.array([2.0] * 12 + [1.0] * 12), + dims=['time'], + coords={'time': timesteps}, + ) fs = FlowSystem(timesteps=timesteps, cluster_weight=cluster_weight) weights = fs.weights - # temporal = timestep_duration * cluster_weight - # timestep_duration is 1h for all, so temporal = cluster_weight + # cluster weight should be in weights (FlowSystem has cluster_weight set) + # But note: 'cluster' only appears in weights if clusters dimension exists + # Since we didn't set clusters, 'cluster' won't be in weights + # The cluster_weight is applied via temporal_weight + assert 'cluster' not in weights # No cluster dimension + + # temporal_weight = timestep_duration * cluster_weight + # timestep_duration is 1h for all expected = 1.0 * cluster_weight - np.testing.assert_array_almost_equal(weights.temporal.values, expected) + np.testing.assert_array_almost_equal(fs.temporal_weight.values, expected.values) class TestClusterMethod: diff --git a/tests/test_io_conversion.py b/tests/test_io_conversion.py index 33bda8c91..7775f987a 100644 --- a/tests/test_io_conversion.py +++ b/tests/test_io_conversion.py @@ -762,6 +762,11 @@ def test_v4_reoptimized_objective_matches_original(self, result_name): new_objective = float(fs.solution['objective'].item()) new_effect_total = float(fs.solution[objective_effect_label].sum().item()) + # Skip comparison for scenarios test case - scenario weights are now always normalized, + # which changes the objective value when loading old results with non-normalized weights + if result_name == '04_scenarios': + pytest.skip('Scenario weights are now always normalized - old results have different weights') + # Verify objective matches (within tolerance) assert new_objective == pytest.approx(old_objective, rel=1e-5, abs=1), ( f'Objective mismatch for {result_name}: new={new_objective}, old={old_objective}' diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index 65ea62d81..2699647ad 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -341,12 +341,14 @@ def test_scenarios_selection(flow_system_piecewise_conversion_scenarios): assert flow_system.scenarios.equals(flow_system_full.scenarios[0:2]) - np.testing.assert_allclose(flow_system.scenario_weights.values, flow_system_full.scenario_weights[0:2]) + # Scenario weights are always normalized - subset is re-normalized to sum to 1 + subset_weights = flow_system_full.scenario_weights[0:2] + expected_normalized = subset_weights / subset_weights.sum() + np.testing.assert_allclose(flow_system.scenario_weights.values, expected_normalized.values) - # Optimize using new API with normalize_weights=False + # Optimize using new API flow_system.optimize( fx.solvers.GurobiSolver(mip_gap=0.01, time_limit_seconds=60), - normalize_weights=False, ) # Penalty has same structure as other effects: 'Penalty' is the total, 'Penalty(temporal)' and 'Penalty(periodic)' are components @@ -769,7 +771,10 @@ def test_weights_selection(): # Verify weights are correctly sliced assert fs_subset.scenarios.equals(pd.Index(['base', 'high'], name='scenario')) - np.testing.assert_allclose(fs_subset.scenario_weights.values, custom_scenario_weights[[0, 2]]) + # Scenario weights are always normalized - subset is re-normalized to sum to 1 + subset_weights = np.array([0.3, 0.2]) # Original weights for selected scenarios + expected_normalized = subset_weights / subset_weights.sum() + np.testing.assert_allclose(fs_subset.scenario_weights.values, expected_normalized) # Verify weights are 1D with just scenario dimension (no period dimension) assert fs_subset.scenario_weights.dims == ('scenario',) From d9771498727c492d5e701597beabe4d96b588070 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 3 Jan 2026 18:47:32 +0100 Subject: [PATCH 11/12] Update CHANGELOG.md --- CHANGELOG.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20f2de7d7..f60497d80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,7 +53,7 @@ Until here --> ## [5.1.0] - Upcoming -**Summary**: Time-series clustering for faster optimization with configurable storage behavior across typical periods. +**Summary**: Time-series clustering for faster optimization with configurable storage behavior across typical periods. Improved weights API with always-normalized scenario weights. ### ✨ Added @@ -121,6 +121,23 @@ charge_state = fs_expanded.solution['SeasonalPit|charge_state'] Use `'cyclic'` for short-term storage like batteries or hot water tanks where only daily patterns matter. Use `'independent'` for quick estimates when storage behavior isn't critical. +### 💥 Breaking Changes + +- `FlowSystem.scenario_weights` are now always normalized to sum to 1 when set (including after `.sel()` subsetting) + +### ♻️ Changed + +- `FlowSystem.weights` returns `dict[str, xr.DataArray]` (unit weights instead of `1.0` float fallback) +- `FlowSystemDimensions` type now includes `'cluster'` + +### 🗑️ Deprecated + +- `normalize_weights` parameter in `create_model()`, `build_model()`, `optimize()` + +### 🐛 Fixed + +- `temporal_weight` and `sum_temporal()` now use consistent implementation + ### 👷 Development **New Test Suites for Clustering**: From 2b698c0c34dfce76592ec2a789ae6c9886af9bbd Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 3 Jan 2026 18:50:34 +0100 Subject: [PATCH 12/12] 1. ClusterStructure.n_clusters naming - Added explicit rename (matching n_representatives pattern) to avoid "None" variable names in serialized datasets 2. original_timesteps validation - Added explicit KeyError with actionable message when original_time coordinate is missing 3. active_hours bounds simplified - Passing total_hours DataArray directly instead of .max().item() fallback, allowing proper per-(period, scenario) bounds --- flixopt/clustering/base.py | 14 ++++++++++++-- flixopt/features.py | 6 +----- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/flixopt/clustering/base.py b/flixopt/clustering/base.py index c60d309aa..fadf28247 100644 --- a/flixopt/clustering/base.py +++ b/flixopt/clustering/base.py @@ -112,8 +112,10 @@ def _create_reference_structure(self) -> tuple[dict, dict[str, xr.DataArray]]: # Store scalar values if isinstance(self.n_clusters, xr.DataArray): - arrays[str(self.n_clusters.name)] = self.n_clusters - ref['n_clusters'] = f':::{self.n_clusters.name}' + n_clusters_name = self.n_clusters.name or 'n_clusters' + self.n_clusters = self.n_clusters.rename(n_clusters_name) + arrays[n_clusters_name] = self.n_clusters + ref['n_clusters'] = f':::{n_clusters_name}' else: ref['n_clusters'] = int(self.n_clusters) @@ -1121,7 +1123,15 @@ def original_timesteps(self) -> pd.DatetimeIndex: """Original timesteps before clustering. Derived from the 'original_time' coordinate of timestep_mapping. + + Raises: + KeyError: If 'original_time' coordinate is missing from timestep_mapping. """ + if 'original_time' not in self.result.timestep_mapping.coords: + raise KeyError( + "timestep_mapping is missing 'original_time' coordinate. " + 'This may indicate corrupted or incompatible clustering results.' + ) return pd.DatetimeIndex(self.result.timestep_mapping.coords['original_time'].values) diff --git a/flixopt/features.py b/flixopt/features.py index e60545c71..289640ddd 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -203,11 +203,7 @@ def _do_modeling(self): tracked_expression=self._model.sum_temporal(self.status), bounds=( self.parameters.active_hours_min if self.parameters.active_hours_min is not None else 0, - self.parameters.active_hours_max - if self.parameters.active_hours_max is not None - else total_hours.max().item() - if hasattr(total_hours, 'max') - else total_hours, + self.parameters.active_hours_max if self.parameters.active_hours_max is not None else total_hours, ), short_name='active_hours', coords=['period', 'scenario'],