From 0b6d62c0a75706277ac2f0aa68b541901c2761a2 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 12 Oct 2025 00:57:15 +0200 Subject: [PATCH 01/18] First try --- flixopt/elements.py | 21 +++++++++ flixopt/features.py | 21 +++++++++ flixopt/flow_system.py | 100 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 142 insertions(+) diff --git a/flixopt/elements.py b/flixopt/elements.py index f00b4c9b9..1ab22c7da 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -506,6 +506,9 @@ def _do_modeling(self): short_name='flow_rate', ) + # Add scenario equality constraints for flow_rate if required + self._add_scenario_equality_constraints_for_flow_rate() + self._constraint_flow_rate() # Total flow hours tracking @@ -552,6 +555,24 @@ def _create_investment_model(self): 'investment', ) + def _add_scenario_equality_constraints_for_flow_rate(self): + """Add equality constraints to equalize flow_rate across scenarios if configured.""" + flow_system = self._model.flow_system + + # Check if this element should have flow_rate equalized across scenarios + if not flow_system._should_include_scenario_dim(self.label_full, 'flow_rate'): + # Flow rate should be equal across all scenarios + if 'scenario' in self.flow_rate.dims and len(self.flow_rate.coords['scenario']) > 1: + # Create constraints: flow_rate[time, period, scenario_i] == flow_rate[time, period, scenario_0] for all i > 0 + scenarios = list(self.flow_rate.coords['scenario'].values) + base_scenario = scenarios[0] + + for scenario in scenarios[1:]: + self.add_constraints( + self.flow_rate.sel(scenario=base_scenario) == self.flow_rate.sel(scenario=scenario), + short_name=f'equal_flow_rate_scenario_{scenario}', + ) + def _constraint_flow_rate(self): if not self.with_investment and not self.with_on_off: # Most basic case. Already covered by direct variable bounds diff --git a/flixopt/features.py b/flixopt/features.py index 7be0c5f30..b118d757b 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -61,6 +61,9 @@ def _create_variables_and_constraints(self): coords=self._model.get_coords(['period', 'scenario']), ) + # Add scenario equality constraints if required + self._add_scenario_equality_constraints_for_size() + # Optional (not mandatory) if not self.parameters.mandatory: self.add_variables( @@ -89,6 +92,24 @@ def _create_variables_and_constraints(self): short_name='segments', ) + def _add_scenario_equality_constraints_for_size(self): + """Add equality constraints to equalize size across scenarios if configured.""" + flow_system = self._model.flow_system + + # Check if this element should have size equalized across scenarios + if not flow_system._should_include_scenario_dim(self.label_of_element, 'size'): + # Size should be equal across all scenarios + if 'scenario' in self.size.dims and len(self.size.coords['scenario']) > 1: + # Create constraints: size[period, scenario_i] == size[period, scenario_0] for all i > 0 + scenarios = list(self.size.coords['scenario'].values) + base_scenario = scenarios[0] + + for scenario in scenarios[1:]: + self.add_constraints( + self.size.sel(scenario=base_scenario) == self.size.sel(scenario=scenario), + short_name=f'equal_size_scenario_{scenario}', + ) + def _add_effects(self): """Add investment effects""" if self.parameters.effects_of_investment: diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 769391fb7..f67f34fe0 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -60,6 +60,14 @@ class FlowSystem(Interface): If you use an array, take care that its long enough to cover all previous values! weights: The weights of each period and scenario. If None, all scenarios have the same weight (normalized to 1). Its recommended to normalize the weights to sum up to 1. + size_per_scenario: Controls whether investment sizes vary by scenario. + - False: All sizes are shared across scenarios (default) + - True: All sizes are optimized separately per scenario + - list[str]: Only specified components (by label_full) vary per scenario + flow_rate_per_scenario: Controls whether flow rates vary by scenario. + - True: All flow rates are optimized separately per scenario (default) + - False: All flow rates are shared across scenarios + - list[str]: Only specified flows (by label_full) vary per scenario Notes: - Creates an empty registry for components and buses, an empty EffectCollection, and a placeholder for a SystemModel. @@ -75,6 +83,8 @@ def __init__( hours_of_last_timestep: float | None = None, hours_of_previous_timesteps: int | float | np.ndarray | None = None, weights: PeriodicDataUser | None = None, + size_per_scenario: bool | list[str] = False, + flow_rate_per_scenario: bool | list[str] = True, ): self.timesteps = self._validate_timesteps(timesteps) self.timesteps_extra = self._create_timesteps_with_extra(self.timesteps, hours_of_last_timestep) @@ -104,6 +114,10 @@ def __init__( self._network_app = None + # Use properties to validate and store scenario dimension settings + self.size_per_scenario = size_per_scenario + self.flow_rate_per_scenario = flow_rate_per_scenario + @staticmethod def _validate_timesteps(timesteps: pd.DatetimeIndex) -> pd.DatetimeIndex: """Validate timesteps format and rename if needed.""" @@ -186,6 +200,30 @@ def _calculate_hours_of_previous_timesteps( first_interval = timesteps[1] - timesteps[0] return first_interval.total_seconds() / 3600 # Convert to hours + def _should_include_scenario_dim( + self, element_label_full: str, parameter_type: Literal['size', 'flow_rate'] + ) -> bool: + """ + Determine if 'scenario' dimension should be included for this element's parameter. + + Args: + element_label_full: The full label of the component or flow + parameter_type: Whether checking for 'size' or 'flow_rate' + + Returns: + True if scenario dimension should be included, False otherwise + """ + if self.scenarios is None: + # No scenarios defined, so no scenario dimension + return False + + config = self.size_per_scenario if parameter_type == 'size' else self.flow_rate_per_scenario + + if isinstance(config, bool): + return config + else: # list[str] + return element_label_full in config + def _create_reference_structure(self) -> tuple[dict, dict[str, xr.DataArray]]: """ Override Interface method to handle FlowSystem-specific serialization. @@ -762,6 +800,68 @@ def coords(self) -> dict[FlowSystemDimensions, pd.Index]: def used_in_calculation(self) -> bool: return self._used_in_calculation + @property + def size_per_scenario(self) -> bool | list[str]: + """ + Controls whether investment sizes vary by scenario. + + Returns: + bool or list[str]: Configuration for scenario-dependent sizing + """ + return self._size_per_scenario + + @size_per_scenario.setter + def size_per_scenario(self, value: bool | list[str]) -> None: + """ + Set whether investment sizes should vary by scenario. + + Args: + value: False (shared), True (all separate), or list of component label_full strings + + Raises: + TypeError: If value is not bool or list[str] + ValueError: If list contains non-string elements + """ + if isinstance(value, bool): + self._size_per_scenario = value + elif isinstance(value, list): + if not all(isinstance(item, str) for item in value): + raise ValueError('size_per_scenario list must contain only strings (component label_full values)') + self._size_per_scenario = value + else: + raise TypeError(f'size_per_scenario must be bool or list[str], got {type(value).__name__}') + + @property + def flow_rate_per_scenario(self) -> bool | list[str]: + """ + Controls whether flow rates vary by scenario. + + Returns: + bool or list[str]: Configuration for scenario-dependent flow rates + """ + return self._flow_rate_per_scenario + + @flow_rate_per_scenario.setter + def flow_rate_per_scenario(self, value: bool | list[str]) -> None: + """ + Set whether flow rates should vary by scenario. + + Args: + value: False (shared), True (all separate), or list of flow label_full strings + + Raises: + TypeError: If value is not bool or list[str] + ValueError: If list contains non-string elements + """ + if isinstance(value, bool): + self._flow_rate_per_scenario = value + elif isinstance(value, list): + if not all(isinstance(item, str) for item in value): + raise ValueError('flow_rate_per_scenario list must contain only strings (flow label_full values)') + self._flow_rate_per_scenario = value + else: + raise TypeError(f'flow_rate_per_scenario must be bool or list[str], got {type(value).__name__}') + def sel( self, time: str | slice | list[str] | pd.Timestamp | pd.DatetimeIndex | None = None, From 6b8692c6162e0332be9b97b26a170adabaf60ac9 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 12 Oct 2025 01:00:43 +0200 Subject: [PATCH 02/18] Centralize in FlowSystem --- flixopt/elements.py | 21 --------------------- flixopt/features.py | 21 --------------------- flixopt/structure.py | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 42 deletions(-) diff --git a/flixopt/elements.py b/flixopt/elements.py index 1ab22c7da..f00b4c9b9 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -506,9 +506,6 @@ def _do_modeling(self): short_name='flow_rate', ) - # Add scenario equality constraints for flow_rate if required - self._add_scenario_equality_constraints_for_flow_rate() - self._constraint_flow_rate() # Total flow hours tracking @@ -555,24 +552,6 @@ def _create_investment_model(self): 'investment', ) - def _add_scenario_equality_constraints_for_flow_rate(self): - """Add equality constraints to equalize flow_rate across scenarios if configured.""" - flow_system = self._model.flow_system - - # Check if this element should have flow_rate equalized across scenarios - if not flow_system._should_include_scenario_dim(self.label_full, 'flow_rate'): - # Flow rate should be equal across all scenarios - if 'scenario' in self.flow_rate.dims and len(self.flow_rate.coords['scenario']) > 1: - # Create constraints: flow_rate[time, period, scenario_i] == flow_rate[time, period, scenario_0] for all i > 0 - scenarios = list(self.flow_rate.coords['scenario'].values) - base_scenario = scenarios[0] - - for scenario in scenarios[1:]: - self.add_constraints( - self.flow_rate.sel(scenario=base_scenario) == self.flow_rate.sel(scenario=scenario), - short_name=f'equal_flow_rate_scenario_{scenario}', - ) - def _constraint_flow_rate(self): if not self.with_investment and not self.with_on_off: # Most basic case. Already covered by direct variable bounds diff --git a/flixopt/features.py b/flixopt/features.py index b118d757b..7be0c5f30 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -61,9 +61,6 @@ def _create_variables_and_constraints(self): coords=self._model.get_coords(['period', 'scenario']), ) - # Add scenario equality constraints if required - self._add_scenario_equality_constraints_for_size() - # Optional (not mandatory) if not self.parameters.mandatory: self.add_variables( @@ -92,24 +89,6 @@ def _create_variables_and_constraints(self): short_name='segments', ) - def _add_scenario_equality_constraints_for_size(self): - """Add equality constraints to equalize size across scenarios if configured.""" - flow_system = self._model.flow_system - - # Check if this element should have size equalized across scenarios - if not flow_system._should_include_scenario_dim(self.label_of_element, 'size'): - # Size should be equal across all scenarios - if 'scenario' in self.size.dims and len(self.size.coords['scenario']) > 1: - # Create constraints: size[period, scenario_i] == size[period, scenario_0] for all i > 0 - scenarios = list(self.size.coords['scenario'].values) - base_scenario = scenarios[0] - - for scenario in scenarios[1:]: - self.add_constraints( - self.size.sel(scenario=base_scenario) == self.size.sel(scenario=scenario), - short_name=f'equal_size_scenario_{scenario}', - ) - def _add_effects(self): """Add investment effects""" if self.parameters.effects_of_investment: diff --git a/flixopt/structure.py b/flixopt/structure.py index defccd4b8..fa940a7c6 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -103,6 +103,42 @@ def do_modeling(self): for bus in self.flow_system.buses.values(): bus.create_model(self) + # Add scenario equality constraints after all elements are modeled + self._add_scenario_equality_constraints() + + def _add_scenario_equality_constraints(self): + """Add equality constraints to equalize variables across scenarios based on FlowSystem configuration.""" + # Only proceed if we have scenarios + if self.flow_system.scenarios is None or len(self.flow_system.scenarios) <= 1: + return + + scenarios = list(self.flow_system.scenarios.values) + base_scenario = scenarios[0] + + # Add size equality constraints for flows and components + for flow in self.flow_system.flows.values(): + # Check if this flow should have size equalized + if not self.flow_system._should_include_scenario_dim(flow.label_full, 'size'): + # Check if flow has investment + if hasattr(flow.submodel, 'investment') and flow.submodel.investment is not None: + size_var = flow.submodel.investment.size + if 'scenario' in size_var.dims: + for scenario in scenarios[1:]: + self.add_constraints( + size_var.sel(scenario=base_scenario) == size_var.sel(scenario=scenario), + name=f'{flow.label_full}|equal_size_scenario_{scenario}', + ) + + # Check if this flow should have flow_rate equalized + if not self.flow_system._should_include_scenario_dim(flow.label_full, 'flow_rate'): + flow_rate_var = flow.submodel.flow_rate + if 'scenario' in flow_rate_var.dims: + for scenario in scenarios[1:]: + self.add_constraints( + flow_rate_var.sel(scenario=base_scenario) == flow_rate_var.sel(scenario=scenario), + name=f'{flow.label_full}|equal_flow_rate_scenario_{scenario}', + ) + @property def solution(self): solution = super().solution From 6b343f8ee1a24f4250165340b6151b15c1d93efd Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 12 Oct 2025 01:39:15 +0200 Subject: [PATCH 03/18] Add centralized handling --- flixopt/structure.py | 53 ++++++++++++++++++++++---------------------- 1 file changed, 26 insertions(+), 27 deletions(-) diff --git a/flixopt/structure.py b/flixopt/structure.py index fa940a7c6..dec31c558 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -110,34 +110,33 @@ def _add_scenario_equality_constraints(self): """Add equality constraints to equalize variables across scenarios based on FlowSystem configuration.""" # Only proceed if we have scenarios if self.flow_system.scenarios is None or len(self.flow_system.scenarios) <= 1: - return + return None + + if self.flow_system.flow_rate_per_scenario is not True: + if self.flow_system.flow_rate_per_scenario is False: + flow_vars = [var for var in self.variables if var.endswith('|flow_rate')] + else: + flow_vars = [f'{flow}|flow_rate' for flow in self.flow_system.flow_rate_per_scenario] + + for flow_var in flow_vars: + self.add_constraints( + self.variables[flow_var].isel(scenario=0) == self.variables[flow_var].isel(scenario=slice(1, None)), + name=f'{flow_var}|scenario_independent', + ) + + if self.flow_system.size_per_scenario is not True: + if self.flow_system.size_per_scenario is False: + size_vars = [var for var in self.variables if var.endswith('|size')] + else: + size_vars = [f'{element}|size' for element in self.flow_system.size_per_scenario] + + for size_var in size_vars: + self.add_constraints( + self.variables[size_var].isel(scenario=0) == self.variables[size_var].isel(scenario=slice(1, None)), + name=f'{size_var}|scenario_independent', + ) - scenarios = list(self.flow_system.scenarios.values) - base_scenario = scenarios[0] - - # Add size equality constraints for flows and components - for flow in self.flow_system.flows.values(): - # Check if this flow should have size equalized - if not self.flow_system._should_include_scenario_dim(flow.label_full, 'size'): - # Check if flow has investment - if hasattr(flow.submodel, 'investment') and flow.submodel.investment is not None: - size_var = flow.submodel.investment.size - if 'scenario' in size_var.dims: - for scenario in scenarios[1:]: - self.add_constraints( - size_var.sel(scenario=base_scenario) == size_var.sel(scenario=scenario), - name=f'{flow.label_full}|equal_size_scenario_{scenario}', - ) - - # Check if this flow should have flow_rate equalized - if not self.flow_system._should_include_scenario_dim(flow.label_full, 'flow_rate'): - flow_rate_var = flow.submodel.flow_rate - if 'scenario' in flow_rate_var.dims: - for scenario in scenarios[1:]: - self.add_constraints( - flow_rate_var.sel(scenario=base_scenario) == flow_rate_var.sel(scenario=scenario), - name=f'{flow.label_full}|equal_flow_rate_scenario_{scenario}', - ) + return None @property def solution(self): From 057f6ba5bf5073b49a992b8c3a3597d188e9c29a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 12 Oct 2025 01:49:00 +0200 Subject: [PATCH 04/18] Logical Bug --- flixopt/structure.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/flixopt/structure.py b/flixopt/structure.py index dec31c558..e3239f7a4 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -114,9 +114,13 @@ def _add_scenario_equality_constraints(self): if self.flow_system.flow_rate_per_scenario is not True: if self.flow_system.flow_rate_per_scenario is False: + # All flow rates should be scenario-independent flow_vars = [var for var in self.variables if var.endswith('|flow_rate')] else: - flow_vars = [f'{flow}|flow_rate' for flow in self.flow_system.flow_rate_per_scenario] + # Only flow rates NOT in the list should be scenario-independent + all_flow_vars = [var for var in self.variables if var.endswith('|flow_rate')] + allowed_to_vary = {f'{flow}|flow_rate' for flow in self.flow_system.flow_rate_per_scenario} + flow_vars = [var for var in all_flow_vars if var not in allowed_to_vary] for flow_var in flow_vars: self.add_constraints( @@ -126,9 +130,13 @@ def _add_scenario_equality_constraints(self): if self.flow_system.size_per_scenario is not True: if self.flow_system.size_per_scenario is False: + # All sizes should be scenario-independent size_vars = [var for var in self.variables if var.endswith('|size')] else: - size_vars = [f'{element}|size' for element in self.flow_system.size_per_scenario] + # Only sizes NOT in the list should be scenario-independent + all_size_vars = [var for var in self.variables if var.endswith('|size')] + allowed_to_vary = {f'{element}|size' for element in self.flow_system.size_per_scenario} + size_vars = [var for var in all_size_vars if var not in allowed_to_vary] for size_var in size_vars: self.add_constraints( From 36ff6454f14a2d2eceedaca378efbbe0a7243439 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 12 Oct 2025 01:49:12 +0200 Subject: [PATCH 05/18] Add to IO --- flixopt/flow_system.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index f67f34fe0..fedbf7808 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -306,6 +306,8 @@ def from_dataset(cls, ds: xr.Dataset) -> FlowSystem: else None, hours_of_last_timestep=reference_structure.get('hours_of_last_timestep'), hours_of_previous_timesteps=reference_structure.get('hours_of_previous_timesteps'), + size_per_scenario=reference_structure.get('size_per_scenario', False), + flow_rate_per_scenario=reference_structure.get('flow_rate_per_scenario', True), ) # Restore components From 687640c0a2d4c14429be7c2845f0a7ad119d17e0 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 12 Oct 2025 01:49:25 +0200 Subject: [PATCH 06/18] Add test --- tests/test_scenarios.py | 352 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 352 insertions(+) diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index 1ff9e9cea..ba9c32fc4 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -337,3 +337,355 @@ def test_scenarios_selection(flow_system_piecewise_conversion_scenarios): ) ## Account for rounding errors assert calc.results.solution.indexes['scenario'].equals(flow_system_full.scenarios[0:2]) + + +def test_size_per_scenario_default(): + """Test that size_per_scenario defaults to False (sizes shared across scenarios).""" + timesteps = pd.date_range('2023-01-01', periods=24, freq='h') + scenarios = pd.Index(['base', 'high'], name='scenario') + + fs = fx.FlowSystem(timesteps=timesteps, scenarios=scenarios) + + assert fs.size_per_scenario is False + assert fs.flow_rate_per_scenario is True + + +def test_size_per_scenario_bool(): + """Test size_per_scenario with boolean values.""" + timesteps = pd.date_range('2023-01-01', periods=24, freq='h') + scenarios = pd.Index(['base', 'high'], name='scenario') + + # Test False + fs1 = fx.FlowSystem(timesteps=timesteps, scenarios=scenarios, size_per_scenario=False) + assert fs1.size_per_scenario is False + + # Test True + fs2 = fx.FlowSystem(timesteps=timesteps, scenarios=scenarios, size_per_scenario=True) + assert fs2.size_per_scenario is True + + +def test_size_per_scenario_list(): + """Test size_per_scenario with list of element labels.""" + timesteps = pd.date_range('2023-01-01', periods=24, freq='h') + scenarios = pd.Index(['base', 'high'], name='scenario') + + fs = fx.FlowSystem( + timesteps=timesteps, + scenarios=scenarios, + size_per_scenario=['solar->grid', 'battery->grid'], + ) + + assert fs.size_per_scenario == ['solar->grid', 'battery->grid'] + assert fs._should_include_scenario_dim('solar->grid', 'size') is True + assert fs._should_include_scenario_dim('battery->grid', 'size') is True + assert fs._should_include_scenario_dim('wind->grid', 'size') is False + + +def test_flow_rate_per_scenario_default(): + """Test that flow_rate_per_scenario defaults to True (flow rates vary by scenario).""" + timesteps = pd.date_range('2023-01-01', periods=24, freq='h') + scenarios = pd.Index(['base', 'high'], name='scenario') + + fs = fx.FlowSystem(timesteps=timesteps, scenarios=scenarios) + + assert fs.flow_rate_per_scenario is True + + +def test_flow_rate_per_scenario_bool(): + """Test flow_rate_per_scenario with boolean values.""" + timesteps = pd.date_range('2023-01-01', periods=24, freq='h') + scenarios = pd.Index(['base', 'high'], name='scenario') + + # Test False (shared) + fs1 = fx.FlowSystem(timesteps=timesteps, scenarios=scenarios, flow_rate_per_scenario=False) + assert fs1.flow_rate_per_scenario is False + + # Test True (per scenario) + fs2 = fx.FlowSystem(timesteps=timesteps, scenarios=scenarios, flow_rate_per_scenario=True) + assert fs2.flow_rate_per_scenario is True + + +def test_scenario_parameters_property_setters(): + """Test that scenario parameters can be changed via property setters.""" + timesteps = pd.date_range('2023-01-01', periods=24, freq='h') + scenarios = pd.Index(['base', 'high'], name='scenario') + + fs = fx.FlowSystem(timesteps=timesteps, scenarios=scenarios) + + # Change size_per_scenario + fs.size_per_scenario = True + assert fs.size_per_scenario is True + + fs.size_per_scenario = ['component1', 'component2'] + assert fs.size_per_scenario == ['component1', 'component2'] + + # Change flow_rate_per_scenario + fs.flow_rate_per_scenario = False + assert fs.flow_rate_per_scenario is False + + fs.flow_rate_per_scenario = ['flow1', 'flow2'] + assert fs.flow_rate_per_scenario == ['flow1', 'flow2'] + + +def test_scenario_parameters_validation(): + """Test that scenario parameters are validated correctly.""" + timesteps = pd.date_range('2023-01-01', periods=24, freq='h') + scenarios = pd.Index(['base', 'high'], name='scenario') + + fs = fx.FlowSystem(timesteps=timesteps, scenarios=scenarios) + + # Test invalid type + with pytest.raises(TypeError, match='must be bool or list'): + fs.size_per_scenario = 'invalid' + + # Test invalid list content + with pytest.raises(ValueError, match='must contain only strings'): + fs.size_per_scenario = [1, 2, 3] + + +def test_size_equality_constraints(): + """Test that size equality constraints are created when size_per_scenario=False.""" + timesteps = pd.date_range('2023-01-01', periods=24, freq='h') + scenarios = pd.Index(['base', 'high'], name='scenario') + + fs = fx.FlowSystem( + timesteps=timesteps, + scenarios=scenarios, + size_per_scenario=False, # Sizes should be equal + flow_rate_per_scenario=True, # Flow rates can vary + ) + + bus = fx.Bus('grid') + source = fx.Source( + label='solar', + outputs=[ + fx.Flow( + label='out', + bus='grid', + size=fx.InvestParameters( + minimum_size=10, + maximum_size=100, + effects_of_investment_per_size={'cost': 100}, + ), + ) + ], + ) + + fs.add_elements(bus, source, fx.Effect('cost', 'Total cost', '€', is_objective=True)) + + calc = fx.FullCalculation('test', fs) + calc.do_modeling() + + # Check that size equality constraint exists + constraint_names = [str(c) for c in calc.model.constraints] + size_constraints = [c for c in constraint_names if 'scenario_independent' in c and 'size' in c] + + assert len(size_constraints) > 0, 'Size equality constraint should exist' + + +def test_flow_rate_equality_constraints(): + """Test that flow_rate equality constraints are created when flow_rate_per_scenario=False.""" + timesteps = pd.date_range('2023-01-01', periods=24, freq='h') + scenarios = pd.Index(['base', 'high'], name='scenario') + + fs = fx.FlowSystem( + timesteps=timesteps, + scenarios=scenarios, + size_per_scenario=True, # Sizes can vary + flow_rate_per_scenario=False, # Flow rates should be equal + ) + + bus = fx.Bus('grid') + source = fx.Source( + label='solar', + outputs=[ + fx.Flow( + label='out', + bus='grid', + size=fx.InvestParameters( + minimum_size=10, + maximum_size=100, + effects_of_investment_per_size={'cost': 100}, + ), + ) + ], + ) + + fs.add_elements(bus, source, fx.Effect('cost', 'Total cost', '€', is_objective=True)) + + calc = fx.FullCalculation('test', fs) + calc.do_modeling() + + # Check that flow_rate equality constraint exists + constraint_names = [str(c) for c in calc.model.constraints] + flow_rate_constraints = [c for c in constraint_names if 'scenario_independent' in c and 'flow_rate' in c] + + assert len(flow_rate_constraints) > 0, 'Flow rate equality constraint should exist' + + +def test_selective_scenario_independence(): + """Test selective scenario independence with specific element lists.""" + timesteps = pd.date_range('2023-01-01', periods=24, freq='h') + scenarios = pd.Index(['base', 'high'], name='scenario') + + fs = fx.FlowSystem( + timesteps=timesteps, + scenarios=scenarios, + size_per_scenario=['solar(out)'], # Only solar size varies + flow_rate_per_scenario=['demand(in)'], # Only demand flow_rate varies + ) + + bus = fx.Bus('grid') + source = fx.Source( + label='solar', + outputs=[ + fx.Flow( + label='out', + bus='grid', + size=fx.InvestParameters( + minimum_size=10, maximum_size=100, effects_of_investment_per_size={'cost': 100} + ), + ) + ], + ) + sink = fx.Sink( + label='demand', + inputs=[fx.Flow(label='in', bus='grid', size=50)], + ) + + fs.add_elements(bus, source, sink, fx.Effect('cost', 'Total cost', '€', is_objective=True)) + + calc = fx.FullCalculation('test', fs) + calc.do_modeling() + + constraint_names = [str(c) for c in calc.model.constraints] + + # Solar should NOT have size constraints (it's in the list, so varies per scenario) + solar_size_constraints = [c for c in constraint_names if 'solar(out)|size' in c and 'scenario_independent' in c] + assert len(solar_size_constraints) == 0 + + # Solar SHOULD have flow_rate constraints (not in the list, so shared) + solar_flow_constraints = [ + c for c in constraint_names if 'solar(out)|flow_rate' in c and 'scenario_independent' in c + ] + assert len(solar_flow_constraints) > 0 + + # Demand should NOT have flow_rate constraints (it's in the list, so varies per scenario) + demand_flow_constraints = [ + c for c in constraint_names if 'demand(in)|flow_rate' in c and 'scenario_independent' in c + ] + assert len(demand_flow_constraints) == 0 + + +def test_scenario_parameters_io_persistence(): + """Test that size_per_scenario and flow_rate_per_scenario persist through IO operations.""" + import shutil + import tempfile + + timesteps = pd.date_range('2023-01-01', periods=24, freq='h') + scenarios = pd.Index(['base', 'high'], name='scenario') + + # Create FlowSystem with custom scenario parameters + fs_original = fx.FlowSystem( + timesteps=timesteps, + scenarios=scenarios, + size_per_scenario=['solar(out)'], + flow_rate_per_scenario=False, + ) + + bus = fx.Bus('grid') + source = fx.Source( + label='solar', + outputs=[ + fx.Flow( + label='out', + bus='grid', + size=fx.InvestParameters( + minimum_size=10, maximum_size=100, effects_of_investment_per_size={'cost': 100} + ), + ) + ], + ) + + fs_original.add_elements(bus, source, fx.Effect('cost', 'Total cost', '€', is_objective=True)) + + # Save to dataset + fs_original.connect_and_transform() + ds = fs_original.to_dataset() + + # Load from dataset + fs_loaded = fx.FlowSystem.from_dataset(ds) + + # Verify parameters persisted + assert fs_loaded.size_per_scenario == fs_original.size_per_scenario + assert fs_loaded.flow_rate_per_scenario == fs_original.flow_rate_per_scenario + + +def test_scenario_parameters_io_with_calculation(): + """Test that scenario parameters persist through full calculation IO.""" + import shutil + import tempfile + + timesteps = pd.date_range('2023-01-01', periods=24, freq='h') + scenarios = pd.Index(['base', 'high'], name='scenario') + + fs = fx.FlowSystem( + timesteps=timesteps, + scenarios=scenarios, + size_per_scenario=False, + flow_rate_per_scenario=['demand(in)'], + ) + + bus = fx.Bus('grid') + source = fx.Source( + label='solar', + outputs=[ + fx.Flow( + label='out', + bus='grid', + size=fx.InvestParameters( + minimum_size=10, maximum_size=100, effects_of_investment_per_size={'cost': 100} + ), + ) + ], + ) + sink = fx.Sink( + label='demand', + inputs=[fx.Flow(label='in', bus='grid', size=50)], + ) + + fs.add_elements(bus, source, sink, fx.Effect('cost', 'Total cost', '€', is_objective=True)) + + # Create temp directory for results + temp_dir = tempfile.mkdtemp() + + try: + # Solve and save + calc = fx.FullCalculation('test_io', fs, folder=temp_dir) + calc.do_modeling() + calc.solve(fx.solvers.HighsSolver(mip_gap=0.01, time_limit_seconds=60)) + calc.results.to_file() + + # Load results + results = fx.results.CalculationResults.from_file(temp_dir, 'test_io') + fs_loaded = fx.FlowSystem.from_dataset(results.flow_system_data) + + # Verify parameters persisted + assert fs_loaded.size_per_scenario == fs.size_per_scenario + assert fs_loaded.flow_rate_per_scenario == fs.flow_rate_per_scenario + + # Verify constraints are recreated correctly + calc2 = fx.FullCalculation('test_io_2', fs_loaded, folder=temp_dir) + calc2.do_modeling() + + constraint_names1 = [str(c) for c in calc.model.constraints] + constraint_names2 = [str(c) for c in calc2.model.constraints] + + size_constraints1 = [c for c in constraint_names1 if 'scenario_independent' in c and 'size' in c] + size_constraints2 = [c for c in constraint_names2 if 'scenario_independent' in c and 'size' in c] + + assert len(size_constraints1) == len(size_constraints2) + + finally: + # Clean up + shutil.rmtree(temp_dir) From df41dfeb2f68148a9989e1b4be30311cc615ff57 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 12 Oct 2025 01:52:31 +0200 Subject: [PATCH 07/18] Add some error handling and logging --- flixopt/structure.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/flixopt/structure.py b/flixopt/structure.py index e3239f7a4..9dbba1493 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -121,7 +121,12 @@ def _add_scenario_equality_constraints(self): all_flow_vars = [var for var in self.variables if var.endswith('|flow_rate')] allowed_to_vary = {f'{flow}|flow_rate' for flow in self.flow_system.flow_rate_per_scenario} flow_vars = [var for var in all_flow_vars if var not in allowed_to_vary] + # Validate that all specified variables exist + missing_vars = [v for v in flow_vars if v not in self.variables] + if missing_vars: + raise ValueError(f'flow_rate_per_scenario contains invalid labels: {missing_vars}') + logger.debug(f'Adding scenario equality constraints for {len(flow_vars)} flow_rate variables') for flow_var in flow_vars: self.add_constraints( self.variables[flow_var].isel(scenario=0) == self.variables[flow_var].isel(scenario=slice(1, None)), @@ -137,7 +142,12 @@ def _add_scenario_equality_constraints(self): all_size_vars = [var for var in self.variables if var.endswith('|size')] allowed_to_vary = {f'{element}|size' for element in self.flow_system.size_per_scenario} size_vars = [var for var in all_size_vars if var not in allowed_to_vary] + # Validate that all specified variables exist + missing_vars = [v for v in size_vars if v not in self.variables] + if missing_vars: + raise ValueError(f'size_per_scenario contains invalid labels: {missing_vars}') + logger.debug(f'Adding scenario equality constraints for {len(size_vars)} size variables') for size_var in size_vars: self.add_constraints( self.variables[size_var].isel(scenario=0) == self.variables[size_var].isel(scenario=slice(1, None)), From 9c73bac580034a9463f966d29c74d0750d8b2b4d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 12 Oct 2025 21:15:51 +0200 Subject: [PATCH 08/18] Rename variable --- flixopt/flow_system.py | 50 +++++++++---------- flixopt/structure.py | 16 +++---- tests/test_scenarios.py | 104 ++++++++++++++++++++-------------------- 3 files changed, 85 insertions(+), 85 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index fedbf7808..3f06f6075 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -60,11 +60,11 @@ class FlowSystem(Interface): If you use an array, take care that its long enough to cover all previous values! weights: The weights of each period and scenario. If None, all scenarios have the same weight (normalized to 1). Its recommended to normalize the weights to sum up to 1. - size_per_scenario: Controls whether investment sizes vary by scenario. + sizes_per_scenario: Controls whether investment sizes vary by scenario. - False: All sizes are shared across scenarios (default) - True: All sizes are optimized separately per scenario - list[str]: Only specified components (by label_full) vary per scenario - flow_rate_per_scenario: Controls whether flow rates vary by scenario. + flow_rates_per_scenario: Controls whether flow rates vary by scenario. - True: All flow rates are optimized separately per scenario (default) - False: All flow rates are shared across scenarios - list[str]: Only specified flows (by label_full) vary per scenario @@ -83,8 +83,8 @@ def __init__( hours_of_last_timestep: float | None = None, hours_of_previous_timesteps: int | float | np.ndarray | None = None, weights: PeriodicDataUser | None = None, - size_per_scenario: bool | list[str] = False, - flow_rate_per_scenario: bool | list[str] = True, + sizes_per_scenario: bool | list[str] = False, + flow_rates_per_scenario: bool | list[str] = True, ): self.timesteps = self._validate_timesteps(timesteps) self.timesteps_extra = self._create_timesteps_with_extra(self.timesteps, hours_of_last_timestep) @@ -115,8 +115,8 @@ def __init__( self._network_app = None # Use properties to validate and store scenario dimension settings - self.size_per_scenario = size_per_scenario - self.flow_rate_per_scenario = flow_rate_per_scenario + self.sizes_per_scenario = sizes_per_scenario + self.flow_rates_per_scenario = flow_rates_per_scenario @staticmethod def _validate_timesteps(timesteps: pd.DatetimeIndex) -> pd.DatetimeIndex: @@ -217,7 +217,7 @@ def _should_include_scenario_dim( # No scenarios defined, so no scenario dimension return False - config = self.size_per_scenario if parameter_type == 'size' else self.flow_rate_per_scenario + config = self.sizes_per_scenario if parameter_type == 'size' else self.flow_rates_per_scenario if isinstance(config, bool): return config @@ -306,8 +306,8 @@ def from_dataset(cls, ds: xr.Dataset) -> FlowSystem: else None, hours_of_last_timestep=reference_structure.get('hours_of_last_timestep'), hours_of_previous_timesteps=reference_structure.get('hours_of_previous_timesteps'), - size_per_scenario=reference_structure.get('size_per_scenario', False), - flow_rate_per_scenario=reference_structure.get('flow_rate_per_scenario', True), + sizes_per_scenario=reference_structure.get('sizes_per_scenario', False), + flow_rates_per_scenario=reference_structure.get('flow_rates_per_scenario', True), ) # Restore components @@ -803,17 +803,17 @@ def used_in_calculation(self) -> bool: return self._used_in_calculation @property - def size_per_scenario(self) -> bool | list[str]: + def sizes_per_scenario(self) -> bool | list[str]: """ Controls whether investment sizes vary by scenario. Returns: bool or list[str]: Configuration for scenario-dependent sizing """ - return self._size_per_scenario + return self._sizes_per_scenario - @size_per_scenario.setter - def size_per_scenario(self, value: bool | list[str]) -> None: + @sizes_per_scenario.setter + def sizes_per_scenario(self, value: bool | list[str]) -> None: """ Set whether investment sizes should vary by scenario. @@ -825,26 +825,26 @@ def size_per_scenario(self, value: bool | list[str]) -> None: ValueError: If list contains non-string elements """ if isinstance(value, bool): - self._size_per_scenario = value + self._sizes_per_scenario = value elif isinstance(value, list): if not all(isinstance(item, str) for item in value): - raise ValueError('size_per_scenario list must contain only strings (component label_full values)') - self._size_per_scenario = value + raise ValueError('sizes_per_scenario list must contain only strings (component label_full values)') + self._sizes_per_scenario = value else: - raise TypeError(f'size_per_scenario must be bool or list[str], got {type(value).__name__}') + raise TypeError(f'sizes_per_scenario must be bool or list[str], got {type(value).__name__}') @property - def flow_rate_per_scenario(self) -> bool | list[str]: + def flow_rates_per_scenario(self) -> bool | list[str]: """ Controls whether flow rates vary by scenario. Returns: bool or list[str]: Configuration for scenario-dependent flow rates """ - return self._flow_rate_per_scenario + return self._flow_rates_per_scenario - @flow_rate_per_scenario.setter - def flow_rate_per_scenario(self, value: bool | list[str]) -> None: + @flow_rates_per_scenario.setter + def flow_rates_per_scenario(self, value: bool | list[str]) -> None: """ Set whether flow rates should vary by scenario. @@ -856,13 +856,13 @@ def flow_rate_per_scenario(self, value: bool | list[str]) -> None: ValueError: If list contains non-string elements """ if isinstance(value, bool): - self._flow_rate_per_scenario = value + self._flow_rates_per_scenario = value elif isinstance(value, list): if not all(isinstance(item, str) for item in value): - raise ValueError('flow_rate_per_scenario list must contain only strings (flow label_full values)') - self._flow_rate_per_scenario = value + raise ValueError('flow_rates_per_scenario list must contain only strings (flow label_full values)') + self._flow_rates_per_scenario = value else: - raise TypeError(f'flow_rate_per_scenario must be bool or list[str], got {type(value).__name__}') + raise TypeError(f'flow_rates_per_scenario must be bool or list[str], got {type(value).__name__}') def sel( self, diff --git a/flixopt/structure.py b/flixopt/structure.py index 9dbba1493..7765ddc91 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -112,19 +112,19 @@ def _add_scenario_equality_constraints(self): if self.flow_system.scenarios is None or len(self.flow_system.scenarios) <= 1: return None - if self.flow_system.flow_rate_per_scenario is not True: - if self.flow_system.flow_rate_per_scenario is False: + if self.flow_system.flow_rates_per_scenario is not True: + if self.flow_system.flow_rates_per_scenario is False: # All flow rates should be scenario-independent flow_vars = [var for var in self.variables if var.endswith('|flow_rate')] else: # Only flow rates NOT in the list should be scenario-independent all_flow_vars = [var for var in self.variables if var.endswith('|flow_rate')] - allowed_to_vary = {f'{flow}|flow_rate' for flow in self.flow_system.flow_rate_per_scenario} + allowed_to_vary = {f'{flow}|flow_rate' for flow in self.flow_system.flow_rates_per_scenario} flow_vars = [var for var in all_flow_vars if var not in allowed_to_vary] # Validate that all specified variables exist missing_vars = [v for v in flow_vars if v not in self.variables] if missing_vars: - raise ValueError(f'flow_rate_per_scenario contains invalid labels: {missing_vars}') + raise ValueError(f'flow_rates_per_scenario contains invalid labels: {missing_vars}') logger.debug(f'Adding scenario equality constraints for {len(flow_vars)} flow_rate variables') for flow_var in flow_vars: @@ -133,19 +133,19 @@ def _add_scenario_equality_constraints(self): name=f'{flow_var}|scenario_independent', ) - if self.flow_system.size_per_scenario is not True: - if self.flow_system.size_per_scenario is False: + if self.flow_system.sizes_per_scenario is not True: + if self.flow_system.sizes_per_scenario is False: # All sizes should be scenario-independent size_vars = [var for var in self.variables if var.endswith('|size')] else: # Only sizes NOT in the list should be scenario-independent all_size_vars = [var for var in self.variables if var.endswith('|size')] - allowed_to_vary = {f'{element}|size' for element in self.flow_system.size_per_scenario} + allowed_to_vary = {f'{element}|size' for element in self.flow_system.sizes_per_scenario} size_vars = [var for var in all_size_vars if var not in allowed_to_vary] # Validate that all specified variables exist missing_vars = [v for v in size_vars if v not in self.variables] if missing_vars: - raise ValueError(f'size_per_scenario contains invalid labels: {missing_vars}') + raise ValueError(f'sizes_per_scenario contains invalid labels: {missing_vars}') logger.debug(f'Adding scenario equality constraints for {len(size_vars)} size variables') for size_var in size_vars: diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index ba9c32fc4..f83ecf076 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -339,70 +339,70 @@ def test_scenarios_selection(flow_system_piecewise_conversion_scenarios): assert calc.results.solution.indexes['scenario'].equals(flow_system_full.scenarios[0:2]) -def test_size_per_scenario_default(): - """Test that size_per_scenario defaults to False (sizes shared across scenarios).""" +def test_sizes_per_scenario_default(): + """Test that sizes_per_scenario defaults to False (sizes shared across scenarios).""" timesteps = pd.date_range('2023-01-01', periods=24, freq='h') scenarios = pd.Index(['base', 'high'], name='scenario') fs = fx.FlowSystem(timesteps=timesteps, scenarios=scenarios) - assert fs.size_per_scenario is False - assert fs.flow_rate_per_scenario is True + assert fs.sizes_per_scenario is False + assert fs.flow_rates_per_scenario is True -def test_size_per_scenario_bool(): - """Test size_per_scenario with boolean values.""" +def test_sizes_per_scenario_bool(): + """Test sizes_per_scenario with boolean values.""" timesteps = pd.date_range('2023-01-01', periods=24, freq='h') scenarios = pd.Index(['base', 'high'], name='scenario') # Test False - fs1 = fx.FlowSystem(timesteps=timesteps, scenarios=scenarios, size_per_scenario=False) - assert fs1.size_per_scenario is False + fs1 = fx.FlowSystem(timesteps=timesteps, scenarios=scenarios, sizes_per_scenario=False) + assert fs1.sizes_per_scenario is False # Test True - fs2 = fx.FlowSystem(timesteps=timesteps, scenarios=scenarios, size_per_scenario=True) - assert fs2.size_per_scenario is True + fs2 = fx.FlowSystem(timesteps=timesteps, scenarios=scenarios, sizes_per_scenario=True) + assert fs2.sizes_per_scenario is True -def test_size_per_scenario_list(): - """Test size_per_scenario with list of element labels.""" +def test_sizes_per_scenario_list(): + """Test sizes_per_scenario with list of element labels.""" timesteps = pd.date_range('2023-01-01', periods=24, freq='h') scenarios = pd.Index(['base', 'high'], name='scenario') fs = fx.FlowSystem( timesteps=timesteps, scenarios=scenarios, - size_per_scenario=['solar->grid', 'battery->grid'], + sizes_per_scenario=['solar->grid', 'battery->grid'], ) - assert fs.size_per_scenario == ['solar->grid', 'battery->grid'] + assert fs.sizes_per_scenario == ['solar->grid', 'battery->grid'] assert fs._should_include_scenario_dim('solar->grid', 'size') is True assert fs._should_include_scenario_dim('battery->grid', 'size') is True assert fs._should_include_scenario_dim('wind->grid', 'size') is False -def test_flow_rate_per_scenario_default(): - """Test that flow_rate_per_scenario defaults to True (flow rates vary by scenario).""" +def test_flow_rates_per_scenario_default(): + """Test that flow_rates_per_scenario defaults to True (flow rates vary by scenario).""" timesteps = pd.date_range('2023-01-01', periods=24, freq='h') scenarios = pd.Index(['base', 'high'], name='scenario') fs = fx.FlowSystem(timesteps=timesteps, scenarios=scenarios) - assert fs.flow_rate_per_scenario is True + assert fs.flow_rates_per_scenario is True -def test_flow_rate_per_scenario_bool(): - """Test flow_rate_per_scenario with boolean values.""" +def test_flow_rates_per_scenario_bool(): + """Test flow_rates_per_scenario with boolean values.""" timesteps = pd.date_range('2023-01-01', periods=24, freq='h') scenarios = pd.Index(['base', 'high'], name='scenario') # Test False (shared) - fs1 = fx.FlowSystem(timesteps=timesteps, scenarios=scenarios, flow_rate_per_scenario=False) - assert fs1.flow_rate_per_scenario is False + fs1 = fx.FlowSystem(timesteps=timesteps, scenarios=scenarios, flow_rates_per_scenario=False) + assert fs1.flow_rates_per_scenario is False # Test True (per scenario) - fs2 = fx.FlowSystem(timesteps=timesteps, scenarios=scenarios, flow_rate_per_scenario=True) - assert fs2.flow_rate_per_scenario is True + fs2 = fx.FlowSystem(timesteps=timesteps, scenarios=scenarios, flow_rates_per_scenario=True) + assert fs2.flow_rates_per_scenario is True def test_scenario_parameters_property_setters(): @@ -412,19 +412,19 @@ def test_scenario_parameters_property_setters(): fs = fx.FlowSystem(timesteps=timesteps, scenarios=scenarios) - # Change size_per_scenario - fs.size_per_scenario = True - assert fs.size_per_scenario is True + # Change sizes_per_scenario + fs.sizes_per_scenario = True + assert fs.sizes_per_scenario is True - fs.size_per_scenario = ['component1', 'component2'] - assert fs.size_per_scenario == ['component1', 'component2'] + fs.sizes_per_scenario = ['component1', 'component2'] + assert fs.sizes_per_scenario == ['component1', 'component2'] - # Change flow_rate_per_scenario - fs.flow_rate_per_scenario = False - assert fs.flow_rate_per_scenario is False + # Change flow_rates_per_scenario + fs.flow_rates_per_scenario = False + assert fs.flow_rates_per_scenario is False - fs.flow_rate_per_scenario = ['flow1', 'flow2'] - assert fs.flow_rate_per_scenario == ['flow1', 'flow2'] + fs.flow_rates_per_scenario = ['flow1', 'flow2'] + assert fs.flow_rates_per_scenario == ['flow1', 'flow2'] def test_scenario_parameters_validation(): @@ -436,23 +436,23 @@ def test_scenario_parameters_validation(): # Test invalid type with pytest.raises(TypeError, match='must be bool or list'): - fs.size_per_scenario = 'invalid' + fs.sizes_per_scenario = 'invalid' # Test invalid list content with pytest.raises(ValueError, match='must contain only strings'): - fs.size_per_scenario = [1, 2, 3] + fs.sizes_per_scenario = [1, 2, 3] def test_size_equality_constraints(): - """Test that size equality constraints are created when size_per_scenario=False.""" + """Test that size equality constraints are created when sizes_per_scenario=False.""" timesteps = pd.date_range('2023-01-01', periods=24, freq='h') scenarios = pd.Index(['base', 'high'], name='scenario') fs = fx.FlowSystem( timesteps=timesteps, scenarios=scenarios, - size_per_scenario=False, # Sizes should be equal - flow_rate_per_scenario=True, # Flow rates can vary + sizes_per_scenario=False, # Sizes should be equal + flow_rates_per_scenario=True, # Flow rates can vary ) bus = fx.Bus('grid') @@ -484,15 +484,15 @@ def test_size_equality_constraints(): def test_flow_rate_equality_constraints(): - """Test that flow_rate equality constraints are created when flow_rate_per_scenario=False.""" + """Test that flow_rate equality constraints are created when flow_rates_per_scenario=False.""" timesteps = pd.date_range('2023-01-01', periods=24, freq='h') scenarios = pd.Index(['base', 'high'], name='scenario') fs = fx.FlowSystem( timesteps=timesteps, scenarios=scenarios, - size_per_scenario=True, # Sizes can vary - flow_rate_per_scenario=False, # Flow rates should be equal + sizes_per_scenario=True, # Sizes can vary + flow_rates_per_scenario=False, # Flow rates should be equal ) bus = fx.Bus('grid') @@ -531,8 +531,8 @@ def test_selective_scenario_independence(): fs = fx.FlowSystem( timesteps=timesteps, scenarios=scenarios, - size_per_scenario=['solar(out)'], # Only solar size varies - flow_rate_per_scenario=['demand(in)'], # Only demand flow_rate varies + sizes_per_scenario=['solar(out)'], # Only solar size varies + flow_rates_per_scenario=['demand(in)'], # Only demand flow_rate varies ) bus = fx.Bus('grid') @@ -578,7 +578,7 @@ def test_selective_scenario_independence(): def test_scenario_parameters_io_persistence(): - """Test that size_per_scenario and flow_rate_per_scenario persist through IO operations.""" + """Test that sizes_per_scenario and flow_rates_per_scenario persist through IO operations.""" import shutil import tempfile @@ -589,8 +589,8 @@ def test_scenario_parameters_io_persistence(): fs_original = fx.FlowSystem( timesteps=timesteps, scenarios=scenarios, - size_per_scenario=['solar(out)'], - flow_rate_per_scenario=False, + sizes_per_scenario=['solar(out)'], + flow_rates_per_scenario=False, ) bus = fx.Bus('grid') @@ -617,8 +617,8 @@ def test_scenario_parameters_io_persistence(): fs_loaded = fx.FlowSystem.from_dataset(ds) # Verify parameters persisted - assert fs_loaded.size_per_scenario == fs_original.size_per_scenario - assert fs_loaded.flow_rate_per_scenario == fs_original.flow_rate_per_scenario + assert fs_loaded.sizes_per_scenario == fs_original.sizes_per_scenario + assert fs_loaded.flow_rates_per_scenario == fs_original.flow_rates_per_scenario def test_scenario_parameters_io_with_calculation(): @@ -632,8 +632,8 @@ def test_scenario_parameters_io_with_calculation(): fs = fx.FlowSystem( timesteps=timesteps, scenarios=scenarios, - size_per_scenario=False, - flow_rate_per_scenario=['demand(in)'], + sizes_per_scenario=False, + flow_rates_per_scenario=['demand(in)'], ) bus = fx.Bus('grid') @@ -671,8 +671,8 @@ def test_scenario_parameters_io_with_calculation(): fs_loaded = fx.FlowSystem.from_dataset(results.flow_system_data) # Verify parameters persisted - assert fs_loaded.size_per_scenario == fs.size_per_scenario - assert fs_loaded.flow_rate_per_scenario == fs.flow_rate_per_scenario + assert fs_loaded.sizes_per_scenario == fs.sizes_per_scenario + assert fs_loaded.flow_rates_per_scenario == fs.flow_rates_per_scenario # Verify constraints are recreated correctly calc2 = fx.FullCalculation('test_io_2', fs_loaded, folder=temp_dir) From 601913bb38fd3d5dc4fbb3cbf488658f1611f6f0 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 12 Oct 2025 21:34:40 +0200 Subject: [PATCH 09/18] Change parameter naming --- flixopt/flow_system.py | 90 +++++++++++++++-------------- flixopt/structure.py | 24 ++++---- tests/test_scenarios.py | 124 +++++++++++++++++++++------------------- 3 files changed, 124 insertions(+), 114 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 3f06f6075..28c63b5c7 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -60,14 +60,14 @@ class FlowSystem(Interface): If you use an array, take care that its long enough to cover all previous values! weights: The weights of each period and scenario. If None, all scenarios have the same weight (normalized to 1). Its recommended to normalize the weights to sum up to 1. - sizes_per_scenario: Controls whether investment sizes vary by scenario. - - False: All sizes are shared across scenarios (default) - - True: All sizes are optimized separately per scenario - - list[str]: Only specified components (by label_full) vary per scenario - flow_rates_per_scenario: Controls whether flow rates vary by scenario. - - True: All flow rates are optimized separately per scenario (default) - - False: All flow rates are shared across scenarios - - list[str]: Only specified flows (by label_full) vary per scenario + 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 (default) + - list[str]: Only specified components (by label_full) are equalized across scenarios + scenario_independent_flow_rates: Controls whether flow rates are equalized across scenarios. + - True: All flow rates are shared/equalized across scenarios + - False: All flow rates are optimized separately per scenario (default) + - list[str]: Only specified flows (by label_full) are equalized across scenarios Notes: - Creates an empty registry for components and buses, an empty EffectCollection, and a placeholder for a SystemModel. @@ -83,8 +83,8 @@ def __init__( hours_of_last_timestep: float | None = None, hours_of_previous_timesteps: int | float | np.ndarray | None = None, weights: PeriodicDataUser | None = None, - sizes_per_scenario: bool | list[str] = False, - flow_rates_per_scenario: bool | list[str] = True, + scenario_independent_sizes: bool | list[str] = False, + scenario_independent_flow_rates: bool | list[str] = False, ): self.timesteps = self._validate_timesteps(timesteps) self.timesteps_extra = self._create_timesteps_with_extra(self.timesteps, hours_of_last_timestep) @@ -115,8 +115,8 @@ def __init__( self._network_app = None # Use properties to validate and store scenario dimension settings - self.sizes_per_scenario = sizes_per_scenario - self.flow_rates_per_scenario = flow_rates_per_scenario + self.scenario_independent_sizes = scenario_independent_sizes + self.scenario_independent_flow_rates = scenario_independent_flow_rates @staticmethod def _validate_timesteps(timesteps: pd.DatetimeIndex) -> pd.DatetimeIndex: @@ -211,18 +211,20 @@ def _should_include_scenario_dim( parameter_type: Whether checking for 'size' or 'flow_rate' Returns: - True if scenario dimension should be included, False otherwise + True if scenario dimension should be included (varies per scenario), False otherwise (equalized) """ if self.scenarios is None: # No scenarios defined, so no scenario dimension return False - config = self.sizes_per_scenario if parameter_type == 'size' else self.flow_rates_per_scenario + config = self.scenario_independent_sizes if parameter_type == 'size' else self.scenario_independent_flow_rates if isinstance(config, bool): - return config + # True means equalize (no scenario dim), False means vary (include scenario dim) + return not config else: # list[str] - return element_label_full in config + # List contains elements to equalize, so if in list -> no scenario dim + return element_label_full not in config def _create_reference_structure(self) -> tuple[dict, dict[str, xr.DataArray]]: """ @@ -306,8 +308,8 @@ def from_dataset(cls, ds: xr.Dataset) -> FlowSystem: else None, hours_of_last_timestep=reference_structure.get('hours_of_last_timestep'), hours_of_previous_timesteps=reference_structure.get('hours_of_previous_timesteps'), - sizes_per_scenario=reference_structure.get('sizes_per_scenario', False), - flow_rates_per_scenario=reference_structure.get('flow_rates_per_scenario', True), + scenario_independent_sizes=reference_structure.get('scenario_independent_sizes', False), + scenario_independent_flow_rates=reference_structure.get('scenario_independent_flow_rates', False), ) # Restore components @@ -803,66 +805,70 @@ def used_in_calculation(self) -> bool: return self._used_in_calculation @property - def sizes_per_scenario(self) -> bool | list[str]: + def scenario_independent_sizes(self) -> bool | list[str]: """ - Controls whether investment sizes vary by scenario. + Controls whether investment sizes are equalized across scenarios. Returns: - bool or list[str]: Configuration for scenario-dependent sizing + bool or list[str]: Configuration for scenario-independent sizing """ - return self._sizes_per_scenario + return self._scenario_independent_sizes - @sizes_per_scenario.setter - def sizes_per_scenario(self, value: bool | list[str]) -> None: + @scenario_independent_sizes.setter + def scenario_independent_sizes(self, value: bool | list[str]) -> None: """ - Set whether investment sizes should vary by scenario. + Set whether investment sizes should be equalized across scenarios. Args: - value: False (shared), True (all separate), or list of component label_full strings + value: True (all equalized), False (all vary), or list of component label_full strings to equalize Raises: TypeError: If value is not bool or list[str] ValueError: If list contains non-string elements """ if isinstance(value, bool): - self._sizes_per_scenario = value + self._scenario_independent_sizes = value elif isinstance(value, list): if not all(isinstance(item, str) for item in value): - raise ValueError('sizes_per_scenario list must contain only strings (component label_full values)') - self._sizes_per_scenario = value + raise ValueError( + 'scenario_independent_sizes list must contain only strings (component label_full values)' + ) + self._scenario_independent_sizes = value else: - raise TypeError(f'sizes_per_scenario must be bool or list[str], got {type(value).__name__}') + raise TypeError(f'scenario_independent_sizes must be bool or list[str], got {type(value).__name__}') @property - def flow_rates_per_scenario(self) -> bool | list[str]: + def scenario_independent_flow_rates(self) -> bool | list[str]: """ - Controls whether flow rates vary by scenario. + Controls whether flow rates are equalized across scenarios. Returns: - bool or list[str]: Configuration for scenario-dependent flow rates + bool or list[str]: Configuration for scenario-independent flow rates """ - return self._flow_rates_per_scenario + return self._scenario_independent_flow_rates - @flow_rates_per_scenario.setter - def flow_rates_per_scenario(self, value: bool | list[str]) -> None: + @scenario_independent_flow_rates.setter + def scenario_independent_flow_rates(self, value: bool | list[str]) -> None: """ - Set whether flow rates should vary by scenario. + Set whether flow rates should be equalized across scenarios. Args: - value: False (shared), True (all separate), or list of flow label_full strings + value: True (all equalized), False (all vary), or list of flow label_full strings to equalize Raises: TypeError: If value is not bool or list[str] ValueError: If list contains non-string elements """ if isinstance(value, bool): - self._flow_rates_per_scenario = value + self._scenario_independent_flow_rates = value elif isinstance(value, list): if not all(isinstance(item, str) for item in value): - raise ValueError('flow_rates_per_scenario list must contain only strings (flow label_full values)') - self._flow_rates_per_scenario = value + raise ValueError( + 'scenario_independent_flow_rates list must contain only strings (flow label_full values)' + ) + self._scenario_independent_flow_rates = value else: - raise TypeError(f'flow_rates_per_scenario must be bool or list[str], got {type(value).__name__}') + raise TypeError(f'scenario_independent_flow_rates must be bool or list[str], got {type(value).__name__}') def sel( self, diff --git a/flixopt/structure.py b/flixopt/structure.py index 7765ddc91..f4d15f016 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -112,19 +112,19 @@ def _add_scenario_equality_constraints(self): if self.flow_system.scenarios is None or len(self.flow_system.scenarios) <= 1: return None - if self.flow_system.flow_rates_per_scenario is not True: - if self.flow_system.flow_rates_per_scenario is False: + if self.flow_system.scenario_independent_flow_rates is not False: + if self.flow_system.scenario_independent_flow_rates is True: # All flow rates should be scenario-independent flow_vars = [var for var in self.variables if var.endswith('|flow_rate')] else: - # Only flow rates NOT in the list should be scenario-independent + # Only flow rates in the list should be scenario-independent all_flow_vars = [var for var in self.variables if var.endswith('|flow_rate')] - allowed_to_vary = {f'{flow}|flow_rate' for flow in self.flow_system.flow_rates_per_scenario} - flow_vars = [var for var in all_flow_vars if var not in allowed_to_vary] + to_equalize = {f'{flow}|flow_rate' for flow in self.flow_system.scenario_independent_flow_rates} + flow_vars = [var for var in all_flow_vars if var in to_equalize] # Validate that all specified variables exist missing_vars = [v for v in flow_vars if v not in self.variables] if missing_vars: - raise ValueError(f'flow_rates_per_scenario contains invalid labels: {missing_vars}') + raise ValueError(f'scenario_independent_flow_rates contains invalid labels: {missing_vars}') logger.debug(f'Adding scenario equality constraints for {len(flow_vars)} flow_rate variables') for flow_var in flow_vars: @@ -133,19 +133,19 @@ def _add_scenario_equality_constraints(self): name=f'{flow_var}|scenario_independent', ) - if self.flow_system.sizes_per_scenario is not True: - if self.flow_system.sizes_per_scenario is False: + if self.flow_system.scenario_independent_sizes is not False: + if self.flow_system.scenario_independent_sizes is True: # All sizes should be scenario-independent size_vars = [var for var in self.variables if var.endswith('|size')] else: - # Only sizes NOT in the list should be scenario-independent + # Only sizes in the list should be scenario-independent all_size_vars = [var for var in self.variables if var.endswith('|size')] - allowed_to_vary = {f'{element}|size' for element in self.flow_system.sizes_per_scenario} - size_vars = [var for var in all_size_vars if var not in allowed_to_vary] + to_equalize = {f'{element}|size' for element in self.flow_system.scenario_independent_sizes} + size_vars = [var for var in all_size_vars if var in to_equalize] # Validate that all specified variables exist missing_vars = [v for v in size_vars if v not in self.variables] if missing_vars: - raise ValueError(f'sizes_per_scenario contains invalid labels: {missing_vars}') + raise ValueError(f'scenario_independent_sizes contains invalid labels: {missing_vars}') logger.debug(f'Adding scenario equality constraints for {len(size_vars)} size variables') for size_var in size_vars: diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index f83ecf076..129b50889 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -340,69 +340,69 @@ def test_scenarios_selection(flow_system_piecewise_conversion_scenarios): def test_sizes_per_scenario_default(): - """Test that sizes_per_scenario defaults to False (sizes shared across scenarios).""" + """Test that scenario_independent_sizes defaults to False (sizes vary across scenarios).""" timesteps = pd.date_range('2023-01-01', periods=24, freq='h') scenarios = pd.Index(['base', 'high'], name='scenario') fs = fx.FlowSystem(timesteps=timesteps, scenarios=scenarios) - assert fs.sizes_per_scenario is False - assert fs.flow_rates_per_scenario is True + assert fs.scenario_independent_sizes is False + assert fs.scenario_independent_flow_rates is False def test_sizes_per_scenario_bool(): - """Test sizes_per_scenario with boolean values.""" + """Test scenario_independent_sizes with boolean values.""" timesteps = pd.date_range('2023-01-01', periods=24, freq='h') scenarios = pd.Index(['base', 'high'], name='scenario') - # Test False - fs1 = fx.FlowSystem(timesteps=timesteps, scenarios=scenarios, sizes_per_scenario=False) - assert fs1.sizes_per_scenario is False + # Test False (vary per scenario) + fs1 = fx.FlowSystem(timesteps=timesteps, scenarios=scenarios, scenario_independent_sizes=False) + assert fs1.scenario_independent_sizes is False - # Test True - fs2 = fx.FlowSystem(timesteps=timesteps, scenarios=scenarios, sizes_per_scenario=True) - assert fs2.sizes_per_scenario is True + # Test True (equalized across scenarios) + fs2 = fx.FlowSystem(timesteps=timesteps, scenarios=scenarios, scenario_independent_sizes=True) + assert fs2.scenario_independent_sizes is True def test_sizes_per_scenario_list(): - """Test sizes_per_scenario with list of element labels.""" + """Test scenario_independent_sizes with list of element labels.""" timesteps = pd.date_range('2023-01-01', periods=24, freq='h') scenarios = pd.Index(['base', 'high'], name='scenario') fs = fx.FlowSystem( timesteps=timesteps, scenarios=scenarios, - sizes_per_scenario=['solar->grid', 'battery->grid'], + scenario_independent_sizes=['solar->grid', 'battery->grid'], ) - assert fs.sizes_per_scenario == ['solar->grid', 'battery->grid'] - assert fs._should_include_scenario_dim('solar->grid', 'size') is True - assert fs._should_include_scenario_dim('battery->grid', 'size') is True - assert fs._should_include_scenario_dim('wind->grid', 'size') is False + assert fs.scenario_independent_sizes == ['solar->grid', 'battery->grid'] + assert fs._should_include_scenario_dim('solar->grid', 'size') is False + assert fs._should_include_scenario_dim('battery->grid', 'size') is False + assert fs._should_include_scenario_dim('wind->grid', 'size') is True def test_flow_rates_per_scenario_default(): - """Test that flow_rates_per_scenario defaults to True (flow rates vary by scenario).""" + """Test that scenario_independent_flow_rates defaults to False (flow rates vary by scenario).""" timesteps = pd.date_range('2023-01-01', periods=24, freq='h') scenarios = pd.Index(['base', 'high'], name='scenario') fs = fx.FlowSystem(timesteps=timesteps, scenarios=scenarios) - assert fs.flow_rates_per_scenario is True + assert fs.scenario_independent_flow_rates is False def test_flow_rates_per_scenario_bool(): - """Test flow_rates_per_scenario with boolean values.""" + """Test scenario_independent_flow_rates with boolean values.""" timesteps = pd.date_range('2023-01-01', periods=24, freq='h') scenarios = pd.Index(['base', 'high'], name='scenario') - # Test False (shared) - fs1 = fx.FlowSystem(timesteps=timesteps, scenarios=scenarios, flow_rates_per_scenario=False) - assert fs1.flow_rates_per_scenario is False + # Test False (vary per scenario) + fs1 = fx.FlowSystem(timesteps=timesteps, scenarios=scenarios, scenario_independent_flow_rates=False) + assert fs1.scenario_independent_flow_rates is False - # Test True (per scenario) - fs2 = fx.FlowSystem(timesteps=timesteps, scenarios=scenarios, flow_rates_per_scenario=True) - assert fs2.flow_rates_per_scenario is True + # Test True (equalized across scenarios) + fs2 = fx.FlowSystem(timesteps=timesteps, scenarios=scenarios, scenario_independent_flow_rates=True) + assert fs2.scenario_independent_flow_rates is True def test_scenario_parameters_property_setters(): @@ -412,19 +412,19 @@ def test_scenario_parameters_property_setters(): fs = fx.FlowSystem(timesteps=timesteps, scenarios=scenarios) - # Change sizes_per_scenario - fs.sizes_per_scenario = True - assert fs.sizes_per_scenario is True + # Change scenario_independent_sizes + fs.scenario_independent_sizes = True + assert fs.scenario_independent_sizes is True - fs.sizes_per_scenario = ['component1', 'component2'] - assert fs.sizes_per_scenario == ['component1', 'component2'] + fs.scenario_independent_sizes = ['component1', 'component2'] + assert fs.scenario_independent_sizes == ['component1', 'component2'] - # Change flow_rates_per_scenario - fs.flow_rates_per_scenario = False - assert fs.flow_rates_per_scenario is False + # Change scenario_independent_flow_rates + fs.scenario_independent_flow_rates = True + assert fs.scenario_independent_flow_rates is True - fs.flow_rates_per_scenario = ['flow1', 'flow2'] - assert fs.flow_rates_per_scenario == ['flow1', 'flow2'] + fs.scenario_independent_flow_rates = ['flow1', 'flow2'] + assert fs.scenario_independent_flow_rates == ['flow1', 'flow2'] def test_scenario_parameters_validation(): @@ -436,23 +436,23 @@ def test_scenario_parameters_validation(): # Test invalid type with pytest.raises(TypeError, match='must be bool or list'): - fs.sizes_per_scenario = 'invalid' + fs.scenario_independent_sizes = 'invalid' # Test invalid list content with pytest.raises(ValueError, match='must contain only strings'): - fs.sizes_per_scenario = [1, 2, 3] + fs.scenario_independent_sizes = [1, 2, 3] def test_size_equality_constraints(): - """Test that size equality constraints are created when sizes_per_scenario=False.""" + """Test that size equality constraints are created when scenario_independent_sizes=True.""" timesteps = pd.date_range('2023-01-01', periods=24, freq='h') scenarios = pd.Index(['base', 'high'], name='scenario') fs = fx.FlowSystem( timesteps=timesteps, scenarios=scenarios, - sizes_per_scenario=False, # Sizes should be equal - flow_rates_per_scenario=True, # Flow rates can vary + scenario_independent_sizes=True, # Sizes should be equalized + scenario_independent_flow_rates=False, # Flow rates can vary ) bus = fx.Bus('grid') @@ -484,15 +484,15 @@ def test_size_equality_constraints(): def test_flow_rate_equality_constraints(): - """Test that flow_rate equality constraints are created when flow_rates_per_scenario=False.""" + """Test that flow_rate equality constraints are created when scenario_independent_flow_rates=True.""" timesteps = pd.date_range('2023-01-01', periods=24, freq='h') scenarios = pd.Index(['base', 'high'], name='scenario') fs = fx.FlowSystem( timesteps=timesteps, scenarios=scenarios, - sizes_per_scenario=True, # Sizes can vary - flow_rates_per_scenario=False, # Flow rates should be equal + scenario_independent_sizes=False, # Sizes can vary + scenario_independent_flow_rates=True, # Flow rates should be equalized ) bus = fx.Bus('grid') @@ -531,8 +531,8 @@ def test_selective_scenario_independence(): fs = fx.FlowSystem( timesteps=timesteps, scenarios=scenarios, - sizes_per_scenario=['solar(out)'], # Only solar size varies - flow_rates_per_scenario=['demand(in)'], # Only demand flow_rate varies + scenario_independent_sizes=['solar(out)'], # Only solar size is equalized + scenario_independent_flow_rates=['demand(in)'], # Only demand flow_rate is equalized ) bus = fx.Bus('grid') @@ -560,25 +560,29 @@ def test_selective_scenario_independence(): constraint_names = [str(c) for c in calc.model.constraints] - # Solar should NOT have size constraints (it's in the list, so varies per scenario) + # Solar SHOULD have size constraints (it's in the list, so equalized) solar_size_constraints = [c for c in constraint_names if 'solar(out)|size' in c and 'scenario_independent' in c] - assert len(solar_size_constraints) == 0 + assert len(solar_size_constraints) > 0 - # Solar SHOULD have flow_rate constraints (not in the list, so shared) + # Solar should NOT have flow_rate constraints (not in the list, so varies per scenario) solar_flow_constraints = [ c for c in constraint_names if 'solar(out)|flow_rate' in c and 'scenario_independent' in c ] - assert len(solar_flow_constraints) > 0 + assert len(solar_flow_constraints) == 0 - # Demand should NOT have flow_rate constraints (it's in the list, so varies per scenario) + # Demand should NOT have size constraints (no InvestParameters, size is fixed) + demand_size_constraints = [c for c in constraint_names if 'demand(in)|size' in c and 'scenario_independent' in c] + assert len(demand_size_constraints) == 0 + + # Demand SHOULD have flow_rate constraints (it's in the list, so equalized) demand_flow_constraints = [ c for c in constraint_names if 'demand(in)|flow_rate' in c and 'scenario_independent' in c ] - assert len(demand_flow_constraints) == 0 + assert len(demand_flow_constraints) > 0 def test_scenario_parameters_io_persistence(): - """Test that sizes_per_scenario and flow_rates_per_scenario persist through IO operations.""" + """Test that scenario_independent_sizes and scenario_independent_flow_rates persist through IO operations.""" import shutil import tempfile @@ -589,8 +593,8 @@ def test_scenario_parameters_io_persistence(): fs_original = fx.FlowSystem( timesteps=timesteps, scenarios=scenarios, - sizes_per_scenario=['solar(out)'], - flow_rates_per_scenario=False, + scenario_independent_sizes=['solar(out)'], + scenario_independent_flow_rates=True, ) bus = fx.Bus('grid') @@ -617,8 +621,8 @@ def test_scenario_parameters_io_persistence(): fs_loaded = fx.FlowSystem.from_dataset(ds) # Verify parameters persisted - assert fs_loaded.sizes_per_scenario == fs_original.sizes_per_scenario - assert fs_loaded.flow_rates_per_scenario == fs_original.flow_rates_per_scenario + assert fs_loaded.scenario_independent_sizes == fs_original.scenario_independent_sizes + assert fs_loaded.scenario_independent_flow_rates == fs_original.scenario_independent_flow_rates def test_scenario_parameters_io_with_calculation(): @@ -632,8 +636,8 @@ def test_scenario_parameters_io_with_calculation(): fs = fx.FlowSystem( timesteps=timesteps, scenarios=scenarios, - sizes_per_scenario=False, - flow_rates_per_scenario=['demand(in)'], + scenario_independent_sizes=True, + scenario_independent_flow_rates=['demand(in)'], ) bus = fx.Bus('grid') @@ -671,8 +675,8 @@ def test_scenario_parameters_io_with_calculation(): fs_loaded = fx.FlowSystem.from_dataset(results.flow_system_data) # Verify parameters persisted - assert fs_loaded.sizes_per_scenario == fs.sizes_per_scenario - assert fs_loaded.flow_rates_per_scenario == fs.flow_rates_per_scenario + assert fs_loaded.scenario_independent_sizes == fs.scenario_independent_sizes + assert fs_loaded.scenario_independent_flow_rates == fs.scenario_independent_flow_rates # Verify constraints are recreated correctly calc2 = fx.FullCalculation('test_io_2', fs_loaded, folder=temp_dir) From 8537f7907ba751251c06176e819ea6da6a2c7293 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 12 Oct 2025 21:35:17 +0200 Subject: [PATCH 10/18] Remove not needed method --- flixopt/flow_system.py | 26 -------------------------- tests/test_scenarios.py | 3 --- 2 files changed, 29 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 28c63b5c7..cd8cb4803 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -200,32 +200,6 @@ def _calculate_hours_of_previous_timesteps( first_interval = timesteps[1] - timesteps[0] return first_interval.total_seconds() / 3600 # Convert to hours - def _should_include_scenario_dim( - self, element_label_full: str, parameter_type: Literal['size', 'flow_rate'] - ) -> bool: - """ - Determine if 'scenario' dimension should be included for this element's parameter. - - Args: - element_label_full: The full label of the component or flow - parameter_type: Whether checking for 'size' or 'flow_rate' - - Returns: - True if scenario dimension should be included (varies per scenario), False otherwise (equalized) - """ - if self.scenarios is None: - # No scenarios defined, so no scenario dimension - return False - - config = self.scenario_independent_sizes if parameter_type == 'size' else self.scenario_independent_flow_rates - - if isinstance(config, bool): - # True means equalize (no scenario dim), False means vary (include scenario dim) - return not config - else: # list[str] - # List contains elements to equalize, so if in list -> no scenario dim - return element_label_full not in config - def _create_reference_structure(self) -> tuple[dict, dict[str, xr.DataArray]]: """ Override Interface method to handle FlowSystem-specific serialization. diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index 129b50889..489712adf 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -376,9 +376,6 @@ def test_sizes_per_scenario_list(): ) assert fs.scenario_independent_sizes == ['solar->grid', 'battery->grid'] - assert fs._should_include_scenario_dim('solar->grid', 'size') is False - assert fs._should_include_scenario_dim('battery->grid', 'size') is False - assert fs._should_include_scenario_dim('wind->grid', 'size') is True def test_flow_rates_per_scenario_default(): From a9b497ca4a34a9bda74b0cc17fe356a2a1e777ba Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 12 Oct 2025 21:45:58 +0200 Subject: [PATCH 11/18] Refactor to reduce duplication --- flixopt/flow_system.py | 45 ++++++++++++---------- flixopt/structure.py | 84 ++++++++++++++++++++---------------------- 2 files changed, 65 insertions(+), 64 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index cd8cb4803..6bb2b9b39 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -778,6 +778,27 @@ def coords(self) -> dict[FlowSystemDimensions, pd.Index]: def used_in_calculation(self) -> bool: return self._used_in_calculation + def _validate_scenario_parameter(self, value: bool | list[str], param_name: str, element_type: str) -> None: + """ + Validate scenario parameter value. + + Args: + value: The value to validate + param_name: Name of the parameter (for error messages) + element_type: Type of elements expected in list (e.g., 'component label_full', 'flow label_full') + + Raises: + TypeError: If value is not bool or list[str] + ValueError: If list contains non-string elements + """ + if isinstance(value, bool): + return # Valid + elif isinstance(value, list): + if not all(isinstance(item, str) for item in value): + raise ValueError(f'{param_name} list must contain only strings ({element_type} values)') + else: + raise TypeError(f'{param_name} must be bool or list[str], got {type(value).__name__}') + @property def scenario_independent_sizes(self) -> bool | list[str]: """ @@ -800,16 +821,8 @@ def scenario_independent_sizes(self, value: bool | list[str]) -> None: TypeError: If value is not bool or list[str] ValueError: If list contains non-string elements """ - if isinstance(value, bool): - self._scenario_independent_sizes = value - elif isinstance(value, list): - if not all(isinstance(item, str) for item in value): - raise ValueError( - 'scenario_independent_sizes list must contain only strings (component label_full values)' - ) - self._scenario_independent_sizes = value - else: - raise TypeError(f'scenario_independent_sizes must be bool or list[str], got {type(value).__name__}') + self._validate_scenario_parameter(value, 'scenario_independent_sizes', 'Element.label_full') + self._scenario_independent_sizes = value @property def scenario_independent_flow_rates(self) -> bool | list[str]: @@ -833,16 +846,8 @@ def scenario_independent_flow_rates(self, value: bool | list[str]) -> None: TypeError: If value is not bool or list[str] ValueError: If list contains non-string elements """ - if isinstance(value, bool): - self._scenario_independent_flow_rates = value - elif isinstance(value, list): - if not all(isinstance(item, str) for item in value): - raise ValueError( - 'scenario_independent_flow_rates list must contain only strings (flow label_full values)' - ) - self._scenario_independent_flow_rates = value - else: - raise TypeError(f'scenario_independent_flow_rates must be bool or list[str], got {type(value).__name__}') + self._validate_scenario_parameter(value, 'scenario_independent_flow_rates', 'Flow.label_full') + self._scenario_independent_flow_rates = value def sel( self, diff --git a/flixopt/structure.py b/flixopt/structure.py index f4d15f016..72efc3df2 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -106,55 +106,51 @@ def do_modeling(self): # Add scenario equality constraints after all elements are modeled self._add_scenario_equality_constraints() + def _add_scenario_equality_for_parameter_type( + self, + parameter_type: Literal['flow_rate', 'size'], + config: bool | list[str], + ): + """Add scenario equality constraints for a specific parameter type. + + Args: + parameter_type: The type of parameter ('flow_rate' or 'size') + config: Configuration value (True = equalize all, False = equalize none, list = equalize these) + """ + if config is False: + return # All vary per scenario, no constraints needed + + suffix = f'|{parameter_type}' + if config is True: + # All should be scenario-independent + vars_to_constrain = [var for var in self.variables if var.endswith(suffix)] + else: + # Only those in the list should be scenario-independent + all_vars = [var for var in self.variables if var.endswith(suffix)] + to_equalize = {f'{element}{suffix}' for element in config} + vars_to_constrain = [var for var in all_vars if var in to_equalize] + + # Validate that all specified variables exist + missing_vars = [v for v in vars_to_constrain if v not in self.variables] + if missing_vars: + param_name = 'scenario_independent_sizes' if parameter_type == 'size' else 'scenario_independent_flow_rates' + raise ValueError(f'{param_name} contains invalid labels: {missing_vars}') + + logger.debug(f'Adding scenario equality constraints for {len(vars_to_constrain)} {parameter_type} variables') + for var in vars_to_constrain: + self.add_constraints( + self.variables[var].isel(scenario=0) == self.variables[var].isel(scenario=slice(1, None)), + name=f'{var}|scenario_independent', + ) + def _add_scenario_equality_constraints(self): """Add equality constraints to equalize variables across scenarios based on FlowSystem configuration.""" # Only proceed if we have scenarios if self.flow_system.scenarios is None or len(self.flow_system.scenarios) <= 1: - return None - - if self.flow_system.scenario_independent_flow_rates is not False: - if self.flow_system.scenario_independent_flow_rates is True: - # All flow rates should be scenario-independent - flow_vars = [var for var in self.variables if var.endswith('|flow_rate')] - else: - # Only flow rates in the list should be scenario-independent - all_flow_vars = [var for var in self.variables if var.endswith('|flow_rate')] - to_equalize = {f'{flow}|flow_rate' for flow in self.flow_system.scenario_independent_flow_rates} - flow_vars = [var for var in all_flow_vars if var in to_equalize] - # Validate that all specified variables exist - missing_vars = [v for v in flow_vars if v not in self.variables] - if missing_vars: - raise ValueError(f'scenario_independent_flow_rates contains invalid labels: {missing_vars}') - - logger.debug(f'Adding scenario equality constraints for {len(flow_vars)} flow_rate variables') - for flow_var in flow_vars: - self.add_constraints( - self.variables[flow_var].isel(scenario=0) == self.variables[flow_var].isel(scenario=slice(1, None)), - name=f'{flow_var}|scenario_independent', - ) - - if self.flow_system.scenario_independent_sizes is not False: - if self.flow_system.scenario_independent_sizes is True: - # All sizes should be scenario-independent - size_vars = [var for var in self.variables if var.endswith('|size')] - else: - # Only sizes in the list should be scenario-independent - all_size_vars = [var for var in self.variables if var.endswith('|size')] - to_equalize = {f'{element}|size' for element in self.flow_system.scenario_independent_sizes} - size_vars = [var for var in all_size_vars if var in to_equalize] - # Validate that all specified variables exist - missing_vars = [v for v in size_vars if v not in self.variables] - if missing_vars: - raise ValueError(f'scenario_independent_sizes contains invalid labels: {missing_vars}') - - logger.debug(f'Adding scenario equality constraints for {len(size_vars)} size variables') - for size_var in size_vars: - self.add_constraints( - self.variables[size_var].isel(scenario=0) == self.variables[size_var].isel(scenario=slice(1, None)), - name=f'{size_var}|scenario_independent', - ) + return - return None + self._add_scenario_equality_for_parameter_type('flow_rate', self.flow_system.scenario_independent_flow_rates) + self._add_scenario_equality_for_parameter_type('size', self.flow_system.scenario_independent_sizes) @property def solution(self): From a6f57d3b656bc08afa5ce25ada4f210a0a2decf6 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 12 Oct 2025 22:06:08 +0200 Subject: [PATCH 12/18] Change defaults --- flixopt/flow_system.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 6bb2b9b39..188b15ef8 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -83,7 +83,7 @@ def __init__( hours_of_last_timestep: float | None = None, hours_of_previous_timesteps: int | float | np.ndarray | None = None, weights: PeriodicDataUser | None = None, - scenario_independent_sizes: bool | list[str] = False, + scenario_independent_sizes: bool | list[str] = True, scenario_independent_flow_rates: bool | list[str] = False, ): self.timesteps = self._validate_timesteps(timesteps) @@ -282,7 +282,7 @@ def from_dataset(cls, ds: xr.Dataset) -> FlowSystem: else None, hours_of_last_timestep=reference_structure.get('hours_of_last_timestep'), hours_of_previous_timesteps=reference_structure.get('hours_of_previous_timesteps'), - scenario_independent_sizes=reference_structure.get('scenario_independent_sizes', False), + scenario_independent_sizes=reference_structure.get('scenario_independent_sizes', True), scenario_independent_flow_rates=reference_structure.get('scenario_independent_flow_rates', False), ) From 1d1f369dbb7b6a14031a9de5f9cecbdabc452bee Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 12 Oct 2025 22:08:00 +0200 Subject: [PATCH 13/18] Change defaults --- flixopt/flow_system.py | 4 ++-- tests/test_scenarios.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 188b15ef8..ad43c183b 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -62,11 +62,11 @@ class FlowSystem(Interface): Its recommended to normalize the weights to sum up to 1. 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 (default) + - False: All sizes are optimized separately per scenario - list[str]: Only specified components (by label_full) are equalized across scenarios scenario_independent_flow_rates: Controls whether flow rates are equalized across scenarios. - True: All flow rates are shared/equalized across scenarios - - False: All flow rates are optimized separately per scenario (default) + - False: All flow rates are optimized separately per scenario - list[str]: Only specified flows (by label_full) are equalized across scenarios Notes: diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index 489712adf..3f0637c91 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -340,13 +340,13 @@ def test_scenarios_selection(flow_system_piecewise_conversion_scenarios): def test_sizes_per_scenario_default(): - """Test that scenario_independent_sizes defaults to False (sizes vary across scenarios).""" + """Test that scenario_independent_sizes defaults to True (sizes equalized) and flow_rates to False (vary).""" timesteps = pd.date_range('2023-01-01', periods=24, freq='h') scenarios = pd.Index(['base', 'high'], name='scenario') fs = fx.FlowSystem(timesteps=timesteps, scenarios=scenarios) - assert fs.scenario_independent_sizes is False + assert fs.scenario_independent_sizes is True assert fs.scenario_independent_flow_rates is False From bc9699ac072963a511f62109419a00b7684c677a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 12 Oct 2025 22:06:08 +0200 Subject: [PATCH 14/18] Change defaults --- flixopt/flow_system.py | 8 ++++---- tests/test_scenarios.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 6bb2b9b39..ad43c183b 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -62,11 +62,11 @@ class FlowSystem(Interface): Its recommended to normalize the weights to sum up to 1. 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 (default) + - False: All sizes are optimized separately per scenario - list[str]: Only specified components (by label_full) are equalized across scenarios scenario_independent_flow_rates: Controls whether flow rates are equalized across scenarios. - True: All flow rates are shared/equalized across scenarios - - False: All flow rates are optimized separately per scenario (default) + - False: All flow rates are optimized separately per scenario - list[str]: Only specified flows (by label_full) are equalized across scenarios Notes: @@ -83,7 +83,7 @@ def __init__( hours_of_last_timestep: float | None = None, hours_of_previous_timesteps: int | float | np.ndarray | None = None, weights: PeriodicDataUser | None = None, - scenario_independent_sizes: bool | list[str] = False, + scenario_independent_sizes: bool | list[str] = True, scenario_independent_flow_rates: bool | list[str] = False, ): self.timesteps = self._validate_timesteps(timesteps) @@ -282,7 +282,7 @@ def from_dataset(cls, ds: xr.Dataset) -> FlowSystem: else None, hours_of_last_timestep=reference_structure.get('hours_of_last_timestep'), hours_of_previous_timesteps=reference_structure.get('hours_of_previous_timesteps'), - scenario_independent_sizes=reference_structure.get('scenario_independent_sizes', False), + scenario_independent_sizes=reference_structure.get('scenario_independent_sizes', True), scenario_independent_flow_rates=reference_structure.get('scenario_independent_flow_rates', False), ) diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index 489712adf..3f0637c91 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -340,13 +340,13 @@ def test_scenarios_selection(flow_system_piecewise_conversion_scenarios): def test_sizes_per_scenario_default(): - """Test that scenario_independent_sizes defaults to False (sizes vary across scenarios).""" + """Test that scenario_independent_sizes defaults to True (sizes equalized) and flow_rates to False (vary).""" timesteps = pd.date_range('2023-01-01', periods=24, freq='h') scenarios = pd.Index(['base', 'high'], name='scenario') fs = fx.FlowSystem(timesteps=timesteps, scenarios=scenarios) - assert fs.scenario_independent_sizes is False + assert fs.scenario_independent_sizes is True assert fs.scenario_independent_flow_rates is False From 5b71985e65f76a718579e8d5254d8d527f116007 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 12 Oct 2025 23:04:20 +0200 Subject: [PATCH 15/18] Update docs --- CHANGELOG.md | 2 ++ .../mathematical-notation/dimensions.md | 26 +++++++++++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97486fb78..40e12ec42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,8 @@ This release introduces new model dimensions (periods and scenarios) for multi-p - **Period dimension**: Enables multi-period investment modeling with distinct decisions in each period for transformation pathway optimization - **Scenario dimension**: Supports stochastic modeling with weighted scenarios for robust decision-making under uncertainty (demand, prices, weather) + - Control variable independence across scenarios via `scenario_independent_sizes` and `scenario_independent_flow_rates` parameters + - By default, investment sizes are shared across scenarios while flow rates vary per scenario **Redesigned effect sharing system:** diff --git a/docs/user-guide/mathematical-notation/dimensions.md b/docs/user-guide/mathematical-notation/dimensions.md index e7104fa6a..ea2406f9d 100644 --- a/docs/user-guide/mathematical-notation/dimensions.md +++ b/docs/user-guide/mathematical-notation/dimensions.md @@ -132,9 +132,9 @@ $$ ### Shared Periodic Decisions: The Exception -**Within a period, periodic (investment) decisions are shared across all scenarios:** +**Investment decisions (sizes) can be shared across all scenarios:** -If a period has multiple scenarios, periodic variables (e.g., component size) are **scenario-independent** but **temporal variables are scenario-specific**. +By default, sizes (e.g., Storage capacity, Thermal power, ...) are **scenario-independent** but **flow_rates are scenario-specific**. **Example - Flow with investment:** @@ -153,6 +153,28 @@ $$ This reflects real-world investment under uncertainty: you build capacity once (periodic/investment decision), but it operates under different conditions (temporal/operational decisions per scenario). +**Controlling Scenario Independence:** + +You can control which variables are shared across scenarios using `FlowSystem` parameters: + +```python +flow_system = fx.FlowSystem( + timesteps=timesteps, + scenarios=scenarios, + scenario_independent_sizes=True, # Sizes equalized (default) + scenario_independent_flow_rates=False # Flow rates vary per scenario (default) +) +``` + +Options: +- `True`: All variables of this type are equalized across scenarios +- `False`: All variables of this type vary independently per scenario +- `['component1', 'component2']`: Only listed components are equalized + +This allows flexible modeling, such as: +- Scenario-specific capacity planning (set `scenario_independent_sizes=False`) +- Equalizing only critical infrastructure across scenarios (use selective lists) + --- ## Dimensional Impact on Objective Function From d4ad8dd2d142d2634c36cc1a45c2d38dc7fa087d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 12 Oct 2025 23:13:02 +0200 Subject: [PATCH 16/18] Update docs --- .../mathematical-notation/dimensions.md | 28 ++++++++----------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/docs/user-guide/mathematical-notation/dimensions.md b/docs/user-guide/mathematical-notation/dimensions.md index ea2406f9d..12483c5c3 100644 --- a/docs/user-guide/mathematical-notation/dimensions.md +++ b/docs/user-guide/mathematical-notation/dimensions.md @@ -153,27 +153,21 @@ $$ This reflects real-world investment under uncertainty: you build capacity once (periodic/investment decision), but it operates under different conditions (temporal/operational decisions per scenario). -**Controlling Scenario Independence:** +**Mathematical Flexibility:** -You can control which variables are shared across scenarios using `FlowSystem` parameters: +Variables can be either scenario-independent or scenario-specific: -```python -flow_system = fx.FlowSystem( - timesteps=timesteps, - scenarios=scenarios, - scenario_independent_sizes=True, # Sizes equalized (default) - scenario_independent_flow_rates=False # Flow rates vary per scenario (default) -) -``` +| Variable Type | Scenario-Independent | Scenario-Specific | +|---------------|---------------------|-------------------| +| **Sizes** (e.g., $\text{P}$) | $\text{P}(y)$ - Single value per period | $\text{P}(y, s)$ - Different per scenario | +| **Flow rates** (e.g., $p(\text{t}_i)$) | $p(\text{t}_i, y)$ - Same across scenarios | $p(\text{t}_i, y, s)$ - Different per scenario | -Options: -- `True`: All variables of this type are equalized across scenarios -- `False`: All variables of this type vary independently per scenario -- `['component1', 'component2']`: Only listed components are equalized +**Use Cases:** +- **All sizes shared** (default): Hedge investment - build capacity that works across all scenarios +- **All sizes vary**: Scenario-specific planning where you can adapt investment to each future +- **Selective sharing**: Critical infrastructure shared, optional or short capacity varies per scenario -This allows flexible modeling, such as: -- Scenario-specific capacity planning (set `scenario_independent_sizes=False`) -- Equalizing only critical infrastructure across scenarios (use selective lists) +For implementation details on controlling scenario independence, see the [`FlowSystem`][flixopt.flow_system.FlowSystem] API reference. --- From 3e5daf914aa4e8917994209311942c30c85af11f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 12 Oct 2025 23:16:18 +0200 Subject: [PATCH 17/18] Update docs --- docs/user-guide/mathematical-notation/dimensions.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/user-guide/mathematical-notation/dimensions.md b/docs/user-guide/mathematical-notation/dimensions.md index 12483c5c3..2401513bb 100644 --- a/docs/user-guide/mathematical-notation/dimensions.md +++ b/docs/user-guide/mathematical-notation/dimensions.md @@ -163,9 +163,15 @@ Variables can be either scenario-independent or scenario-specific: | **Flow rates** (e.g., $p(\text{t}_i)$) | $p(\text{t}_i, y)$ - Same across scenarios | $p(\text{t}_i, y, s)$ - Different per scenario | **Use Cases:** -- **All sizes shared** (default): Hedge investment - build capacity that works across all scenarios -- **All sizes vary**: Scenario-specific planning where you can adapt investment to each future -- **Selective sharing**: Critical infrastructure shared, optional or short capacity varies per scenario + +*Investment problems (with sizes):* +- **Sizes shared** (default): Investment under uncertainty - build capacity that performs well across all scenarios +- **Sizes vary**: Scenario-specific capacity expansion where investment can be adapted to each future +- **Selected sizes shared**: Critical infrastructure shared, optional or short-lived capacity varies per scenario + +*Dispatch problems (without investments):* +- **Flow rates shared**: Find robust operational strategy that works across all scenarios (e.g., day-ahead commitment with uncertain forecasts) +- **Flow rates vary** (default): Adapt operations to scenario-specific conditions (demand, weather, prices) For implementation details on controlling scenario independence, see the [`FlowSystem`][flixopt.flow_system.FlowSystem] API reference. From d7a59a77db7322392eccf9a1f9bfe4adc6ae7aa9 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 12 Oct 2025 23:17:59 +0200 Subject: [PATCH 18/18] Update docs --- docs/user-guide/mathematical-notation/dimensions.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/user-guide/mathematical-notation/dimensions.md b/docs/user-guide/mathematical-notation/dimensions.md index 2401513bb..d1bc99c8e 100644 --- a/docs/user-guide/mathematical-notation/dimensions.md +++ b/docs/user-guide/mathematical-notation/dimensions.md @@ -164,14 +164,14 @@ Variables can be either scenario-independent or scenario-specific: **Use Cases:** -*Investment problems (with sizes):* +*Investment problems (with InvestParameters):* - **Sizes shared** (default): Investment under uncertainty - build capacity that performs well across all scenarios -- **Sizes vary**: Scenario-specific capacity expansion where investment can be adapted to each future -- **Selected sizes shared**: Critical infrastructure shared, optional or short-lived capacity varies per scenario +- **Sizes vary**: Scenario-specific capacity planning where different investments can be made for each future +- **Selected sizes shared**: Mix of shared critical infrastructure and scenario-specific optional/flexible capacity -*Dispatch problems (without investments):* -- **Flow rates shared**: Find robust operational strategy that works across all scenarios (e.g., day-ahead commitment with uncertain forecasts) -- **Flow rates vary** (default): Adapt operations to scenario-specific conditions (demand, weather, prices) +*Dispatch problems (fixed sizes, no investments):* +- **Flow rates shared**: Robust dispatch - find a single operational strategy that works across all forecast scenarios (e.g., day-ahead unit commitment under demand/weather uncertainty) +- **Flow rates vary** (default): Scenario-adaptive dispatch - optimize operations for each scenario's specific conditions (demand, weather, prices) For implementation details on controlling scenario independence, see the [`FlowSystem`][flixopt.flow_system.FlowSystem] API reference.