diff --git a/.github/workflows/python-app.yaml b/.github/workflows/python-app.yaml index 776737e41..fa3b3eb29 100644 --- a/.github/workflows/python-app.yaml +++ b/.github/workflows/python-app.yaml @@ -142,11 +142,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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c7c80efa..a0c51e11d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ Take Care: The CI will automatically append a "Whats CHanged" section to the cha This contains all COmmits, PR's and Contributers. Therefore, the Changelog should focus on the user-facing changes. +Template: +---- ## [Unreleased] - ????-??-?? ### Changed @@ -24,8 +26,22 @@ Therefore, the Changelog should focus on the user-facing changes. ### Development +---- +Upcoming Release: + +## [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` 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. + Until here --> + ## [2.1.7] - 2025-09-13 This update is a maintenance release to improve Code Quality, CI and update the dependencies. diff --git a/flixopt/__init__.py b/flixopt/__init__.py index 34306ae32..b5b4ec483 100644 --- a/flixopt/__init__.py +++ b/flixopt/__init__.py @@ -22,6 +22,7 @@ Piecewise, PiecewiseConversion, PiecewiseEffects, + PiecewiseEffectsPerFlowHour, SegmentedCalculation, Sink, Source, diff --git a/flixopt/commons.py b/flixopt/commons.py index 68412d6fe..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 +from .interface import ( + InvestParameters, + OnOffParameters, + Piece, + Piecewise, + PiecewiseConversion, + PiecewiseEffects, + PiecewiseEffectsPerFlowHour, +) __all__ = [ 'TimeSeriesData', @@ -48,4 +56,5 @@ 'results', 'linear_converters', 'solvers', + 'PiecewiseEffectsPerFlowHour', ] diff --git a/flixopt/components.py b/flixopt/components.py index a84a89625..4af630e20 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -24,9 +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: 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 `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 + ), + ) + ``` + + 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. + See Also: + PiecewiseConversion: For variable efficiency modeling + OnOffParameters: For binary on/off control + Flow: Input and output flow definitions """ def __init__( @@ -39,21 +149,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. - """ super().__init__(label, inputs, outputs, on_off_parameters, meta_data=meta_data) self.conversion_factors = conversion_factors or [] self.piecewise_conversion = piecewise_conversion diff --git a/flixopt/elements.py b/flixopt/elements.py index e9a3ef65c..61eef4361 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, PiecewiseEffectsPerFlowHourModel, PreventSimultaneousUsageModel +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): @@ -398,6 +403,21 @@ def _create_shares(self): target='operation', ) + if self.element.piecewise_effects_per_flow_hour is not None: + self.piecewise_effects = self.add( + 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_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, + ), + ) + 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 eb954944b..1824496d8 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, Piece, Piecewise from .structure import Model, SystemModel logger = logging.getLogger('flixopt') @@ -841,7 +841,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). @@ -850,7 +850,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) @@ -896,7 +898,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( @@ -904,13 +906,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', @@ -1079,6 +1083,65 @@ def do_modeling(self): ) +class PiecewiseEffectsPerFlowHourModel(Model): + def __init__( + self, + model: SystemModel, + label_of_element: str, + piecewise_origin: Tuple[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.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 = piecewise_shares + + 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 * self._model.hours_per_step for effect, variable in self.shares.items()}, + target='operation', + ) + + class PreventSimultaneousUsageModel(Model): """ Prevents multiple Multiple Binary variables from being 1 at the same time diff --git a/flixopt/interface.py b/flixopt/interface.py index c38d6c619..dfb7b6f7b 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -63,15 +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 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: 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 + ] + ), + } + ) + ``` - Args: - piecewises: Dict of Piecewises defining the conversion factors. flow labels as keys, piecewise as values - """ + 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 + + """ + + def __init__(self, piecewises: Dict[str, Piecewise]): self.piecewises = piecewises def items(self): @@ -102,6 +255,112 @@ def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): # piecewise.transform_data(flow_system, f'{name_prefix}|PiecewiseEffects|{name}') +@register_class_for_io +class PiecewiseEffectsPerFlowHour(Interface): + """ + 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 + ]), + '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) + ]), + 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 (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 + + 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}') + + @register_class_for_io class InvestParameters(Interface): """ diff --git a/tests/conftest.py b/tests/conftest.py index ac2bab5f4..b11ce6cd6 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'), diff --git a/tests/test_flow.py b/tests/test_flow.py index 626008a8b..414583227 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -116,6 +116,167 @@ def test_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( + '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)]), + 'CO2': fx.Piecewise([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) + 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)|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', + '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)|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', + } + + 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)|PiecewiseEffectsPerFlowHour|Costs'] * 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) + + 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( + flow_rate, + [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( + model.variables['Sink(Wärme)|PiecewiseEffectsPerFlowHour|Costs'].solution, + 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, + ) + + class TestFlowInvestModel: """Test the FlowModel class.""" diff --git a/tests/test_linear_converter.py b/tests/test_linear_converter.py index f4414e055..9b9148e44 100644 --- a/tests/test_linear_converter.py +++ b/tests/test_linear_converter.py @@ -374,7 +374,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, + == 1, ) def test_piecewise_conversion_with_onoff(self, basic_flow_system_linopy):