From a7e1d8a07eb42197f0fa9472748824508d93a803 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 10 Apr 2025 12:38:04 +0200 Subject: [PATCH 01/34] Bugfix in FlowModel --- flixopt/elements.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixopt/elements.py b/flixopt/elements.py index 05898d4e5..a6a2dfcb4 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -346,7 +346,7 @@ def do_modeling(self): self.total_flow_hours = self.add( self._model.add_variables( - lower=self.element.flow_hours_total_min if self.element.flow_hours_total_min is not None else -np.inf, + lower=self.element.flow_hours_total_min if self.element.flow_hours_total_min is not None else 0, upper=self.element.flow_hours_total_max if self.element.flow_hours_total_max is not None else np.inf, coords=None, name=f'{self.label_full}|total_flow_hours', From 5561314932ba291a83e7d93e4fd4d44e15504aa0 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 10 Apr 2025 13:13:57 +0200 Subject: [PATCH 02/34] Bugfix in OnOffModel --- flixopt/elements.py | 55 ++++++++++++++++++++++++++++++++++++--------- 1 file changed, 44 insertions(+), 11 deletions(-) diff --git a/flixopt/elements.py b/flixopt/elements.py index a6a2dfcb4..3608e04e5 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -306,8 +306,8 @@ def do_modeling(self): # eq relative_minimum(t) * size <= flow_rate(t) <= relative_maximum(t) * size self.flow_rate: linopy.Variable = self.add( self._model.add_variables( - lower=self.absolute_flow_rate_bounds[0] if self.element.on_off_parameters is None else 0, - upper=self.absolute_flow_rate_bounds[1], + lower=self.flow_rate_lower_bound, + upper=self.flow_rate_upper_bound, coords=self._model.coords, name=f'{self.label_full}|flow_rate', ), @@ -322,7 +322,7 @@ def do_modeling(self): label_of_element=self.label_of_element, on_off_parameters=self.element.on_off_parameters, defining_variables=[self.flow_rate], - defining_bounds=[self.absolute_flow_rate_bounds], + defining_bounds=[self.flow_rate_bounds_on], previous_values=[self.element.previous_flow_rate], ), 'on_off', @@ -337,7 +337,8 @@ def do_modeling(self): label_of_element=self.label_of_element, parameters=self.element.size, defining_variable=self.flow_rate, - relative_bounds_of_defining_variable=self.relative_flow_rate_bounds, + relative_bounds_of_defining_variable=(self.flow_rate_lower_bound_relative, + self.flow_rate_upper_bound_relative), on_variable=self.on_off.on if self.on_off is not None else None, ), 'investment', @@ -414,9 +415,9 @@ def _create_bounds_for_load_factor(self): ) @property - def absolute_flow_rate_bounds(self) -> Tuple[NumericData, NumericData]: + def flow_rate_bounds_on(self) -> Tuple[NumericData, NumericData]: """Returns absolute flow rate bounds. Important for OnOffModel""" - relative_minimum, relative_maximum = self.relative_flow_rate_bounds + relative_minimum, relative_maximum = self.flow_rate_lower_bound_relative, self.flow_rate_upper_bound_relative size = self.element.size if not isinstance(size, InvestParameters): return relative_minimum * size, relative_maximum * size @@ -425,12 +426,44 @@ def absolute_flow_rate_bounds(self) -> Tuple[NumericData, NumericData]: return relative_minimum * size.minimum_size, relative_maximum * size.maximum_size @property - def relative_flow_rate_bounds(self) -> Tuple[NumericData, NumericData]: - """Returns relative flow rate bounds.""" + def flow_rate_lower_bound_relative(self) -> NumericData: + """Returns the lower bound of the flow_rate relative to its size""" fixed_profile = self.element.fixed_relative_profile if fixed_profile is None: - return self.element.relative_minimum.active_data, self.element.relative_maximum.active_data - return fixed_profile.active_data, fixed_profile.active_data + return self.element.relative_minimum.selected_data + return fixed_profile.selected_data + + @property + def flow_rate_upper_bound_relative(self) -> NumericData: + """ Returns the upper bound of the flow_rate relative to its size""" + fixed_profile = self.element.fixed_relative_profile + if fixed_profile is None: + return self.element.relative_maximum.selected_data + return fixed_profile.selected_data + + @property + def flow_rate_lower_bound(self) -> NumericData: + """ + Returns the minimum bound the flow_rate can reach. + Further constraining might be done in OnOffModel and InvestmentModel + """ + if self.element.on_off_parameters is not None: + return 0 + if isinstance(self.element.size, InvestParameters): + if self.element.size.optional: + return 0 + return self.flow_rate_lower_bound_relative * self.element.size.minimum_size + return self.flow_rate_lower_bound_relative * self.element.size + + @property + def flow_rate_upper_bound(self) -> NumericData: + """ + Returns the maximum bound the flow_rate can reach. + Further constraining might be done in OnOffModel and InvestmentModel + """ + if isinstance(self.element.size, InvestParameters): + return self.flow_rate_upper_bound_relative * self.element.size.maximum_size + return self.flow_rate_upper_bound_relative * self.element.size class BusModel(ElementModel): @@ -508,7 +541,7 @@ def do_modeling(self): self.element.on_off_parameters, self.label_of_element, defining_variables=[flow.model.flow_rate for flow in all_flows], - defining_bounds=[flow.model.absolute_flow_rate_bounds for flow in all_flows], + defining_bounds=[flow.model.bounds_for_on for flow in all_flows], previous_values=[flow.previous_flow_rate for flow in all_flows], ) ) From 458df34cf8b4b9e82b62a457574026ed8b69048d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 10 Apr 2025 14:05:00 +0200 Subject: [PATCH 03/34] bugfix in rename from cherry pick --- flixopt/elements.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/flixopt/elements.py b/flixopt/elements.py index 3608e04e5..faec478cb 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -430,16 +430,16 @@ def flow_rate_lower_bound_relative(self) -> NumericData: """Returns the lower bound of the flow_rate relative to its size""" fixed_profile = self.element.fixed_relative_profile if fixed_profile is None: - return self.element.relative_minimum.selected_data - return fixed_profile.selected_data + return self.element.relative_minimum.active_data + return fixed_profile.active_data @property def flow_rate_upper_bound_relative(self) -> NumericData: """ Returns the upper bound of the flow_rate relative to its size""" fixed_profile = self.element.fixed_relative_profile if fixed_profile is None: - return self.element.relative_maximum.selected_data - return fixed_profile.selected_data + return self.element.relative_maximum.active_data + return fixed_profile.active_data @property def flow_rate_lower_bound(self) -> NumericData: From 28e5823788f1ef5481c2e9e579ba5479cc7eda85 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 10 Apr 2025 14:26:27 +0200 Subject: [PATCH 04/34] bugfix in rename from cherry pick --- flixopt/elements.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixopt/elements.py b/flixopt/elements.py index faec478cb..68367c645 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -541,7 +541,7 @@ def do_modeling(self): self.element.on_off_parameters, self.label_of_element, defining_variables=[flow.model.flow_rate for flow in all_flows], - defining_bounds=[flow.model.bounds_for_on for flow in all_flows], + defining_bounds=[flow.model.flow_rate_bounds_on for flow in all_flows], previous_values=[flow.previous_flow_rate for flow in all_flows], ) ) From e3b41f118ac20d22f9983a64dd5d9e193bf905d0 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 10 Apr 2025 18:14:35 +0200 Subject: [PATCH 05/34] Add tests of mathematical model for classes --- tests/conftest.py | 93 ++++- tests/test_bus.py | 58 +++ tests/test_effect.py | 143 +++++++ tests/test_flow.py | 896 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 1188 insertions(+), 2 deletions(-) create mode 100644 tests/test_bus.py create mode 100644 tests/test_effect.py create mode 100644 tests/test_flow.py diff --git a/tests/conftest.py b/tests/conftest.py index 72aa1dee1..9484fbdac 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,8 +9,11 @@ import numpy as np import pandas as pd import pytest +import xarray as xr +import linopy.testing import flixopt as fx +from flixopt.structure import SystemModel @pytest.fixture() @@ -403,8 +406,94 @@ def flow_system_long(): } -def create_calculation_and_solve(flow_system: fx.FlowSystem, solver, name: str) -> fx.FullCalculation: +def create_calculation_and_solve(flow_system: fx.FlowSystem, solver, name: str, allow_infeasible: bool=False) -> fx.FullCalculation: calculation = fx.FullCalculation(name, flow_system) calculation.do_modeling() - calculation.solve(solver) + try: + calculation.solve(solver) + except RuntimeError as e: + if allow_infeasible: + pass + else: + raise RuntimeError from e return calculation + + +def create_linopy_model(flow_system: fx.FlowSystem) -> SystemModel: + calculation = fx.FullCalculation('GenericName', flow_system) + calculation.do_modeling() + return calculation.model + +@pytest.fixture(params=['h', '3h']) +def timesteps_linopy(request): + return pd.date_range('2020-01-01', periods=10, freq=request.param, name='time') + + +@pytest.fixture +def basic_flow_system_linopy(timesteps_linopy) -> fx.FlowSystem: + """Create basic elements for component testing""" + flow_system = fx.FlowSystem(pd.date_range('2020-01-01', periods=10, freq='h', name='time')) + thermal_load = np.array([np.random.random() for _ in range(10)]) * 180 + p_el = (np.array([np.random.random() for _ in range(10)]) + 0.5) / 1.5 * 50 + + flow_system.add_elements( + fx.Bus('Strom'), + fx.Bus('Fernwärme'), + fx.Bus('Gas'), + fx.Effect('Costs', '€', 'Kosten', is_standard=True, is_objective=True), + fx.Sink('Wärmelast', sink=fx.Flow('Q_th_Last', 'Fernwärme', size=1, fixed_relative_profile=thermal_load)), + fx.Source('Gastarif', source=fx.Flow('Q_Gas', 'Gas', size=1000, effects_per_flow_hour=0.04)), + fx.Sink('Einspeisung', sink=fx.Flow('P_el', 'Strom', effects_per_flow_hour=-1 * p_el)), + ) + + return flow_system + +def assert_conequal(actual: linopy.Constraint, desired: linopy.Constraint): + """Assert that two constraints are equal with detailed error messages.""" + name = actual.name + + try: + linopy.testing.assert_linequal(actual.lhs, desired.lhs) + except AssertionError as e: + raise AssertionError(f"{name} left-hand sides don't match:\n{e}") + + try: + linopy.testing.assert_linequal(actual.rhs, desired.rhs) + except AssertionError as e: + raise AssertionError(f"{name} right-hand sides don't match:\n{e}") + + try: + xr.testing.assert_equal(actual.sign, desired.sign) + except AssertionError: + raise AssertionError(f"{name} signs don't match:\nActual: {actual.sign}\nExpected: {desired.sign}") + + +def assert_var_equal(actual: linopy.Variable, desired: linopy.Variable): + """Assert that two variables are equal with detailed error messages.""" + name = actual.name + try: + xr.testing.assert_equal(actual.lower, desired.lower) + except AssertionError: + raise AssertionError(f"{name} lower bounds don't match:\nActual: {actual.lower}\nExpected: {desired.lower}") + + try: + xr.testing.assert_equal(actual.upper, desired.upper) + except AssertionError: + raise AssertionError(f"{name} upper bounds don't match:\nActual: {actual.upper}\nExpected: {desired.upper}") + + if actual.type != desired.type: + raise AssertionError(f"{name} types don't match: {actual.type} != {desired.type}") + + if actual.size != desired.size: + raise AssertionError(f"{name} sizes don't match: {actual.size} != {desired.size}") + + if actual.shape != desired.shape: + raise AssertionError(f"{name} shapes don't match: {actual.shape} != {desired.shape}") + + try: + xr.testing.assert_equal(actual.coords, desired.coords) + except AssertionError: + raise AssertionError(f"{name} coordinates don't match:\nActual: {actual.coords}\nExpected: {desired.coords}") + + if actual.coord_dims != desired.coord_dims: + raise AssertionError(f"{name} coordinate dimensions don't match: {actual.coord_dims} != {desired.coord_dims}") diff --git a/tests/test_bus.py b/tests/test_bus.py new file mode 100644 index 000000000..a868f8002 --- /dev/null +++ b/tests/test_bus.py @@ -0,0 +1,58 @@ +import numpy as np +import pandas as pd +import pytest +import xarray as xr + +import flixopt as fx + +from .conftest import basic_flow_system_linopy, create_linopy_model, assert_var_equal, assert_conequal + + +class TestBusModel: + """Test the FlowModel class.""" + + def test_bus(self, basic_flow_system_linopy): + """Test that flow model constraints are correctly generated.""" + flow_system = basic_flow_system_linopy + bus = fx.Bus('TestBus', excess_penalty_per_flow_hour=None) + flow_system.add_elements(bus, + fx.Sink('WärmelastTest', sink=fx.Flow('Q_th_Last', 'TestBus')), + fx.Source('GastarifTest', source=fx.Flow('Q_Gas', 'TestBus'))) + model = create_linopy_model(flow_system) + + assert set(bus.model.variables) == {'WärmelastTest(Q_th_Last)|flow_rate', 'GastarifTest(Q_Gas)|flow_rate'} + assert set(bus.model.constraints) == {'TestBus|balance'} + + assert_conequal( + model.constraints['TestBus|balance'], + model.variables['GastarifTest(Q_Gas)|flow_rate'] == model.variables['WärmelastTest(Q_th_Last)|flow_rate'] + ) + + def test_bus_penalty(self, basic_flow_system_linopy): + """Test that flow model constraints are correctly generated.""" + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + bus = fx.Bus('TestBus') + flow_system.add_elements(bus, + fx.Sink('WärmelastTest', sink=fx.Flow('Q_th_Last', 'TestBus')), + fx.Source('GastarifTest', source=fx.Flow('Q_Gas', 'TestBus'))) + model = create_linopy_model(flow_system) + + assert set(bus.model.variables) == {'TestBus|excess_input', + 'TestBus|excess_output', + 'WärmelastTest(Q_th_Last)|flow_rate', + 'GastarifTest(Q_Gas)|flow_rate'} + assert set(bus.model.constraints) == {'TestBus|balance'} + + assert_var_equal(model.variables['TestBus|excess_input'], model.add_variables(lower=0, coords = (timesteps,))) + assert_var_equal(model.variables['TestBus|excess_output'], model.add_variables(lower=0, coords=(timesteps,))) + + assert_conequal( + model.constraints['TestBus|balance'], + model.variables['GastarifTest(Q_Gas)|flow_rate'] - model.variables['WärmelastTest(Q_th_Last)|flow_rate'] + model.variables['TestBus|excess_input'] - model.variables['TestBus|excess_output'] == 0 + ) + + assert_conequal( + model.constraints['TestBus->Penalty'], + model.variables['TestBus->Penalty'] == (model.variables['TestBus|excess_input'] * 1e5 * model.hours_per_step).sum() + (model.variables['TestBus|excess_output'] * 1e5 * model.hours_per_step).sum(), + ) diff --git a/tests/test_effect.py b/tests/test_effect.py new file mode 100644 index 000000000..60c8e0672 --- /dev/null +++ b/tests/test_effect.py @@ -0,0 +1,143 @@ +import numpy as np +import pandas as pd +import pytest +import xarray as xr + +import flixopt as fx + +from .conftest import basic_flow_system_linopy, create_linopy_model, assert_var_equal, assert_conequal + + +class TestBusModel: + """Test the FlowModel class.""" + + def test_minimal(self, basic_flow_system_linopy): + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + effect = fx.Effect('Effect1', '€', 'Testing Effect') + + flow_system.add_elements(effect) + model = create_linopy_model(flow_system) + + assert set(effect.model.variables) == {'Effect1(invest)|total', + 'Effect1(operation)|total', + 'Effect1(operation)|total_per_timestep', + 'Effect1|total',} + assert set(effect.model.constraints) == {'Effect1(invest)|total', + 'Effect1(operation)|total', + 'Effect1(operation)|total_per_timestep', + 'Effect1|total',} + + assert_var_equal(model.variables['Effect1|total'], model.add_variables()) + assert_var_equal(model.variables['Effect1(invest)|total'], model.add_variables()) + assert_var_equal(model.variables['Effect1(operation)|total'], model.add_variables()) + assert_var_equal(model.variables['Effect1(operation)|total_per_timestep'], model.add_variables(coords=(timesteps,))) + + assert_conequal(model.constraints['Effect1|total'], + model.variables['Effect1|total'] == model.variables['Effect1(operation)|total'] + model.variables['Effect1(invest)|total']) + assert_conequal(model.constraints['Effect1(invest)|total'], model.variables['Effect1(invest)|total'] == 0) + assert_conequal(model.constraints['Effect1(operation)|total'], + model.variables['Effect1(operation)|total'] == model.variables['Effect1(operation)|total_per_timestep'].sum()) + assert_conequal(model.constraints['Effect1(operation)|total_per_timestep'], + model.variables['Effect1(operation)|total_per_timestep'] ==0) + + def test_bounds(self, basic_flow_system_linopy): + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + effect = fx.Effect('Effect1', '€', 'Testing Effect', + minimum_operation=1.0, + maximum_operation=1.1, + minimum_invest=2.0, + maximum_invest=2.1, + minimum_total=3.0, + maximum_total=3.1, + minimum_operation_per_hour=4.0, + maximum_operation_per_hour=4.1 + ) + + flow_system.add_elements(effect) + model = create_linopy_model(flow_system) + + assert set(effect.model.variables) == {'Effect1(invest)|total', + 'Effect1(operation)|total', + 'Effect1(operation)|total_per_timestep', + 'Effect1|total',} + assert set(effect.model.constraints) == {'Effect1(invest)|total', + 'Effect1(operation)|total', + 'Effect1(operation)|total_per_timestep', + 'Effect1|total',} + + assert_var_equal(model.variables['Effect1|total'], model.add_variables(lower=3.0, upper=3.1)) + assert_var_equal(model.variables['Effect1(invest)|total'], model.add_variables(lower=2.0, upper=2.1)) + assert_var_equal(model.variables['Effect1(operation)|total'], model.add_variables(lower=1.0, upper=1.1)) + assert_var_equal( + model.variables['Effect1(operation)|total_per_timestep'], model.add_variables( + lower=4.0 * model.hours_per_step, upper=4.1* model.hours_per_step, coords=(timesteps,)) + ) + + assert_conequal(model.constraints['Effect1|total'], + model.variables['Effect1|total'] == model.variables['Effect1(operation)|total'] + model.variables['Effect1(invest)|total']) + assert_conequal(model.constraints['Effect1(invest)|total'], model.variables['Effect1(invest)|total'] == 0) + assert_conequal(model.constraints['Effect1(operation)|total'], + model.variables['Effect1(operation)|total'] == model.variables['Effect1(operation)|total_per_timestep'].sum()) + assert_conequal(model.constraints['Effect1(operation)|total_per_timestep'], + model.variables['Effect1(operation)|total_per_timestep'] ==0) + + def test_shares(self, basic_flow_system_linopy): + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + effect1 = fx.Effect('Effect1', '€', 'Testing Effect', + specific_share_to_other_effects_operation={ + 'Effect2': 1.1, + 'Effect3': 1.2 + }, + specific_share_to_other_effects_invest={ + 'Effect2': 2.1, + 'Effect3': 2.2 + } + ) + effect2 = fx.Effect('Effect2', '€', 'Testing Effect') + effect3 = fx.Effect('Effect3', '€', 'Testing Effect') + flow_system.add_elements(effect1, effect2, effect3) + model = create_linopy_model(flow_system) + + assert set(effect2.model.variables) == { + 'Effect2(invest)|total', + 'Effect2(operation)|total', + 'Effect2(operation)|total_per_timestep', + 'Effect2|total', + 'Effect1(invest)->Effect2(invest)', + 'Effect1(operation)->Effect2(operation)', + } + assert set(effect2.model.constraints) == { + 'Effect2(invest)|total', + 'Effect2(operation)|total', + 'Effect2(operation)|total_per_timestep', + 'Effect2|total', + 'Effect1(invest)->Effect2(invest)', + 'Effect1(operation)->Effect2(operation)', + } + + assert_conequal( + model.constraints['Effect2(invest)|total'], + model.variables['Effect2(invest)|total'] == model.variables['Effect1(invest)->Effect2(invest)'], + ) + + assert_conequal( + model.constraints['Effect2(operation)|total_per_timestep'], + model.variables['Effect2(operation)|total_per_timestep'] == model.variables['Effect1(operation)->Effect2(operation)'], + ) + + assert_conequal( + model.constraints['Effect1(operation)->Effect2(operation)'], + model.variables['Effect1(operation)->Effect2(operation)'] + == model.variables['Effect1(operation)|total_per_timestep'] * 1.1 + ) + + assert_conequal( + model.constraints['Effect1(invest)->Effect2(invest)'], + model.variables['Effect1(invest)->Effect2(invest)'] + == model.variables['Effect1(invest)|total'] * 2.1, + ) + + diff --git a/tests/test_flow.py b/tests/test_flow.py new file mode 100644 index 000000000..02e053721 --- /dev/null +++ b/tests/test_flow.py @@ -0,0 +1,896 @@ +import numpy as np +import pytest +import pandas as pd +import xarray as xr + +import flixopt as fx + +from .conftest import basic_flow_system_linopy, create_linopy_model, assert_var_equal, assert_conequal + + +class TestFlowModel: + """Test the FlowModel class.""" + + def test_flow_minimal(self, basic_flow_system_linopy): + """Test that flow model constraints are correctly generated.""" + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + flow = fx.Flow('Wärme', bus='Fernwärme', size=100) + + flow_system.add_elements(fx.Sink('Sink', sink=flow)) + + model = create_linopy_model(flow_system) + + assert_conequal( + model.constraints['Sink(Wärme)|total_flow_hours'], + flow.model.variables['Sink(Wärme)|total_flow_hours'] == (flow.model.variables['Sink(Wärme)|flow_rate'] * model.hours_per_step).sum() + ) + assert_var_equal(flow.model.flow_rate, + model.add_variables(lower=0, upper=100, coords=(timesteps,))) + assert_var_equal(flow.model.total_flow_hours, model.add_variables(lower=0)) + + assert set(flow.model.variables) == set(['Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate']) + assert set(flow.model.constraints) == set(['Sink(Wärme)|total_flow_hours']) + + def test_flow(self, basic_flow_system_linopy): + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + flow = fx.Flow( + 'Wärme', + bus='Fernwärme', + size=100, + relative_minimum=np.linspace(0, 0.5, timesteps.size), + relative_maximum=np.linspace(0.5, 1, timesteps.size), + flow_hours_total_max=1000, + flow_hours_total_min=10, + load_factor_min=0.1, + load_factor_max=0.9, + ) + + flow_system.add_elements(fx.Sink('Sink', sink=flow)) + model = create_linopy_model(flow_system) + + # total_flow_hours + assert_conequal( + model.constraints['Sink(Wärme)|total_flow_hours'], + flow.model.variables['Sink(Wärme)|total_flow_hours'] + == (flow.model.variables['Sink(Wärme)|flow_rate'] * model.hours_per_step).sum(), + ) + + assert_var_equal( + flow.model.total_flow_hours, + model.add_variables(lower=10, upper=1000) + ) + + assert_var_equal( + flow.model.flow_rate, + model.add_variables(lower=np.linspace(0, 0.5, timesteps.size) * 100, + upper=np.linspace(0.5, 1, timesteps.size) * 100, + coords=(timesteps,)) + ) + + assert_conequal( + model.constraints['Sink(Wärme)|load_factor_min'], + flow.model.variables['Sink(Wärme)|total_flow_hours'] + >= model.hours_per_step.sum('time') * 0.1 * 100, + ) + + assert_conequal( + model.constraints['Sink(Wärme)|load_factor_max'], + flow.model.variables['Sink(Wärme)|total_flow_hours'] + <= model.hours_per_step.sum('time') * 0.9 * 100, + ) + + assert set(flow.model.variables) == set(['Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate']) + assert set(flow.model.constraints) == set(['Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|load_factor_max', 'Sink(Wärme)|load_factor_min']) + + def test_effects_per_flow_hour(self, basic_flow_system_linopy): + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + + costs_per_flow_hour = xr.DataArray(np.linspace(1,2,timesteps.size), coords=(timesteps,)) + co2_per_flow_hour = xr.DataArray(np.linspace(4, 5, timesteps.size), coords=(timesteps,)) + + flow = fx.Flow( + 'Wärme', + bus='Fernwärme', + effects_per_flow_hour={'Costs': costs_per_flow_hour, 'CO2': co2_per_flow_hour} + ) + flow_system.add_elements(fx.Sink('Sink', sink=flow), fx.Effect('CO2', 't', '')) + model = create_linopy_model(flow_system) + costs, co2 = flow_system.effects['Costs'], flow_system.effects['CO2'] + + assert set(flow.model.variables) == {'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate'} + assert set(flow.model.constraints) == {'Sink(Wärme)|total_flow_hours'} + + assert 'Sink(Wärme)->Costs(operation)' in set(costs.model.constraints) + assert 'Sink(Wärme)->CO2(operation)' in set(co2.model.constraints) + + assert_conequal( + model.constraints['Sink(Wärme)->Costs(operation)'], + model.variables['Sink(Wärme)->Costs(operation)'] == flow.model.variables['Sink(Wärme)|flow_rate'] * model.hours_per_step * costs_per_flow_hour) + + assert_conequal( + model.constraints['Sink(Wärme)->CO2(operation)'], + model.variables['Sink(Wärme)->CO2(operation)'] == flow.model.variables['Sink(Wärme)|flow_rate'] * model.hours_per_step * co2_per_flow_hour) + + +class TestFlowInvestModel: + """Test the FlowModel class.""" + + def test_flow_invest(self, basic_flow_system_linopy): + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + + flow = fx.Flow( + 'Wärme', + bus='Fernwärme', + size=fx.InvestParameters(minimum_size=20, maximum_size=100, optional=False), + relative_minimum=np.linspace(0.1, 0.5, timesteps.size), + relative_maximum=np.linspace(0.5, 1, timesteps.size), + ) + + flow_system.add_elements(fx.Sink('Sink', sink=flow)) + model = create_linopy_model(flow_system) + + assert set(flow.model.variables) == set( + [ + 'Sink(Wärme)|total_flow_hours', + 'Sink(Wärme)|flow_rate', + 'Sink(Wärme)|size', + ] + ) + assert set(flow.model.constraints) == set( + [ + 'Sink(Wärme)|total_flow_hours', + 'Sink(Wärme)|lb_Sink(Wärme)|flow_rate', + 'Sink(Wärme)|ub_Sink(Wärme)|flow_rate', + ] + ) + + # size + assert_var_equal(model['Sink(Wärme)|size'], model.add_variables(lower=20, upper=100)) + + # flow_rate + assert_var_equal( + flow.model.flow_rate, + model.add_variables( + lower=np.linspace(0.1, 0.5, timesteps.size) * 20, + upper=np.linspace(0.5, 1, timesteps.size) * 100, + coords=(timesteps,), + ), + ) + assert_conequal( + model.constraints['Sink(Wärme)|lb_Sink(Wärme)|flow_rate'], + flow.model.variables['Sink(Wärme)|flow_rate'] + >= flow.model.variables['Sink(Wärme)|size'] + * xr.DataArray(np.linspace(0.1, 0.5, timesteps.size), coords=(timesteps,)), + ) + assert_conequal( + model.constraints['Sink(Wärme)|ub_Sink(Wärme)|flow_rate'], + flow.model.variables['Sink(Wärme)|flow_rate'] + <= flow.model.variables['Sink(Wärme)|size'] + * xr.DataArray(np.linspace(0.5, 1, timesteps.size), coords=(timesteps,)), + ) + + def test_flow_invest_optional(self, basic_flow_system_linopy): + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + + flow = fx.Flow( + 'Wärme', + bus='Fernwärme', + size=fx.InvestParameters(minimum_size=20, maximum_size=100, optional=True), + relative_minimum=np.linspace(0.1, 0.5, timesteps.size), + relative_maximum=np.linspace(0.5, 1, timesteps.size), + ) + + flow_system.add_elements(fx.Sink('Sink', sink=flow)) + model = create_linopy_model(flow_system) + + assert set(flow.model.variables) == set( + ['Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate', 'Sink(Wärme)|size', 'Sink(Wärme)|is_invested'] + ) + assert set(flow.model.constraints) == set( + [ + 'Sink(Wärme)|total_flow_hours', + 'Sink(Wärme)|is_invested_ub', + 'Sink(Wärme)|is_invested_lb', + 'Sink(Wärme)|lb_Sink(Wärme)|flow_rate', + 'Sink(Wärme)|ub_Sink(Wärme)|flow_rate', + ] + ) + + assert_var_equal(model['Sink(Wärme)|size'], model.add_variables(lower=0, upper=100)) + + assert_var_equal(model['Sink(Wärme)|is_invested'], model.add_variables(binary=True)) + + # flow_rate + assert_var_equal( + flow.model.flow_rate, + model.add_variables( + lower=0, # Optional investment + upper=np.linspace(0.5, 1, timesteps.size) * 100, + coords=(timesteps,), + ), + ) + assert_conequal( + model.constraints['Sink(Wärme)|lb_Sink(Wärme)|flow_rate'], + flow.model.variables['Sink(Wärme)|flow_rate'] + >= flow.model.variables['Sink(Wärme)|size'] + * xr.DataArray(np.linspace(0.1, 0.5, timesteps.size), coords=(timesteps,)), + ) + assert_conequal( + model.constraints['Sink(Wärme)|ub_Sink(Wärme)|flow_rate'], + flow.model.variables['Sink(Wärme)|flow_rate'] + <= flow.model.variables['Sink(Wärme)|size'] + * xr.DataArray(np.linspace(0.5, 1, timesteps.size), coords=(timesteps,)), + ) + + # Is invested + assert_conequal( + model.constraints['Sink(Wärme)|is_invested_ub'], + flow.model.variables['Sink(Wärme)|size'] <= flow.model.variables['Sink(Wärme)|is_invested'] * 100, + ) + assert_conequal( + model.constraints['Sink(Wärme)|is_invested_lb'], + flow.model.variables['Sink(Wärme)|size'] >= flow.model.variables['Sink(Wärme)|is_invested'] * 20, + ) + + def test_flow_invest_fixed_size(self, basic_flow_system_linopy): + """Test flow with fixed size investment.""" + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + + flow = fx.Flow( + 'Wärme', + bus='Fernwärme', + size=fx.InvestParameters(fixed_size=75, optional=False), + relative_minimum=0.2, + relative_maximum=0.9, + ) + + flow_system.add_elements(fx.Sink('Sink', sink=flow)) + model = create_linopy_model(flow_system) + + assert set(flow.model.variables) == {'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate', 'Sink(Wärme)|size'} + + # Check that size is fixed to 75 + assert_var_equal(flow.model.variables['Sink(Wärme)|size'], model.add_variables(lower=75, upper=75)) + + # Check flow rate bounds + assert_var_equal(flow.model.flow_rate, model.add_variables(lower=0.2 * 75, upper=0.9 * 75, coords=(timesteps,))) + + def test_flow_invest_with_effects(self, basic_flow_system_linopy): + """Test flow with investment effects.""" + flow_system = basic_flow_system_linopy + + # Create effects + co2 = fx.Effect(label='CO2', unit='ton', description='CO2 emissions') + + flow = fx.Flow( + 'Wärme', + bus='Fernwärme', + size=fx.InvestParameters( + minimum_size=20, + maximum_size=100, + optional=True, + fix_effects={'Costs': 1000, 'CO2': 5}, # Fixed investment effects + specific_effects={'Costs': 500, 'CO2': 0.1}, # Specific investment effects + ), + ) + + flow_system.add_elements( fx.Sink('Sink', sink=flow), co2) + model = create_linopy_model(flow_system) + + # Check investment effects + assert 'Sink(Wärme)->Costs(invest)' in model.variables + assert 'Sink(Wärme)->CO2(invest)' in model.variables + + # Check fix effects (applied only when is_invested=1) + assert_conequal( + model.constraints['Sink(Wärme)->Costs(invest)'], + model.variables['Sink(Wärme)->Costs(invest)'] + == flow.model.variables['Sink(Wärme)|is_invested'] * 1000 + flow.model.variables['Sink(Wärme)|size'] * 500, + ) + + assert_conequal( + model.constraints['Sink(Wärme)->CO2(invest)'], + model.variables['Sink(Wärme)->CO2(invest)'] + == flow.model.variables['Sink(Wärme)|is_invested'] * 5 + flow.model.variables['Sink(Wärme)|size'] * 0.1, + ) + + def test_flow_invest_divest_effects(self, basic_flow_system_linopy): + """Test flow with divestment effects.""" + flow_system = basic_flow_system_linopy + + flow = fx.Flow( + 'Wärme', + bus='Fernwärme', + size=fx.InvestParameters( + minimum_size=20, + maximum_size=100, + optional=True, + divest_effects={'Costs': 500}, # Cost incurred when NOT investing + ), + ) + + flow_system.add_elements(fx.Sink('Sink', sink=flow)) + model = create_linopy_model(flow_system) + + # Check divestment effects + assert 'Sink(Wärme)->Costs(invest)' in model.constraints + + assert_conequal( + model.constraints['Sink(Wärme)->Costs(invest)'], + model.variables['Sink(Wärme)->Costs(invest)'] + (model.variables['Sink(Wärme)|is_invested'] -1) * 500 == 0 + ) + + +class TestFlowOnModel: + """Test the FlowModel class.""" + + def test_flow_on(self, basic_flow_system_linopy): + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + flow = fx.Flow( + 'Wärme', + bus='Fernwärme', + size=100, + relative_minimum=xr.DataArray(0.2, coords=(timesteps,)), + relative_maximum=xr.DataArray(0.8, coords=(timesteps,)), + on_off_parameters=fx.OnOffParameters(), + ) + flow_system.add_elements(fx.Sink('Sink', sink=flow)) + model = create_linopy_model(flow_system) + + assert set(flow.model.variables) == set( + ['Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate', 'Sink(Wärme)|on', 'Sink(Wärme)|on_hours_total'] + ) + + assert set(flow.model.constraints) == set( + [ + 'Sink(Wärme)|total_flow_hours', + 'Sink(Wärme)|on_hours_total', + 'Sink(Wärme)|on_con1', + 'Sink(Wärme)|on_con2', + ] + ) + # flow_rate + assert_var_equal( + flow.model.flow_rate, + model.add_variables( + lower=0, + upper=0.8 * 100, + coords=(timesteps,), + ), + ) + + # OnOff + assert_var_equal( + flow.model.on_off.on, + model.add_variables(binary=True, coords=(timesteps,)), + ) + assert_var_equal( + model.variables['Sink(Wärme)|on_hours_total'], + model.add_variables(lower=0), + ) + assert_conequal( + model.constraints['Sink(Wärme)|on_con1'], + flow.model.variables['Sink(Wärme)|on'] * 0.2 * 100 <= flow.model.variables['Sink(Wärme)|flow_rate'], + ) + assert_conequal( + model.constraints['Sink(Wärme)|on_con2'], + flow.model.variables['Sink(Wärme)|on'] * 0.8 * 100 >= flow.model.variables['Sink(Wärme)|flow_rate'], + ) + + assert_conequal( + model.constraints['Sink(Wärme)|on_hours_total'], + flow.model.variables['Sink(Wärme)|on_hours_total'] + == (flow.model.variables['Sink(Wärme)|on'] * model.hours_per_step).sum(), + ) + + def test_effects_per_running_hour(self, basic_flow_system_linopy): + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + + costs_per_running_hour = xr.DataArray(np.linspace(1, 2, timesteps.size), coords=(timesteps,)) + co2_per_running_hour = xr.DataArray(np.linspace(4, 5, timesteps.size), coords=(timesteps,)) + + flow = fx.Flow( + 'Wärme', + bus='Fernwärme', + on_off_parameters=fx.OnOffParameters( + effects_per_running_hour={'Costs': costs_per_running_hour, 'CO2': co2_per_running_hour} + ), + ) + flow_system.add_elements(fx.Sink('Sink', sink=flow), fx.Effect('CO2', 't', '')) + model = create_linopy_model(flow_system) + costs, co2 = flow_system.effects['Costs'], flow_system.effects['CO2'] + + assert set(flow.model.variables) == { + 'Sink(Wärme)|total_flow_hours', + 'Sink(Wärme)|flow_rate', + 'Sink(Wärme)|on', + 'Sink(Wärme)|on_hours_total', + } + assert set(flow.model.constraints) == { + 'Sink(Wärme)|total_flow_hours', + 'Sink(Wärme)|on_con1', + 'Sink(Wärme)|on_con2', + 'Sink(Wärme)|on_hours_total', + } + + assert 'Sink(Wärme)->Costs(operation)' in set(costs.model.constraints) + assert 'Sink(Wärme)->CO2(operation)' in set(co2.model.constraints) + + assert_conequal( + model.constraints['Sink(Wärme)->Costs(operation)'], + model.variables['Sink(Wärme)->Costs(operation)'] + == flow.model.variables['Sink(Wärme)|on'] * model.hours_per_step * costs_per_running_hour, + ) + + assert_conequal( + model.constraints['Sink(Wärme)->CO2(operation)'], + model.variables['Sink(Wärme)->CO2(operation)'] + == flow.model.variables['Sink(Wärme)|on'] * model.hours_per_step * co2_per_running_hour, + ) + + def test_consecutive_on_hours(self, basic_flow_system_linopy): + """Test flow with minimum and maximum consecutive on hours.""" + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + + flow = fx.Flow( + 'Wärme', + bus='Fernwärme', + size=100, + on_off_parameters=fx.OnOffParameters( + consecutive_on_hours_min=2, # Must run for at least 2 hours when turned on + consecutive_on_hours_max=8, # Can't run more than 8 consecutive hours + ), + ) + + flow_system.add_elements( fx.Sink('Sink', sink=flow)) + model = create_linopy_model(flow_system) + + assert {'Sink(Wärme)|consecutive_on_hours', 'Sink(Wärme)|on'}.issubset(set(flow.model.variables)) + + assert { + 'Sink(Wärme)|consecutive_on_hours_con1', + 'Sink(Wärme)|consecutive_on_hours_con2a', + 'Sink(Wärme)|consecutive_on_hours_con2b', + 'Sink(Wärme)|consecutive_on_hours_initial', + 'Sink(Wärme)|consecutive_on_hours_minimum_duration' + }.issubset(set(flow.model.constraints)) + + assert_var_equal( + model.variables['Sink(Wärme)|consecutive_on_hours'], + model.add_variables(lower=0, upper=8, coords=(timesteps,)) + ) + + mega = model.hours_per_step.sum('time') + + assert_conequal( + model.constraints['Sink(Wärme)|consecutive_on_hours_con1'], + model.variables['Sink(Wärme)|consecutive_on_hours'] <= model.variables['Sink(Wärme)|on'] * mega + ) + + assert_conequal( + model.constraints['Sink(Wärme)|consecutive_on_hours_con2a'], + model.variables['Sink(Wärme)|consecutive_on_hours'].isel(time=slice(1, None)) + <= model.variables['Sink(Wärme)|consecutive_on_hours'].isel(time=slice(None, -1)) + model.hours_per_step.isel(time=slice(None, -1)) + ) + + # eq: duration(t) >= duration(t - 1) + dt(t) + (On(t) - 1) * BIG + assert_conequal( + model.constraints['Sink(Wärme)|consecutive_on_hours_con2b'], + model.variables['Sink(Wärme)|consecutive_on_hours'].isel(time=slice(1, None)) + >= model.variables['Sink(Wärme)|consecutive_on_hours'].isel(time=slice(None, -1)) + + model.hours_per_step.isel(time=slice(None, -1)) + + (model.variables['Sink(Wärme)|on'].isel(time=slice(1, None)) - 1) * mega + ) + + assert_conequal( + model.constraints['Sink(Wärme)|consecutive_on_hours_initial'], + model.variables['Sink(Wärme)|consecutive_on_hours'].isel(time=0) == model.variables['Sink(Wärme)|on'].isel(time=0) * model.hours_per_step.isel(time=0) + ) + + assert_conequal( + model.constraints['Sink(Wärme)|consecutive_on_hours_initial'], + model.variables['Sink(Wärme)|consecutive_on_hours'].isel(time=0) + == model.variables['Sink(Wärme)|on'].isel(time=0) * model.hours_per_step.isel(time=0), + ) + + assert_conequal( + model.constraints['Sink(Wärme)|consecutive_on_hours_minimum_duration'], + model.variables['Sink(Wärme)|consecutive_on_hours'] + >= (model.variables['Sink(Wärme)|on'].isel(time=slice(None, -1)) - model.variables['Sink(Wärme)|on'].isel(time=slice(1, None))) * 2 + ) + + def test_consecutive_off_hours(self, basic_flow_system_linopy): + """Test flow with minimum and maximum consecutive off hours.""" + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + + flow = fx.Flow( + 'Wärme', + bus='Fernwärme', + size=100, + on_off_parameters=fx.OnOffParameters( + consecutive_off_hours_min=4, # Must stay off for at least 4 hours when shut down + consecutive_off_hours_max=12, # Can't be off for more than 12 consecutive hours + ), + ) + + flow_system.add_elements( fx.Sink('Sink', sink=flow)) + model = create_linopy_model(flow_system) + + assert {'Sink(Wärme)|consecutive_off_hours', 'Sink(Wärme)|off'}.issubset(set(flow.model.variables)) + + assert { + 'Sink(Wärme)|consecutive_off_hours_con1', + 'Sink(Wärme)|consecutive_off_hours_con2a', + 'Sink(Wärme)|consecutive_off_hours_con2b', + 'Sink(Wärme)|consecutive_off_hours_initial', + 'Sink(Wärme)|consecutive_off_hours_minimum_duration' + }.issubset(set(flow.model.constraints)) + + assert_var_equal( + model.variables['Sink(Wärme)|consecutive_off_hours'], + model.add_variables(lower=0, upper=12, coords=(timesteps,)) + ) + + mega = model.hours_per_step.sum('time') + 1 # previously off for 1h + + assert_conequal( + model.constraints['Sink(Wärme)|consecutive_off_hours_con1'], + model.variables['Sink(Wärme)|consecutive_off_hours'] <= model.variables['Sink(Wärme)|off'] * mega + ) + + assert_conequal( + model.constraints['Sink(Wärme)|consecutive_off_hours_con2a'], + model.variables['Sink(Wärme)|consecutive_off_hours'].isel(time=slice(1, None)) + <= model.variables['Sink(Wärme)|consecutive_off_hours'].isel(time=slice(None, -1)) + model.hours_per_step.isel(time=slice(None, -1)) + ) + + # eq: duration(t) >= duration(t - 1) + dt(t) + (On(t) - 1) * BIG + assert_conequal( + model.constraints['Sink(Wärme)|consecutive_off_hours_con2b'], + model.variables['Sink(Wärme)|consecutive_off_hours'].isel(time=slice(1, None)) + >= model.variables['Sink(Wärme)|consecutive_off_hours'].isel(time=slice(None, -1)) + + model.hours_per_step.isel(time=slice(None, -1)) + + (model.variables['Sink(Wärme)|off'].isel(time=slice(1, None)) - 1) * mega + ) + + assert_conequal( + model.constraints['Sink(Wärme)|consecutive_off_hours_initial'], + model.variables['Sink(Wärme)|consecutive_off_hours'].isel(time=0) == model.variables['Sink(Wärme)|off'].isel(time=0) * model.hours_per_step.isel(time=0) + ) + + assert_conequal( + model.constraints['Sink(Wärme)|consecutive_off_hours_initial'], + model.variables['Sink(Wärme)|consecutive_off_hours'].isel(time=0) + == model.variables['Sink(Wärme)|off'].isel(time=0) * model.hours_per_step.isel(time=0), + ) + + assert_conequal( + model.constraints['Sink(Wärme)|consecutive_off_hours_minimum_duration'], + model.variables['Sink(Wärme)|consecutive_off_hours'] + >= (model.variables['Sink(Wärme)|off'].isel(time=slice(None, -1)) - model.variables['Sink(Wärme)|off'].isel(time=slice(1, None))) * 4 + ) + + def test_switch_on_constraints(self, basic_flow_system_linopy): + """Test flow with constraints on the number of startups.""" + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + + flow = fx.Flow( + 'Wärme', + bus='Fernwärme', + size=100, + on_off_parameters=fx.OnOffParameters( + switch_on_total_max=5, # Maximum 5 startups + effects_per_switch_on={'Costs': 100}, # 100 EUR startup cost + ), + ) + + flow_system.add_elements(fx.Sink('Sink', sink=flow)) + model = create_linopy_model(flow_system) + + # Check that variables exist + assert {'Sink(Wärme)|switch_on', 'Sink(Wärme)|switch_off', 'Sink(Wärme)|switch_on_nr'}.issubset( + set(flow.model.variables) + ) + + # Check that constraints exist + assert { + 'Sink(Wärme)|switch_con', + 'Sink(Wärme)|initial_switch_con', + 'Sink(Wärme)|switch_on_or_off', + 'Sink(Wärme)|switch_on_nr', + }.issubset(set(flow.model.constraints)) + + # Check switch_on_nr variable bounds + assert_var_equal(flow.model.variables['Sink(Wärme)|switch_on_nr'], model.add_variables(lower=0, upper=5)) + + # Verify switch_on_nr constraint (limits number of startups) + assert_conequal( + model.constraints['Sink(Wärme)|switch_on_nr'], + flow.model.variables['Sink(Wärme)|switch_on_nr'] + == flow.model.variables['Sink(Wärme)|switch_on'].sum('time'), + ) + + # Check that startup cost effect constraint exists + assert 'Sink(Wärme)->Costs(operation)' in model.constraints + + # Verify the startup cost effect constraint + assert_conequal( + model.constraints['Sink(Wärme)->Costs(operation)'], + model.variables['Sink(Wärme)->Costs(operation)'] == flow.model.variables['Sink(Wärme)|switch_on'] * 100, + ) + + def test_on_hours_limits(self, basic_flow_system_linopy): + """Test flow with limits on total on hours.""" + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + + flow = fx.Flow( + 'Wärme', + bus='Fernwärme', + size=100, + on_off_parameters=fx.OnOffParameters( + on_hours_total_min=20, # Minimum 20 hours of operation + on_hours_total_max=100, # Maximum 100 hours of operation + ), + ) + + flow_system.add_elements(fx.Sink('Sink', sink=flow)) + model = create_linopy_model(flow_system) + + # Check that variables exist + assert {'Sink(Wärme)|on', 'Sink(Wärme)|on_hours_total'}.issubset(set(flow.model.variables)) + + # Check that constraints exist + assert 'Sink(Wärme)|on_hours_total' in model.constraints + + # Check on_hours_total variable bounds + assert_var_equal(flow.model.variables['Sink(Wärme)|on_hours_total'], model.add_variables(lower=20, upper=100)) + + # Check on_hours_total constraint + assert_conequal( + model.constraints['Sink(Wärme)|on_hours_total'], + flow.model.variables['Sink(Wärme)|on_hours_total'] + == (flow.model.variables['Sink(Wärme)|on'] * model.hours_per_step).sum(), + ) + + +class TestFlowOnInvestModel: + """Test the FlowModel class.""" + + def test_flow_on_invest_optional(self, basic_flow_system_linopy): + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + flow = fx.Flow( + 'Wärme', + bus='Fernwärme', + size=fx.InvestParameters(minimum_size=20, maximum_size=200, optional=True), + relative_minimum=xr.DataArray(0.2, coords=(timesteps,)), + relative_maximum=xr.DataArray(0.8, coords=(timesteps,)), + on_off_parameters=fx.OnOffParameters(), + ) + flow_system.add_elements(fx.Sink('Sink', sink=flow)) + model = create_linopy_model(flow_system) + + assert set(flow.model.variables) == set( + [ + 'Sink(Wärme)|total_flow_hours', + 'Sink(Wärme)|flow_rate', + 'Sink(Wärme)|is_invested', + 'Sink(Wärme)|size', + 'Sink(Wärme)|on', + 'Sink(Wärme)|on_hours_total', + ] + ) + + assert set(flow.model.constraints) == set( + [ + 'Sink(Wärme)|total_flow_hours', + 'Sink(Wärme)|on_hours_total', + 'Sink(Wärme)|on_con1', + 'Sink(Wärme)|on_con2', + 'Sink(Wärme)|is_invested_lb', + 'Sink(Wärme)|is_invested_ub', + 'Sink(Wärme)|lb_Sink(Wärme)|flow_rate', + 'Sink(Wärme)|ub_Sink(Wärme)|flow_rate', + ] + ) + + # flow_rate + assert_var_equal( + flow.model.flow_rate, + model.add_variables( + lower=0, + upper=0.8 * 200, + coords=(timesteps,), + ), + ) + + # OnOff + assert_var_equal( + flow.model.on_off.on, + model.add_variables(binary=True, coords=(timesteps,)), + ) + assert_var_equal( + model.variables['Sink(Wärme)|on_hours_total'], + model.add_variables(lower=0), + ) + assert_conequal( + model.constraints['Sink(Wärme)|on_con1'], + flow.model.variables['Sink(Wärme)|on'] * 0.2 * 20 <= flow.model.variables['Sink(Wärme)|flow_rate'], + ) + assert_conequal( + model.constraints['Sink(Wärme)|on_con2'], + flow.model.variables['Sink(Wärme)|on'] * 0.8 * 200 >= flow.model.variables['Sink(Wärme)|flow_rate'], + ) + assert_conequal( + model.constraints['Sink(Wärme)|on_hours_total'], + flow.model.variables['Sink(Wärme)|on_hours_total'] + == (flow.model.variables['Sink(Wärme)|on'] * model.hours_per_step).sum(), + ) + + # Investment + assert_var_equal(model['Sink(Wärme)|size'], model.add_variables(lower=0, upper=200)) + + mega = 0.2 * 200 # Relative minimum * maximum size + assert_conequal( + model.constraints['Sink(Wärme)|lb_Sink(Wärme)|flow_rate'], + flow.model.variables['Sink(Wärme)|flow_rate'] + >= flow.model.variables['Sink(Wärme)|on'] * mega + flow.model.variables['Sink(Wärme)|size'] * 0.2 - mega, + ) + assert_conequal( + model.constraints['Sink(Wärme)|ub_Sink(Wärme)|flow_rate'], + flow.model.variables['Sink(Wärme)|flow_rate'] <= flow.model.variables['Sink(Wärme)|size'] * 0.8, + ) + + def test_flow_on_invest_non_optional(self, basic_flow_system_linopy): + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + flow = fx.Flow( + 'Wärme', + bus='Fernwärme', + size=fx.InvestParameters(minimum_size=20, maximum_size=200, optional=False), + relative_minimum=xr.DataArray(0.2, coords=(timesteps,)), + relative_maximum=xr.DataArray(0.8, coords=(timesteps,)), + on_off_parameters=fx.OnOffParameters(), + ) + flow_system.add_elements(fx.Sink('Sink', sink=flow)) + model = create_linopy_model(flow_system) + + assert set(flow.model.variables) == set( + [ + 'Sink(Wärme)|total_flow_hours', + 'Sink(Wärme)|flow_rate', + 'Sink(Wärme)|size', + 'Sink(Wärme)|on', + 'Sink(Wärme)|on_hours_total', + ] + ) + + assert set(flow.model.constraints) == set( + [ + 'Sink(Wärme)|total_flow_hours', + 'Sink(Wärme)|on_hours_total', + 'Sink(Wärme)|on_con1', + 'Sink(Wärme)|on_con2', + 'Sink(Wärme)|lb_Sink(Wärme)|flow_rate', + 'Sink(Wärme)|ub_Sink(Wärme)|flow_rate', + ] + ) + + # flow_rate + assert_var_equal( + flow.model.flow_rate, + model.add_variables( + lower=0, + upper=0.8 * 200, + coords=(timesteps,), + ), + ) + + # OnOff + assert_var_equal( + flow.model.on_off.on, + model.add_variables(binary=True, coords=(timesteps,)), + ) + assert_var_equal( + model.variables['Sink(Wärme)|on_hours_total'], + model.add_variables(lower=0), + ) + assert_conequal( + model.constraints['Sink(Wärme)|on_con1'], + flow.model.variables['Sink(Wärme)|on'] * 0.2 * 20 <= flow.model.variables['Sink(Wärme)|flow_rate'], + ) + assert_conequal( + model.constraints['Sink(Wärme)|on_con2'], + flow.model.variables['Sink(Wärme)|on'] * 0.8 * 200 >= flow.model.variables['Sink(Wärme)|flow_rate'], + ) + assert_conequal( + model.constraints['Sink(Wärme)|on_hours_total'], + flow.model.variables['Sink(Wärme)|on_hours_total'] + == (flow.model.variables['Sink(Wärme)|on'] * model.hours_per_step).sum(), + ) + + # Investment + assert_var_equal(model['Sink(Wärme)|size'], model.add_variables(lower=20, upper=200)) + + mega = 0.2 * 200 # Relative minimum * maximum size + assert_conequal( + model.constraints['Sink(Wärme)|lb_Sink(Wärme)|flow_rate'], + flow.model.variables['Sink(Wärme)|flow_rate'] + >= flow.model.variables['Sink(Wärme)|on'] * mega + flow.model.variables['Sink(Wärme)|size'] * 0.2 - mega, + ) + assert_conequal( + model.constraints['Sink(Wärme)|ub_Sink(Wärme)|flow_rate'], + flow.model.variables['Sink(Wärme)|flow_rate'] <= flow.model.variables['Sink(Wärme)|size'] * 0.8, + ) + + +class TestFlowWithFixedProfile: + """Test Flow with fixed relative profile.""" + + def test_fixed_relative_profile(self, basic_flow_system_linopy): + """Test flow with a fixed relative profile.""" + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + + # Create a time-varying profile (e.g., for a load or renewable generation) + profile = np.sin(np.linspace(0, 2 * np.pi, len(timesteps))) * 0.5 + 0.5 # Values between 0 and 1 + + flow = fx.Flow( + 'Wärme', bus='Fernwärme', size=100, fixed_relative_profile=xr.DataArray(profile, coords=(timesteps,)) + ) + + flow_system.add_elements(fx.Sink('Sink', sink=flow)) + model = create_linopy_model(flow_system) + + assert_var_equal(flow.model.variables['Sink(Wärme)|flow_rate'], + model.add_variables(lower=profile * 100, + upper=profile * 100, + coords=(timesteps,)) + ) + + + def test_fixed_profile_with_investment(self, basic_flow_system_linopy): + """Test flow with fixed profile and investment.""" + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + + # Create a fixed profile + profile = np.sin(np.linspace(0, 2 * np.pi, len(timesteps))) * 0.5 + 0.5 + + flow = fx.Flow( + 'Wärme', + bus='Fernwärme', + size=fx.InvestParameters(minimum_size=50, maximum_size=200, optional=True), + fixed_relative_profile=xr.DataArray(profile, coords=(timesteps,)), + ) + + flow_system.add_elements( fx.Sink('Sink', sink=flow)) + model = create_linopy_model(flow_system) + + assert_var_equal( + flow.model.variables['Sink(Wärme)|flow_rate'], + model.add_variables(lower=0, upper=profile * 200, coords=(timesteps,)), + ) + + # The constraint should link flow_rate to size * profile + assert_conequal( + model.constraints['Sink(Wärme)|fix_Sink(Wärme)|flow_rate'], + flow.model.variables['Sink(Wärme)|flow_rate'] + == flow.model.variables['Sink(Wärme)|size'] * xr.DataArray(profile, coords=(timesteps,)), + ) + + +if __name__ == '__main__': + pytest.main() From c4330549049457f51e977c90c790c8857139f011 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 10 Apr 2025 16:50:38 +0200 Subject: [PATCH 06/34] Bugfix divest effects --- flixopt/features.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixopt/features.py b/flixopt/features.py index 92caf9dc2..21eacc2d4 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -90,7 +90,7 @@ def _create_shares(self): # share: divest_effects - isInvested * divest_effects self._model.effects.add_share_to_effects( name=self.label_of_element, - expressions={effect: -self.is_invested * factor + factor for effect, factor in fix_effects.items()}, + expressions={effect: -self.is_invested * factor + factor for effect, factor in self.parameters.divest_effects.items()}, target='invest', ) From dd682be65a29c0899575aec2d698d63d071f9934 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 10 Apr 2025 18:02:19 +0200 Subject: [PATCH 07/34] Add lower bound to variable (just for good measure) --- flixopt/features.py | 1 + 1 file changed, 1 insertion(+) diff --git a/flixopt/features.py b/flixopt/features.py index 21eacc2d4..5014290c4 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -314,6 +314,7 @@ def do_modeling(self): self.switch_on_nr = self.add( self._model.add_variables( + lower=0, upper=self.parameters.switch_on_total_max if self.parameters.switch_on_total_max is not None else np.inf, From 2c94f8b26b64d35378b076a23d0ed8fe527c9588 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 10 Apr 2025 18:28:47 +0200 Subject: [PATCH 08/34] ruff check --- tests/conftest.py | 22 +++++++++++----------- tests/test_bus.py | 2 +- tests/test_effect.py | 3 +-- tests/test_flow.py | 6 ++---- 4 files changed, 15 insertions(+), 18 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 9484fbdac..50dad5a82 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,11 +6,11 @@ import os +import linopy.testing import numpy as np import pandas as pd import pytest import xarray as xr -import linopy.testing import flixopt as fx from flixopt.structure import SystemModel @@ -455,17 +455,17 @@ def assert_conequal(actual: linopy.Constraint, desired: linopy.Constraint): try: linopy.testing.assert_linequal(actual.lhs, desired.lhs) except AssertionError as e: - raise AssertionError(f"{name} left-hand sides don't match:\n{e}") + raise AssertionError(f"{name} left-hand sides don't match:\n{e}") from e try: linopy.testing.assert_linequal(actual.rhs, desired.rhs) except AssertionError as e: - raise AssertionError(f"{name} right-hand sides don't match:\n{e}") + raise AssertionError(f"{name} right-hand sides don't match:\n{e}") from e try: xr.testing.assert_equal(actual.sign, desired.sign) - except AssertionError: - raise AssertionError(f"{name} signs don't match:\nActual: {actual.sign}\nExpected: {desired.sign}") + except AssertionError as e: + raise AssertionError(f"{name} signs don't match:\nActual: {actual.sign}\nExpected: {desired.sign}") from e def assert_var_equal(actual: linopy.Variable, desired: linopy.Variable): @@ -473,13 +473,13 @@ def assert_var_equal(actual: linopy.Variable, desired: linopy.Variable): name = actual.name try: xr.testing.assert_equal(actual.lower, desired.lower) - except AssertionError: - raise AssertionError(f"{name} lower bounds don't match:\nActual: {actual.lower}\nExpected: {desired.lower}") + except AssertionError as e: + raise AssertionError(f"{name} lower bounds don't match:\nActual: {actual.lower}\nExpected: {desired.lower}") from e try: xr.testing.assert_equal(actual.upper, desired.upper) - except AssertionError: - raise AssertionError(f"{name} upper bounds don't match:\nActual: {actual.upper}\nExpected: {desired.upper}") + except AssertionError as e: + raise AssertionError(f"{name} upper bounds don't match:\nActual: {actual.upper}\nExpected: {desired.upper}") from e if actual.type != desired.type: raise AssertionError(f"{name} types don't match: {actual.type} != {desired.type}") @@ -492,8 +492,8 @@ def assert_var_equal(actual: linopy.Variable, desired: linopy.Variable): try: xr.testing.assert_equal(actual.coords, desired.coords) - except AssertionError: - raise AssertionError(f"{name} coordinates don't match:\nActual: {actual.coords}\nExpected: {desired.coords}") + except AssertionError as e: + raise AssertionError(f"{name} coordinates don't match:\nActual: {actual.coords}\nExpected: {desired.coords}") from e if actual.coord_dims != desired.coord_dims: raise AssertionError(f"{name} coordinate dimensions don't match: {actual.coord_dims} != {desired.coord_dims}") diff --git a/tests/test_bus.py b/tests/test_bus.py index a868f8002..4a41a9f9e 100644 --- a/tests/test_bus.py +++ b/tests/test_bus.py @@ -5,7 +5,7 @@ import flixopt as fx -from .conftest import basic_flow_system_linopy, create_linopy_model, assert_var_equal, assert_conequal +from .conftest import assert_conequal, assert_var_equal, create_linopy_model class TestBusModel: diff --git a/tests/test_effect.py b/tests/test_effect.py index 60c8e0672..5cbc04ac6 100644 --- a/tests/test_effect.py +++ b/tests/test_effect.py @@ -5,7 +5,7 @@ import flixopt as fx -from .conftest import basic_flow_system_linopy, create_linopy_model, assert_var_equal, assert_conequal +from .conftest import assert_conequal, assert_var_equal, create_linopy_model class TestBusModel: @@ -85,7 +85,6 @@ def test_bounds(self, basic_flow_system_linopy): def test_shares(self, basic_flow_system_linopy): flow_system = basic_flow_system_linopy - timesteps = flow_system.time_series_collection.timesteps effect1 = fx.Effect('Effect1', '€', 'Testing Effect', specific_share_to_other_effects_operation={ 'Effect2': 1.1, diff --git a/tests/test_flow.py b/tests/test_flow.py index 02e053721..6fb7884be 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -1,11 +1,11 @@ import numpy as np -import pytest import pandas as pd +import pytest import xarray as xr import flixopt as fx -from .conftest import basic_flow_system_linopy, create_linopy_model, assert_var_equal, assert_conequal +from .conftest import assert_conequal, assert_var_equal, create_linopy_model class TestFlowModel: @@ -583,7 +583,6 @@ def test_consecutive_off_hours(self, basic_flow_system_linopy): def test_switch_on_constraints(self, basic_flow_system_linopy): """Test flow with constraints on the number of startups.""" flow_system = basic_flow_system_linopy - timesteps = flow_system.time_series_collection.timesteps flow = fx.Flow( 'Wärme', @@ -633,7 +632,6 @@ def test_switch_on_constraints(self, basic_flow_system_linopy): def test_on_hours_limits(self, basic_flow_system_linopy): """Test flow with limits on total on hours.""" flow_system = basic_flow_system_linopy - timesteps = flow_system.time_series_collection.timesteps flow = fx.Flow( 'Wärme', From dd344500bcf3395e615b13e5b18e17e15a4343ef Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 10 Apr 2025 18:36:29 +0200 Subject: [PATCH 09/34] Add pytest mark "slow" --- tests/test_examples.py | 1 + tests/test_integration.py | 2 +- tests/test_io.py | 2 +- tests/test_plots.py | 2 +- tests/test_results_plots.py | 4 ++-- tests/test_timeseries.py | 2 +- 6 files changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/test_examples.py b/tests/test_examples.py index 85f87d4cc..ad2846679 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -16,6 +16,7 @@ ), # Sort by parent and script name ids=lambda path: str(path.relative_to(EXAMPLES_DIR)), # Show relative file paths ) +@pytest.mark.slow def test_example_scripts(example_script): """ Test all example scripts in the examples directory. diff --git a/tests/test_integration.py b/tests/test_integration.py index e3d4faf0d..dc203c33e 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -354,7 +354,7 @@ def test_piecewise_conversion(self, flow_system_piecewise_conversion, highs_solv 'Speicher investCosts_segmented_costs doesnt match expected value', ) - +@pytest.mark.slow class TestModelingTypes: @pytest.fixture(params=['full', 'segmented', 'aggregated']) def modeling_calculation(self, request, flow_system_long, highs_solver): diff --git a/tests/test_io.py b/tests/test_io.py index 84536f61d..2e6c61ccf 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -22,7 +22,7 @@ def flow_system(request): else: return fs[0] - +@pytest.mark.slow def test_flow_system_file_io(flow_system, highs_solver): calculation_0 = fx.FullCalculation('IO', flow_system=flow_system) calculation_0.do_modeling() diff --git a/tests/test_plots.py b/tests/test_plots.py index d08e9ba4e..5113e29f2 100644 --- a/tests/test_plots.py +++ b/tests/test_plots.py @@ -14,7 +14,7 @@ from flixopt import plotting - +@pytest.mark.slow class TestPlots(unittest.TestCase): def setUp(self): np.random.seed(72) diff --git a/tests/test_results_plots.py b/tests/test_results_plots.py index 26197c98a..855944a48 100644 --- a/tests/test_results_plots.py +++ b/tests/test_results_plots.py @@ -40,7 +40,7 @@ def plotting_engine(request): def color_spec(request): return request.param - +@pytest.mark.slow def test_results_plots(flow_system, plotting_engine, show, save, color_spec): calculation = create_calculation_and_solve(flow_system, fx.solvers.HighsSolver(0.01, 30), 'test_results_plots') results = calculation.results @@ -67,7 +67,7 @@ def test_results_plots(flow_system, plotting_engine, show, save, color_spec): plt.close('all') - +@pytest.mark.slow def test_color_handling_edge_cases(flow_system, plotting_engine, show, save): """Test edge cases for color handling""" calculation = create_calculation_and_solve(flow_system, fx.solvers.HighsSolver(0.01, 30), 'test_color_edge_cases') diff --git a/tests/test_timeseries.py b/tests/test_timeseries.py index 91b0b26f3..a8bc5fa85 100644 --- a/tests/test_timeseries.py +++ b/tests/test_timeseries.py @@ -555,7 +555,7 @@ def test_restore_data(self, populated_collection): def test_class_method_with_uniform_timesteps(self): """Test the with_uniform_timesteps class method.""" collection = TimeSeriesCollection.with_uniform_timesteps( - start_time=pd.Timestamp('2023-01-01'), periods=24, freq='H', hours_per_step=1 + start_time=pd.Timestamp('2023-01-01'), periods=24, freq='h', hours_per_step=1 ) assert len(collection.timesteps) == 24 From 0f8c03b08a20e7934f0637f6d9c46b795e51f37c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 10 Apr 2025 18:39:31 +0200 Subject: [PATCH 10/34] ruff check --- tests/test_plots.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_plots.py b/tests/test_plots.py index 5113e29f2..840b4e7b3 100644 --- a/tests/test_plots.py +++ b/tests/test_plots.py @@ -14,6 +14,7 @@ from flixopt import plotting + @pytest.mark.slow class TestPlots(unittest.TestCase): def setUp(self): From 267e3ab594ef7ec4cf343b07e377b2c55fd74a26 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 10 Apr 2025 19:00:08 +0200 Subject: [PATCH 11/34] remove code duplicate --- tests/test_flow.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/tests/test_flow.py b/tests/test_flow.py index 6fb7884be..fdb9f30ab 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -491,11 +491,6 @@ def test_consecutive_on_hours(self, basic_flow_system_linopy): + (model.variables['Sink(Wärme)|on'].isel(time=slice(1, None)) - 1) * mega ) - assert_conequal( - model.constraints['Sink(Wärme)|consecutive_on_hours_initial'], - model.variables['Sink(Wärme)|consecutive_on_hours'].isel(time=0) == model.variables['Sink(Wärme)|on'].isel(time=0) * model.hours_per_step.isel(time=0) - ) - assert_conequal( model.constraints['Sink(Wärme)|consecutive_on_hours_initial'], model.variables['Sink(Wärme)|consecutive_on_hours'].isel(time=0) @@ -563,11 +558,6 @@ def test_consecutive_off_hours(self, basic_flow_system_linopy): + (model.variables['Sink(Wärme)|off'].isel(time=slice(1, None)) - 1) * mega ) - assert_conequal( - model.constraints['Sink(Wärme)|consecutive_off_hours_initial'], - model.variables['Sink(Wärme)|consecutive_off_hours'].isel(time=0) == model.variables['Sink(Wärme)|off'].isel(time=0) * model.hours_per_step.isel(time=0) - ) - assert_conequal( model.constraints['Sink(Wärme)|consecutive_off_hours_initial'], model.variables['Sink(Wärme)|consecutive_off_hours'].isel(time=0) From e24b11e26ad74f5f1ca4e3d3515fa142e7f543d2 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 10 Apr 2025 19:31:38 +0200 Subject: [PATCH 12/34] Bugfix consecutive duration and add tests --- flixopt/features.py | 36 ++++++++++++------------ tests/test_on_hours_computation.py | 44 ++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 18 deletions(-) create mode 100644 tests/test_on_hours_computation.py diff --git a/flixopt/features.py b/flixopt/features.py index 5014290c4..026ebbe25 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -673,28 +673,28 @@ def compute_consecutive_duration( elif np.isscalar(binary_values) and not np.isscalar(hours_per_timestep): return binary_values * hours_per_timestep[-1] - # Find the indexes where value=`0` in a 1D-array - zero_indices = np.where(np.isclose(binary_values, 0, atol=CONFIG.modeling.EPSILON))[0] - length_of_last_duration = zero_indices[-1] + 1 if zero_indices.size > 0 else len(binary_values) - - if not np.isscalar(binary_values) and np.isscalar(hours_per_timestep): - return np.sum(binary_values[-length_of_last_duration:] * hours_per_timestep) - - elif not np.isscalar(binary_values) and not np.isscalar(hours_per_timestep): - if length_of_last_duration > len(hours_per_timestep): # check that lengths are compatible - raise TypeError( - f'When trying to calculate the consecutive duration, the length of the last duration ' - f'({len(length_of_last_duration)}) is longer than the hours_per_timestep ({len(hours_per_timestep)}), ' - f'as {binary_values=}' - ) - return np.sum(binary_values[-length_of_last_duration:] * hours_per_timestep[-length_of_last_duration:]) + if np.isclose(binary_values[-1], 0, atol=CONFIG.modeling.EPSILON): + return 0 + + if np.isscalar(hours_per_timestep): + hours_per_timestep = np.ones(len(binary_values)) * hours_per_timestep + hours_per_timestep: np.ndarray + indexes_with_zero_values = np.where(np.isclose(binary_values, 0, atol=CONFIG.modeling.EPSILON))[0] + if len(indexes_with_zero_values) == 0: + nr_of_indexes_with_consecutive_ones = len(binary_values) else: - raise Exception( - f'Unexpected state reached in function get_consecutive_duration(). binary_values={binary_values}; ' - f'hours_per_timestep={hours_per_timestep}' + nr_of_indexes_with_consecutive_ones = len(binary_values) - indexes_with_zero_values[-1] - 1 + + if len(hours_per_timestep) < nr_of_indexes_with_consecutive_ones: + raise ValueError( + f'When trying to calculate the consecutive duration, the length of the last duration ' + f'({len(nr_of_indexes_with_consecutive_ones)}) is longer than the provided hours_per_timestep ({len(hours_per_timestep)}), ' + f'as {binary_values=}' ) + return np.sum(binary_values[-nr_of_indexes_with_consecutive_ones:] * hours_per_timestep[-nr_of_indexes_with_consecutive_ones:]) + class PieceModel(Model): """Class for modeling a linear piece of one or more variables in parallel""" diff --git a/tests/test_on_hours_computation.py b/tests/test_on_hours_computation.py new file mode 100644 index 000000000..632a7a187 --- /dev/null +++ b/tests/test_on_hours_computation.py @@ -0,0 +1,44 @@ +import numpy as np +import pytest + +from flixopt.features import OnOffModel + + +class TestComputeConsecutiveDuration: + """Tests for the compute_consecutive_duration static method.""" + + @pytest.mark.parametrize("binary_values, hours_per_timestep, expected", [ + # Case 1: Both scalar inputs + (1, 5, 5), + (0, 3, 0), + + # Case 2: Scalar binary, array hours + (1, np.array([1, 2, 3]), 3), + (0, np.array([2, 4, 6]), 0), + + # Case 3: Array binary, scalar hours + (np.array([0, 0, 1, 1, 1, 0]), 2, 0), + (np.array([0, 1, 1, 0, 1, 1]), 1, 2), + (np.array([1, 1, 1]), 2, 6), + + # Case 4: Both array inputs + (np.array([0, 1, 1, 0, 1, 1]), np.array([1, 2, 3, 4, 5, 6]), 11), # 5+6 + (np.array([1, 0, 0, 1, 1, 1]), np.array([2, 2, 2, 3, 4, 5]), 12), # 3+4+5 + + # Case 5: Edge cases + (np.array([1]), np.array([4]), 4), + (np.array([0]), np.array([3]), 0), + ]) + def test_compute_duration(self, binary_values, hours_per_timestep, expected): + """Test compute_consecutive_duration with various inputs.""" + result = OnOffModel.compute_consecutive_duration(binary_values, hours_per_timestep) + assert np.isclose(result, expected) + + @pytest.mark.parametrize("binary_values, hours_per_timestep", [ + # Case: Incompatible array lengths + (np.array([1, 1, 1, 1, 1]), np.array([1, 2])), + ]) + def test_compute_duration_raises_error(self, binary_values, hours_per_timestep): + """Test error conditions.""" + with pytest.raises(TypeError): + OnOffModel.compute_consecutive_duration(binary_values, hours_per_timestep) From 3194feed8d7d951c05e9bf908b02edb93b061ebc Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 10 Apr 2025 19:39:41 +0200 Subject: [PATCH 13/34] Add test for compute_previous_on_states() --- tests/test_on_hours_computation.py | 61 ++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/tests/test_on_hours_computation.py b/tests/test_on_hours_computation.py index 632a7a187..5608155c0 100644 --- a/tests/test_on_hours_computation.py +++ b/tests/test_on_hours_computation.py @@ -42,3 +42,64 @@ def test_compute_duration_raises_error(self, binary_values, hours_per_timestep): """Test error conditions.""" with pytest.raises(TypeError): OnOffModel.compute_consecutive_duration(binary_values, hours_per_timestep) + + +class TestComputePreviousOnStates: + """Tests for the compute_previous_on_states static method.""" + + @pytest.mark.parametrize( + 'previous_values, expected', + [ + # Case 1: Empty list + ([], np.array([0])), + + # Case 2: All None values + ([None, None], np.array([0])), + + # Case 3: Single value arrays + ([np.array([0])], np.array([0])), + ([np.array([1])], np.array([1])), + ([np.array([0.001])], np.array([1])), # Using default epsilon + ([np.array([1e-4])], np.array([1])), + ([np.array([1e-8])], np.array([0])), + + # Case 4: Multiple 1D arrays + ([np.array([0, 5, 0]), np.array([0, 0, 1])], np.array([0, 1, 1])), + ([np.array([0.1, 0, 0.3]), None, np.array([0, 0, 0])], np.array([1, 0, 1])), + ([np.array([0, 0, 0]), np.array([0, 1, 0])], np.array([0, 1, 0])), + ([np.array([0.1, 0, 0]), np.array([0, 0, 0.2])], np.array([1, 0, 1])), + + # Case 6: Mix of None, 1D and 2D arrays + ([None, np.array([0, 0, 0]), np.array([0, 1, 0]), np.array([0, 0, 0])], np.array([0, 1, 0])), + ([np.array([0, 0, 0]), None, np.array([0, 0, 0]), np.array([0, 0, 0])], np.array([0, 0, 0])), + ], + ) + def test_compute_previous_on_states(self, previous_values, expected): + """Test compute_previous_on_states with various inputs.""" + result = OnOffModel.compute_previous_on_states(previous_values) + np.testing.assert_array_equal(result, expected) + + @pytest.mark.parametrize("previous_values, epsilon, expected", [ + # Testing with different epsilon values + ([np.array([1e-6, 1e-4, 1e-2])], 1e-3, np.array([0, 0, 1])), + ([np.array([1e-6, 1e-4, 1e-2])], 1e-5, np.array([0, 1, 1])), + ([np.array([1e-6, 1e-4, 1e-2])], 1e-1, np.array([0, 0, 0])), + + # Mixed case with custom epsilon + ([np.array([0.05, 0.005, 0.0005])], 0.01, np.array([1, 0, 0])), + ]) + def test_compute_previous_on_states_with_epsilon(self, previous_values, epsilon, expected): + """Test compute_previous_on_states with custom epsilon values.""" + result = OnOffModel.compute_previous_on_states(previous_values, epsilon) + np.testing.assert_array_equal(result, expected) + + @pytest.mark.parametrize("previous_values, expected_shape", [ + # Check that output shapes match expected dimensions + ([np.array([0, 1, 0, 1])], (4,)), + ([np.array([0, 1]), np.array([1, 0]), np.array([0, 0])], (2,)), + ([np.array([0, 1]), np.array([1, 0])], (2,)), + ]) + def test_output_shapes(self, previous_values, expected_shape): + """Test that output array has the correct shape.""" + result = OnOffModel.compute_previous_on_states(previous_values) + assert result.shape == expected_shape From 348942d3ed8c59efa254c730b64cb43c9ad3c595 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 10 Apr 2025 19:42:05 +0200 Subject: [PATCH 14/34] Add check in compute_previous_on_states() for array size --- flixopt/features.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/flixopt/features.py b/flixopt/features.py index 026ebbe25..67f036147 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -636,6 +636,9 @@ def compute_previous_on_states(previous_values: List[Optional[NumericData]], eps A binary array (0 and 1) indicating the previous on/off states of the variables. Returns `array([0])` if no previous values are available. """ + for arr in previous_values: + if isinstance(arr, np.ndarray) and arr.ndim > 1: + raise ValueError('Only 1D arrays or None values are supported for previous_values') if not previous_values or all([val is None for val in previous_values]): return np.array([0]) From 8b62bc0dd3735fc1b811ac310180fd114a35e67c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 7 Apr 2025 16:23:00 +0200 Subject: [PATCH 15/34] Split OnOffModel into smaller Models --- flixopt/features.py | 549 ++++++++++++++------------------------------ 1 file changed, 176 insertions(+), 373 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index 67f036147..89c9a5662 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -11,7 +11,7 @@ from . import utils from .config import CONFIG -from .core import NumericData, Scalar, TimeSeries +from .core import Scalar, TimeSeries, NumericData, Scalar from .interface import InvestParameters, OnOffParameters, Piece, Piecewise, PiecewiseConversion, PiecewiseEffects from .structure import Model, SystemModel @@ -193,53 +193,34 @@ def _create_bounds_for_defining_variable(self): # anmerkung: Glg bei Spezialfall relative_minimum = 0 redundant zu OnOff ?? -class OnOffModel(Model): +class BinaryStateComponent(Model): """ - Class for modeling the on and off state of a variable - If defining_bounds are given, creates sufficient lower bounds + Handles basic on/off binary states for defining variables """ def __init__( self, model: SystemModel, - on_off_parameters: OnOffParameters, label_of_element: str, defining_variables: List[linopy.Variable], defining_bounds: List[Tuple[NumericData, NumericData]], - previous_values: List[Optional[NumericData]], + use_off: bool = True, + on_hours_total_min: Optional[NumericData] = 0, + on_hours_total_max: Optional[NumericData] = np.inf, label: Optional[str] = None, ): - """ - Constructor for OnOffModel - - Args: - model: Reference to the SystemModel - on_off_parameters: Parameters for the OnOffModel - label_of_element: Label of the Parent - defining_variables: List of Variables that are used to define the OnOffModel - defining_bounds: List of Tuples, defining the absolute bounds of each defining variable - previous_values: List of previous values of the defining variables - label: Label of the OnOffModel - """ super().__init__(model, label_of_element, label) assert len(defining_variables) == len(defining_bounds), 'Every defining Variable needs bounds to Model OnOff' - self.parameters = on_off_parameters self._defining_variables = defining_variables - # Ensure that no lower bound is below a certain threshold - self._defining_bounds = [(np.maximum(lb, CONFIG.modeling.EPSILON), ub) for lb, ub in defining_bounds] - self._previous_values = previous_values + self._defining_bounds = defining_bounds + self._on_hours_total_min = on_hours_total_min + self._on_hours_total_max = on_hours_total_max + self._use_off = use_off - self.on: Optional[linopy.Variable] = None + self.on = None self.total_on_hours: Optional[linopy.Variable] = None + self.off = None - self.consecutive_on_hours: Optional[linopy.Variable] = None - self.consecutive_off_hours: Optional[linopy.Variable] = None - - self.off: Optional[linopy.Variable] = None - - self.switch_on: Optional[linopy.Variable] = None - self.switch_off: Optional[linopy.Variable] = None - self.switch_on_nr: Optional[linopy.Variable] = None def do_modeling(self): self.on = self.add( @@ -253,8 +234,9 @@ def do_modeling(self): self.total_on_hours = self.add( self._model.add_variables( - lower=self.parameters.on_hours_total_min if self.parameters.on_hours_total_min is not None else 0, - upper=self.parameters.on_hours_total_max if self.parameters.on_hours_total_max is not None else np.inf, + lower=self._on_hours_total_min, + upper=self._on_hours_total_max, + coords=self._model.get_coords(time_dim=False), name=f'{self.label_full}|on_hours_total', ), 'on_hours_total', @@ -268,9 +250,10 @@ def do_modeling(self): 'on_hours_total', ) - self._add_on_constraints() + # Add defining constraints for each variable + self._add_defining_constraints() - if self.parameters.use_off: + if self._use_off: self.off = self.add( self._model.add_variables( name=f'{self.label_full}|off', @@ -280,423 +263,243 @@ def do_modeling(self): 'off', ) - # eq: var_on(t) + var_off(t) = 1 - self.add(self._model.add_constraints(self.on + self.off == 1, name=f'{self.label_full}|off'), 'off') - - if self.parameters.use_consecutive_on_hours: - self.consecutive_on_hours = self._get_duration_in_hours( - 'consecutive_on_hours', - self.on, - self.previous_consecutive_on_hours, - self.parameters.consecutive_on_hours_min, - self.parameters.consecutive_on_hours_max, - ) - - if self.parameters.use_consecutive_off_hours: - self.consecutive_off_hours = self._get_duration_in_hours( - 'consecutive_off_hours', - self.off, - self.previous_consecutive_off_hours, - self.parameters.consecutive_off_hours_min, - self.parameters.consecutive_off_hours_max, - ) - - if self.parameters.use_switch_on: - self.switch_on = self.add( - self._model.add_variables(binary=True, name=f'{self.label_full}|switch_on', coords=self._model.coords), - 'switch_on', - ) - - self.switch_off = self.add( - self._model.add_variables(binary=True, name=f'{self.label_full}|switch_off', coords=self._model.coords), - 'switch_off', - ) - - self.switch_on_nr = self.add( - self._model.add_variables( - lower=0, - upper=self.parameters.switch_on_total_max - if self.parameters.switch_on_total_max is not None - else np.inf, - name=f'{self.label_full}|switch_on_nr', - ), - 'switch_on_nr', - ) - - self._add_switch_constraints() - - self._create_shares() + # Constraint: on + off = 1 + self.add(self._model.add_constraints(self.on + self.off == 1, name=f'{self.label}|off'), 'off') - def _add_on_constraints(self): - assert self.on is not None, f'On variable of {self.label_full} must be defined to add constraints' - # % Bedingungen 1) und 2) müssen erfüllt sein: - - # % Anmerkung: Falls "abschnittsweise linear" gewählt, dann ist eigentlich nur Bedingung 1) noch notwendig - # % (und dann auch nur wenn erstes Piece bei Q_th=0 beginnt. Dann soll bei Q_th=0 (d.h. die Maschine ist Aus) On = 0 und segment1.onSeg = 0):) - # % Fazit: Wenn kein Performance-Verlust durch mehr Gleichungen, dann egal! + return self + def _add_defining_constraints(self): + """Add constraints that link defining variables to the on state""" nr_of_def_vars = len(self._defining_variables) - assert nr_of_def_vars > 0, 'Achtung: mindestens 1 Flow notwendig' if nr_of_def_vars == 1: + # Case for a single defining variable def_var = self._defining_variables[0] lb, ub = self._defining_bounds[0] - # eq: On(t) * max(epsilon, lower_bound) <= Q_th(t) - self.add( + # Constraint: on * lower_bound <= def_var + self.add_constraint( self._model.add_constraints( - self.on * np.maximum(CONFIG.modeling.EPSILON, lb) <= def_var, name=f'{self.label_full}|on_con1' + self.on * np.maximum(CONFIG.modeling.EPSILON, lb) <= def_var, name=f'{self.label}|on_con1' ), 'on_con1', ) - # eq: Q_th(t) <= Q_th_max * On(t) - self.add( - self._model.add_constraints( - self.on * np.maximum(CONFIG.modeling.EPSILON, ub) >= def_var, name=f'{self.label_full}|on_con2' - ), - 'on_con2', + # Constraint: def_var <= on * upper_bound + self.add_constraint( + self._model.add_constraints(def_var <= self.on * ub, name=f'{self.label}|on_con2'), 'on_con2' ) - - else: # Bei mehreren Leistungsvariablen: + else: + # Case for multiple defining variables ub = sum(bound[1] for bound in self._defining_bounds) lb = CONFIG.modeling.EPSILON - # When all defining variables are 0, On is 0 - # eq: On(t) * Epsilon <= sum(alle Leistungen(t)) - self.add( + # Constraint: on * epsilon <= sum(all_defining_variables) + self.add_constraint( self._model.add_constraints( - self.on * lb <= sum(self._defining_variables), name=f'{self.label_full}|on_con1' + self.on * lb <= sum(self._defining_variables), name=f'{self.label}|on_con1' ), 'on_con1', ) - ## sum(alle Leistung) >0 -> On = 1|On=0 -> sum(Leistung)=0 - # eq: sum( Leistung(t,i)) - sum(Leistung_max(i)) * On(t) <= 0 - # --> damit Gleichungswerte nicht zu groß werden, noch durch nr_of_flows geteilt: - # eq: sum( Leistung(t,i) / nr_of_flows ) - sum(Leistung_max(i)) / nr_of_flows * On(t) <= 0 - self.add( + # Constraint to ensure all variables are zero when off + self.add_constraint( self._model.add_constraints( - self.on * ub >= sum([def_var / nr_of_def_vars for def_var in self._defining_variables]), - name=f'{self.label_full}|on_con2', + sum([def_var / nr_of_def_vars for def_var in self._defining_variables]) + <= self.on * ub / nr_of_def_vars, + name=f'{self.label}|on_con2', ), 'on_con2', ) - if np.max(ub) > CONFIG.modeling.BIG_BINARY_BOUND: - logger.warning( - f'In "{self.label_full}", a binary definition was created with a big upper bound ' - f'({np.max(ub)}). This can lead to wrong results regarding the on and off variables. ' - f'Avoid this warning by reducing the size of {self.label_full} ' - f'(or the maximum_size of the corresponding InvestParameters). ' - f'If its a Component, you might need to adjust the sizes of all of its flows.' - ) - - def _get_duration_in_hours( - self, - variable_name: str, - binary_variable: linopy.Variable, - previous_duration: Scalar, - minimum_duration: Optional[TimeSeries], - maximum_duration: Optional[TimeSeries], - ) -> linopy.Variable: - """ - creates duration variable and adds constraints to a time-series variable to enforce duration limits based on - binary activity. - The minimum duration in the last time step is not restricted. - Previous values before t=0 are not recognised! - - Args: - variable_name: Label for the duration variable to be created. - binary_variable: Time-series binary variable (e.g., [0, 0, 1, 1, 1, 0, ...]) representing activity states. - minimum_duration: Minimum duration the activity must remain active once started. - If None, no minimum duration constraint is applied. - maximum_duration: Maximum duration the activity can remain active. - If None, the maximum duration is set to the total available time. - - Returns: - The created duration variable representing consecutive active durations. - - Example: - binary_variable: [0, 0, 1, 1, 1, 1, 0, 1, 1, 1, 0, ...] - duration_in_hours: [0, 0, 1, 2, 3, 4, 0, 1, 2, 3, 0, ...] (only if dt_in_hours=1) - Here, duration_in_hours increments while binary_variable is 1. Minimum and maximum durations - can be enforced to constrain how long the activity remains active. - - Notes: - - To count consecutive zeros instead of ones, use a transformed binary variable - (e.g., `1 - binary_variable`). - - Constraints ensure the duration variable properly resets or increments based on activity. +class SwitchBinaryModel(Model): + """ + Handles switch on/off transitions + """ - Raises: - AssertionError: If the binary_variable is None, indicating the duration constraints cannot be applied. + def __init__( + self, + model: SystemModel, + label_of_element: str, + state_variable: linopy.Variable, + previous_value=0, + switch_on_max: Scalar = np.inf, + label: Optional[str] = None, + ): + super().__init__(model, label_of_element, label) + self._state_variable = state_variable + self.previous_value = previous_value + self._switch_on_max = switch_on_max - """ - assert binary_variable is not None, f'Duration Variable of {self.label_full} must be defined to add constraints' + self.switch_on = None + self.switch_off = None + self.switch_on_nr = None - mega = self._model.hours_per_step.sum() + previous_duration + def do_modeling(self): + """Create switch variables and constraints""" - if maximum_duration is not None: - first_step_max: Scalar = maximum_duration.isel(time=0) + # Create switch variables + self.switch_on = self.add( + self._model.add_variables(binary=True, name=f'{self.label}|switch_on', coords=self._model.get_coords()), + 'switch_on', + ) - if previous_duration + self._model.hours_per_step[0] > first_step_max: - logger.warning( - f'The maximum duration of "{variable_name}" is set to {maximum_duration.active_data}h, ' - f'but the consecutive_duration previous to this model is {previous_duration}h. ' - f'This forces "{binary_variable.name} = 0" in the first time step ' - f'(dt={self._model.hours_per_step[0]}h)!' - ) + self.switch_off = self.add( + self._model.add_variables(binary=True, name=f'{self.label}|switch_off', coords=self._model.get_coords()), + 'switch_off', + ) - duration_in_hours = self.add( + # Create count variable for number of switches + self.switch_on_nr = self.add( self._model.add_variables( - lower=0, - upper=maximum_duration.active_data if maximum_duration is not None else mega, - coords=self._model.coords, - name=f'{self.label_full}|{variable_name}', + upper=self._switch_on_max, + name=f'{self.label}|switch_on_nr', ), - variable_name, + 'switch_on_nr', ) - # 1) eq: duration(t) - On(t) * BIG <= 0 + # Add switch constraints for all entries after the first timestep self.add( self._model.add_constraints( - duration_in_hours <= binary_variable * mega, name=f'{self.label_full}|{variable_name}_con1' + self.switch_on.isel(time=slice(1, None)) - self.switch_off.isel(time=slice(1, None)) + == self._state_variable.isel(time=slice(1, None)) - self._state_variable.isel(time=slice(None, -1)), + name=f'{self.label}|switch_con', ), - f'{variable_name}_con1', + 'switch_con', ) - # 2a) eq: duration(t) - duration(t-1) <= dt(t) - # on(t)=1 -> duration(t) - duration(t-1) <= dt(t) - # on(t)=0 -> duration(t-1) >= negat. value + # Initial switch constraint self.add( self._model.add_constraints( - duration_in_hours.isel(time=slice(1, None)) - <= duration_in_hours.isel(time=slice(None, -1)) + self._model.hours_per_step.isel(time=slice(None, -1)), - name=f'{self.label_full}|{variable_name}_con2a', + self.switch_on.isel(time=0) - self.switch_off.isel(time=0) + == + self._state_variable.isel(time=0) - self.previous_value, + name=f'{self.label}|initial_switch_con', ), - f'{variable_name}_con2a', + 'initial_switch_con', ) - # 2b) eq: dt(t) - BIG * ( 1-On(t) ) <= duration(t) - duration(t-1) - # eq: -duration(t) + duration(t-1) + On(t) * BIG <= -dt(t) + BIG - # with BIG = dt_in_hours_total. - # on(t)=1 -> duration(t)- duration(t-1) >= dt(t) - # on(t)=0 -> duration(t)- duration(t-1) >= negat. value + # Mutual exclusivity constraint + self.add( + self._model.add_constraints(self.switch_on + self.switch_off <= 1.1, name=f'{self.label}|switch_on_or_off'), + 'switch_on_or_off', + ) + # Total switch-on count constraint self.add( self._model.add_constraints( - duration_in_hours.isel(time=slice(1, None)) - >= duration_in_hours.isel(time=slice(None, -1)) - + self._model.hours_per_step.isel(time=slice(None, -1)) - + (binary_variable.isel(time=slice(1, None)) - 1) * mega, - name=f'{self.label_full}|{variable_name}_con2b', + self.switch_on_nr == self.switch_on.sum('time'), name=f'{self.label}|switch_on_nr' ), - f'{variable_name}_con2b', + 'switch_on_nr', ) - # 3) check minimum_duration before switchOff-step + return self - if minimum_duration is not None: - # Note: switchOff-step is when: On(t) - On(t+1) == 1 - # Note: (last on-time period (with last timestep of period t=n) is not checked and can be shorter) - # Note: (previous values before t=1 are not recognised!) - # eq: duration(t) >= minimum_duration(t) * [On(t) - On(t+1)] for t=1..(n-1) - # eq: -duration(t) + minimum_duration(t) * On(t) - minimum_duration(t) * On(t+1) <= 0 - self.add( - self._model.add_constraints( - duration_in_hours - >= (binary_variable.isel(time=slice(None, -1)) - binary_variable.isel(time=slice(1, None))) - * minimum_duration.isel(time=slice(None, -1)), - name=f'{self.label_full}|{variable_name}_minimum_duration', - ), - f'{variable_name}_minimum_duration', - ) - if 0 < previous_duration < minimum_duration.isel(time=0): - # Force the first step to be = 1, if the minimum_duration is not reached in previous_values - # Note: Only if the previous consecutive_duration is smaller than the minimum duration - # and the previous_duration is greater 0! - # eq: On(t=0) = 1 - self.add( - self._model.add_constraints( - binary_variable.isel(time=0) == 1, name=f'{self.label_full}|{variable_name}_minimum_inital' - ), - f'{variable_name}_minimum_inital', - ) +class ConsecutiveBinaryModel(Model): + """ + Handles tracking consecutive durations in a state + """ - # 4) first index: - # eq: duration(t=0)= dt(0) * On(0) - self.add( - self._model.add_constraints( - duration_in_hours.isel(time=0) - == self._model.hours_per_step.isel(time=0) * binary_variable.isel(time=0), - name=f'{self.label_full}|{variable_name}_initial', - ), - f'{variable_name}_initial', - ) + def __init__( + self, + model: SystemModel, + label_of_element: str, + state_variable: linopy.Variable, + minimum_duration: NumericData = 0, + maximum_duration: Optional[NumericData] = None, + previous_duration=0 + ): + super().__init__(model, label_of_element) + self._state_variable = state_variable + self._previous_duration = previous_duration + self._minimum_duration = minimum_duration + self._maximum_duration = maximum_duration - return duration_in_hours + self.duration = None - def _add_switch_constraints(self): - assert self.switch_on is not None, f'Switch On Variable of {self.label_full} must be defined to add constraints' - assert self.switch_off is not None, ( - f'Switch Off Variable of {self.label_full} must be defined to add constraints' - ) - assert self.switch_on_nr is not None, ( - f'Nr of Switch On Variable of {self.label_full} must be defined to add constraints' - ) - assert self.on is not None, f'On Variable of {self.label_full} must be defined to add constraints' - # % Schaltänderung aus On-Variable - # % SwitchOn(t)-SwitchOff(t) = On(t)-On(t-1) - self.add( - self._model.add_constraints( - self.switch_on.isel(time=slice(1, None)) - self.switch_off.isel(time=slice(1, None)) - == self.on.isel(time=slice(1, None)) - self.on.isel(time=slice(None, -1)), - name=f'{self.label_full}|switch_con', + def do_modeling(self): + """Create consecutive duration variables and constraints""" + # Get the hours per step + hours_per_step = self._model.hours_per_step + mega = hours_per_step.sum('time') + self._previous_duration + + # Create the duration variable + self.duration = self.add( + self._model.add_variables( + lower=0, + upper=self._maximum_duration if self._maximum_duration is not None else mega, + coords=self._model.get_coords(), + name=f'{self.label_full}|consecutive', ), - 'switch_con', + f'consecutive', ) - # Initital switch on - # eq: SwitchOn(t=0)-SwitchOff(t=0) = On(t=0) - On(t=-1) + + # Add constraints + + # Upper bound constraint self.add( self._model.add_constraints( - self.switch_on.isel(time=0) - self.switch_off.isel(time=0) - == self.on.isel(time=0) - self.previous_on_values[-1], - name=f'{self.label_full}|initial_switch_con', + self.duration <= self._state_variable * mega, name=f'{self.label_full}|consecutive_con1' ), - 'initial_switch_con', + f'consecutive_con1', ) - ## Entweder SwitchOff oder SwitchOn - # eq: SwitchOn(t) + SwitchOff(t) <= 1.1 + + # Forward constraint self.add( self._model.add_constraints( - self.switch_on + self.switch_off <= 1.1, name=f'{self.label_full}|switch_on_or_off' + self.duration.isel(time=slice(1, None)) + <= self.duration.isel(time=slice(None, -1)) + hours_per_step.isel(time=slice(None, -1)), + name=f'{self.label_full}|consecutive_con2a', ), - 'switch_on_or_off', + f'consecutive_con2a', ) - ## Anzahl Starts: - # eq: nrSwitchOn = sum(SwitchOn(t)) + # Backward constraint self.add( self._model.add_constraints( - self.switch_on_nr == self.switch_on.sum(), name=f'{self.label_full}|switch_on_nr' + self.duration.isel(time=slice(1, None)) + >= self.duration.isel(time=slice(None, -1)) + + hours_per_step.isel(time=slice(None, -1)) + + (self._state_variable.isel(time=slice(1, None)) - 1) * mega, + name=f'{self.label_full}|consecutive_con2b', ), - 'switch_on_nr', + f'consecutive_con2b', ) - def _create_shares(self): - # Anfahrkosten: - effects_per_switch_on = self.parameters.effects_per_switch_on - if effects_per_switch_on != {}: - self._model.effects.add_share_to_effects( - name=self.label_of_element, - expressions={effect: self.switch_on * factor for effect, factor in effects_per_switch_on.items()}, - target='operation', - ) - - # Betriebskosten: - effects_per_running_hour = self.parameters.effects_per_running_hour - if effects_per_running_hour != {}: - self._model.effects.add_share_to_effects( - name=self.label_of_element, - expressions={ - effect: self.on * factor * self._model.hours_per_step - for effect, factor in effects_per_running_hour.items() - }, - target='operation', + # Add minimum duration constraints if specified + if self._minimum_duration is not None: + self.add( + self._model.add_constraints( + self.duration + >= (self._state_variable.isel(time=slice(None, -1)) - self._state_variable.isel(time=slice(1, None))) + * self._minimum_duration.isel(time=slice(None, -1)), + name=f'{self.label_full}|consecutive_minimum', + ), + f'consecutive_minimum', ) - @property - def previous_on_values(self) -> np.ndarray: - return self.compute_previous_on_states(self._previous_values) - - @property - def previous_off_values(self) -> np.ndarray: - return 1 - self.previous_on_values - - @property - def previous_consecutive_on_hours(self) -> Scalar: - return self.compute_consecutive_duration(self.previous_on_values, self._model.hours_per_step) - - @property - def previous_consecutive_off_hours(self) -> Scalar: - return self.compute_consecutive_duration(self.previous_off_values, self._model.hours_per_step) - - @staticmethod - def compute_previous_on_states(previous_values: List[Optional[NumericData]], epsilon: float = 1e-5) -> np.ndarray: - """ - Computes the previous 'on' states {0, 1} of defining variables as a binary array from their previous values. - - Args: - previous_values: List of previous values of the defining variables. In Range [0, inf] or None (ignored) - epsilon: Tolerance for equality to determine "off" state, default is 1e-5. - - Returns: - A binary array (0 and 1) indicating the previous on/off states of the variables. - Returns `array([0])` if no previous values are available. - """ - for arr in previous_values: - if isinstance(arr, np.ndarray) and arr.ndim > 1: - raise ValueError('Only 1D arrays or None values are supported for previous_values') - - if not previous_values or all([val is None for val in previous_values]): - return np.array([0]) - else: # Convert to 2D-array and compute binary on/off states - previous_values = np.array([values for values in previous_values if values is not None]) # Filter out None - if previous_values.ndim > 1: - return np.any(~np.isclose(previous_values, 0, atol=epsilon), axis=0).astype(int) - else: - return (~np.isclose(previous_values, 0, atol=epsilon)).astype(int) - - @staticmethod - def compute_consecutive_duration( - binary_values: NumericData, hours_per_timestep: Union[int, float, np.ndarray] - ) -> Scalar: - """ - Computes the final consecutive duration in State 'on' (=1) in hours, from a binary. - - hours_per_timestep is handled in a way, that maximizes compatability. - Its length must only be as long as the last consecutive duration in binary_values. - - Args: - binary_values: An int or 1D binary array containing only `0`s and `1`s. - hours_per_timestep: The duration of each timestep in hours. - - Returns: - The duration of the binary variable in hours. - - Raises - ------ - TypeError - If the length of binary_values and dt_in_hours is not equal, but None is a scalar. - """ - if np.isscalar(binary_values) and np.isscalar(hours_per_timestep): - return binary_values * hours_per_timestep - elif np.isscalar(binary_values) and not np.isscalar(hours_per_timestep): - return binary_values * hours_per_timestep[-1] - - if np.isclose(binary_values[-1], 0, atol=CONFIG.modeling.EPSILON): - return 0 - - if np.isscalar(hours_per_timestep): - hours_per_timestep = np.ones(len(binary_values)) * hours_per_timestep - hours_per_timestep: np.ndarray - - indexes_with_zero_values = np.where(np.isclose(binary_values, 0, atol=CONFIG.modeling.EPSILON))[0] - if len(indexes_with_zero_values) == 0: - nr_of_indexes_with_consecutive_ones = len(binary_values) - else: - nr_of_indexes_with_consecutive_ones = len(binary_values) - indexes_with_zero_values[-1] - 1 + # Handle initial condition + if 0 < self._previous_duration < self._minimum_duration.isel(time=0): + self.add( + self._model.add_constraints( + self._state_variable.isel(time=0) == 1, + name=f'{self.label_full}|consecutive_minimum_initial' + ), + f'consecutive_minimum_initial', + ) - if len(hours_per_timestep) < nr_of_indexes_with_consecutive_ones: - raise ValueError( - f'When trying to calculate the consecutive duration, the length of the last duration ' - f'({len(nr_of_indexes_with_consecutive_ones)}) is longer than the provided hours_per_timestep ({len(hours_per_timestep)}), ' - f'as {binary_values=}' - ) + # Set initial value + self.add( + self._model.add_constraints( + self.duration.isel(time=0) == hours_per_step.isel(time=0) * self._state_variable.isel(time=0), + name=f'{self.label}|consecutive_initial', + ), + f'consecutive_initial', + ) - return np.sum(binary_values[-nr_of_indexes_with_consecutive_ones:] * hours_per_timestep[-nr_of_indexes_with_consecutive_ones:]) + return self class PieceModel(Model): From 66ac5aceb9c59640884481b972bab058f9ffb2ce Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 7 Apr 2025 16:40:32 +0200 Subject: [PATCH 16/34] Update partly models --- flixopt/features.py | 134 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 118 insertions(+), 16 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index 89c9a5662..70baaef99 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -4,15 +4,15 @@ """ import logging -from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Tuple, Union +from typing import Dict, List, Optional, Tuple, Union import linopy import numpy as np from . import utils from .config import CONFIG -from .core import Scalar, TimeSeries, NumericData, Scalar -from .interface import InvestParameters, OnOffParameters, Piece, Piecewise, PiecewiseConversion, PiecewiseEffects +from .core import Scalar, TimestepData +from .interface import InvestParameters, Piecewise from .structure import Model, SystemModel logger = logging.getLogger('flixopt') @@ -203,25 +203,28 @@ def __init__( model: SystemModel, label_of_element: str, defining_variables: List[linopy.Variable], - defining_bounds: List[Tuple[NumericData, NumericData]], + defining_bounds: List[Tuple[TimestepData, TimestepData]], + previous_values: List[Optional[TimestepData]] = None, use_off: bool = True, - on_hours_total_min: Optional[NumericData] = 0, - on_hours_total_max: Optional[NumericData] = np.inf, + on_hours_total_min: Optional[TimestepData] = 0, + on_hours_total_max: Optional[TimestepData] = np.inf, + effects_per_running_hour: Dict[str, TimestepData] = None, label: Optional[str] = None, ): super().__init__(model, label_of_element, label) assert len(defining_variables) == len(defining_bounds), 'Every defining Variable needs bounds to Model OnOff' self._defining_variables = defining_variables self._defining_bounds = defining_bounds + self._previous_values = previous_values or [] self._on_hours_total_min = on_hours_total_min self._on_hours_total_max = on_hours_total_max self._use_off = use_off + self._effects_per_running_hour = effects_per_running_hour or {} self.on = None self.total_on_hours: Optional[linopy.Variable] = None self.off = None - def do_modeling(self): self.on = self.add( self._model.add_variables( @@ -266,6 +269,8 @@ def do_modeling(self): # Constraint: on + off = 1 self.add(self._model.add_constraints(self.on + self.off == 1, name=f'{self.label}|off'), 'off') + self._create_shares() + return self def _add_defining_constraints(self): @@ -312,6 +317,47 @@ def _add_defining_constraints(self): 'on_con2', ) + def _create_shares(self): + if self._effects_per_running_hour: + self._model.effects.add_share_to_effects( + name=self.label_of_element, + expressions={ + effect: self.on * factor * self._model.hours_per_step + for effect, factor in self._effects_per_running_hour.items() + }, + target='operation', + ) + + @property + def previous_on_values(self): + return self.compute_previous_on_states(self._previous_values) + + @property + def previous_off_values(self): + return 1 - self.previous_on_values + + @staticmethod + def compute_previous_on_states(previous_values: List[Optional[TimestepData]], epsilon: float = 1e-5) -> np.ndarray: + """ + Computes the previous 'on' states {0, 1} of defining variables as a binary array from their previous values. + + Args: + previous_values: List of previous values of the defining variables. In Range [0, inf] or None (ignored) + epsilon: Tolerance for equality to determine "off" state, default is 1e-5. + + Returns: + A binary array (0 and 1) indicating the previous on/off states of the variables. + Returns `array([0])` if no previous values are available. + """ + if not previous_values or all([val is None for val in previous_values]): + return np.array([0]) + else: # Convert to 2D-array and compute binary on/off states + previous_values = np.array([values for values in previous_values if values is not None]) # Filter out None + if previous_values.ndim > 1: + return np.any(~np.isclose(previous_values, 0, atol=epsilon), axis=0).astype(int) + else: + return (~np.isclose(previous_values, 0, atol=epsilon)).astype(int) + class SwitchBinaryModel(Model): """ @@ -325,12 +371,14 @@ def __init__( state_variable: linopy.Variable, previous_value=0, switch_on_max: Scalar = np.inf, + effects_per_switch_on: Dict[str, TimestepData] = None, label: Optional[str] = None, ): super().__init__(model, label_of_element, label) self._state_variable = state_variable self.previous_value = previous_value self._switch_on_max = switch_on_max + self._effects_per_switch_on = effects_per_switch_on or {} self.switch_on = None self.switch_off = None @@ -373,8 +421,7 @@ def do_modeling(self): self.add( self._model.add_constraints( self.switch_on.isel(time=0) - self.switch_off.isel(time=0) - == - self._state_variable.isel(time=0) - self.previous_value, + == self._state_variable.isel(time=0) - self.previous_value, name=f'{self.label}|initial_switch_con', ), 'initial_switch_con', @@ -394,8 +441,20 @@ def do_modeling(self): 'switch_on_nr', ) + self._create_shares() + return self + def _create_shares(self): + if self._effects_per_switch_on: + self._model.effects.add_share_to_effects( + name=self.label_of_element, + expressions={ + effect: self.switch_on * factor for effect, factor in self._effects_per_switch_on.items() + }, + target='operation', + ) + class ConsecutiveBinaryModel(Model): """ @@ -407,11 +466,12 @@ def __init__( model: SystemModel, label_of_element: str, state_variable: linopy.Variable, - minimum_duration: NumericData = 0, - maximum_duration: Optional[NumericData] = None, - previous_duration=0 + minimum_duration: Optional[TimestepData] = None, + maximum_duration: Optional[TimestepData] = None, + previous_duration=0, + label: Optional[str] = None, ): - super().__init__(model, label_of_element) + super().__init__(model, label_of_element, label) self._state_variable = state_variable self._previous_duration = previous_duration self._minimum_duration = minimum_duration @@ -473,7 +533,9 @@ def do_modeling(self): self.add( self._model.add_constraints( self.duration - >= (self._state_variable.isel(time=slice(None, -1)) - self._state_variable.isel(time=slice(1, None))) + >= ( + self._state_variable.isel(time=slice(None, -1)) - self._state_variable.isel(time=slice(1, None)) + ) * self._minimum_duration.isel(time=slice(None, -1)), name=f'{self.label_full}|consecutive_minimum', ), @@ -484,8 +546,7 @@ def do_modeling(self): if 0 < self._previous_duration < self._minimum_duration.isel(time=0): self.add( self._model.add_constraints( - self._state_variable.isel(time=0) == 1, - name=f'{self.label_full}|consecutive_minimum_initial' + self._state_variable.isel(time=0) == 1, name=f'{self.label_full}|consecutive_minimum_initial' ), f'consecutive_minimum_initial', ) @@ -501,6 +562,47 @@ def do_modeling(self): return self + @staticmethod + def compute_consecutive_duration( + binary_values: TimestepData, hours_per_timestep: Union[int, float, np.ndarray] + ) -> Scalar: + """ + Computes the final consecutive duration in State 'on' (=1) in hours, from a binary. + + hours_per_timestep is handled in a way, that maximizes compatability. + Its length must only be as long as the last consecutive duration in binary_values. + + Args: + binary_values: An int or 1D binary array containing only `0`s and `1`s. + hours_per_timestep: The duration of each timestep in hours. + + Returns: + The duration of the binary variable in hours. + """ + if np.isscalar(binary_values) and np.isscalar(hours_per_timestep): + return binary_values * hours_per_timestep + elif np.isscalar(binary_values) and not np.isscalar(hours_per_timestep): + return binary_values * hours_per_timestep[-1] + + # Find the indexes where value=`0` in a 1D-array + zero_indices = np.where(np.isclose(binary_values, 0, atol=CONFIG.modeling.EPSILON))[0] + length_of_last_duration = zero_indices[-1] + 1 if zero_indices.size > 0 else len(binary_values) + + if not np.isscalar(binary_values) and np.isscalar(hours_per_timestep): + return np.sum(binary_values[-length_of_last_duration:] * hours_per_timestep) + elif not np.isscalar(binary_values) and not np.isscalar(hours_per_timestep): + if length_of_last_duration > len(hours_per_timestep): # check that lengths are compatible + raise TypeError( + f'When trying to calculate the consecutive duration, the length of the last duration ' + f'({length_of_last_duration}) is longer than the hours_per_timestep ({len(hours_per_timestep)})' + ) + return np.sum(binary_values[-length_of_last_duration:] * hours_per_timestep[-length_of_last_duration:]) + else: + raise Exception( + f'Unexpected state reached in function compute_consecutive_duration(). binary_values={binary_values}; ' + f'hours_per_timestep={hours_per_timestep}' + ) + class PieceModel(Model): """Class for modeling a linear piece of one or more variables in parallel""" From fca7b1c52d321fcf8d4e9825a452f7b84f1aa539 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 7 Apr 2025 17:35:22 +0200 Subject: [PATCH 17/34] Bugfixes and improvements --- flixopt/features.py | 288 ++++++++++++++++++++++++++++++-------------- 1 file changed, 200 insertions(+), 88 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index 70baaef99..22d24b012 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -11,8 +11,8 @@ from . import utils from .config import CONFIG -from .core import Scalar, TimestepData -from .interface import InvestParameters, Piecewise +from .core import Scalar, TimestepData, TimeSeries +from .interface import InvestParameters, Piecewise, OnOffParameters from .structure import Model, SystemModel logger = logging.getLogger('flixopt') @@ -155,7 +155,7 @@ def _create_bounds_for_defining_variable(self): ) if self._on_variable is not None: raise ValueError( - f'Flow {self.label} has a fixed relative flow rate and an on_variable.' + f'Flow {self.label_full} has a fixed relative flow rate and an on_variable.' f'This combination is currently not supported.' ) return @@ -193,7 +193,7 @@ def _create_bounds_for_defining_variable(self): # anmerkung: Glg bei Spezialfall relative_minimum = 0 redundant zu OnOff ?? -class BinaryStateComponent(Model): +class StateModel(Model): """ Handles basic on/off binary states for defining variables """ @@ -207,17 +207,32 @@ def __init__( previous_values: List[Optional[TimestepData]] = None, use_off: bool = True, on_hours_total_min: Optional[TimestepData] = 0, - on_hours_total_max: Optional[TimestepData] = np.inf, + on_hours_total_max: Optional[TimestepData] = None, effects_per_running_hour: Dict[str, TimestepData] = None, label: Optional[str] = None, ): + """ + Models binary state variables based on a continous variable. + + Args: + model: The SystemModel that is used to create the model. + label_of_element: The label of the parent (Element). Used to construct the full label of the model. + defining_variables: List of Variables that are used to define the state + defining_bounds: List of Tuples, defining the absolute bounds of each defining variable + previous_values: List of previous values of the defining variables + use_off: Whether to use the off state or not + on_hours_total_min: min. overall sum of operating hours. + on_hours_total_max: max. overall sum of operating hours. + effects_per_running_hour: Costs per operating hours + label: Label of the OnOffModel + """ super().__init__(model, label_of_element, label) assert len(defining_variables) == len(defining_bounds), 'Every defining Variable needs bounds to Model OnOff' self._defining_variables = defining_variables self._defining_bounds = defining_bounds self._previous_values = previous_values or [] - self._on_hours_total_min = on_hours_total_min - self._on_hours_total_max = on_hours_total_max + self._on_hours_total_min = on_hours_total_min if on_hours_total_min is not None else 0 + self._on_hours_total_max = on_hours_total_max if on_hours_total_max is not None else np.inf self._use_off = use_off self._effects_per_running_hour = effects_per_running_hour or {} @@ -267,7 +282,7 @@ def do_modeling(self): ) # Constraint: on + off = 1 - self.add(self._model.add_constraints(self.on + self.off == 1, name=f'{self.label}|off'), 'off') + self.add(self._model.add_constraints(self.on + self.off == 1, name=f'{self.label_full}|off'), 'off') self._create_shares() @@ -283,16 +298,16 @@ def _add_defining_constraints(self): lb, ub = self._defining_bounds[0] # Constraint: on * lower_bound <= def_var - self.add_constraint( + self.add( self._model.add_constraints( - self.on * np.maximum(CONFIG.modeling.EPSILON, lb) <= def_var, name=f'{self.label}|on_con1' + self.on * np.maximum(CONFIG.modeling.EPSILON, lb) <= def_var, name=f'{self.label_full}|on_con1' ), 'on_con1', ) # Constraint: def_var <= on * upper_bound - self.add_constraint( - self._model.add_constraints(def_var <= self.on * ub, name=f'{self.label}|on_con2'), 'on_con2' + self.add( + self._model.add_constraints(def_var <= self.on * ub, name=f'{self.label_full}|on_con2'), 'on_con2' ) else: # Case for multiple defining variables @@ -300,19 +315,19 @@ def _add_defining_constraints(self): lb = CONFIG.modeling.EPSILON # Constraint: on * epsilon <= sum(all_defining_variables) - self.add_constraint( + self.add( self._model.add_constraints( - self.on * lb <= sum(self._defining_variables), name=f'{self.label}|on_con1' + self.on * lb <= sum(self._defining_variables), name=f'{self.label_full}|on_con1' ), 'on_con1', ) # Constraint to ensure all variables are zero when off - self.add_constraint( + self.add( self._model.add_constraints( sum([def_var / nr_of_def_vars for def_var in self._defining_variables]) <= self.on * ub / nr_of_def_vars, - name=f'{self.label}|on_con2', + name=f'{self.label_full}|on_con2', ), 'on_con2', ) @@ -329,37 +344,28 @@ def _create_shares(self): ) @property - def previous_on_values(self): - return self.compute_previous_on_states(self._previous_values) + def previous_states(self) -> np.ndarray: + """Computes the previous states {0, 1} of defining variables as a binary array from their previous values.""" + if not self._previous_values or all([val is None for val in self._previous_values]): + return np.array([0]) - @property - def previous_off_values(self): - return 1 - self.previous_on_values + # Convert to 2D-array and compute binary on/off states + previous_values = np.array([values for values in self._previous_values if values is not None]) # Filter out None + if previous_values.ndim > 1: + return np.any(~np.isclose(previous_values, 0, atol=CONFIG.modeling.EPSILON), axis=0).astype(int) - @staticmethod - def compute_previous_on_states(previous_values: List[Optional[TimestepData]], epsilon: float = 1e-5) -> np.ndarray: - """ - Computes the previous 'on' states {0, 1} of defining variables as a binary array from their previous values. + return (~np.isclose(previous_values, 0, atol=CONFIG.modeling.EPSILON)).astype(int) - Args: - previous_values: List of previous values of the defining variables. In Range [0, inf] or None (ignored) - epsilon: Tolerance for equality to determine "off" state, default is 1e-5. + @property + def previous_on_states(self) -> np.ndarray: + return self.previous_states - Returns: - A binary array (0 and 1) indicating the previous on/off states of the variables. - Returns `array([0])` if no previous values are available. - """ - if not previous_values or all([val is None for val in previous_values]): - return np.array([0]) - else: # Convert to 2D-array and compute binary on/off states - previous_values = np.array([values for values in previous_values if values is not None]) # Filter out None - if previous_values.ndim > 1: - return np.any(~np.isclose(previous_values, 0, atol=epsilon), axis=0).astype(int) - else: - return (~np.isclose(previous_values, 0, atol=epsilon)).astype(int) + @property + def previous_off_states(self): + return 1 - self.previous_states -class SwitchBinaryModel(Model): +class SwitchStateModel(Model): """ Handles switch on/off transitions """ @@ -369,15 +375,15 @@ def __init__( model: SystemModel, label_of_element: str, state_variable: linopy.Variable, - previous_value=0, - switch_on_max: Scalar = np.inf, + previous_state=0, + switch_on_max: Optional[Scalar] = None, effects_per_switch_on: Dict[str, TimestepData] = None, label: Optional[str] = None, ): super().__init__(model, label_of_element, label) self._state_variable = state_variable - self.previous_value = previous_value - self._switch_on_max = switch_on_max + self.previous_state = previous_state + self._switch_on_max = switch_on_max if switch_on_max is not None else np.inf self._effects_per_switch_on = effects_per_switch_on or {} self.switch_on = None @@ -389,12 +395,12 @@ def do_modeling(self): # Create switch variables self.switch_on = self.add( - self._model.add_variables(binary=True, name=f'{self.label}|switch_on', coords=self._model.get_coords()), + self._model.add_variables(binary=True, name=f'{self.label_full}|switch_on', coords=self._model.get_coords()), 'switch_on', ) self.switch_off = self.add( - self._model.add_variables(binary=True, name=f'{self.label}|switch_off', coords=self._model.get_coords()), + self._model.add_variables(binary=True, name=f'{self.label_full}|switch_off', coords=self._model.get_coords()), 'switch_off', ) @@ -402,7 +408,7 @@ def do_modeling(self): self.switch_on_nr = self.add( self._model.add_variables( upper=self._switch_on_max, - name=f'{self.label}|switch_on_nr', + name=f'{self.label_full}|switch_on_nr', ), 'switch_on_nr', ) @@ -412,7 +418,7 @@ def do_modeling(self): self._model.add_constraints( self.switch_on.isel(time=slice(1, None)) - self.switch_off.isel(time=slice(1, None)) == self._state_variable.isel(time=slice(1, None)) - self._state_variable.isel(time=slice(None, -1)), - name=f'{self.label}|switch_con', + name=f'{self.label_full}|switch_con', ), 'switch_con', ) @@ -421,22 +427,22 @@ def do_modeling(self): self.add( self._model.add_constraints( self.switch_on.isel(time=0) - self.switch_off.isel(time=0) - == self._state_variable.isel(time=0) - self.previous_value, - name=f'{self.label}|initial_switch_con', + == self._state_variable.isel(time=0) - self.previous_state, + name=f'{self.label_full}|initial_switch_con', ), 'initial_switch_con', ) # Mutual exclusivity constraint self.add( - self._model.add_constraints(self.switch_on + self.switch_off <= 1.1, name=f'{self.label}|switch_on_or_off'), + self._model.add_constraints(self.switch_on + self.switch_off <= 1.1, name=f'{self.label_full}|switch_on_or_off'), 'switch_on_or_off', ) # Total switch-on count constraint self.add( self._model.add_constraints( - self.switch_on_nr == self.switch_on.sum('time'), name=f'{self.label}|switch_on_nr' + self.switch_on_nr == self.switch_on.sum('time'), name=f'{self.label_full}|switch_on_nr' ), 'switch_on_nr', ) @@ -456,7 +462,7 @@ def _create_shares(self): ) -class ConsecutiveBinaryModel(Model): +class ConsecutiveStateModel(Model): """ Handles tracking consecutive durations in a state """ @@ -468,22 +474,27 @@ def __init__( state_variable: linopy.Variable, minimum_duration: Optional[TimestepData] = None, maximum_duration: Optional[TimestepData] = None, - previous_duration=0, + previous_states: Optional[TimestepData] = None, label: Optional[str] = None, ): super().__init__(model, label_of_element, label) self._state_variable = state_variable - self._previous_duration = previous_duration + self._previous_states = previous_states self._minimum_duration = minimum_duration self._maximum_duration = maximum_duration + if isinstance(self._minimum_duration, TimeSeries): + self._minimum_duration = self._minimum_duration.selected_data + if isinstance(self._maximum_duration, TimeSeries): + self._maximum_duration = self._maximum_duration.selected_data + self.duration = None def do_modeling(self): """Create consecutive duration variables and constraints""" # Get the hours per step hours_per_step = self._model.hours_per_step - mega = hours_per_step.sum('time') + self._previous_duration + mega = hours_per_step.sum('time') + self.previous_duration # Create the duration variable self.duration = self.add( @@ -543,7 +554,7 @@ def do_modeling(self): ) # Handle initial condition - if 0 < self._previous_duration < self._minimum_duration.isel(time=0): + if 0 < self.previous_duration < self._minimum_duration.isel(time=0): self.add( self._model.add_constraints( self._state_variable.isel(time=0) == 1, name=f'{self.label_full}|consecutive_minimum_initial' @@ -555,53 +566,154 @@ def do_modeling(self): self.add( self._model.add_constraints( self.duration.isel(time=0) == hours_per_step.isel(time=0) * self._state_variable.isel(time=0), - name=f'{self.label}|consecutive_initial', + name=f'{self.label_full}|consecutive_initial', ), f'consecutive_initial', ) return self - @staticmethod - def compute_consecutive_duration( - binary_values: TimestepData, hours_per_timestep: Union[int, float, np.ndarray] - ) -> Scalar: - """ - Computes the final consecutive duration in State 'on' (=1) in hours, from a binary. - - hours_per_timestep is handled in a way, that maximizes compatability. - Its length must only be as long as the last consecutive duration in binary_values. + @property + def previous_duration(self) -> Scalar: + """Computes the previous duration of the state variable""" + if not self._previous_states: + return 0 - Args: - binary_values: An int or 1D binary array containing only `0`s and `1`s. - hours_per_timestep: The duration of each timestep in hours. + if np.isscalar(self._previous_states) and np.isscalar(self._model.hours_per_step): + return self._previous_states * self._model.hours_per_step - Returns: - The duration of the binary variable in hours. - """ - if np.isscalar(binary_values) and np.isscalar(hours_per_timestep): - return binary_values * hours_per_timestep - elif np.isscalar(binary_values) and not np.isscalar(hours_per_timestep): - return binary_values * hours_per_timestep[-1] + elif np.isscalar(self._previous_states) and not np.isscalar(self._model.hours_per_step): + return self._previous_states * self._model.hours_per_step[-1] # Find the indexes where value=`0` in a 1D-array - zero_indices = np.where(np.isclose(binary_values, 0, atol=CONFIG.modeling.EPSILON))[0] - length_of_last_duration = zero_indices[-1] + 1 if zero_indices.size > 0 else len(binary_values) + zero_indices = np.where(np.isclose(self._previous_states, 0, atol=CONFIG.modeling.EPSILON))[0] + length_of_last_duration = zero_indices[-1] + 1 if zero_indices.size > 0 else len(self._previous_states) - if not np.isscalar(binary_values) and np.isscalar(hours_per_timestep): - return np.sum(binary_values[-length_of_last_duration:] * hours_per_timestep) - elif not np.isscalar(binary_values) and not np.isscalar(hours_per_timestep): - if length_of_last_duration > len(hours_per_timestep): # check that lengths are compatible + if not np.isscalar(self._previous_states) and np.isscalar(self._model.hours_per_step): + return np.sum(self._previous_states[-length_of_last_duration:] * self._model.hours_per_step) + elif not np.isscalar(self._previous_states) and not np.isscalar(self._model.hours_per_step): + if length_of_last_duration > len(self._model.hours_per_step): # check that lengths are compatible raise TypeError( f'When trying to calculate the consecutive duration, the length of the last duration ' - f'({length_of_last_duration}) is longer than the hours_per_timestep ({len(hours_per_timestep)})' + f'({length_of_last_duration}) is longer than the hours_per_timestep ({len(self._model.hours_per_step)})' ) - return np.sum(binary_values[-length_of_last_duration:] * hours_per_timestep[-length_of_last_duration:]) + return np.sum(self._previous_states[-length_of_last_duration:] * self._model.hours_per_step[-length_of_last_duration:]) else: raise Exception( - f'Unexpected state reached in function compute_consecutive_duration(). binary_values={binary_values}; ' - f'hours_per_timestep={hours_per_timestep}' + f'Unexpected state reached in function compute_consecutive_duration(). binary_values={self._previous_states}; ' + f'hours_per_timestep={self._model.hours_per_step}' + ) + + +class OnOffModel(Model): + """ + Class for modeling the on and off state of a variable + Uses component models to create a modular implementation + """ + + def __init__( + self, + model: SystemModel, + on_off_parameters: OnOffParameters, + label_of_element: str, + defining_variables: List[linopy.Variable], + defining_bounds: List[Tuple[TimestepData, TimestepData]], + previous_values: List[Optional[TimestepData]], + label: Optional[str] = None, + ): + """ + Constructor for OnOffModel + + Args: + model: Reference to the SystemModel + on_off_parameters: Parameters for the OnOffModel + label_of_element: Label of the Parent + defining_variables: List of Variables that are used to define the OnOffModel + defining_bounds: List of Tuples, defining the absolute bounds of each defining variable + previous_values: List of previous values of the defining variables + label: Label of the OnOffModel + """ + super().__init__(model, label_of_element, label) + self.parameters = on_off_parameters + self._defining_variables = defining_variables + self._defining_bounds = defining_bounds + self._previous_values = previous_values + + self.state_model = None + self.switch_state_model = None + self.consecutive_on_model = None + self.consecutive_off_model = None + + def do_modeling(self): + """Create all variables and constraints for the OnOffModel""" + + # Create binary state component + self.state_model = StateModel( + model=self._model, + label_of_element=self.label_of_element, + defining_variables=self._defining_variables, + defining_bounds=self._defining_bounds, + previous_values=self._previous_values, + use_off=self.parameters.use_off, + on_hours_total_min=self.parameters.on_hours_total_min, + on_hours_total_max=self.parameters.on_hours_total_max, + effects_per_running_hour=self.parameters.effects_per_running_hour, + label=f'OnOff', + ) + self.add(self.state_model) + self.state_model.do_modeling() + + # Create switch component if needed + if self.parameters.use_switch_on: + self.switch_state_model = SwitchStateModel( + model=self._model, + label_of_element=self.label_of_element, + state_variable=self.state_model.on, + previous_state=self.state_model.previous_on_states[-1], + switch_on_max=self.parameters.switch_on_total_max, + effects_per_switch_on=self.parameters.effects_per_switch_on, + label=f'{self.label_full}|switch', + ) + self.add(self.switch_state_model) + self.switch_state_model.do_modeling() + + # Create consecutive on hours component if needed + if self.parameters.use_consecutive_on_hours: + self.consecutive_on_model = ConsecutiveStateModel( + model=self._model, + label_of_element=self.label_of_element, + state_variable=self.state_model.on, + minimum_duration=self.parameters.consecutive_on_hours_min, + maximum_duration=self.parameters.consecutive_on_hours_max, + previous_states=self.state_model.previous_on_states, + label=f'{self.label_full}|ConsecutiveOn', + ) + self.add(self.consecutive_on_model) + self.consecutive_on_model.do_modeling() + + # Create consecutive off hours component if needed + if self.parameters.use_consecutive_off_hours: + if not self.parameters.use_off: + logger.warning( + f'In {self.label_full}, consecutive_off_hours are requested, but use_off=False. ' + f'Setting use_off=True automatically.' + ) + + self.consecutive_off_model = ConsecutiveStateModel( + model=self._model, + label_of_element=self.label_of_element, + state_variable=self.state_model.off, + minimum_duration=self.parameters.consecutive_off_hours_min, + maximum_duration=self.parameters.consecutive_off_hours_max, + previous_states=self.state_model.previous_off_states, + label=f'{self.label_full}|ConsecutiveOff', ) + self.add(self.consecutive_off_model) + self.consecutive_off_model.do_modeling() + + @property + def on(self): + return self.state_model.on class PieceModel(Model): From 92ec09cfb75e0427577b6d902c16f6b4ae805928 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 7 Apr 2025 17:39:24 +0200 Subject: [PATCH 18/34] Update Model names in OnOffModel --- flixopt/features.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index 22d24b012..81f88d4f0 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -658,7 +658,7 @@ def do_modeling(self): on_hours_total_min=self.parameters.on_hours_total_min, on_hours_total_max=self.parameters.on_hours_total_max, effects_per_running_hour=self.parameters.effects_per_running_hour, - label=f'OnOff', + label='State', ) self.add(self.state_model) self.state_model.do_modeling() @@ -672,7 +672,7 @@ def do_modeling(self): previous_state=self.state_model.previous_on_states[-1], switch_on_max=self.parameters.switch_on_total_max, effects_per_switch_on=self.parameters.effects_per_switch_on, - label=f'{self.label_full}|switch', + label=f'SwitchState', ) self.add(self.switch_state_model) self.switch_state_model.do_modeling() @@ -686,7 +686,7 @@ def do_modeling(self): minimum_duration=self.parameters.consecutive_on_hours_min, maximum_duration=self.parameters.consecutive_on_hours_max, previous_states=self.state_model.previous_on_states, - label=f'{self.label_full}|ConsecutiveOn', + label=f'ConsecutiveOn', ) self.add(self.consecutive_on_model) self.consecutive_on_model.do_modeling() @@ -706,7 +706,7 @@ def do_modeling(self): minimum_duration=self.parameters.consecutive_off_hours_min, maximum_duration=self.parameters.consecutive_off_hours_max, previous_states=self.state_model.previous_off_states, - label=f'{self.label_full}|ConsecutiveOff', + label=f'ConsecutiveOff', ) self.add(self.consecutive_off_model) self.consecutive_off_model.do_modeling() From 47a69741afa47258fd8b76d2bca77fc7c83ee207 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 7 Apr 2025 17:45:25 +0200 Subject: [PATCH 19/34] Move shares to OnOffModel --- flixopt/features.py | 63 +++++++++++++++++++++++++-------------------- 1 file changed, 35 insertions(+), 28 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index 81f88d4f0..1b40778c0 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -284,8 +284,6 @@ def do_modeling(self): # Constraint: on + off = 1 self.add(self._model.add_constraints(self.on + self.off == 1, name=f'{self.label_full}|off'), 'off') - self._create_shares() - return self def _add_defining_constraints(self): @@ -332,17 +330,6 @@ def _add_defining_constraints(self): 'on_con2', ) - def _create_shares(self): - if self._effects_per_running_hour: - self._model.effects.add_share_to_effects( - name=self.label_of_element, - expressions={ - effect: self.on * factor * self._model.hours_per_step - for effect, factor in self._effects_per_running_hour.items() - }, - target='operation', - ) - @property def previous_states(self) -> np.ndarray: """Computes the previous states {0, 1} of defining variables as a binary array from their previous values.""" @@ -377,14 +364,12 @@ def __init__( state_variable: linopy.Variable, previous_state=0, switch_on_max: Optional[Scalar] = None, - effects_per_switch_on: Dict[str, TimestepData] = None, label: Optional[str] = None, ): super().__init__(model, label_of_element, label) self._state_variable = state_variable self.previous_state = previous_state self._switch_on_max = switch_on_max if switch_on_max is not None else np.inf - self._effects_per_switch_on = effects_per_switch_on or {} self.switch_on = None self.switch_off = None @@ -447,20 +432,8 @@ def do_modeling(self): 'switch_on_nr', ) - self._create_shares() - return self - def _create_shares(self): - if self._effects_per_switch_on: - self._model.effects.add_share_to_effects( - name=self.label_of_element, - expressions={ - effect: self.switch_on * factor for effect, factor in self._effects_per_switch_on.items() - }, - target='operation', - ) - class ConsecutiveStateModel(Model): """ @@ -477,6 +450,18 @@ def __init__( previous_states: Optional[TimestepData] = None, label: Optional[str] = None, ): + """ + Model and constraint the consecutive duration of a state variable. + + Args: + model: The SystemModel that is used to create the model. + label_of_element: The label of the parent (Element). Used to construct the full label of the model. + state_variable: The state variable that is used to model the duration. state = {0, 1} + minimum_duration: The minimum duration of the state variable. + maximum_duration: The maximum duration of the state variable. + previous_states: The previous states of the state variable. + label: The label of the model. Used to construct the full label of the model. + """ super().__init__(model, label_of_element, label) self._state_variable = state_variable self._previous_states = previous_states @@ -671,7 +656,6 @@ def do_modeling(self): state_variable=self.state_model.on, previous_state=self.state_model.previous_on_states[-1], switch_on_max=self.parameters.switch_on_total_max, - effects_per_switch_on=self.parameters.effects_per_switch_on, label=f'SwitchState', ) self.add(self.switch_state_model) @@ -711,6 +695,29 @@ def do_modeling(self): self.add(self.consecutive_off_model) self.consecutive_off_model.do_modeling() + self._create_shares() + + def _create_shares(self): + if self.parameters.effects_per_running_hour: + self._model.effects.add_share_to_effects( + name=self.label_of_element, + expressions={ + effect: self.state_model.on * factor * self._model.hours_per_step + for effect, factor in self.parameters.effects_per_running_hour.items() + }, + target='operation', + ) + + if self.parameters.effects_per_switch_on: + self._model.effects.add_share_to_effects( + name=self.label_of_element, + expressions={ + effect: self.switch_state_model.switch_on * factor + for effect, factor in self.parameters.effects_per_switch_on.items() + }, + target='operation', + ) + @property def on(self): return self.state_model.on From 0d0c3f6a58f84f2af31ac7bc70c8300700f0e3be Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 7 Apr 2025 17:57:19 +0200 Subject: [PATCH 20/34] Bugfix --- flixopt/features.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixopt/features.py b/flixopt/features.py index 1b40778c0..036808c83 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -695,7 +695,7 @@ def do_modeling(self): self.add(self.consecutive_off_model) self.consecutive_off_model.do_modeling() - self._create_shares() + self._create_shares() def _create_shares(self): if self.parameters.effects_per_running_hour: From bc8bdd22a0cdb23a3c26f312c7435d0515e43d9b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 7 Apr 2025 17:57:34 +0200 Subject: [PATCH 21/34] Add acessors for variables --- flixopt/features.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/flixopt/features.py b/flixopt/features.py index 036808c83..b835d22e2 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -722,6 +722,30 @@ def _create_shares(self): def on(self): return self.state_model.on + @property + def off(self): + return self.state_model.off + + @property + def switch_on(self): + return self.switch_state_model.switch_on + + @property + def switch_off(self): + return self.switch_state_model.switch_off + + @property + def switch_on_nr(self): + return self.switch_state_model.switch_on_nr + + @property + def consecutive_on_hours(self): + return self.consecutive_on_model.duration + + @property + def consecutive_off_hours(self): + return self.consecutive_off_model.duration + class PieceModel(Model): """Class for modeling a linear piece of one or more variables in parallel""" From 8430a1e4b5473fb60953e6847e3b29e73f303bfa Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 7 Apr 2025 18:00:35 +0200 Subject: [PATCH 22/34] Remove not needed check --- flixopt/features.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index b835d22e2..b51e75add 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -677,12 +677,6 @@ def do_modeling(self): # Create consecutive off hours component if needed if self.parameters.use_consecutive_off_hours: - if not self.parameters.use_off: - logger.warning( - f'In {self.label_full}, consecutive_off_hours are requested, but use_off=False. ' - f'Setting use_off=True automatically.' - ) - self.consecutive_off_model = ConsecutiveStateModel( model=self._model, label_of_element=self.label_of_element, From 4f013118c0b977b03cad4229cef8f96c3bf30390 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 7 Apr 2025 18:14:58 +0200 Subject: [PATCH 23/34] BUGFIX: in previous duration in ConsecutiveStateModel --- flixopt/features.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/flixopt/features.py b/flixopt/features.py index b51e75add..5d24939b7 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -550,7 +550,8 @@ def do_modeling(self): # Set initial value self.add( self._model.add_constraints( - self.duration.isel(time=0) == hours_per_step.isel(time=0) * self._state_variable.isel(time=0), + self.duration.isel(time=0) == + (hours_per_step.isel(time=0) + self.previous_duration) * self._state_variable.isel(time=0), name=f'{self.label_full}|consecutive_initial', ), f'consecutive_initial', From 771ef7f8c6c970e963c39f7db958f12f28a04f0c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 7 Apr 2025 18:16:29 +0200 Subject: [PATCH 24/34] Revert names in OnOffModel to change current naming as little as possible --- flixopt/features.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index 5d24939b7..7e90feb81 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -644,7 +644,6 @@ def do_modeling(self): on_hours_total_min=self.parameters.on_hours_total_min, on_hours_total_max=self.parameters.on_hours_total_max, effects_per_running_hour=self.parameters.effects_per_running_hour, - label='State', ) self.add(self.state_model) self.state_model.do_modeling() @@ -657,7 +656,6 @@ def do_modeling(self): state_variable=self.state_model.on, previous_state=self.state_model.previous_on_states[-1], switch_on_max=self.parameters.switch_on_total_max, - label=f'SwitchState', ) self.add(self.switch_state_model) self.switch_state_model.do_modeling() From 4cd0416ae28dd9338270ff511ad0023979d33ddd Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 10 Apr 2025 19:56:10 +0200 Subject: [PATCH 25/34] Revert renaming --- flixopt/features.py | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index 7e90feb81..d2eb6784d 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -11,7 +11,7 @@ from . import utils from .config import CONFIG -from .core import Scalar, TimestepData, TimeSeries +from .core import Scalar, NumericData, TimeSeries from .interface import InvestParameters, Piecewise, OnOffParameters from .structure import Model, SystemModel @@ -203,12 +203,12 @@ def __init__( model: SystemModel, label_of_element: str, defining_variables: List[linopy.Variable], - defining_bounds: List[Tuple[TimestepData, TimestepData]], - previous_values: List[Optional[TimestepData]] = None, + defining_bounds: List[Tuple[NumericData, NumericData]], + previous_values: List[Optional[NumericData]] = None, use_off: bool = True, - on_hours_total_min: Optional[TimestepData] = 0, - on_hours_total_max: Optional[TimestepData] = None, - effects_per_running_hour: Dict[str, TimestepData] = None, + on_hours_total_min: Optional[NumericData] = 0, + on_hours_total_max: Optional[NumericData] = None, + effects_per_running_hour: Dict[str, NumericData] = None, label: Optional[str] = None, ): """ @@ -254,7 +254,7 @@ def do_modeling(self): self._model.add_variables( lower=self._on_hours_total_min, upper=self._on_hours_total_max, - coords=self._model.get_coords(time_dim=False), + coords=None, name=f'{self.label_full}|on_hours_total', ), 'on_hours_total', @@ -380,12 +380,12 @@ def do_modeling(self): # Create switch variables self.switch_on = self.add( - self._model.add_variables(binary=True, name=f'{self.label_full}|switch_on', coords=self._model.get_coords()), + self._model.add_variables(binary=True, name=f'{self.label_full}|switch_on', coords=self._model.coords), 'switch_on', ) self.switch_off = self.add( - self._model.add_variables(binary=True, name=f'{self.label_full}|switch_off', coords=self._model.get_coords()), + self._model.add_variables(binary=True, name=f'{self.label_full}|switch_off', coords=self._model.coords), 'switch_off', ) @@ -445,9 +445,9 @@ def __init__( model: SystemModel, label_of_element: str, state_variable: linopy.Variable, - minimum_duration: Optional[TimestepData] = None, - maximum_duration: Optional[TimestepData] = None, - previous_states: Optional[TimestepData] = None, + minimum_duration: Optional[NumericData] = None, + maximum_duration: Optional[NumericData] = None, + previous_states: Optional[NumericData] = None, label: Optional[str] = None, ): """ @@ -469,9 +469,9 @@ def __init__( self._maximum_duration = maximum_duration if isinstance(self._minimum_duration, TimeSeries): - self._minimum_duration = self._minimum_duration.selected_data + self._minimum_duration = self._minimum_duration.active_data if isinstance(self._maximum_duration, TimeSeries): - self._maximum_duration = self._maximum_duration.selected_data + self._maximum_duration = self._maximum_duration.active_data self.duration = None @@ -486,7 +486,7 @@ def do_modeling(self): self._model.add_variables( lower=0, upper=self._maximum_duration if self._maximum_duration is not None else mega, - coords=self._model.get_coords(), + coords=self._model.coords, name=f'{self.label_full}|consecutive', ), f'consecutive', @@ -603,8 +603,8 @@ def __init__( on_off_parameters: OnOffParameters, label_of_element: str, defining_variables: List[linopy.Variable], - defining_bounds: List[Tuple[TimestepData, TimestepData]], - previous_values: List[Optional[TimestepData]], + defining_bounds: List[Tuple[NumericData, NumericData]], + previous_values: List[Optional[NumericData]], label: Optional[str] = None, ): """ From 32e3c923a73826fc4e2f8cc44913d0ef833d0d4d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 10 Apr 2025 20:01:52 +0200 Subject: [PATCH 26/34] Invert constraint in test --- tests/test_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_flow.py b/tests/test_flow.py index fdb9f30ab..09d4f077a 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -381,7 +381,7 @@ def test_flow_on(self, basic_flow_system_linopy): ) assert_conequal( model.constraints['Sink(Wärme)|on_con2'], - flow.model.variables['Sink(Wärme)|on'] * 0.8 * 100 >= flow.model.variables['Sink(Wärme)|flow_rate'], + flow.model.variables['Sink(Wärme)|flow_rate'] <= flow.model.variables['Sink(Wärme)|on'] * 0.8 * 100, ) assert_conequal( From 519cd1da4f98ec8000baad09f87cce6173ecc6dc Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 10 Apr 2025 20:14:06 +0200 Subject: [PATCH 27/34] Rename variables and constraints in new Models to better names --- flixopt/features.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index d2eb6784d..2cf8f226f 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -487,9 +487,9 @@ def do_modeling(self): lower=0, upper=self._maximum_duration if self._maximum_duration is not None else mega, coords=self._model.coords, - name=f'{self.label_full}|consecutive', + name=f'{self.label_full}|hours', ), - f'consecutive', + f'hours', ) # Add constraints @@ -497,9 +497,9 @@ def do_modeling(self): # Upper bound constraint self.add( self._model.add_constraints( - self.duration <= self._state_variable * mega, name=f'{self.label_full}|consecutive_con1' + self.duration <= self._state_variable * mega, name=f'{self.label_full}|con1' ), - f'consecutive_con1', + f'con1', ) # Forward constraint @@ -507,9 +507,9 @@ def do_modeling(self): self._model.add_constraints( self.duration.isel(time=slice(1, None)) <= self.duration.isel(time=slice(None, -1)) + hours_per_step.isel(time=slice(None, -1)), - name=f'{self.label_full}|consecutive_con2a', + name=f'{self.label_full}|con2a', ), - f'consecutive_con2a', + f'con2a', ) # Backward constraint @@ -519,9 +519,9 @@ def do_modeling(self): >= self.duration.isel(time=slice(None, -1)) + hours_per_step.isel(time=slice(None, -1)) + (self._state_variable.isel(time=slice(1, None)) - 1) * mega, - name=f'{self.label_full}|consecutive_con2b', + name=f'{self.label_full}|con2b', ), - f'consecutive_con2b', + f'con2b', ) # Add minimum duration constraints if specified @@ -533,18 +533,18 @@ def do_modeling(self): self._state_variable.isel(time=slice(None, -1)) - self._state_variable.isel(time=slice(1, None)) ) * self._minimum_duration.isel(time=slice(None, -1)), - name=f'{self.label_full}|consecutive_minimum', + name=f'{self.label_full}|minimum', ), - f'consecutive_minimum', + f'minimum', ) # Handle initial condition if 0 < self.previous_duration < self._minimum_duration.isel(time=0): self.add( self._model.add_constraints( - self._state_variable.isel(time=0) == 1, name=f'{self.label_full}|consecutive_minimum_initial' + self._state_variable.isel(time=0) == 1, name=f'{self.label_full}|initial_minimum' ), - f'consecutive_minimum_initial', + f'initial_minimum', ) # Set initial value @@ -552,9 +552,9 @@ def do_modeling(self): self._model.add_constraints( self.duration.isel(time=0) == (hours_per_step.isel(time=0) + self.previous_duration) * self._state_variable.isel(time=0), - name=f'{self.label_full}|consecutive_initial', + name=f'{self.label_full}|initial', ), - f'consecutive_initial', + f'initial', ) return self From c7cf37ca4d25aa604819635fee8c6b8cb4686f67 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 10 Apr 2025 20:14:28 +0200 Subject: [PATCH 28/34] Rename variables in tests --- tests/test_flow.py | 79 +++++++++++++++++++++++----------------------- 1 file changed, 39 insertions(+), 40 deletions(-) diff --git a/tests/test_flow.py b/tests/test_flow.py index 09d4f077a..cbeb07b5e 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -454,52 +454,51 @@ def test_consecutive_on_hours(self, basic_flow_system_linopy): flow_system.add_elements( fx.Sink('Sink', sink=flow)) model = create_linopy_model(flow_system) - assert {'Sink(Wärme)|consecutive_on_hours', 'Sink(Wärme)|on'}.issubset(set(flow.model.variables)) + assert {'Sink(Wärme)|ConsecutiveOn|hours', 'Sink(Wärme)|on'}.issubset(set(flow.model.variables)) - assert { - 'Sink(Wärme)|consecutive_on_hours_con1', - 'Sink(Wärme)|consecutive_on_hours_con2a', - 'Sink(Wärme)|consecutive_on_hours_con2b', - 'Sink(Wärme)|consecutive_on_hours_initial', - 'Sink(Wärme)|consecutive_on_hours_minimum_duration' + assert {'Sink(Wärme)|ConsecutiveOn|con1', + 'Sink(Wärme)|ConsecutiveOn|con2a', + 'Sink(Wärme)|ConsecutiveOn|con2b', + 'Sink(Wärme)|ConsecutiveOn|initial', + 'Sink(Wärme)|ConsecutiveOn|minimum', }.issubset(set(flow.model.constraints)) assert_var_equal( - model.variables['Sink(Wärme)|consecutive_on_hours'], + model.variables['Sink(Wärme)|ConsecutiveOn|hours'], model.add_variables(lower=0, upper=8, coords=(timesteps,)) ) mega = model.hours_per_step.sum('time') assert_conequal( - model.constraints['Sink(Wärme)|consecutive_on_hours_con1'], - model.variables['Sink(Wärme)|consecutive_on_hours'] <= model.variables['Sink(Wärme)|on'] * mega + model.constraints['Sink(Wärme)|ConsecutiveOn|con1'], + model.variables['Sink(Wärme)|ConsecutiveOn|hours'] <= model.variables['Sink(Wärme)|on'] * mega ) assert_conequal( - model.constraints['Sink(Wärme)|consecutive_on_hours_con2a'], - model.variables['Sink(Wärme)|consecutive_on_hours'].isel(time=slice(1, None)) - <= model.variables['Sink(Wärme)|consecutive_on_hours'].isel(time=slice(None, -1)) + model.hours_per_step.isel(time=slice(None, -1)) + model.constraints['Sink(Wärme)|ConsecutiveOn|con2a'], + model.variables['Sink(Wärme)|ConsecutiveOn|hours'].isel(time=slice(1, None)) + <= model.variables['Sink(Wärme)|ConsecutiveOn|hours'].isel(time=slice(None, -1)) + model.hours_per_step.isel(time=slice(None, -1)) ) # eq: duration(t) >= duration(t - 1) + dt(t) + (On(t) - 1) * BIG assert_conequal( - model.constraints['Sink(Wärme)|consecutive_on_hours_con2b'], - model.variables['Sink(Wärme)|consecutive_on_hours'].isel(time=slice(1, None)) - >= model.variables['Sink(Wärme)|consecutive_on_hours'].isel(time=slice(None, -1)) + model.constraints['Sink(Wärme)|ConsecutiveOn|con2b'], + model.variables['Sink(Wärme)|ConsecutiveOn|hours'].isel(time=slice(1, None)) + >= model.variables['Sink(Wärme)|ConsecutiveOn|hours'].isel(time=slice(None, -1)) + model.hours_per_step.isel(time=slice(None, -1)) + (model.variables['Sink(Wärme)|on'].isel(time=slice(1, None)) - 1) * mega ) assert_conequal( - model.constraints['Sink(Wärme)|consecutive_on_hours_initial'], - model.variables['Sink(Wärme)|consecutive_on_hours'].isel(time=0) + model.constraints['Sink(Wärme)|ConsecutiveOn|initial'], + model.variables['Sink(Wärme)|ConsecutiveOn|hours'].isel(time=0) == model.variables['Sink(Wärme)|on'].isel(time=0) * model.hours_per_step.isel(time=0), ) assert_conequal( - model.constraints['Sink(Wärme)|consecutive_on_hours_minimum_duration'], - model.variables['Sink(Wärme)|consecutive_on_hours'] + model.constraints['Sink(Wärme)|ConsecutiveOn|minimum'], + model.variables['Sink(Wärme)|ConsecutiveOn|hours'] >= (model.variables['Sink(Wärme)|on'].isel(time=slice(None, -1)) - model.variables['Sink(Wärme)|on'].isel(time=slice(1, None))) * 2 ) @@ -521,52 +520,52 @@ def test_consecutive_off_hours(self, basic_flow_system_linopy): flow_system.add_elements( fx.Sink('Sink', sink=flow)) model = create_linopy_model(flow_system) - assert {'Sink(Wärme)|consecutive_off_hours', 'Sink(Wärme)|off'}.issubset(set(flow.model.variables)) + assert {'Sink(Wärme)|ConsecutiveOff|hours', 'Sink(Wärme)|off'}.issubset(set(flow.model.variables)) assert { - 'Sink(Wärme)|consecutive_off_hours_con1', - 'Sink(Wärme)|consecutive_off_hours_con2a', - 'Sink(Wärme)|consecutive_off_hours_con2b', - 'Sink(Wärme)|consecutive_off_hours_initial', - 'Sink(Wärme)|consecutive_off_hours_minimum_duration' + 'Sink(Wärme)|ConsecutiveOff|con1', + 'Sink(Wärme)|ConsecutiveOff|con2a', + 'Sink(Wärme)|ConsecutiveOff|con2b', + 'Sink(Wärme)|ConsecutiveOff|initial', + 'Sink(Wärme)|ConsecutiveOff|minimum' }.issubset(set(flow.model.constraints)) assert_var_equal( - model.variables['Sink(Wärme)|consecutive_off_hours'], + model.variables['Sink(Wärme)|ConsecutiveOff|hours'], model.add_variables(lower=0, upper=12, coords=(timesteps,)) ) mega = model.hours_per_step.sum('time') + 1 # previously off for 1h assert_conequal( - model.constraints['Sink(Wärme)|consecutive_off_hours_con1'], - model.variables['Sink(Wärme)|consecutive_off_hours'] <= model.variables['Sink(Wärme)|off'] * mega + model.constraints['Sink(Wärme)|ConsecutiveOff|con1'], + model.variables['Sink(Wärme)|ConsecutiveOff|hours'] <= model.variables['Sink(Wärme)|off'] * mega ) assert_conequal( - model.constraints['Sink(Wärme)|consecutive_off_hours_con2a'], - model.variables['Sink(Wärme)|consecutive_off_hours'].isel(time=slice(1, None)) - <= model.variables['Sink(Wärme)|consecutive_off_hours'].isel(time=slice(None, -1)) + model.hours_per_step.isel(time=slice(None, -1)) + model.constraints['Sink(Wärme)|ConsecutiveOff|con2a'], + model.variables['Sink(Wärme)|ConsecutiveOff|hours'].isel(time=slice(1, None)) + <= model.variables['Sink(Wärme)|ConsecutiveOff|hours'].isel(time=slice(None, -1)) + model.hours_per_step.isel(time=slice(None, -1)) ) # eq: duration(t) >= duration(t - 1) + dt(t) + (On(t) - 1) * BIG assert_conequal( - model.constraints['Sink(Wärme)|consecutive_off_hours_con2b'], - model.variables['Sink(Wärme)|consecutive_off_hours'].isel(time=slice(1, None)) - >= model.variables['Sink(Wärme)|consecutive_off_hours'].isel(time=slice(None, -1)) + model.constraints['Sink(Wärme)|ConsecutiveOff|con2b'], + model.variables['Sink(Wärme)|ConsecutiveOff|hours'].isel(time=slice(1, None)) + >= model.variables['Sink(Wärme)|ConsecutiveOff|hours'].isel(time=slice(None, -1)) + model.hours_per_step.isel(time=slice(None, -1)) + (model.variables['Sink(Wärme)|off'].isel(time=slice(1, None)) - 1) * mega ) assert_conequal( - model.constraints['Sink(Wärme)|consecutive_off_hours_initial'], - model.variables['Sink(Wärme)|consecutive_off_hours'].isel(time=0) - == model.variables['Sink(Wärme)|off'].isel(time=0) * model.hours_per_step.isel(time=0), + model.constraints['Sink(Wärme)|ConsecutiveOff|initial'], + model.variables['Sink(Wärme)|ConsecutiveOff|hours'].isel(time=0) + == model.variables['Sink(Wärme)|off'].isel(time=0) * (model.hours_per_step.isel(time=0)+1), ) assert_conequal( - model.constraints['Sink(Wärme)|consecutive_off_hours_minimum_duration'], - model.variables['Sink(Wärme)|consecutive_off_hours'] + model.constraints['Sink(Wärme)|ConsecutiveOff|minimum'], + model.variables['Sink(Wärme)|ConsecutiveOff|hours'] >= (model.variables['Sink(Wärme)|off'].isel(time=slice(None, -1)) - model.variables['Sink(Wärme)|off'].isel(time=slice(1, None))) * 4 ) From a1f00c67bd0629d24695306ffdc789e1906f7123 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 10 Apr 2025 20:16:57 +0200 Subject: [PATCH 29/34] Add lower bound of 0 back --- flixopt/features.py | 1 + 1 file changed, 1 insertion(+) diff --git a/flixopt/features.py b/flixopt/features.py index 2cf8f226f..f4e99645a 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -393,6 +393,7 @@ def do_modeling(self): self.switch_on_nr = self.add( self._model.add_variables( upper=self._switch_on_max, + lower=0, name=f'{self.label_full}|switch_on_nr', ), 'switch_on_nr', From 272f34c2b4f8ccc78d378830ad0b0c50df4ce8d9 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 10 Apr 2025 20:18:53 +0200 Subject: [PATCH 30/34] Invert constraint --- tests/test_flow.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_flow.py b/tests/test_flow.py index cbeb07b5e..8761abcfa 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -718,7 +718,7 @@ def test_flow_on_invest_optional(self, basic_flow_system_linopy): ) assert_conequal( model.constraints['Sink(Wärme)|on_con2'], - flow.model.variables['Sink(Wärme)|on'] * 0.8 * 200 >= flow.model.variables['Sink(Wärme)|flow_rate'], + flow.model.variables['Sink(Wärme)|flow_rate']<= flow.model.variables['Sink(Wärme)|on'] * 0.8 * 200, ) assert_conequal( model.constraints['Sink(Wärme)|on_hours_total'], @@ -800,7 +800,7 @@ def test_flow_on_invest_non_optional(self, basic_flow_system_linopy): ) assert_conequal( model.constraints['Sink(Wärme)|on_con2'], - flow.model.variables['Sink(Wärme)|on'] * 0.8 * 200 >= flow.model.variables['Sink(Wärme)|flow_rate'], + flow.model.variables['Sink(Wärme)|flow_rate'] <= flow.model.variables['Sink(Wärme)|on'] * 0.8 * 200, ) assert_conequal( model.constraints['Sink(Wärme)|on_hours_total'], From b7545d0daa92429977101ff681c0c45806782257 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 10 Apr 2025 20:30:44 +0200 Subject: [PATCH 31/34] Move computation of previous states to dedicated function to ensure testability --- flixopt/features.py | 24 +++++++++++++++--------- tests/test_on_hours_computation.py | 8 ++++---- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index f4e99645a..a082ddc0f 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -333,15 +333,7 @@ def _add_defining_constraints(self): @property def previous_states(self) -> np.ndarray: """Computes the previous states {0, 1} of defining variables as a binary array from their previous values.""" - if not self._previous_values or all([val is None for val in self._previous_values]): - return np.array([0]) - - # Convert to 2D-array and compute binary on/off states - previous_values = np.array([values for values in self._previous_values if values is not None]) # Filter out None - if previous_values.ndim > 1: - return np.any(~np.isclose(previous_values, 0, atol=CONFIG.modeling.EPSILON), axis=0).astype(int) - - return (~np.isclose(previous_values, 0, atol=CONFIG.modeling.EPSILON)).astype(int) + return StateModel.compute_previous_states(self._previous_values, epsilon=CONFIG.modeling.EPSILON) @property def previous_on_states(self) -> np.ndarray: @@ -351,6 +343,20 @@ def previous_on_states(self) -> np.ndarray: def previous_off_states(self): return 1 - self.previous_states + @staticmethod + def compute_previous_states(previous_values: List[NumericData], epsilon: float = 1e-5) -> np.ndarray: + """Computes the previous states {0, 1} of defining variables as a binary array from their previous values.""" + if not previous_values or all([val is None for val in previous_values]): + return np.array([0]) + + # Convert to 2D-array and compute binary on/off states + previous_values = np.array([values for values in previous_values if values is not None]) # Filter out None + if previous_values.ndim > 1: + return np.any(~np.isclose(previous_values, 0, atol=epsilon), axis=0).astype(int) + + return (~np.isclose(previous_values, 0, atol=epsilon)).astype(int) + + class SwitchStateModel(Model): """ diff --git a/tests/test_on_hours_computation.py b/tests/test_on_hours_computation.py index 5608155c0..6a7b02753 100644 --- a/tests/test_on_hours_computation.py +++ b/tests/test_on_hours_computation.py @@ -1,7 +1,7 @@ import numpy as np import pytest -from flixopt.features import OnOffModel +from flixopt.features import OnOffModel, StateModel class TestComputeConsecutiveDuration: @@ -76,7 +76,7 @@ class TestComputePreviousOnStates: ) def test_compute_previous_on_states(self, previous_values, expected): """Test compute_previous_on_states with various inputs.""" - result = OnOffModel.compute_previous_on_states(previous_values) + result = StateModel.compute_previous_states(previous_values) np.testing.assert_array_equal(result, expected) @pytest.mark.parametrize("previous_values, epsilon, expected", [ @@ -90,7 +90,7 @@ def test_compute_previous_on_states(self, previous_values, expected): ]) def test_compute_previous_on_states_with_epsilon(self, previous_values, epsilon, expected): """Test compute_previous_on_states with custom epsilon values.""" - result = OnOffModel.compute_previous_on_states(previous_values, epsilon) + result = StateModel.compute_previous_states(previous_values, epsilon) np.testing.assert_array_equal(result, expected) @pytest.mark.parametrize("previous_values, expected_shape", [ @@ -101,5 +101,5 @@ def test_compute_previous_on_states_with_epsilon(self, previous_values, epsilon, ]) def test_output_shapes(self, previous_values, expected_shape): """Test that output array has the correct shape.""" - result = OnOffModel.compute_previous_on_states(previous_values) + result = StateModel.compute_previous_states(previous_values) assert result.shape == expected_shape From 89aa7b6e5164dc02e0480841204b6d9837816096 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 10 Apr 2025 20:39:01 +0200 Subject: [PATCH 32/34] Moved complex computations into staticmethods to ensure testability --- flixopt/features.py | 68 ++++++++++++++++++++---------- tests/test_on_hours_computation.py | 6 +-- 2 files changed, 49 insertions(+), 25 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index a082ddc0f..ac0b7bc5e 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -357,7 +357,6 @@ def compute_previous_states(previous_values: List[NumericData], epsilon: float = return (~np.isclose(previous_values, 0, atol=epsilon)).astype(int) - class SwitchStateModel(Model): """ Handles switch on/off transitions @@ -569,34 +568,59 @@ def do_modeling(self): @property def previous_duration(self) -> Scalar: """Computes the previous duration of the state variable""" - if not self._previous_states: - return 0 + #TODO: Allow for other/dynamic timestep resolutions + return ConsecutiveStateModel.compute_consecutive_hours_in_state( + self._previous_states, self._model.hours_per_step.isel(time=0).item() + ) + + @staticmethod + def compute_consecutive_hours_in_state( + binary_values: NumericData, hours_per_timestep: Union[int, float, np.ndarray] + ) -> Scalar: + """ + Computes the final consecutive duration in state 'on' (=1) in hours, from a binary array. - if np.isscalar(self._previous_states) and np.isscalar(self._model.hours_per_step): - return self._previous_states * self._model.hours_per_step + Args: + binary_values: An int or 1D binary array containing only `0`s and `1`s. + hours_per_timestep: The duration of each timestep in hours. + If a scalar is provided, it is used for all timesteps. + If an array is provided, it must be as long as the last consecutive duration in binary_values. + + Returns: + The duration of the binary variable in hours. + + Raises + ------ + TypeError + If the length of binary_values and dt_in_hours is not equal, but None is a scalar. + """ + if np.isscalar(binary_values) and np.isscalar(hours_per_timestep): + return binary_values * hours_per_timestep + elif np.isscalar(binary_values) and not np.isscalar(hours_per_timestep): + return binary_values * hours_per_timestep[-1] - elif np.isscalar(self._previous_states) and not np.isscalar(self._model.hours_per_step): - return self._previous_states * self._model.hours_per_step[-1] + if np.isclose(binary_values[-1], 0, atol=CONFIG.modeling.EPSILON): + return 0 - # Find the indexes where value=`0` in a 1D-array - zero_indices = np.where(np.isclose(self._previous_states, 0, atol=CONFIG.modeling.EPSILON))[0] - length_of_last_duration = zero_indices[-1] + 1 if zero_indices.size > 0 else len(self._previous_states) + if np.isscalar(hours_per_timestep): + hours_per_timestep = np.ones(len(binary_values)) * hours_per_timestep + hours_per_timestep: np.ndarray - if not np.isscalar(self._previous_states) and np.isscalar(self._model.hours_per_step): - return np.sum(self._previous_states[-length_of_last_duration:] * self._model.hours_per_step) - elif not np.isscalar(self._previous_states) and not np.isscalar(self._model.hours_per_step): - if length_of_last_duration > len(self._model.hours_per_step): # check that lengths are compatible - raise TypeError( - f'When trying to calculate the consecutive duration, the length of the last duration ' - f'({length_of_last_duration}) is longer than the hours_per_timestep ({len(self._model.hours_per_step)})' - ) - return np.sum(self._previous_states[-length_of_last_duration:] * self._model.hours_per_step[-length_of_last_duration:]) + indexes_with_zero_values = np.where(np.isclose(binary_values, 0, atol=CONFIG.modeling.EPSILON))[0] + if len(indexes_with_zero_values) == 0: + nr_of_indexes_with_consecutive_ones = len(binary_values) else: - raise Exception( - f'Unexpected state reached in function compute_consecutive_duration(). binary_values={self._previous_states}; ' - f'hours_per_timestep={self._model.hours_per_step}' + nr_of_indexes_with_consecutive_ones = len(binary_values) - indexes_with_zero_values[-1] - 1 + + if len(hours_per_timestep) < nr_of_indexes_with_consecutive_ones: + raise ValueError( + f'When trying to calculate the consecutive duration, the length of the last duration ' + f'({len(nr_of_indexes_with_consecutive_ones)}) is longer than the provided hours_per_timestep ({len(hours_per_timestep)}), ' + f'as {binary_values=}' ) + return np.sum(binary_values[-nr_of_indexes_with_consecutive_ones:] * hours_per_timestep[-nr_of_indexes_with_consecutive_ones:]) + class OnOffModel(Model): """ diff --git a/tests/test_on_hours_computation.py b/tests/test_on_hours_computation.py index 6a7b02753..a873bbd12 100644 --- a/tests/test_on_hours_computation.py +++ b/tests/test_on_hours_computation.py @@ -1,7 +1,7 @@ import numpy as np import pytest -from flixopt.features import OnOffModel, StateModel +from flixopt.features import ConsecutiveStateModel, StateModel class TestComputeConsecutiveDuration: @@ -31,7 +31,7 @@ class TestComputeConsecutiveDuration: ]) def test_compute_duration(self, binary_values, hours_per_timestep, expected): """Test compute_consecutive_duration with various inputs.""" - result = OnOffModel.compute_consecutive_duration(binary_values, hours_per_timestep) + result = ConsecutiveStateModel.compute_consecutive_hours_in_state(binary_values, hours_per_timestep) assert np.isclose(result, expected) @pytest.mark.parametrize("binary_values, hours_per_timestep", [ @@ -41,7 +41,7 @@ def test_compute_duration(self, binary_values, hours_per_timestep, expected): def test_compute_duration_raises_error(self, binary_values, hours_per_timestep): """Test error conditions.""" with pytest.raises(TypeError): - OnOffModel.compute_consecutive_duration(binary_values, hours_per_timestep) + ConsecutiveStateModel.compute_consecutive_hours_in_state(binary_values, hours_per_timestep) class TestComputePreviousOnStates: From c6c885a5774fbf3484b8ce4f660c1e74b2e268c8 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 10 Apr 2025 20:43:26 +0200 Subject: [PATCH 33/34] ruff check --- flixopt/features.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index ac0b7bc5e..f0099d94e 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -11,8 +11,8 @@ from . import utils from .config import CONFIG -from .core import Scalar, NumericData, TimeSeries -from .interface import InvestParameters, Piecewise, OnOffParameters +from .core import NumericData, Scalar, TimeSeries +from .interface import InvestParameters, OnOffParameters, Piecewise from .structure import Model, SystemModel logger = logging.getLogger('flixopt') @@ -495,7 +495,7 @@ def do_modeling(self): coords=self._model.coords, name=f'{self.label_full}|hours', ), - f'hours', + 'hours', ) # Add constraints @@ -505,7 +505,7 @@ def do_modeling(self): self._model.add_constraints( self.duration <= self._state_variable * mega, name=f'{self.label_full}|con1' ), - f'con1', + 'con1', ) # Forward constraint @@ -515,7 +515,7 @@ def do_modeling(self): <= self.duration.isel(time=slice(None, -1)) + hours_per_step.isel(time=slice(None, -1)), name=f'{self.label_full}|con2a', ), - f'con2a', + 'con2a', ) # Backward constraint @@ -527,7 +527,7 @@ def do_modeling(self): + (self._state_variable.isel(time=slice(1, None)) - 1) * mega, name=f'{self.label_full}|con2b', ), - f'con2b', + 'con2b', ) # Add minimum duration constraints if specified @@ -541,7 +541,7 @@ def do_modeling(self): * self._minimum_duration.isel(time=slice(None, -1)), name=f'{self.label_full}|minimum', ), - f'minimum', + 'minimum', ) # Handle initial condition @@ -550,7 +550,7 @@ def do_modeling(self): self._model.add_constraints( self._state_variable.isel(time=0) == 1, name=f'{self.label_full}|initial_minimum' ), - f'initial_minimum', + 'initial_minimum', ) # Set initial value @@ -560,7 +560,7 @@ def do_modeling(self): (hours_per_step.isel(time=0) + self.previous_duration) * self._state_variable.isel(time=0), name=f'{self.label_full}|initial', ), - f'initial', + 'initial', ) return self @@ -700,7 +700,7 @@ def do_modeling(self): minimum_duration=self.parameters.consecutive_on_hours_min, maximum_duration=self.parameters.consecutive_on_hours_max, previous_states=self.state_model.previous_on_states, - label=f'ConsecutiveOn', + label='ConsecutiveOn', ) self.add(self.consecutive_on_model) self.consecutive_on_model.do_modeling() @@ -714,7 +714,7 @@ def do_modeling(self): minimum_duration=self.parameters.consecutive_off_hours_min, maximum_duration=self.parameters.consecutive_off_hours_max, previous_states=self.state_model.previous_off_states, - label=f'ConsecutiveOff', + label='ConsecutiveOff', ) self.add(self.consecutive_off_model) self.consecutive_off_model.do_modeling() From 62acb1eba8e60dc2d845b555bb846395a1e32fd6 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 11 Apr 2025 15:28:01 +0200 Subject: [PATCH 34/34] Revert some changes from merge --- flixopt/features.py | 10 +++++----- tests/test_flow.py | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index 1cdacc2e2..c2a62adb1 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -303,9 +303,9 @@ def _add_defining_constraints(self): 'on_con1', ) - # Constraint: def_var <= on * upper_bound + # Constraint: on * upper_bound >= def_var self.add( - self._model.add_constraints(def_var <= self.on * ub, name=f'{self.label_full}|on_con2'), 'on_con2' + self._model.add_constraints(self.on * ub >= def_var, name=f'{self.label_full}|on_con2'), 'on_con2' ) else: # Case for multiple defining variables @@ -320,11 +320,11 @@ def _add_defining_constraints(self): 'on_con1', ) - # Constraint to ensure all variables are zero when off + # Constraint to ensure all variables are zero when off. + # Divide by nr_of_def_vars to improve numerical stability (smaller factors) self.add( self._model.add_constraints( - sum([def_var / nr_of_def_vars for def_var in self._defining_variables]) - <= self.on * ub / nr_of_def_vars, + self.on * ub >= sum([def_var / nr_of_def_vars for def_var in self._defining_variables]), name=f'{self.label_full}|on_con2', ), 'on_con2', diff --git a/tests/test_flow.py b/tests/test_flow.py index 192cb0170..10266b1f3 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -495,7 +495,7 @@ def test_flow_on(self, basic_flow_system_linopy): ) assert_conequal( model.constraints['Sink(Wärme)|on_con2'], - flow.model.variables['Sink(Wärme)|flow_rate'] <= flow.model.variables['Sink(Wärme)|on'] * 0.8 * 100, + flow.model.variables['Sink(Wärme)|on'] * 0.8 * 100 >= flow.model.variables['Sink(Wärme)|flow_rate'], ) assert_conequal( @@ -832,7 +832,7 @@ def test_flow_on_invest_optional(self, basic_flow_system_linopy): ) assert_conequal( model.constraints['Sink(Wärme)|on_con2'], - flow.model.variables['Sink(Wärme)|flow_rate']<= flow.model.variables['Sink(Wärme)|on'] * 0.8 * 200, + flow.model.variables['Sink(Wärme)|on'] * 0.8 * 200 >= flow.model.variables['Sink(Wärme)|flow_rate'], ) assert_conequal( model.constraints['Sink(Wärme)|on_hours_total'], @@ -914,7 +914,7 @@ def test_flow_on_invest_non_optional(self, basic_flow_system_linopy): ) assert_conequal( model.constraints['Sink(Wärme)|on_con2'], - flow.model.variables['Sink(Wärme)|flow_rate'] <= flow.model.variables['Sink(Wärme)|on'] * 0.8 * 200, + flow.model.variables['Sink(Wärme)|on'] * 0.8 * 200 >= flow.model.variables['Sink(Wärme)|flow_rate'], ) assert_conequal( model.constraints['Sink(Wärme)|on_hours_total'],