From c402d573407d9bf6fc4b777d83fe0f72d8478325 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 12 Sep 2025 07:43:13 +0200 Subject: [PATCH 01/22] First iteration --- flixopt/__init__.py | 1 + flixopt/commons.py | 3 ++- flixopt/elements.py | 22 ++++++++++++++++++++-- flixopt/features.py | 17 +++++++++++++---- flixopt/interface.py | 18 ++++++++++++++++++ 5 files changed, 54 insertions(+), 7 deletions(-) diff --git a/flixopt/__init__.py b/flixopt/__init__.py index b92766449..1c2174330 100644 --- a/flixopt/__init__.py +++ b/flixopt/__init__.py @@ -30,6 +30,7 @@ plotting, results, solvers, + PiecewiseEffectsPerFlowHour, ) CONFIG.load_config() diff --git a/flixopt/commons.py b/flixopt/commons.py index 68412d6fe..c1cc14635 100644 --- a/flixopt/commons.py +++ b/flixopt/commons.py @@ -18,7 +18,7 @@ from .effects import Effect from .elements import Bus, Flow from .flow_system import FlowSystem -from .interface import InvestParameters, OnOffParameters, Piece, Piecewise, PiecewiseConversion, PiecewiseEffects +from .interface import InvestParameters, OnOffParameters, Piece, Piecewise, PiecewiseConversion, PiecewiseEffects, PiecewiseEffectsPerFlowHour __all__ = [ 'TimeSeriesData', @@ -48,4 +48,5 @@ 'results', 'linear_converters', 'solvers', + 'PiecewiseEffectsPerFlowHour', ] diff --git a/flixopt/elements.py b/flixopt/elements.py index a0bd8c91f..54898d8ed 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -12,8 +12,8 @@ from .config import CONFIG from .core import NumericData, NumericDataTS, PlausibilityError, Scalar, TimeSeriesCollection from .effects import EffectValuesUser -from .features import InvestmentModel, OnOffModel, PreventSimultaneousUsageModel -from .interface import InvestParameters, OnOffParameters +from .features import InvestmentModel, OnOffModel, PreventSimultaneousUsageModel, PiecewiseEffectsModel +from .interface import InvestParameters, OnOffParameters, PiecewiseEffectsPerFlowHour from .structure import Element, ElementModel, SystemModel, register_class_for_io if TYPE_CHECKING: @@ -159,6 +159,7 @@ def __init__( relative_minimum: NumericDataTS = 0, relative_maximum: NumericDataTS = 1, effects_per_flow_hour: Optional[EffectValuesUser] = None, + piecewise_effects_per_flow_hour: Optional[PiecewiseEffectsPerFlowHour] = None, on_off_parameters: Optional[OnOffParameters] = None, flow_hours_total_max: Optional[Scalar] = None, flow_hours_total_min: Optional[Scalar] = None, @@ -180,6 +181,7 @@ def __init__( def: :math:`load\_factor:= sumFlowHours/ (nominal\_val \cdot \Delta t_{tot})` load_factor_max: maximal load factor (see minimal load factor) effects_per_flow_hour: operational costs, costs per flow-"work" + piecewise_effects_per_flow_hour: piecewise relation between flow hours and effects on_off_parameters: If present, flow can be "off", i.e. be zero (only relevant if relative_minimum > 0) Therefore a binary var "on" is used. Further, several other restrictions and effects can be modeled through this On/Off State (See OnOffParameters) @@ -207,6 +209,7 @@ def __init__( self.load_factor_max = load_factor_max # self.positive_gradient = TimeSeries('positive_gradient', positive_gradient, self) self.effects_per_flow_hour = effects_per_flow_hour if effects_per_flow_hour is not None else {} + self.piecewise_effects_per_flow_hour = piecewise_effects_per_flow_hour self.flow_hours_total_max = flow_hours_total_max self.flow_hours_total_min = flow_hours_total_min self.on_off_parameters = on_off_parameters @@ -248,6 +251,8 @@ def transform_data(self, flow_system: 'FlowSystem'): self.effects_per_flow_hour = flow_system.create_effect_time_series( self.label_full, self.effects_per_flow_hour, 'per_flow_hour' ) + if self.piecewise_effects_per_flow_hour is not None: + self.piecewise_effects_per_flow_hour.transform_data(flow_system, self.label_full) if self.on_off_parameters is not None: self.on_off_parameters.transform_data(flow_system, self.label_full) if isinstance(self.size, InvestParameters): @@ -396,6 +401,19 @@ def _create_shares(self): target='operation', ) + if self.element.piecewise_effects_per_flow_hour is not None: + self.piecewise_effects = self.add( + PiecewiseEffectsModel( + model=self._model, + label_of_element=self.label_of_element, + piecewise_origin=(self.flow_rate.name, self.element.piecewise_effects_per_flow_hour.piecewise_origin), + piecewise_shares=self.element.piecewise_effects_per_flow_hour.piecewise_shares, + zero_point=self.on_off.on if self.on_off is not None else False, + as_time_series=True, + ), + ) + self.piecewise_effects.do_modeling() + def _create_bounds_for_load_factor(self): # TODO: Add Variable load_factor for better evaluation? diff --git a/flixopt/features.py b/flixopt/features.py index c2a62adb1..53e974ac7 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -1027,6 +1027,7 @@ def __init__( piecewise_origin: Tuple[str, Piecewise], piecewise_shares: Dict[str, Piecewise], zero_point: Optional[Union[bool, linopy.Variable]], + as_time_series: bool = False, label: str = 'PiecewiseEffects', ): super().__init__(model, label_of_element, label) @@ -1036,13 +1037,19 @@ def __init__( self._zero_point = zero_point self._piecewise_origin = piecewise_origin self._piecewise_shares = piecewise_shares + self._as_time_series = as_time_series self.shares: Dict[str, linopy.Variable] = {} self.piecewise_model: Optional[PiecewiseModel] = None def do_modeling(self): self.shares = { - effect: self.add(self._model.add_variables(coords=None, name=f'{self.label_full}|{effect}'), f'{effect}') + effect: self.add( + self._model.add_variables( + coords=self._model.coords if self._as_time_series else None, + name=f'{self.label_full}|{effect}'), + f'{effect}' + ) for effect in self._piecewise_shares } @@ -1060,18 +1067,20 @@ def do_modeling(self): label_of_element=self.label_of_element, piecewise_variables=piecewise_variables, zero_point=self._zero_point, - as_time_series=False, + as_time_series=self._as_time_series, label='PiecewiseEffects', ) ) self.piecewise_model.do_modeling() + factor, target = (self._model.hours_per_step, 'operation') if self._as_time_series else (1, 'invest') + # Shares self._model.effects.add_share_to_effects( name=self.label_of_element, - expressions={effect: variable * 1 for effect, variable in self.shares.items()}, - target='invest', + expressions={effect: variable * factor for effect, variable in self.shares.items()}, + target=target, ) diff --git a/flixopt/interface.py b/flixopt/interface.py index c38d6c619..5524d7568 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -101,6 +101,24 @@ def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): # for name, piecewise in self.piecewise_shares.items(): # piecewise.transform_data(flow_system, f'{name_prefix}|PiecewiseEffects|{name}') +@register_class_for_io +class PiecewiseEffectsPerFlowHour(Interface): + def __init__(self, piecewise_origin: Piecewise, piecewise_shares: Dict[str, Piecewise]): + """ + Define piecewise effects related to a variable. + + Args: + piecewise_origin: Piecewise of the related variable + piecewise_shares: Piecewise defining the shares to different Effects + """ + self.piecewise_origin = piecewise_origin + self.piecewise_shares = piecewise_shares + + def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): + self.piecewise_origin.transform_data(flow_system, f'{name_prefix}|PiecewiseEffectsPerFlowHour|origin') + for name, piecewise in self.piecewise_shares.items(): + piecewise.transform_data(flow_system, f'{name_prefix}|PiecewiseEffectsPerFlowHour|{name}') + @register_class_for_io class InvestParameters(Interface): From e707d0a2723c00b1f694e4b31d1223bba5d529fd Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 12 Sep 2025 08:04:30 +0200 Subject: [PATCH 02/22] First iteration --- tests/test_flow.py | 76 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/tests/test_flow.py b/tests/test_flow.py index 2308dbd31..796ff3770 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -114,6 +114,82 @@ def test_effects_per_flow_hour(self, basic_flow_system_linopy): 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) + def test_piecewise_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', + size=100, + piecewise_effects_per_flow_hour=fx.PiecewiseEffectsPerFlowHour( + piecewise_origin=fx.Piecewise([fx.Piece(5, 25), fx.Piece(25, 100)]), + piecewise_shares={ + 'Costs': fx.Piecewise([fx.Piece(3, 2), fx.Piece(2, 1)]), + 'CO2': fx.Piecewise([fx.Piece(10, 30), fx.Piece(30, 50)]), + }, + ), + ) + 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)|flow_rate', + 'Sink(Wärme)|total_flow_hours', + 'Sink(Wärme)|PiecewiseEffects|Costs', + 'Sink(Wärme)|PiecewiseEffects|CO2', + 'Sink(Wärme)|Piece_0|inside_piece', + 'Sink(Wärme)|Piece_0|lambda0', + 'Sink(Wärme)|Piece_0|lambda1', + 'Sink(Wärme)|Piece_1|inside_piece', + 'Sink(Wärme)|Piece_1|lambda0', + 'Sink(Wärme)|Piece_1|lambda1', + } + + assert set(flow.model.constraints) == { + 'Sink(Wärme)|total_flow_hours', + 'Sink(Wärme)|PiecewiseEffects|Sink(Wärme)|flow_rate|lambda', + 'Sink(Wärme)|PiecewiseEffects|Sink(Wärme)|flow_rate|single_segment', + 'Sink(Wärme)|PiecewiseEffects|Sink(Wärme)|PiecewiseEffects|Costs|lambda', + 'Sink(Wärme)|PiecewiseEffects|Sink(Wärme)|PiecewiseEffects|Costs|single_segment', + 'Sink(Wärme)|PiecewiseEffects|Sink(Wärme)|PiecewiseEffects|CO2|lambda', + 'Sink(Wärme)|PiecewiseEffects|Sink(Wärme)|PiecewiseEffects|CO2|single_segment', + 'Sink(Wärme)|Piece_0|inside_piece', + 'Sink(Wärme)|Piece_1|inside_piece', + } + + 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)'] + == model.variables['Sink(Wärme)|PiecewiseEffects|Costs'] * model.hours_per_step, + ) + + assert_conequal( + model.constraints['Sink(Wärme)->CO2(operation)'], + model.variables['Sink(Wärme)->CO2(operation)'] + == model.variables['Sink(Wärme)|PiecewiseEffects|CO2'] * model.hours_per_step, + ) + + model.add_constraints( + model.variables['Sink(Wärme)|flow_rate'] == np.linspace(0, 100, 10), + ) + + xr.testing.assert_allclose( + model.variables['Sink(Wärme)|PiecewiseEffects|Costs'].solution, + model.hours_per_step * np.linspace(0, 100, 10) * np.interp( + np.linspace(0, 100, 10), + [5, 25, 25, 100], + [3, 2, 2, 1], + ), + ) + class TestFlowInvestModel: """Test the FlowModel class.""" From 386310283ccb22ca3e027600311954028521a41b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 12 Sep 2025 09:41:20 +0200 Subject: [PATCH 03/22] Few tests with actual prices instead of amounts --- flixopt/elements.py | 7 ++-- flixopt/features.py | 87 ++++++++++++++++++++++++++++++++++++++++----- tests/test_flow.py | 26 ++++++++------ 3 files changed, 96 insertions(+), 24 deletions(-) diff --git a/flixopt/elements.py b/flixopt/elements.py index 54898d8ed..9b3c775da 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -12,7 +12,7 @@ from .config import CONFIG from .core import NumericData, NumericDataTS, PlausibilityError, Scalar, TimeSeriesCollection from .effects import EffectValuesUser -from .features import InvestmentModel, OnOffModel, PreventSimultaneousUsageModel, PiecewiseEffectsModel +from .features import InvestmentModel, OnOffModel, PreventSimultaneousUsageModel, PiecewiseEffectsPerFlowHourModel from .interface import InvestParameters, OnOffParameters, PiecewiseEffectsPerFlowHour from .structure import Element, ElementModel, SystemModel, register_class_for_io @@ -403,13 +403,12 @@ def _create_shares(self): if self.element.piecewise_effects_per_flow_hour is not None: self.piecewise_effects = self.add( - PiecewiseEffectsModel( + PiecewiseEffectsPerFlowHourModel( model=self._model, label_of_element=self.label_of_element, piecewise_origin=(self.flow_rate.name, self.element.piecewise_effects_per_flow_hour.piecewise_origin), - piecewise_shares=self.element.piecewise_effects_per_flow_hour.piecewise_shares, + piecewise_shares_per_flow_hour=self.element.piecewise_effects_per_flow_hour.piecewise_shares, zero_point=self.on_off.on if self.on_off is not None else False, - as_time_series=True, ), ) self.piecewise_effects.do_modeling() diff --git a/flixopt/features.py b/flixopt/features.py index 53e974ac7..0ab5885ea 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -12,7 +12,7 @@ from . import utils from .config import CONFIG from .core import NumericData, Scalar, TimeSeries -from .interface import InvestParameters, OnOffParameters, Piecewise +from .interface import InvestParameters, OnOffParameters, Piecewise, Piece from .structure import Model, SystemModel logger = logging.getLogger('flixopt') @@ -837,7 +837,7 @@ def __init__( label: str = '', ): """ - Modeling a Piecewise relation between miultiple variables. + Modeling a Piecewise relation between multiple variables. The relation is defined by a list of Pieces, which are assigned to the variables. Each Piece is a tuple of (start, end). @@ -1027,7 +1027,6 @@ def __init__( piecewise_origin: Tuple[str, Piecewise], piecewise_shares: Dict[str, Piecewise], zero_point: Optional[Union[bool, linopy.Variable]], - as_time_series: bool = False, label: str = 'PiecewiseEffects', ): super().__init__(model, label_of_element, label) @@ -1037,7 +1036,6 @@ def __init__( self._zero_point = zero_point self._piecewise_origin = piecewise_origin self._piecewise_shares = piecewise_shares - self._as_time_series = as_time_series self.shares: Dict[str, linopy.Variable] = {} self.piecewise_model: Optional[PiecewiseModel] = None @@ -1046,7 +1044,7 @@ def do_modeling(self): self.shares = { effect: self.add( self._model.add_variables( - coords=self._model.coords if self._as_time_series else None, + coords=None, name=f'{self.label_full}|{effect}'), f'{effect}' ) @@ -1067,20 +1065,91 @@ def do_modeling(self): label_of_element=self.label_of_element, piecewise_variables=piecewise_variables, zero_point=self._zero_point, - as_time_series=self._as_time_series, + as_time_series=False, label='PiecewiseEffects', ) ) self.piecewise_model.do_modeling() - factor, target = (self._model.hours_per_step, 'operation') if self._as_time_series else (1, 'invest') + # Shares + self._model.effects.add_share_to_effects( + name=self.label_of_element, + expressions={effect: variable * 1 for effect, variable in self.shares.items()}, + target='invest', + ) + + +class PiecewiseEffectsPerFlowHourModel(Model): + def __init__( + self, + model: SystemModel, + label_of_element: str, + piecewise_origin: Tuple[str, Piecewise], + piecewise_shares_per_flow_hour: Dict[str, Piecewise], + zero_point: Optional[Union[bool, linopy.Variable]], + label: str = 'PiecewiseEffectsPerFlowHour', + ): + super().__init__(model, label_of_element, label) + assert len(piecewise_origin[1]) == len(list(piecewise_shares_per_flow_hour.values())[0]), ( + 'Piece length of variable_segments and share_segments must be equal' + ) + self._zero_point = zero_point + self._piecewise_origin = piecewise_origin + self.piecewise_shares_per_flow_hour = piecewise_shares_per_flow_hour + + # This needs to be done to turn the effects (per flow hour) [€/MWh] into proper piecewise bounds + self._piecewise_shares = { + effect: Piecewise( + [Piece( + piece_share.start * piece_origin.start, + piece_share.end * piece_origin.end, + ) for piece_share, piece_origin in zip(piecewise_share, self._piecewise_origin[1], strict=True)] + ) + for effect, piecewise_share in self.piecewise_shares_per_flow_hour.items() + } + + self.shares: Dict[str, linopy.Variable] = {} + + self.piecewise_model: Optional[PiecewiseModel] = None + + def do_modeling(self): + self.shares = { + effect: self.add( + self._model.add_variables( + coords=self._model.coords, + name=f'{self.label_full}|{effect}'), + f'{effect}' + ) + for effect in self._piecewise_shares + } + + piecewise_variables = { + self._piecewise_origin[0]: self._piecewise_origin[1], + **{ + self.shares[effect_label].name: self._piecewise_shares[effect_label] + for effect_label in self._piecewise_shares + }, + } + + self.piecewise_model = self.add( + PiecewiseModel( + model=self._model, + label_of_element=self.label_of_element, + piecewise_variables=piecewise_variables, + zero_point=self._zero_point, + as_time_series=True, + label='PiecewiseEffectsPerFlowHour', + ) + ) + + self.piecewise_model.do_modeling() # Shares self._model.effects.add_share_to_effects( name=self.label_of_element, - expressions={effect: variable * factor for effect, variable in self.shares.items()}, - target=target, + expressions={effect: variable * self._model.hours_per_step for effect, variable in self.shares.items()}, + target='operation', ) diff --git a/tests/test_flow.py b/tests/test_flow.py index 796ff3770..8b17d8849 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -140,8 +140,8 @@ def test_piecewise_effects_per_flow_hour(self, basic_flow_system_linopy): assert set(flow.model.variables) == { 'Sink(Wärme)|flow_rate', 'Sink(Wärme)|total_flow_hours', - 'Sink(Wärme)|PiecewiseEffects|Costs', - 'Sink(Wärme)|PiecewiseEffects|CO2', + 'Sink(Wärme)|PiecewiseEffectsPerFlowHour|Costs', + 'Sink(Wärme)|PiecewiseEffectsPerFlowHour|CO2', 'Sink(Wärme)|Piece_0|inside_piece', 'Sink(Wärme)|Piece_0|lambda0', 'Sink(Wärme)|Piece_0|lambda1', @@ -152,12 +152,12 @@ def test_piecewise_effects_per_flow_hour(self, basic_flow_system_linopy): assert set(flow.model.constraints) == { 'Sink(Wärme)|total_flow_hours', - 'Sink(Wärme)|PiecewiseEffects|Sink(Wärme)|flow_rate|lambda', - 'Sink(Wärme)|PiecewiseEffects|Sink(Wärme)|flow_rate|single_segment', - 'Sink(Wärme)|PiecewiseEffects|Sink(Wärme)|PiecewiseEffects|Costs|lambda', - 'Sink(Wärme)|PiecewiseEffects|Sink(Wärme)|PiecewiseEffects|Costs|single_segment', - 'Sink(Wärme)|PiecewiseEffects|Sink(Wärme)|PiecewiseEffects|CO2|lambda', - 'Sink(Wärme)|PiecewiseEffects|Sink(Wärme)|PiecewiseEffects|CO2|single_segment', + 'Sink(Wärme)|PiecewiseEffectsPerFlowHour|Sink(Wärme)|flow_rate|lambda', + 'Sink(Wärme)|PiecewiseEffectsPerFlowHour|Sink(Wärme)|flow_rate|single_segment', + 'Sink(Wärme)|PiecewiseEffectsPerFlowHour|Sink(Wärme)|PiecewiseEffectsPerFlowHour|Costs|lambda', + 'Sink(Wärme)|PiecewiseEffectsPerFlowHour|Sink(Wärme)|PiecewiseEffectsPerFlowHour|Costs|single_segment', + 'Sink(Wärme)|PiecewiseEffectsPerFlowHour|Sink(Wärme)|PiecewiseEffectsPerFlowHour|CO2|lambda', + 'Sink(Wärme)|PiecewiseEffectsPerFlowHour|Sink(Wärme)|PiecewiseEffectsPerFlowHour|CO2|single_segment', 'Sink(Wärme)|Piece_0|inside_piece', 'Sink(Wärme)|Piece_1|inside_piece', } @@ -168,21 +168,23 @@ def test_piecewise_effects_per_flow_hour(self, basic_flow_system_linopy): assert_conequal( model.constraints['Sink(Wärme)->Costs(operation)'], model.variables['Sink(Wärme)->Costs(operation)'] - == model.variables['Sink(Wärme)|PiecewiseEffects|Costs'] * model.hours_per_step, + == model.variables['Sink(Wärme)|PiecewiseEffectsPerFlowHour|Costs'] * model.hours_per_step, ) assert_conequal( model.constraints['Sink(Wärme)->CO2(operation)'], model.variables['Sink(Wärme)->CO2(operation)'] - == model.variables['Sink(Wärme)|PiecewiseEffects|CO2'] * model.hours_per_step, + == model.variables['Sink(Wärme)|PiecewiseEffectsPerFlowHour|CO2'] * model.hours_per_step, ) model.add_constraints( model.variables['Sink(Wärme)|flow_rate'] == np.linspace(0, 100, 10), ) + model.solve() + xr.testing.assert_allclose( - model.variables['Sink(Wärme)|PiecewiseEffects|Costs'].solution, + model.variables['Sink(Wärme)|PiecewiseEffectsPerFlowHour|Costs'].solution, model.hours_per_step * np.linspace(0, 100, 10) * np.interp( np.linspace(0, 100, 10), [5, 25, 25, 100], @@ -190,6 +192,8 @@ def test_piecewise_effects_per_flow_hour(self, basic_flow_system_linopy): ), ) + #TODO: CHeck outside piece + class TestFlowInvestModel: """Test the FlowModel class.""" From ca6424f6bb5ddf2faaf35f7250b6bba429d26695 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 12 Sep 2025 10:07:17 +0200 Subject: [PATCH 04/22] Update to simply use amounts rather than prices --- flixopt/elements.py | 4 ++-- flixopt/features.py | 31 ++++++++++++------------------- flixopt/interface.py | 18 +++++++++++++----- tests/test_flow.py | 12 ++++++------ tests/test_linear_converter.py | 2 +- 5 files changed, 34 insertions(+), 33 deletions(-) diff --git a/flixopt/elements.py b/flixopt/elements.py index 9b3c775da..421f4e868 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -406,8 +406,8 @@ def _create_shares(self): PiecewiseEffectsPerFlowHourModel( model=self._model, label_of_element=self.label_of_element, - piecewise_origin=(self.flow_rate.name, self.element.piecewise_effects_per_flow_hour.piecewise_origin), - piecewise_shares_per_flow_hour=self.element.piecewise_effects_per_flow_hour.piecewise_shares, + piecewise_origin=(self.flow_rate.name, self.element.piecewise_effects_per_flow_hour.piecewise_flow_rate), + piecewise_shares=self.element.piecewise_effects_per_flow_hour.piecewise_shares, zero_point=self.on_off.on if self.on_off is not None else False, ), ) diff --git a/flixopt/features.py b/flixopt/features.py index 0ab5885ea..1da5a961d 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -846,7 +846,9 @@ def __init__( label_of_element: The label of the parent (Element). Used to construct the full label of the model. label: The label of the model. Used to construct the full label of the model. piecewise_variables: The variables to which the Pieces are assigned. - zero_point: A variable that can be used to define a zero point for the Piecewise relation. If None or False, no zero point is defined. + zero_point: A variable that can be used to define a zero point for the Piecewise relation. + If None or False, no zero point is defined. THis leads to 0 not being possible, + unless its its contained in a Piece. as_time_series: Whether the Piecewise relation is defined for a TimeSeries or a single variable. """ super().__init__(model, label_of_element, label) @@ -892,7 +894,7 @@ def do_modeling(self): # b) eq: -On(t) + Segment1.onSeg(t) + Segment2.onSeg(t) + ... = 0 zusätzlich kann alles auch Null sein if isinstance(self._zero_point, linopy.Variable): self.zero_point = self._zero_point - rhs = self.zero_point + sign, rhs = '<=', self.zero_point elif self._zero_point is True: self.zero_point = self.add( self._model.add_variables( @@ -900,13 +902,15 @@ def do_modeling(self): ), 'zero_point', ) - rhs = self.zero_point + sign, rhs = '<=', self.zero_point else: - rhs = 1 + sign, rhs = '=', 1 self.add( self._model.add_constraints( - sum([piece.inside_piece for piece in self.pieces]) <= rhs, + sum([piece.inside_piece for piece in self.pieces]), + sign, + rhs, name=f'{self.label_full}|{variable.name}|single_segment', ), f'{var_name}|single_segment', @@ -1086,28 +1090,17 @@ def __init__( model: SystemModel, label_of_element: str, piecewise_origin: Tuple[str, Piecewise], - piecewise_shares_per_flow_hour: Dict[str, Piecewise], + piecewise_shares: Dict[str, Piecewise], zero_point: Optional[Union[bool, linopy.Variable]], label: str = 'PiecewiseEffectsPerFlowHour', ): super().__init__(model, label_of_element, label) - assert len(piecewise_origin[1]) == len(list(piecewise_shares_per_flow_hour.values())[0]), ( + assert len(piecewise_origin[1]) == len(list(piecewise_shares.values())[0]), ( 'Piece length of variable_segments and share_segments must be equal' ) self._zero_point = zero_point self._piecewise_origin = piecewise_origin - self.piecewise_shares_per_flow_hour = piecewise_shares_per_flow_hour - - # This needs to be done to turn the effects (per flow hour) [€/MWh] into proper piecewise bounds - self._piecewise_shares = { - effect: Piecewise( - [Piece( - piece_share.start * piece_origin.start, - piece_share.end * piece_origin.end, - ) for piece_share, piece_origin in zip(piecewise_share, self._piecewise_origin[1], strict=True)] - ) - for effect, piecewise_share in self.piecewise_shares_per_flow_hour.items() - } + self._piecewise_shares = piecewise_shares self.shares: Dict[str, linopy.Variable] = {} diff --git a/flixopt/interface.py b/flixopt/interface.py index 5524d7568..15adcb3ac 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -103,19 +103,27 @@ def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): @register_class_for_io class PiecewiseEffectsPerFlowHour(Interface): - def __init__(self, piecewise_origin: Piecewise, piecewise_shares: Dict[str, Piecewise]): + def __init__(self, piecewise_flow_rate: Piecewise, piecewise_shares: Dict[str, Piecewise]): """ Define piecewise effects related to a variable. Args: - piecewise_origin: Piecewise of the related variable - piecewise_shares: Piecewise defining the shares to different Effects + piecewise_flow_rate: Piecewise of the flow_rate + piecewise_shares: Piecewise defining the shares to different Effects. + TAKE CARE: The values represent the actual shares to the effects. + Example: + piecewise_flow_rate = Piecewise([Piece(5, 25), Piece(25, 200)]) + piecewise_shares = {'Costs': Piecewise([Piece(5, 25), Piece(25, 100)])} + This means that the share of the Costs effect is 5 if the flow rate is 5, + and 100 if the flow rate is 200. + ITS NOT: flow_rate=5 --> Share=5*5=25 + (Prices unfortunately cannot be represented as a Piecewise. This would be non linear.) """ - self.piecewise_origin = piecewise_origin + self.piecewise_flow_rate = piecewise_flow_rate self.piecewise_shares = piecewise_shares def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): - self.piecewise_origin.transform_data(flow_system, f'{name_prefix}|PiecewiseEffectsPerFlowHour|origin') + self.piecewise_flow_rate.transform_data(flow_system, f'{name_prefix}|PiecewiseEffectsPerFlowHour|origin') for name, piecewise in self.piecewise_shares.items(): piecewise.transform_data(flow_system, f'{name_prefix}|PiecewiseEffectsPerFlowHour|{name}') diff --git a/tests/test_flow.py b/tests/test_flow.py index 8b17d8849..1df777c1e 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -126,10 +126,10 @@ def test_piecewise_effects_per_flow_hour(self, basic_flow_system_linopy): bus='Fernwärme', size=100, piecewise_effects_per_flow_hour=fx.PiecewiseEffectsPerFlowHour( - piecewise_origin=fx.Piecewise([fx.Piece(5, 25), fx.Piece(25, 100)]), + piecewise_flow_rate=fx.Piecewise([fx.Piece(5, 25), fx.Piece(25, 100)]), piecewise_shares={ - 'Costs': fx.Piecewise([fx.Piece(3, 2), fx.Piece(2, 1)]), - 'CO2': fx.Piecewise([fx.Piece(10, 30), fx.Piece(30, 50)]), + 'Costs': fx.Piecewise([fx.Piece(3*5, 2*25), fx.Piece(2*25, 1*100)]), + 'CO2': fx.Piecewise([fx.Piece(10*5, 30*25), fx.Piece(30*25, 50*100)]), }, ), ) @@ -185,10 +185,10 @@ def test_piecewise_effects_per_flow_hour(self, basic_flow_system_linopy): xr.testing.assert_allclose( model.variables['Sink(Wärme)|PiecewiseEffectsPerFlowHour|Costs'].solution, - model.hours_per_step * np.linspace(0, 100, 10) * np.interp( + model.hours_per_step * np.interp( np.linspace(0, 100, 10), - [5, 25, 25, 100], - [3, 2, 2, 1], + [5, 5, 25, 25, 100], + [0, 3*5, 2*25, 2*25, 1*100], ), ) diff --git a/tests/test_linear_converter.py b/tests/test_linear_converter.py index aaab60dcc..7969b75da 100644 --- a/tests/test_linear_converter.py +++ b/tests/test_linear_converter.py @@ -416,7 +416,7 @@ def test_piecewise_conversion(self, basic_flow_system_linopy): assert_conequal( model.constraints['Converter|Converter(input)|flow_rate|single_segment'], sum([model.variables[f'Converter|Piece_{i}|inside_piece'] - for i in range(len(piecewise_model.pieces))]) <= 1 + for i in range(len(piecewise_model.pieces))]) == 1 ) From 69791d7c4d20ff43e884ec503d540cfdce9eed10 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 12 Sep 2025 10:13:12 +0200 Subject: [PATCH 05/22] Improve docstring --- flixopt/interface.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/flixopt/interface.py b/flixopt/interface.py index 15adcb3ac..3cb0580a2 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -106,6 +106,8 @@ class PiecewiseEffectsPerFlowHour(Interface): def __init__(self, piecewise_flow_rate: Piecewise, piecewise_shares: Dict[str, Piecewise]): """ Define piecewise effects related to a variable. + Usually the first Piece contains the zero point. If not, the flow_rate gets bounded by the piecewise_flow_rate, + If no OnOffParameters are used. Args: piecewise_flow_rate: Piecewise of the flow_rate From 10635f875816e059e3a2e2ea4049bc61c972bd93 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 12 Sep 2025 10:17:49 +0200 Subject: [PATCH 06/22] Complete Test --- tests/test_flow.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/tests/test_flow.py b/tests/test_flow.py index 1df777c1e..1ce1c3de5 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -126,10 +126,10 @@ def test_piecewise_effects_per_flow_hour(self, basic_flow_system_linopy): bus='Fernwärme', size=100, piecewise_effects_per_flow_hour=fx.PiecewiseEffectsPerFlowHour( - piecewise_flow_rate=fx.Piecewise([fx.Piece(5, 25), fx.Piece(25, 100)]), + piecewise_flow_rate=fx.Piecewise([fx.Piece(0, 25), fx.Piece(25, 100)]), piecewise_shares={ - 'Costs': fx.Piecewise([fx.Piece(3*5, 2*25), fx.Piece(2*25, 1*100)]), - 'CO2': fx.Piecewise([fx.Piece(10*5, 30*25), fx.Piece(30*25, 50*100)]), + 'Costs': fx.Piecewise([fx.Piece(0, 2*25), fx.Piece(2*25, 1*100)]), + 'CO2': fx.Piecewise([fx.Piece(0, 30*25), fx.Piece(30*25, 50*100)]), }, ), ) @@ -183,16 +183,21 @@ def test_piecewise_effects_per_flow_hour(self, basic_flow_system_linopy): model.solve() + desired_solution = model.hours_per_step * np.interp( + np.linspace(0, 100, 10), + [0, 25, 25, 100], + [0, 2*25, 2*25, 1*100], + ) + xr.testing.assert_allclose( model.variables['Sink(Wärme)|PiecewiseEffectsPerFlowHour|Costs'].solution, - model.hours_per_step * np.interp( - np.linspace(0, 100, 10), - [5, 5, 25, 25, 100], - [0, 3*5, 2*25, 2*25, 1*100], - ), + desired_solution / model.hours_per_step, ) - #TODO: CHeck outside piece + xr.testing.assert_allclose( + model.variables['Sink(Wärme)->Costs(operation)'].solution, + desired_solution, + ) class TestFlowInvestModel: From a0a578585b99e58fb5d8ad1906bd3c7a738da646 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 12 Sep 2025 10:19:48 +0200 Subject: [PATCH 07/22] ruff check --- flixopt/__init__.py | 2 +- flixopt/commons.py | 10 +++++++++- flixopt/elements.py | 2 +- flixopt/features.py | 2 +- tests/test_flow.py | 4 ---- 5 files changed, 12 insertions(+), 8 deletions(-) diff --git a/flixopt/__init__.py b/flixopt/__init__.py index 1c2174330..cd2933715 100644 --- a/flixopt/__init__.py +++ b/flixopt/__init__.py @@ -18,6 +18,7 @@ Piecewise, PiecewiseConversion, PiecewiseEffects, + PiecewiseEffectsPerFlowHour, SegmentedCalculation, Sink, Source, @@ -30,7 +31,6 @@ plotting, results, solvers, - PiecewiseEffectsPerFlowHour, ) CONFIG.load_config() diff --git a/flixopt/commons.py b/flixopt/commons.py index c1cc14635..aad526ab9 100644 --- a/flixopt/commons.py +++ b/flixopt/commons.py @@ -18,7 +18,15 @@ from .effects import Effect from .elements import Bus, Flow from .flow_system import FlowSystem -from .interface import InvestParameters, OnOffParameters, Piece, Piecewise, PiecewiseConversion, PiecewiseEffects, PiecewiseEffectsPerFlowHour +from .interface import ( + InvestParameters, + OnOffParameters, + Piece, + Piecewise, + PiecewiseConversion, + PiecewiseEffects, + PiecewiseEffectsPerFlowHour, +) __all__ = [ 'TimeSeriesData', diff --git a/flixopt/elements.py b/flixopt/elements.py index 421f4e868..80a854ff8 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -12,7 +12,7 @@ from .config import CONFIG from .core import NumericData, NumericDataTS, PlausibilityError, Scalar, TimeSeriesCollection from .effects import EffectValuesUser -from .features import InvestmentModel, OnOffModel, PreventSimultaneousUsageModel, PiecewiseEffectsPerFlowHourModel +from .features import InvestmentModel, OnOffModel, PiecewiseEffectsPerFlowHourModel, PreventSimultaneousUsageModel from .interface import InvestParameters, OnOffParameters, PiecewiseEffectsPerFlowHour from .structure import Element, ElementModel, SystemModel, register_class_for_io diff --git a/flixopt/features.py b/flixopt/features.py index 1da5a961d..4e1212540 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -12,7 +12,7 @@ from . import utils from .config import CONFIG from .core import NumericData, Scalar, TimeSeries -from .interface import InvestParameters, OnOffParameters, Piecewise, Piece +from .interface import InvestParameters, OnOffParameters, Piece, Piecewise from .structure import Model, SystemModel logger = logging.getLogger('flixopt') diff --git a/tests/test_flow.py b/tests/test_flow.py index 1ce1c3de5..b21c0152d 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -116,10 +116,6 @@ def test_effects_per_flow_hour(self, basic_flow_system_linopy): def test_piecewise_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', From 3a88a59ec0b64858f7335a1d6d6365d35bab7408 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 12 Sep 2025 10:36:18 +0200 Subject: [PATCH 08/22] Add new feature to IO test --- tests/conftest.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index c8d2e5606..f7463d6a9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -284,7 +284,19 @@ def flow_system_segments_of_flows_2(flow_system_complex) -> fx.FlowSystem: flow_system.add_elements( fx.LinearConverter( 'KWK', - inputs=[fx.Flow('Q_fu', bus='Gas')], + inputs=[ + fx.Flow( + 'Q_fu', + bus='Gas', + piecewise_effects_per_flow_hour=fx.PiecewiseEffectsPerFlowHour( + piecewise_flow_rate=fx.Piecewise([fx.Piece(0, 25), fx.Piece(25, 200)]), + piecewise_shares={ + 'costs': fx.Piecewise([fx.Piece(0, 2 * 25), fx.Piece(2 * 25, 1 * 200)]), + 'CO2': fx.Piecewise([fx.Piece(0, 30 * 25), fx.Piece(30 * 25, 50 * 200)]), + }, + ), + ) + ], outputs=[ fx.Flow('P_el', bus='Strom', size=60, relative_maximum=55, previous_flow_rate=10), fx.Flow('Q_th', bus='Fernwärme'), From c4db3b7ac7c701d9ad7a27d3c0c9cb3a8f9fede6 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 12 Sep 2025 10:49:54 +0200 Subject: [PATCH 09/22] Add Test that checks for optional shares Modeling --- tests/test_flow.py | 81 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 75 insertions(+), 6 deletions(-) diff --git a/tests/test_flow.py b/tests/test_flow.py index b21c0152d..5996856da 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -114,7 +114,11 @@ def test_effects_per_flow_hour(self, basic_flow_system_linopy): 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) - def test_piecewise_effects_per_flow_hour(self, basic_flow_system_linopy): + +class TestPiecewiseEffectsPerFlowHour: + """Test the PiecewiseEffectsPerFlowHour class.""" + + def test_model_build(self, basic_flow_system_linopy): flow_system = basic_flow_system_linopy flow = fx.Flow( @@ -125,7 +129,6 @@ def test_piecewise_effects_per_flow_hour(self, basic_flow_system_linopy): piecewise_flow_rate=fx.Piecewise([fx.Piece(0, 25), fx.Piece(25, 100)]), piecewise_shares={ 'Costs': fx.Piecewise([fx.Piece(0, 2*25), fx.Piece(2*25, 1*100)]), - 'CO2': fx.Piecewise([fx.Piece(0, 30*25), fx.Piece(30*25, 50*100)]), }, ), ) @@ -167,22 +170,83 @@ def test_piecewise_effects_per_flow_hour(self, basic_flow_system_linopy): == model.variables['Sink(Wärme)|PiecewiseEffectsPerFlowHour|Costs'] * model.hours_per_step, ) - assert_conequal( - model.constraints['Sink(Wärme)->CO2(operation)'], - model.variables['Sink(Wärme)->CO2(operation)'] - == model.variables['Sink(Wärme)|PiecewiseEffectsPerFlowHour|CO2'] * model.hours_per_step, + def test_solution(self, basic_flow_system_linopy): + flow_system = basic_flow_system_linopy + + flow = fx.Flow( + 'Wärme', + bus='Fernwärme', + size=100, + piecewise_effects_per_flow_hour=fx.PiecewiseEffectsPerFlowHour( + piecewise_flow_rate=fx.Piecewise([fx.Piece(0, 25), fx.Piece(25, 100)]), + piecewise_shares={ + 'Costs': fx.Piecewise([fx.Piece(0, 2*25), fx.Piece(2*25, 1*100)]), + }, + ), + ) + flow_system.add_elements(fx.Sink('Sink', sink=flow), fx.Effect('CO2', 't', '')) + model = create_linopy_model(flow_system) + + model.solve() + + desired_solution = model.hours_per_step * np.interp( + np.linspace(0, 100, 10), + [0, 25, 25, 100], + [0, 2*25, 2*25, 1*100], + ) + + xr.testing.assert_allclose( + model.variables['Sink(Wärme)|PiecewiseEffectsPerFlowHour|Costs'].solution, + desired_solution / model.hours_per_step, + ) + + xr.testing.assert_allclose( + model.variables['Sink(Wärme)->Costs(operation)'].solution, + desired_solution, ) + def test_optional_shares(self, basic_flow_system_linopy): + flow_system = basic_flow_system_linopy + + flow = fx.Flow( + 'Wärme', + bus='Fernwärme', + size=100, + piecewise_effects_per_flow_hour=fx.PiecewiseEffectsPerFlowHour( + piecewise_flow_rate=fx.Piecewise([fx.Piece(0, 1000), fx.Piece(0, 25), fx.Piece(25, 100)]), + piecewise_shares={ + 'Costs': fx.Piecewise([fx.Piece(0, 0), fx.Piece(0, 2*25), fx.Piece(2*25, 1*100)]), + 'CO2': fx.Piecewise([fx.Piece(0, 0), fx.Piece(0, 30*25), fx.Piece(30*25, 50*100)]), + }, + ), + ) + flow_system.add_elements(fx.Sink('Sink', sink=flow), fx.Effect('CO2', 't', '')) + model = create_linopy_model(flow_system) + model.add_constraints( model.variables['Sink(Wärme)|flow_rate'] == np.linspace(0, 100, 10), ) + model.add_constraints( + model.variables['Sink(Wärme)|Piece_0|inside_piece'].isel(time=slice(5, None)) == 0, + ) + model.solve() desired_solution = model.hours_per_step * np.interp( np.linspace(0, 100, 10), [0, 25, 25, 100], [0, 2*25, 2*25, 1*100], + ) * np.array([0] * 5 + [1] * 5) + + desired_solution_co2 = ( + model.hours_per_step + * np.interp( + np.linspace(0, 100, 10), + [0, 25, 25, 100], + [0, 30 * 25, 30 * 25, 50 * 100], + ) + * np.array([0] * 5 + [1] * 5) ) xr.testing.assert_allclose( @@ -190,6 +254,11 @@ def test_piecewise_effects_per_flow_hour(self, basic_flow_system_linopy): desired_solution / model.hours_per_step, ) + xr.testing.assert_allclose( + model.variables['Sink(Wärme)|PiecewiseEffectsPerFlowHour|CO2'].solution, + desired_solution_co2 / model.hours_per_step, + ) + xr.testing.assert_allclose( model.variables['Sink(Wärme)->Costs(operation)'].solution, desired_solution, From 12fdae761c8d021801f41c4ab8539964cdd4078d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 12 Sep 2025 10:58:55 +0200 Subject: [PATCH 10/22] Improve docstring --- flixopt/interface.py | 70 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 57 insertions(+), 13 deletions(-) diff --git a/flixopt/interface.py b/flixopt/interface.py index 3cb0580a2..b23ec298d 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -105,21 +105,65 @@ def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): class PiecewiseEffectsPerFlowHour(Interface): def __init__(self, piecewise_flow_rate: Piecewise, piecewise_shares: Dict[str, Piecewise]): """ - Define piecewise effects related to a variable. - Usually the first Piece contains the zero point. If not, the flow_rate gets bounded by the piecewise_flow_rate, - If no OnOffParameters are used. + Define piecewise linear relationships between flow rate and various effects (costs, emissions, etc.). + + This class models situations where the relationship between flow rate and effects changes at + different flow rate levels, such as: + - Pump efficiency curves across operating ranges + - Emission factors that vary with operating levels + - Capacity-dependent transportation costs Args: - piecewise_flow_rate: Piecewise of the flow_rate - piecewise_shares: Piecewise defining the shares to different Effects. - TAKE CARE: The values represent the actual shares to the effects. - Example: - piecewise_flow_rate = Piecewise([Piece(5, 25), Piece(25, 200)]) - piecewise_shares = {'Costs': Piecewise([Piece(5, 25), Piece(25, 100)])} - This means that the share of the Costs effect is 5 if the flow rate is 5, - and 100 if the flow rate is 200. - ITS NOT: flow_rate=5 --> Share=5*5=25 - (Prices unfortunately cannot be represented as a Piecewise. This would be non linear.) + piecewise_flow_rate: `Piecewise` defining the valid flow rate segments. + Each Piece represents a linear segment with (min_flow, max_flow) bounds. + + piecewise_shares: Dictionary mapping effect names to their `Piecewise`. + Keys are effect names (e.g., 'Costs', 'CO2', 'Maintenance'). + Values are `Piecewise` objects defining the absolute effect values (not rates/prices). + + ⚠️ IMPORTANT: Values represent total effect amounts, not unit rates. + For a flow rate of X, the effect value is interpolated from the `Piecewise`. + This is NOT flow_rate × unit_price (which would be non-linear). + + Behavior: + - If the first piece doesn't start at zero, flow rate is automatically bounded + by piecewise_flow_rate (when OnOffParameters are not used) + - Each segment represents a linear relationship within that flow rate range + - Effects are interpolated linearly within each piece + - All `Piece`s of the different `Piecewise`s at index i are active at the same time + - A decision wether to utilize the effect can be modeled by defining multiple Pieces for the same flow rate range + + Examples: + # Tiered cost structure with increasing rates + PiecewiseEffectsPerFlowHour( + piecewise_flow_rate=Piecewise([ + Piece(0, 50), # Low flow segment: 0-50 units + Piece(50, 200) # High flow segment: 50-200 units + ]), + piecewise_shares={ + 'Costs': Piecewise([ + Piece(0, 500), # At flow=0: cost=0, at flow=50: cost=500 + Piece(500, 2000) # At flow=50: cost=500, at flow=200: cost=2000 + ]), + 'CO2': Piecewise([ + Piece(0, 100), # At flow=0: CO2=0, at flow=50: CO2=100 + Piece(100, 800) # At flow=50: CO2=100, at flow=200: CO2=800 + ]) + } + ) + + # In this example: + # - Flow rate 25 → Cost = 250, CO2 = 50 (linear interpolation) + # - Flow rate 100 → Cost = 1000, CO2 = 300 (linear interpolation) + + # Equipment efficiency curve (althought this might be better modeled as a Flow rather than an effect) + PiecewiseEffectsPerFlowHour( + piecewise_flow_rate=Piecewise([Piece(10, 100)]), # Min 10, max 100 units + piecewise_shares={ + 'PowerConsumption': Piecewise([Piece(50, 800)]) # Non-linear efficiency + } + ) + """ self.piecewise_flow_rate = piecewise_flow_rate self.piecewise_shares = piecewise_shares From bcca8a250b4a2ab151ad2cf8263dc30faf049b80 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 12 Sep 2025 11:02:29 +0200 Subject: [PATCH 11/22] Improve docstring --- flixopt/interface.py | 42 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/flixopt/interface.py b/flixopt/interface.py index b23ec298d..bd6d1f8ec 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -112,6 +112,8 @@ def __init__(self, piecewise_flow_rate: Piecewise, piecewise_shares: Dict[str, P - Pump efficiency curves across operating ranges - Emission factors that vary with operating levels - Capacity-dependent transportation costs + - Decision between different operating modes or suppliers + - Optional equipment activation with minimum flow requirements Args: piecewise_flow_rate: `Piecewise` defining the valid flow rate segments. @@ -131,7 +133,7 @@ def __init__(self, piecewise_flow_rate: Piecewise, piecewise_shares: Dict[str, P - Each segment represents a linear relationship within that flow rate range - Effects are interpolated linearly within each piece - All `Piece`s of the different `Piecewise`s at index i are active at the same time - - A decision wether to utilize the effect can be modeled by defining multiple Pieces for the same flow rate range + - A decision whether to utilize the effect can be modeled by defining multiple Pieces for the same flow rate range Examples: # Tiered cost structure with increasing rates @@ -152,11 +154,41 @@ def __init__(self, piecewise_flow_rate: Piecewise, piecewise_shares: Dict[str, P } ) - # In this example: - # - Flow rate 25 → Cost = 250, CO2 = 50 (linear interpolation) - # - Flow rate 100 → Cost = 1000, CO2 = 300 (linear interpolation) + # Decision between two suppliers with overlapping flow ranges + PiecewiseEffectsPerFlowHour( + piecewise_flow_rate=Piecewise([ + Piece(0, 100), # Supplier A: 0-100 units + Piece(50, 150) # Supplier B: 50-150 units (overlaps with A) + ]), + piecewise_shares={ + 'Costs': Piecewise([ + Piece(0, 800), # Supplier A: cheaper for low volumes + Piece(400, 1200) # Supplier B: better rates for high volumes + ]) + } + ) + # Flow range 50-100: Optimizer chooses between suppliers based on cost + + # Optional equipment with minimum activation threshold + PiecewiseEffectsPerFlowHour( + piecewise_flow_rate=Piecewise([ + Piece(0, 0), # Equipment off: no flow + Piece(20, 100) # Equipment on: minimum 20 units required + ]), + piecewise_shares={ + 'Costs': Piecewise([ + Piece(0, 0), # No cost when off + Piece(200, 800) # Fixed startup cost + variable cost + ]), + 'CO2': Piecewise([ + Piece(0, 0), # No CO2 when off + Piece(50, 300) # Decreasing CO2 per fuel burn with higher power + ]) + } + ) + # Decision: Either flow=0 (off) or flow≥20 (on with minimum threshold) - # Equipment efficiency curve (althought this might be better modeled as a Flow rather than an effect) + # Equipment efficiency curve (although this might be better modeled as a Flow rather than an effect) PiecewiseEffectsPerFlowHour( piecewise_flow_rate=Piecewise([Piece(10, 100)]), # Min 10, max 100 units piecewise_shares={ From 1914b70f007e60930172df8e9071b55a4ba699aa Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 12 Sep 2025 11:31:39 +0200 Subject: [PATCH 12/22] Update Changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea38a40e3..1299cb67e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- LinearConverter with `PiecewiseConversion` lead to flows reaching 0 values, even though they didnt have `OnOffParameters` nor did the `PiecewiseConversion` contain 0 in its a `Piece`s. This was fixed to only allow for zeros if the `PiecewiseConversion` contains corresponding `Piece`s with 0 or if the `LinearConverter` has `OnOffParameters`. [[#310](https://github.com/flixOpt/flixopt/pull/310) by [@FBumann](https://github.com/FBumann)] + +### Added +- Added new Interface `PiecewiseEffectsPerFlowHour` to model non-linear relations between flow rates and effects. [[#310](https://github.com/flixOpt/flixopt/pull/310) by [@FBumann](https://github.com/FBumann)] + ## [2.1.6] - 2025-09-02 From ed8a8f2c4975521bfd468e204d7ab7214422378f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 12 Sep 2025 11:54:21 +0200 Subject: [PATCH 13/22] Improve docstrings and add warning for Bugfix --- flixopt/components.py | 10 +++ flixopt/interface.py | 162 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 167 insertions(+), 5 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index e06616daf..f88f367c7 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -53,6 +53,16 @@ def __init__( piecewise_conversion: Define a piecewise linear relation between flow rates of different flows. Either 'conversion_factors' or 'piecewise_conversion' can be used! meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types. + + Warning: + When using `PiecewiseConversion` without `OnOffParameters`, flow_rates cannot reach zero + unless explicitly defined with zero-valued Pieces (e.g., `fx.Piece(0, 0)`). + This behavior prevents unintended zero flows and is the intended design, which got a bugfix in v2.1.7. + + To allow zero flow rates, either: + + - Add OnOffParameters to the `LinearConverter`, or + - Define explicit zero Pieces in your `PiecewiseConversion`. """ super().__init__(label, inputs, outputs, on_off_parameters, meta_data=meta_data) self.conversion_factors = conversion_factors or [] diff --git a/flixopt/interface.py b/flixopt/interface.py index bd6d1f8ec..598c0a9ab 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -64,13 +64,165 @@ def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): @register_class_for_io class PiecewiseConversion(Interface): def __init__(self, piecewises: Dict[str, Piecewise]): - """ - Define a piecewise conversion between multiple Flows. - --> "gaps" can be expressed by a piece not starting at the end of the prior piece: [(1,3), (4,5)] - --> "points" can expressed as piece with same begin and end: [(3,3), (4,4)] + """Define piecewise linear conversion relationships between multiple flows. + + This class models complex conversion processes where the relationship between + input and output flows changes at different operating points, such as: + + - Variable efficiency equipment (heat pumps, engines, turbines) + - Multi-stage chemical processes with different conversion rates + - Equipment with discrete operating modes + - Systems with capacity constraints and thresholds Args: - piecewises: Dict of Piecewises defining the conversion factors. flow labels as keys, piecewise as values + piecewises: Dictionary mapping flow labels to their Piecewise conversion functions. + Keys are flow names (e.g., 'electricity_in', 'heat_out', 'fuel_consumed'). + Values are Piecewise objects defining conversion factors at different operating points. + All Piecewise objects must have the same number of pieces and compatible domains + to ensure consistent conversion relationships across operating ranges. + + Note: + Special modeling features: + + - **Gaps**: Express forbidden operating ranges by creating non-contiguous pieces. + Example: `[(0,50), (100,200)]` - cannot operate between 50-100 units + - **Points**: Express discrete operating points using pieces with identical start/end. + Example: `[(50,50), (100,100)]` - can only operate at exactly 50 or 100 units + + Examples: + Heat pump with variable COP (Coefficient of Performance): + + ```python + PiecewiseConversion( + { + 'electricity_in': Piecewise( + [ + Piece(0, 10), # Low load: 0-10 kW electricity + Piece(10, 25), # High load: 10-25 kW electricity + ] + ), + 'heat_out': Piecewise( + [ + Piece(0, 35), # Low load COP=3.5: 0-35 kW heat output + Piece(35, 75), # High load COP=3.0: 35-75 kW heat output + ] + ), + } + ) + # At 15 kW electricity input → 52.5 kW heat output (interpolated) + ``` + + Engine with fuel consumption and emissions: + + ```python + PiecewiseConversion( + { + 'fuel_input': Piecewise( + [ + Piece(5, 15), # Part load: 5-15 L/h fuel + Piece(15, 30), # Full load: 15-30 L/h fuel + ] + ), + 'power_output': Piecewise( + [ + Piece(10, 25), # Part load: 10-25 kW output + Piece(25, 45), # Full load: 25-45 kW output + ] + ), + 'co2_emissions': Piecewise( + [ + Piece(12, 35), # Part load: 12-35 kg/h CO2 + Piece(35, 78), # Full load: 35-78 kg/h CO2 + ] + ), + } + ) + ``` + + Discrete operating modes (on/off equipment): + + ```python + PiecewiseConversion( + { + 'electricity_in': Piecewise( + [ + Piece(0, 0), # Off mode: no consumption + Piece(20, 20), # On mode: fixed 20 kW consumption + ] + ), + 'cooling_out': Piecewise( + [ + Piece(0, 0), # Off mode: no cooling + Piece(60, 60), # On mode: fixed 60 kW cooling + ] + ), + } + ) + ``` + + Equipment with forbidden operating range: + + ```python + PiecewiseConversion( + { + 'steam_input': Piecewise( + [ + Piece(0, 100), # Low pressure operation + Piece(200, 500), # High pressure (gap: 100-200) + ] + ), + 'power_output': Piecewise( + [ + Piece(0, 80), # Low efficiency at low pressure + Piece(180, 400), # High efficiency at high pressure + ] + ), + } + ) + ``` + + Multi-product chemical reactor: + + ```python + fx.PiecewiseConversion( + { + 'feedstock': fx.Piecewise( + [ + fx.Piece(10, 50), # Small batch: 10-50 kg/h + fx.Piece(50, 200), # Large batch: 50-200 kg/h + ] + ), + 'product_A': fx.Piecewise( + [ + fx.Piece(7, 32), # Small batch yield: 70% + fx.Piece(32, 140), # Large batch yield: 70% + ] + ), + 'product_B': fx.Piecewise( + [ + fx.Piece(2, 12), # Small batch: 20% to product B + fx.Piece(12, 45), # Large batch: better selectivity + ] + ), + 'waste': fx.Piecewise( + [ + fx.Piece(1, 6), # Small batch waste: 10% + fx.Piece(6, 15), # Large batch waste: 7.5% + ] + ), + } + ) + ``` + + Common Use Cases: + - Heat pumps/chillers: COP varies with load and ambient conditions + - Power plants: Heat rate curves showing fuel efficiency vs output + - Chemical reactors: Conversion rates and selectivity vs throughput + - Compressors/pumps: Power consumption vs flow rate + - Multi-stage processes: Different conversion rates per stage + - Equipment with minimum loads: Cannot operate below threshold + - Batch processes: Discrete production campaigns + """ self.piecewises = piecewises From cc259349b53f5769f75dcdcde198cb2c4ea49e2c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 12 Sep 2025 12:11:24 +0200 Subject: [PATCH 14/22] Update CHANGELOG.md --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1299cb67e..2b9aa1d95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [Unreleased] - ????-??-?? ### Fixed -- LinearConverter with `PiecewiseConversion` lead to flows reaching 0 values, even though they didnt have `OnOffParameters` nor did the `PiecewiseConversion` contain 0 in its a `Piece`s. This was fixed to only allow for zeros if the `PiecewiseConversion` contains corresponding `Piece`s with 0 or if the `LinearConverter` has `OnOffParameters`. [[#310](https://github.com/flixOpt/flixopt/pull/310) by [@FBumann](https://github.com/FBumann)] +- LinearConverter with `PiecewiseConversion` lead to flows reaching 0 values, even though they didnt have `OnOffParameters` nor `PiecewiseConversion` containing 0 in its a `Piece`s. This was fixed by [[#310](https://github.com/flixOpt/flixopt/pull/310) by [@FBumann](https://github.com/FBumann)] ### Added - Added new Interface `PiecewiseEffectsPerFlowHour` to model non-linear relations between flow rates and effects. [[#310](https://github.com/flixOpt/flixopt/pull/310) by [@FBumann](https://github.com/FBumann)] From 6d73883cd8ea8601c4baef6a9700de71a6fbd29a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 12 Sep 2025 12:38:02 +0200 Subject: [PATCH 15/22] Moce docstrinf from init to class --- flixopt/components.py | 48 +++++++++++++++++++++---------------------- 1 file changed, 23 insertions(+), 25 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index f88f367c7..aba30ebd8 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -27,6 +27,29 @@ class LinearConverter(Component): """ Converts input-Flows into output-Flows via linear conversion factors + Args: + label: The label of the Element. Used to identify it in the FlowSystem + inputs: The input Flows + outputs: The output Flows + on_off_parameters: Information about on and off state of LinearConverter. + Component is On/Off, if all connected Flows are On/Off. This induces an On-Variable (binary) in all Flows! + If possible, use OnOffParameters in a single Flow instead to keep the number of binary variables low. + See class OnOffParameters. + conversion_factors: linear relation between flows. + Either 'conversion_factors' or 'piecewise_conversion' can be used! + piecewise_conversion: Define a piecewise linear relation between flow rates of different flows. + Either 'conversion_factors' or 'piecewise_conversion' can be used! + meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types. + + Warning: + When using `PiecewiseConversion` without `OnOffParameters`, flow_rates cannot reach zero + unless explicitly defined with zero-valued Pieces (e.g., `fx.Piece(0, 0)`). + This behavior prevents unintended zero flows and is the intended design, which got a bugfix in v2.1.7. + + To allow zero flow rates, either: + + - Add OnOffParameters to the `LinearConverter`, or + - Define explicit zero Pieces in your `PiecewiseConversion`. """ def __init__( @@ -39,31 +62,6 @@ def __init__( piecewise_conversion: Optional[PiecewiseConversion] = None, meta_data: Optional[Dict] = None, ): - """ - Args: - label: The label of the Element. Used to identify it in the FlowSystem - inputs: The input Flows - outputs: The output Flows - on_off_parameters: Information about on and off state of LinearConverter. - Component is On/Off, if all connected Flows are On/Off. This induces an On-Variable (binary) in all Flows! - If possible, use OnOffParameters in a single Flow instead to keep the number of binary variables low. - See class OnOffParameters. - conversion_factors: linear relation between flows. - Either 'conversion_factors' or 'piecewise_conversion' can be used! - piecewise_conversion: Define a piecewise linear relation between flow rates of different flows. - Either 'conversion_factors' or 'piecewise_conversion' can be used! - meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types. - - Warning: - When using `PiecewiseConversion` without `OnOffParameters`, flow_rates cannot reach zero - unless explicitly defined with zero-valued Pieces (e.g., `fx.Piece(0, 0)`). - This behavior prevents unintended zero flows and is the intended design, which got a bugfix in v2.1.7. - - To allow zero flow rates, either: - - - Add OnOffParameters to the `LinearConverter`, or - - Define explicit zero Pieces in your `PiecewiseConversion`. - """ super().__init__(label, inputs, outputs, on_off_parameters, meta_data=meta_data) self.conversion_factors = conversion_factors or [] self.piecewise_conversion = piecewise_conversion From 5ef56a5ab0db09d6ed99e722273a12d74223d5a1 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 12 Sep 2025 12:39:54 +0200 Subject: [PATCH 16/22] Update docstring of LinearConverter --- flixopt/components.py | 127 +++++++++++++++++++++++++++++++++++------- 1 file changed, 107 insertions(+), 20 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index aba30ebd8..cad808f74 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -24,32 +24,119 @@ @register_class_for_io class LinearConverter(Component): - """ - Converts input-Flows into output-Flows via linear conversion factors + """Convert input flows into output flows using linear or piecewise linear conversion factors. + + This component models conversion equipment where input flows are transformed + into output flows with fixed or variable conversion ratios, such as: + + - Heat pumps and chillers with variable efficiency + - Power plants with fuel-to-electricity conversion + - Chemical processes with multiple inputs/outputs + - Pumps and compressors + - Combined heat and power (CHP) plants Args: - label: The label of the Element. Used to identify it in the FlowSystem - inputs: The input Flows - outputs: The output Flows - on_off_parameters: Information about on and off state of LinearConverter. - Component is On/Off, if all connected Flows are On/Off. This induces an On-Variable (binary) in all Flows! - If possible, use OnOffParameters in a single Flow instead to keep the number of binary variables low. - See class OnOffParameters. - conversion_factors: linear relation between flows. - Either 'conversion_factors' or 'piecewise_conversion' can be used! - piecewise_conversion: Define a piecewise linear relation between flow rates of different flows. - Either 'conversion_factors' or 'piecewise_conversion' can be used! - meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types. + label: Unique identifier for the component in the FlowSystem. + inputs: List of input Flow objects that feed into the converter. + outputs: List of output Flow objects produced by the converter. + on_off_parameters: Controls binary on/off behavior of the converter. + When specified, the component can be completely turned on or off, affecting + all connected flows. This creates binary variables in the optimization. + For better performance, consider using OnOffParameters on individual flows instead. + conversion_factors: Linear conversion ratios between flows as time series data. + List of dictionaries mapping flow labels to their conversion factors. + Mutually exclusive with piecewise_conversion. + piecewise_conversion: Piecewise linear conversion relationships between flows. + Enables modeling of variable efficiency or discrete operating modes. + Mutually exclusive with conversion_factors. + meta_data: Additional information stored with the component. + Saved in results but not used internally. Use only Python native types. Warning: - When using `PiecewiseConversion` without `OnOffParameters`, flow_rates cannot reach zero - unless explicitly defined with zero-valued Pieces (e.g., `fx.Piece(0, 0)`). - This behavior prevents unintended zero flows and is the intended design, which got a bugfix in v2.1.7. + When using `piecewise_conversion` without `on_off_parameters`, flow rates cannot + reach zero unless explicitly defined with zero-valued pieces (e.g., `fx.Piece(0, 0)`). + This prevents unintended zero flows and maintains mathematical consistency. + + To allow zero flow rates: + + - Add `on_off_parameters` to enable complete shutdown, or + - Include explicit zero pieces in your `piecewise_conversion` definition + + This behavior was clarified in v2.1.7 to prevent optimization edge cases. + + Examples: + Simple heat pump with fixed COP: + + ```python + heat_pump = fx.LinearConverter( + label='heat_pump', + inputs=[electricity_flow], + outputs=[heat_flow], + conversion_factors=[ + { + 'electricity_flow': 1.0, # 1 kW electricity input + 'heat_flow': 3.5, # 3.5 kW heat output (COP=3.5) + } + ], + ) + ``` + + Variable efficiency heat pump: + + ```python + heat_pump = fx.LinearConverter( + label='variable_heat_pump', + inputs=[electricity_flow], + outputs=[heat_flow], + piecewise_conversion=fx.PiecewiseConversion( + { + 'electricity_flow': fx.Piecewise( + [ + fx.Piece(0, 10), # Allow zero to 10 kW input + fx.Piece(10, 25), # Higher load operation + ] + ), + 'heat_flow': fx.Piecewise( + [ + fx.Piece(0, 35), # COP=3.5 at low loads + fx.Piece(35, 75), # COP=3.0 at high loads + ] + ), + } + ), + ) + ``` + + Combined heat and power plant: + + ```python + chp_plant = fx.LinearConverter( + label='chp_plant', + inputs=[natural_gas_flow], + outputs=[electricity_flow, heat_flow], + conversion_factors=[ + { + 'natural_gas_flow': 1.0, # 1 MW fuel input + 'electricity_flow': 0.4, # 40% electrical efficiency + 'heat_flow': 0.45, # 45% thermal efficiency + } + ], + on_off_parameters=fx.OnOffParameters( + min_on_hours=4, # Minimum 4-hour operation + min_off_hours=2, # Minimum 2-hour downtime + ), + ) + ``` - To allow zero flow rates, either: + Note: + Either `conversion_factors` or `piecewise_conversion` must be specified, but not both. + The component automatically handles the mathematical relationships between all + connected flows according to the specified conversion ratios. - - Add OnOffParameters to the `LinearConverter`, or - - Define explicit zero Pieces in your `PiecewiseConversion`. + See Also: + PiecewiseConversion: For variable efficiency modeling + OnOffParameters: For binary on/off control + Flow: Input and output flow definitions """ def __init__( From d5e8e24d3d0ccaf5d6a88638947576979ad47587 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 12 Sep 2025 12:41:30 +0200 Subject: [PATCH 17/22] Move docstring to class --- flixopt/interface.py | 492 ++++++++++++++++++++++--------------------- 1 file changed, 247 insertions(+), 245 deletions(-) diff --git a/flixopt/interface.py b/flixopt/interface.py index 598c0a9ab..d0eb5ef52 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -63,167 +63,168 @@ def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): @register_class_for_io class PiecewiseConversion(Interface): - def __init__(self, piecewises: Dict[str, Piecewise]): - """Define piecewise linear conversion relationships between multiple flows. + """Define piecewise linear conversion relationships between multiple flows. + + This class models complex conversion processes where the relationship between + input and output flows changes at different operating points, such as: + + - Variable efficiency equipment (heat pumps, engines, turbines) + - Multi-stage chemical processes with different conversion rates + - Equipment with discrete operating modes + - Systems with capacity constraints and thresholds + + Args: + piecewises: Dictionary mapping flow labels to their Piecewise conversion functions. + Keys are flow names (e.g., 'electricity_in', 'heat_out', 'fuel_consumed'). + Values are Piecewise objects defining conversion factors at different operating points. + All Piecewise objects must have the same number of pieces and compatible domains + to ensure consistent conversion relationships across operating ranges. + + Note: + Special modeling features: + + - **Gaps**: Express forbidden operating ranges by creating non-contiguous pieces. + Example: `[(0,50), (100,200)]` - cannot operate between 50-100 units + - **Points**: Express discrete operating points using pieces with identical start/end. + Example: `[(50,50), (100,100)]` - can only operate at exactly 50 or 100 units + + Examples: + Heat pump with variable COP (Coefficient of Performance): + + ```python + PiecewiseConversion( + { + 'electricity_in': Piecewise( + [ + Piece(0, 10), # Low load: 0-10 kW electricity + Piece(10, 25), # High load: 10-25 kW electricity + ] + ), + 'heat_out': Piecewise( + [ + Piece(0, 35), # Low load COP=3.5: 0-35 kW heat output + Piece(35, 75), # High load COP=3.0: 35-75 kW heat output + ] + ), + } + ) + # At 15 kW electricity input → 52.5 kW heat output (interpolated) + ``` + + Engine with fuel consumption and emissions: + + ```python + PiecewiseConversion( + { + 'fuel_input': Piecewise( + [ + Piece(5, 15), # Part load: 5-15 L/h fuel + Piece(15, 30), # Full load: 15-30 L/h fuel + ] + ), + 'power_output': Piecewise( + [ + Piece(10, 25), # Part load: 10-25 kW output + Piece(25, 45), # Full load: 25-45 kW output + ] + ), + 'co2_emissions': Piecewise( + [ + Piece(12, 35), # Part load: 12-35 kg/h CO2 + Piece(35, 78), # Full load: 35-78 kg/h CO2 + ] + ), + } + ) + ``` - This class models complex conversion processes where the relationship between - input and output flows changes at different operating points, such as: + Discrete operating modes (on/off equipment): - - Variable efficiency equipment (heat pumps, engines, turbines) - - Multi-stage chemical processes with different conversion rates - - Equipment with discrete operating modes - - Systems with capacity constraints and thresholds + ```python + PiecewiseConversion( + { + 'electricity_in': Piecewise( + [ + Piece(0, 0), # Off mode: no consumption + Piece(20, 20), # On mode: fixed 20 kW consumption + ] + ), + 'cooling_out': Piecewise( + [ + Piece(0, 0), # Off mode: no cooling + Piece(60, 60), # On mode: fixed 60 kW cooling + ] + ), + } + ) + ``` - Args: - piecewises: Dictionary mapping flow labels to their Piecewise conversion functions. - Keys are flow names (e.g., 'electricity_in', 'heat_out', 'fuel_consumed'). - Values are Piecewise objects defining conversion factors at different operating points. - All Piecewise objects must have the same number of pieces and compatible domains - to ensure consistent conversion relationships across operating ranges. - - Note: - Special modeling features: - - - **Gaps**: Express forbidden operating ranges by creating non-contiguous pieces. - Example: `[(0,50), (100,200)]` - cannot operate between 50-100 units - - **Points**: Express discrete operating points using pieces with identical start/end. - Example: `[(50,50), (100,100)]` - can only operate at exactly 50 or 100 units - - Examples: - Heat pump with variable COP (Coefficient of Performance): - - ```python - PiecewiseConversion( - { - 'electricity_in': Piecewise( - [ - Piece(0, 10), # Low load: 0-10 kW electricity - Piece(10, 25), # High load: 10-25 kW electricity - ] - ), - 'heat_out': Piecewise( - [ - Piece(0, 35), # Low load COP=3.5: 0-35 kW heat output - Piece(35, 75), # High load COP=3.0: 35-75 kW heat output - ] - ), - } - ) - # At 15 kW electricity input → 52.5 kW heat output (interpolated) - ``` - - Engine with fuel consumption and emissions: - - ```python - PiecewiseConversion( - { - 'fuel_input': Piecewise( - [ - Piece(5, 15), # Part load: 5-15 L/h fuel - Piece(15, 30), # Full load: 15-30 L/h fuel - ] - ), - 'power_output': Piecewise( - [ - Piece(10, 25), # Part load: 10-25 kW output - Piece(25, 45), # Full load: 25-45 kW output - ] - ), - 'co2_emissions': Piecewise( - [ - Piece(12, 35), # Part load: 12-35 kg/h CO2 - Piece(35, 78), # Full load: 35-78 kg/h CO2 - ] - ), - } - ) - ``` - - Discrete operating modes (on/off equipment): - - ```python - PiecewiseConversion( - { - 'electricity_in': Piecewise( - [ - Piece(0, 0), # Off mode: no consumption - Piece(20, 20), # On mode: fixed 20 kW consumption - ] - ), - 'cooling_out': Piecewise( - [ - Piece(0, 0), # Off mode: no cooling - Piece(60, 60), # On mode: fixed 60 kW cooling - ] - ), - } - ) - ``` - - Equipment with forbidden operating range: - - ```python - PiecewiseConversion( - { - 'steam_input': Piecewise( - [ - Piece(0, 100), # Low pressure operation - Piece(200, 500), # High pressure (gap: 100-200) - ] - ), - 'power_output': Piecewise( - [ - Piece(0, 80), # Low efficiency at low pressure - Piece(180, 400), # High efficiency at high pressure - ] - ), - } - ) - ``` - - Multi-product chemical reactor: - - ```python - fx.PiecewiseConversion( - { - 'feedstock': fx.Piecewise( - [ - fx.Piece(10, 50), # Small batch: 10-50 kg/h - fx.Piece(50, 200), # Large batch: 50-200 kg/h - ] - ), - 'product_A': fx.Piecewise( - [ - fx.Piece(7, 32), # Small batch yield: 70% - fx.Piece(32, 140), # Large batch yield: 70% - ] - ), - 'product_B': fx.Piecewise( - [ - fx.Piece(2, 12), # Small batch: 20% to product B - fx.Piece(12, 45), # Large batch: better selectivity - ] - ), - 'waste': fx.Piecewise( - [ - fx.Piece(1, 6), # Small batch waste: 10% - fx.Piece(6, 15), # Large batch waste: 7.5% - ] - ), - } - ) - ``` + Equipment with forbidden operating range: + + ```python + PiecewiseConversion( + { + 'steam_input': Piecewise( + [ + Piece(0, 100), # Low pressure operation + Piece(200, 500), # High pressure (gap: 100-200) + ] + ), + 'power_output': Piecewise( + [ + Piece(0, 80), # Low efficiency at low pressure + Piece(180, 400), # High efficiency at high pressure + ] + ), + } + ) + ``` - Common Use Cases: - - Heat pumps/chillers: COP varies with load and ambient conditions - - Power plants: Heat rate curves showing fuel efficiency vs output - - Chemical reactors: Conversion rates and selectivity vs throughput - - Compressors/pumps: Power consumption vs flow rate - - Multi-stage processes: Different conversion rates per stage - - Equipment with minimum loads: Cannot operate below threshold - - Batch processes: Discrete production campaigns + Multi-product chemical reactor: - """ + ```python + fx.PiecewiseConversion( + { + 'feedstock': fx.Piecewise( + [ + fx.Piece(10, 50), # Small batch: 10-50 kg/h + fx.Piece(50, 200), # Large batch: 50-200 kg/h + ] + ), + 'product_A': fx.Piecewise( + [ + fx.Piece(7, 32), # Small batch yield: 70% + fx.Piece(32, 140), # Large batch yield: 70% + ] + ), + 'product_B': fx.Piecewise( + [ + fx.Piece(2, 12), # Small batch: 20% to product B + fx.Piece(12, 45), # Large batch: better selectivity + ] + ), + 'waste': fx.Piecewise( + [ + fx.Piece(1, 6), # Small batch waste: 10% + fx.Piece(6, 15), # Large batch waste: 7.5% + ] + ), + } + ) + ``` + + Common Use Cases: + - Heat pumps/chillers: COP varies with load and ambient conditions + - Power plants: Heat rate curves showing fuel efficiency vs output + - Chemical reactors: Conversion rates and selectivity vs throughput + - Compressors/pumps: Power consumption vs flow rate + - Multi-stage processes: Different conversion rates per stage + - Equipment with minimum loads: Cannot operate below threshold + - Batch processes: Discrete production campaigns + + """ + + def __init__(self, piecewises: Dict[str, Piecewise]): self.piecewises = piecewises def items(self): @@ -255,100 +256,101 @@ def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): @register_class_for_io class PiecewiseEffectsPerFlowHour(Interface): - def __init__(self, piecewise_flow_rate: Piecewise, piecewise_shares: Dict[str, Piecewise]): - """ - Define piecewise linear relationships between flow rate and various effects (costs, emissions, etc.). - - This class models situations where the relationship between flow rate and effects changes at - different flow rate levels, such as: - - Pump efficiency curves across operating ranges - - Emission factors that vary with operating levels - - Capacity-dependent transportation costs - - Decision between different operating modes or suppliers - - Optional equipment activation with minimum flow requirements - - Args: - piecewise_flow_rate: `Piecewise` defining the valid flow rate segments. - Each Piece represents a linear segment with (min_flow, max_flow) bounds. - - piecewise_shares: Dictionary mapping effect names to their `Piecewise`. - Keys are effect names (e.g., 'Costs', 'CO2', 'Maintenance'). - Values are `Piecewise` objects defining the absolute effect values (not rates/prices). - - ⚠️ IMPORTANT: Values represent total effect amounts, not unit rates. - For a flow rate of X, the effect value is interpolated from the `Piecewise`. - This is NOT flow_rate × unit_price (which would be non-linear). - - Behavior: - - If the first piece doesn't start at zero, flow rate is automatically bounded - by piecewise_flow_rate (when OnOffParameters are not used) - - Each segment represents a linear relationship within that flow rate range - - Effects are interpolated linearly within each piece - - All `Piece`s of the different `Piecewise`s at index i are active at the same time - - A decision whether to utilize the effect can be modeled by defining multiple Pieces for the same flow rate range - - Examples: - # Tiered cost structure with increasing rates - PiecewiseEffectsPerFlowHour( - piecewise_flow_rate=Piecewise([ - Piece(0, 50), # Low flow segment: 0-50 units - Piece(50, 200) # High flow segment: 50-200 units + """ + Define piecewise linear relationships between flow rate and various effects (costs, emissions, etc.). + + This class models situations where the relationship between flow rate and effects changes at + different flow rate levels, such as: + - Pump efficiency curves across operating ranges + - Emission factors that vary with operating levels + - Capacity-dependent transportation costs + - Decision between different operating modes or suppliers + - Optional equipment activation with minimum flow requirements + + Args: + piecewise_flow_rate: `Piecewise` defining the valid flow rate segments. + Each Piece represents a linear segment with (min_flow, max_flow) bounds. + + piecewise_shares: Dictionary mapping effect names to their `Piecewise`. + Keys are effect names (e.g., 'Costs', 'CO2', 'Maintenance'). + Values are `Piecewise` objects defining the absolute effect values (not rates/prices). + + ⚠️ IMPORTANT: Values represent total effect amounts, not unit rates. + For a flow rate of X, the effect value is interpolated from the `Piecewise`. + This is NOT flow_rate × unit_price (which would be non-linear). + + Behavior: + - If the first piece doesn't start at zero, flow rate is automatically bounded + by piecewise_flow_rate (when OnOffParameters are not used) + - Each segment represents a linear relationship within that flow rate range + - Effects are interpolated linearly within each piece + - All `Piece`s of the different `Piecewise`s at index i are active at the same time + - A decision whether to utilize the effect can be modeled by defining multiple Pieces for the same flow rate range + + Examples: + # Tiered cost structure with increasing rates + PiecewiseEffectsPerFlowHour( + piecewise_flow_rate=Piecewise([ + Piece(0, 50), # Low flow segment: 0-50 units + Piece(50, 200) # High flow segment: 50-200 units + ]), + piecewise_shares={ + 'Costs': Piecewise([ + Piece(0, 500), # At flow=0: cost=0, at flow=50: cost=500 + Piece(500, 2000) # At flow=50: cost=500, at flow=200: cost=2000 ]), - piecewise_shares={ - 'Costs': Piecewise([ - Piece(0, 500), # At flow=0: cost=0, at flow=50: cost=500 - Piece(500, 2000) # At flow=50: cost=500, at flow=200: cost=2000 - ]), - 'CO2': Piecewise([ - Piece(0, 100), # At flow=0: CO2=0, at flow=50: CO2=100 - Piece(100, 800) # At flow=50: CO2=100, at flow=200: CO2=800 - ]) - } - ) + 'CO2': Piecewise([ + Piece(0, 100), # At flow=0: CO2=0, at flow=50: CO2=100 + Piece(100, 800) # At flow=50: CO2=100, at flow=200: CO2=800 + ]) + } + ) - # Decision between two suppliers with overlapping flow ranges - PiecewiseEffectsPerFlowHour( - piecewise_flow_rate=Piecewise([ - Piece(0, 100), # Supplier A: 0-100 units - Piece(50, 150) # Supplier B: 50-150 units (overlaps with A) + # Decision between two suppliers with overlapping flow ranges + PiecewiseEffectsPerFlowHour( + piecewise_flow_rate=Piecewise([ + Piece(0, 100), # Supplier A: 0-100 units + Piece(50, 150) # Supplier B: 50-150 units (overlaps with A) + ]), + piecewise_shares={ + 'Costs': Piecewise([ + Piece(0, 800), # Supplier A: cheaper for low volumes + Piece(400, 1200) # Supplier B: better rates for high volumes + ]) + } + ) + # Flow range 50-100: Optimizer chooses between suppliers based on cost + + # Optional equipment with minimum activation threshold + PiecewiseEffectsPerFlowHour( + piecewise_flow_rate=Piecewise([ + Piece(0, 0), # Equipment off: no flow + Piece(20, 100) # Equipment on: minimum 20 units required + ]), + piecewise_shares={ + 'Costs': Piecewise([ + Piece(0, 0), # No cost when off + Piece(200, 800) # Fixed startup cost + variable cost ]), - piecewise_shares={ - 'Costs': Piecewise([ - Piece(0, 800), # Supplier A: cheaper for low volumes - Piece(400, 1200) # Supplier B: better rates for high volumes - ]) - } - ) - # Flow range 50-100: Optimizer chooses between suppliers based on cost + 'CO2': Piecewise([ + Piece(0, 0), # No CO2 when off + Piece(50, 300) # Decreasing CO2 per fuel burn with higher power + ]) + } + ) + # Decision: Either flow=0 (off) or flow≥20 (on with minimum threshold) + + # Equipment efficiency curve (although this might be better modeled as a Flow rather than an effect) + PiecewiseEffectsPerFlowHour( + piecewise_flow_rate=Piecewise([Piece(10, 100)]), # Min 10, max 100 units + piecewise_shares={ + 'PowerConsumption': Piecewise([Piece(50, 800)]) # Non-linear efficiency + } + ) - # Optional equipment with minimum activation threshold - PiecewiseEffectsPerFlowHour( - piecewise_flow_rate=Piecewise([ - Piece(0, 0), # Equipment off: no flow - Piece(20, 100) # Equipment on: minimum 20 units required - ]), - piecewise_shares={ - 'Costs': Piecewise([ - Piece(0, 0), # No cost when off - Piece(200, 800) # Fixed startup cost + variable cost - ]), - 'CO2': Piecewise([ - Piece(0, 0), # No CO2 when off - Piece(50, 300) # Decreasing CO2 per fuel burn with higher power - ]) - } - ) - # Decision: Either flow=0 (off) or flow≥20 (on with minimum threshold) - - # Equipment efficiency curve (although this might be better modeled as a Flow rather than an effect) - PiecewiseEffectsPerFlowHour( - piecewise_flow_rate=Piecewise([Piece(10, 100)]), # Min 10, max 100 units - piecewise_shares={ - 'PowerConsumption': Piecewise([Piece(50, 800)]) # Non-linear efficiency - } - ) + """ - """ + def __init__(self, piecewise_flow_rate: Piecewise, piecewise_shares: Dict[str, Piecewise]): self.piecewise_flow_rate = piecewise_flow_rate self.piecewise_shares = piecewise_shares From 14505bb4785ef242d6b4538bb812f5d9bf5184fb Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 12 Sep 2025 13:50:34 +0200 Subject: [PATCH 18/22] Fix corrupted test --- tests/test_flow.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/test_flow.py b/tests/test_flow.py index 5996856da..208a28172 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -128,7 +128,8 @@ def test_model_build(self, basic_flow_system_linopy): piecewise_effects_per_flow_hour=fx.PiecewiseEffectsPerFlowHour( piecewise_flow_rate=fx.Piecewise([fx.Piece(0, 25), fx.Piece(25, 100)]), piecewise_shares={ - 'Costs': fx.Piecewise([fx.Piece(0, 2*25), fx.Piece(2*25, 1*100)]), + 'Costs': fx.Piecewise([fx.Piece(0, 2 * 25), fx.Piece(2 * 25, 1 * 100)]), + 'CO2': fx.Piecewise([fx.Piece(0, 30 * 25), fx.Piece(30 * 25, 50 * 100)]), }, ), ) @@ -187,10 +188,16 @@ def test_solution(self, basic_flow_system_linopy): flow_system.add_elements(fx.Sink('Sink', sink=flow), fx.Effect('CO2', 't', '')) model = create_linopy_model(flow_system) + flow_rate = np.linspace(0, 100, 10) + + model.add_constraints( + model.variables['Sink(Wärme)|flow_rate'] == np.linspace(0, 100, 10), + ) + model.solve() desired_solution = model.hours_per_step * np.interp( - np.linspace(0, 100, 10), + flow_rate, [0, 25, 25, 100], [0, 2*25, 2*25, 1*100], ) From ca1a9f0a0a2d48f3401c3ef49611c75eb57be87b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 12 Sep 2025 22:54:22 +0200 Subject: [PATCH 19/22] ruff format and lint --- flixopt/elements.py | 5 ++++- flixopt/features.py | 12 ++---------- flixopt/interface.py | 3 ++- tests/test_flow.py | 22 +++++++++++++--------- 4 files changed, 21 insertions(+), 21 deletions(-) diff --git a/flixopt/elements.py b/flixopt/elements.py index 8cba56cf2..61eef4361 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -408,7 +408,10 @@ def _create_shares(self): PiecewiseEffectsPerFlowHourModel( model=self._model, label_of_element=self.label_of_element, - piecewise_origin=(self.flow_rate.name, self.element.piecewise_effects_per_flow_hour.piecewise_flow_rate), + piecewise_origin=( + self.flow_rate.name, + self.element.piecewise_effects_per_flow_hour.piecewise_flow_rate, + ), piecewise_shares=self.element.piecewise_effects_per_flow_hour.piecewise_shares, zero_point=self.on_off.on if self.on_off is not None else False, ), diff --git a/flixopt/features.py b/flixopt/features.py index 8da21be6a..1824496d8 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -1050,12 +1050,7 @@ def __init__( def do_modeling(self): self.shares = { - effect: self.add( - self._model.add_variables( - coords=None, - name=f'{self.label_full}|{effect}'), - f'{effect}' - ) + effect: self.add(self._model.add_variables(coords=None, name=f'{self.label_full}|{effect}'), f'{effect}') for effect in self._piecewise_shares } @@ -1113,10 +1108,7 @@ def __init__( def do_modeling(self): self.shares = { effect: self.add( - self._model.add_variables( - coords=self._model.coords, - name=f'{self.label_full}|{effect}'), - f'{effect}' + self._model.add_variables(coords=self._model.coords, name=f'{self.label_full}|{effect}'), f'{effect}' ) for effect in self._piecewise_shares } diff --git a/flixopt/interface.py b/flixopt/interface.py index d0eb5ef52..dfb7b6f7b 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -254,6 +254,7 @@ def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): # for name, piecewise in self.piecewise_shares.items(): # piecewise.transform_data(flow_system, f'{name_prefix}|PiecewiseEffects|{name}') + @register_class_for_io class PiecewiseEffectsPerFlowHour(Interface): """ @@ -357,7 +358,7 @@ def __init__(self, piecewise_flow_rate: Piecewise, piecewise_shares: Dict[str, P def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): self.piecewise_flow_rate.transform_data(flow_system, f'{name_prefix}|PiecewiseEffectsPerFlowHour|origin') for name, piecewise in self.piecewise_shares.items(): - piecewise.transform_data(flow_system, f'{name_prefix}|PiecewiseEffectsPerFlowHour|{name}') + piecewise.transform_data(flow_system, f'{name_prefix}|PiecewiseEffectsPerFlowHour|{name}') @register_class_for_io diff --git a/tests/test_flow.py b/tests/test_flow.py index f7b51fa7e..414583227 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -182,7 +182,7 @@ def test_solution(self, basic_flow_system_linopy): piecewise_effects_per_flow_hour=fx.PiecewiseEffectsPerFlowHour( piecewise_flow_rate=fx.Piecewise([fx.Piece(0, 25), fx.Piece(25, 100)]), piecewise_shares={ - 'Costs': fx.Piecewise([fx.Piece(0, 2*25), fx.Piece(2*25, 1*100)]), + 'Costs': fx.Piecewise([fx.Piece(0, 2 * 25), fx.Piece(2 * 25, 1 * 100)]), }, ), ) @@ -200,7 +200,7 @@ def test_solution(self, basic_flow_system_linopy): desired_solution = model.hours_per_step * np.interp( flow_rate, [0, 25, 25, 100], - [0, 2*25, 2*25, 1*100], + [0, 2 * 25, 2 * 25, 1 * 100], ) xr.testing.assert_allclose( @@ -223,8 +223,8 @@ def test_optional_shares(self, basic_flow_system_linopy): piecewise_effects_per_flow_hour=fx.PiecewiseEffectsPerFlowHour( piecewise_flow_rate=fx.Piecewise([fx.Piece(0, 1000), fx.Piece(0, 25), fx.Piece(25, 100)]), piecewise_shares={ - 'Costs': fx.Piecewise([fx.Piece(0, 0), fx.Piece(0, 2*25), fx.Piece(2*25, 1*100)]), - 'CO2': fx.Piecewise([fx.Piece(0, 0), fx.Piece(0, 30*25), fx.Piece(30*25, 50*100)]), + 'Costs': fx.Piecewise([fx.Piece(0, 0), fx.Piece(0, 2 * 25), fx.Piece(2 * 25, 1 * 100)]), + 'CO2': fx.Piecewise([fx.Piece(0, 0), fx.Piece(0, 30 * 25), fx.Piece(30 * 25, 50 * 100)]), }, ), ) @@ -241,11 +241,15 @@ def test_optional_shares(self, basic_flow_system_linopy): model.solve() - desired_solution = model.hours_per_step * np.interp( - np.linspace(0, 100, 10), - [0, 25, 25, 100], - [0, 2*25, 2*25, 1*100], - ) * np.array([0] * 5 + [1] * 5) + desired_solution = ( + model.hours_per_step + * np.interp( + np.linspace(0, 100, 10), + [0, 25, 25, 100], + [0, 2 * 25, 2 * 25, 1 * 100], + ) + * np.array([0] * 5 + [1] * 5) + ) desired_solution_co2 = ( model.hours_per_step From 454fe194bfbd69bee1335f40fe89c944bba131f1 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 13 Sep 2025 11:54:52 +0200 Subject: [PATCH 20/22] Update Changelog --- CHANGELOG.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99daa4a59..084cc8a3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,15 +29,18 @@ Template: ---- Upcoming Release: -## [2.2.0] - ????-??-?? +Until here --> + +## [2.2.0] - 2025-09-13 +THis release introduces a new interface `PiecewiseEffectsPerFlowHour` to model non-linear relations between flow rates and effects. +This greatly enhances Model flexibility. ### Fixed -- LinearConverter with `PiecewiseConversion` lead to flows reaching 0 values, even though they didnt have `OnOffParameters` nor `PiecewiseConversion` containing 0 in its a `Piece`s. This was fixed by [[#310](https://github.com/flixOpt/flixopt/pull/310) by [@FBumann](https://github.com/FBumann)] +- LinearConverter with `PiecewiseConversion` allowed flows to reach 0 values, even though they didnt have `OnOffParameters` nor `PiecewiseConversion` actually containing 0 in its `Piece`s. ### Added -- Added new Interface `PiecewiseEffectsPerFlowHour` to model non-linear relations between flow rates and effects. [[#310](https://github.com/flixOpt/flixopt/pull/310) by [@FBumann](https://github.com/FBumann)] +- Added new Interface `PiecewiseEffectsPerFlowHour` to model non-linear relations between flow rates and effects. -Until here --> ## [2.1.7] - 2025-09-13 From bd4041441252d862ad1c61610e79706c3c617b50 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 13 Sep 2025 11:57:45 +0200 Subject: [PATCH 21/22] Update Changelog --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 084cc8a3f..a0c51e11d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,8 +29,6 @@ Template: ---- Upcoming Release: -Until here --> - ## [2.2.0] - 2025-09-13 THis release introduces a new interface `PiecewiseEffectsPerFlowHour` to model non-linear relations between flow rates and effects. This greatly enhances Model flexibility. @@ -41,6 +39,8 @@ This greatly enhances Model flexibility. ### Added - Added new Interface `PiecewiseEffectsPerFlowHour` to model non-linear relations between flow rates and effects. +Until here --> + ## [2.1.7] - 2025-09-13 From fc71d85940f19ed736bfa3aa77e12ba9f782fc52 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 13 Sep 2025 12:16:55 +0200 Subject: [PATCH 22/22] Allow rc releases --- .github/workflows/python-app.yaml | 53 +++++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/.github/workflows/python-app.yaml b/.github/workflows/python-app.yaml index be308580f..dc470dab8 100644 --- a/.github/workflows/python-app.yaml +++ b/.github/workflows/python-app.yaml @@ -139,11 +139,58 @@ jobs: with: python-version: ${{ env.PYTHON_VERSION }} - - name: Extract release notes + - name: Check if pre-release + id: prerelease + run: | + if [[ "${{ github.ref }}" =~ (alpha|beta|rc) ]]; then + echo "is_prerelease=true" >> $GITHUB_OUTPUT + echo "✅ Detected pre-release" + else + echo "is_prerelease=false" >> $GITHUB_OUTPUT + echo "✅ Detected stable release" + fi + + - name: Create release notes run: | VERSION=${GITHUB_REF#refs/tags/v} - echo "Extracting release notes for version: $VERSION" - python scripts/extract_release_notes.py $VERSION > current_release_notes.md + echo "Creating release notes for version: $VERSION" + + if [[ "${{ steps.prerelease.outputs.is_prerelease }}" == "true" ]]; then + echo "📝 Generating pre-release notes" + cat > current_release_notes.md << EOF + # Pre-release $VERSION + + This is a pre-release version for testing and feedback. + + ## Installation + \`\`\`bash + pip install flixopt==$VERSION --pre + \`\`\` + + ## What's Changed + See the [unreleased section](https://github.com/flixOpt/flixopt/blob/main/CHANGELOG.md#unreleased) in the changelog for upcoming features and changes. + + ## Feedback + Please report any issues or feedback on [GitHub Issues](https://github.com/flixOpt/flixopt/issues). + EOF + else + echo "📝 Extracting release notes from changelog" + if python scripts/extract_release_notes.py "$VERSION" > current_release_notes.md 2>/dev/null; then + echo "✅ Successfully extracted release notes" + else + echo "⚠️ No release notes found, using fallback" + cat > current_release_notes.md << EOF + # Release $VERSION + + See the [full changelog](https://github.com/flixOpt/flixopt/blob/main/CHANGELOG.md) for details. + EOF + fi + fi + + echo "Generated release notes:" + echo "========================" + cat current_release_notes.md + echo "========================" - name: Create GitHub Release uses: softprops/action-gh-release@v2