Skip to content
Open
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
c402d57
First iteration
FBumann Sep 12, 2025
e707d0a
First iteration
FBumann Sep 12, 2025
3863102
Few tests with actual prices instead of amounts
FBumann Sep 12, 2025
ca6424f
Update to simply use amounts rather than prices
FBumann Sep 12, 2025
69791d7
Improve docstring
FBumann Sep 12, 2025
10635f8
Complete Test
FBumann Sep 12, 2025
a0a5785
ruff check
FBumann Sep 12, 2025
3a88a59
Add new feature to IO test
FBumann Sep 12, 2025
c4db3b7
Add Test that checks for optional shares Modeling
FBumann Sep 12, 2025
12fdae7
Improve docstring
FBumann Sep 12, 2025
bcca8a2
Improve docstring
FBumann Sep 12, 2025
1914b70
Update Changelog
FBumann Sep 12, 2025
ed8a8f2
Improve docstrings and add warning for Bugfix
FBumann Sep 12, 2025
cc25934
Update CHANGELOG.md
FBumann Sep 12, 2025
6d73883
Moce docstrinf from init to class
FBumann Sep 12, 2025
5ef56a5
Update docstring of LinearConverter
FBumann Sep 12, 2025
d5e8e24
Move docstring to class
FBumann Sep 12, 2025
14505bb
Fix corrupted test
FBumann Sep 12, 2025
dd6ea15
Merge branch 'main' into feature/300-piecewiseeffects-for-flows
FBumann Sep 12, 2025
fb13157
Merge branch 'main' into feature/300-piecewiseeffects-for-flows
FBumann Sep 12, 2025
f7658dd
Merge remote-tracking branch 'origin/feature/300-piecewiseeffects-for…
FBumann Sep 12, 2025
380907e
Merge branch 'main' into feature/300-piecewiseeffects-for-flows
FBumann Sep 12, 2025
ca1a9f0
ruff format and lint
FBumann Sep 12, 2025
109c6ab
Merge branch 'main' into feature/300-piecewiseeffects-for-flows
FBumann Sep 13, 2025
454fe19
Update Changelog
FBumann Sep 13, 2025
bd40414
Update Changelog
FBumann Sep 13, 2025
fc71d85
Allow rc releases
FBumann Sep 13, 2025
ffd6b92
Merge remote-tracking branch 'origin/main' into feature/300-piecewise…
FBumann Sep 13, 2025
5ae6b05
Merge branch 'main' into feature/300-piecewiseeffects-for-flows
FBumann Sep 13, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@ 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 `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)]

Comment thread
coderabbitai[bot] marked this conversation as resolved.
### 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
Expand Down
1 change: 1 addition & 0 deletions flixopt/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
Piecewise,
PiecewiseConversion,
PiecewiseEffects,
PiecewiseEffectsPerFlowHour,
SegmentedCalculation,
Sink,
Source,
Expand Down
11 changes: 10 additions & 1 deletion flixopt/commons.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -48,4 +56,5 @@
'results',
'linear_converters',
'solvers',
'PiecewiseEffectsPerFlowHour',
]
129 changes: 112 additions & 17 deletions flixopt/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__(
Expand All @@ -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
Expand Down
24 changes: 22 additions & 2 deletions flixopt/elements.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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()

Comment on lines +406 to +420
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Zero-point handling risks infeasibility when On/Off is absent.

Passing False forces “exactly one segment active,” disallowing zero unless 0 is inside a piece. That can make otherwise valid flows (min=0) infeasible. Recommend allowing zero by default when no On/Off is present.

-                    zero_point=self.on_off.on if self.on_off is not None else False,
+                    zero_point=self.on_off.on if self.on_off is not None else True,

Would you prefer a Flow-level flag (e.g., piecewise_zero_allowed_when_off: bool = True) to make this explicit?


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "PWD: $(pwd)"
echo "Listing flixopt module files (top-level):"
ls -la flixopt || true
echo
echo "Search for Piecewise* classes / identifiers..."
rg -n --hidden --no-ignore-vcs "PiecewiseEffectsPerFlowHourModel" -S || true
rg -n --hidden --no-ignore-vcs "PiecewiseEffects" -S || true
rg -n --hidden --no-ignore-vcs "piecewise_effects_per_flow_hour" -S || true
rg -n --hidden --no-ignore-vcs "piecewise" -S || true
echo
echo "Search for 'zero_point' usages:"
rg -n --hidden --no-ignore-vcs "zero_point" -S || true
echo
echo "Show flixopt/elements.py around the snippet (lines ~390-430):"
if [ -f flixopt/elements.py ]; then
  nl -ba flixopt/elements.py | sed -n '390,430p' || true
else
  echo "flixopt/elements.py not found"
fi
echo
echo "Search for classes with 'Piecewise' in their name (to find model implementation):"
rg -n --hidden --no-ignore-vcs "class .*Piecewise" -S || true
echo
echo "Search for constructor parameters that mention zero_point (def __init__ ... zero_point):"
rg -n --hidden --no-ignore-vcs "def __init__\([^\)]*zero_point" -S || true
echo
echo "Search for 'on_off' near piecewise-related code:"
rg -n --hidden --no-ignore-vcs "on_off" -S || true

Length of output: 53460


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== features.py 820-920 ==="
sed -n '820,920p' flixopt/features.py || true

echo
echo "=== features.py 1040-1140 ==="
sed -n '1040,1140p' flixopt/features.py || true

echo
echo "=== elements.py 392-422 ==="
sed -n '392,422p' flixopt/elements.py || true

echo
echo "=== components.py 528-540 ==="
sed -n '528,540p' flixopt/components.py || true

echo
echo "=== tests/test_linear_converter.py 420-440 ==="
sed -n '420,440p' tests/test_linear_converter.py || true

Length of output: 11083


Allow zero when On/Off is absent — fix in PiecewiseModel, not by passing True at call sites

Confirmed: call sites (e.g. flixopt/elements.py) pass False when no On/Off; PiecewiseModel.do_modeling treats non-True/non-variable as sum(insides) == 1, forcing a segment and making flows with min=0 infeasible. Switching call-sites to True would create an unconstrained binary zero_point (undesirable).

Recommended minimal fix: in flixopt/features.py -> PiecewiseModel.do_modeling change the else branch from
sign, rhs = '=', 1
to
sign, rhs = '<=', 1
so zero (no active segment) is allowed by default. Keep call-sites as zero_point=self.on_off.on if present. Review related call-sites: flixopt/elements.py, flixopt/components.py, flixopt/linear_converters.py.

Would you prefer an explicit Flow-level flag (e.g. piecewise_zero_allowed_when_off: bool) instead of changing the default?

def _create_bounds_for_load_factor(self):
# TODO: Add Variable load_factor for better evaluation?

Expand Down
77 changes: 70 additions & 7 deletions flixopt/features.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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).

Expand All @@ -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)
Expand Down Expand Up @@ -896,21 +898,23 @@ 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(
coords=self._model.coords, binary=True, name=f'{self.label_full}|zero_point'
),
'zero_point',
)
rhs = self.zero_point
sign, rhs = '<=', self.zero_point
else:
rhs = 1
sign, rhs = '=', 1
Comment thread
FBumann marked this conversation as resolved.

Comment on lines 900 to 912
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Bug: single-segment constraint should be equality, not ≤.

With “<=”, on=1 still allows selecting no segment, decoupling the variable from the piecewise definition (variable can collapse to 0). Use equality to enforce “exactly one segment when allowed.”

-                self.zero_point = self._zero_point
-                sign, rhs = '<=', self.zero_point
+                self.zero_point = self._zero_point
+                sign, rhs = '==', self.zero_point
@@
-                sign, rhs = '<=', self.zero_point
+                sign, rhs = '==', self.zero_point
@@
-                sign, rhs = '=', 1
+                sign, rhs = '=', 1

If you intended “≤” to allow both 0 and 1 segments when on=1, please confirm—this weakens the piecewise linkage.


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Searching repo for 'zero_point' occurrences ==="
rg -n --hidden -S 'zero_point' || true

if [ -f flixopt/features.py ]; then
  echo "=== Showing flixopt/features.py (lines 820-940) ==="
  nl -ba flixopt/features.py | sed -n '820,940p'
else
  echo "=== flixopt/features.py not found; listing files with 'features.py' in name ==="
  rg -n --hidden --hidden 'features.py' || true
fi

Length of output: 2206


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Showing flixopt/features.py (lines 760-1140) ==="
sed -n '760,1140p' flixopt/features.py || true

echo
echo "=== Occurrences of sign/rhs/zero_point/on_off in flixopt/features.py (with context) ==="
rg -n -C3 -e 'sign, rhs' -e 'zero_point' -e 'on_off' flixopt/features.py || true

echo
echo "=== All occurrences of the token 'sign' with context ==="
rg -n -C5 '\bsign\b' flixopt/features.py || true

Length of output: 21979


Enforce equality for single-segment when zero_point exists.

Change the constraint sign from '<=' to '=' when self._zero_point is a Variable or True so the constraint is sum(pieces.inside_piece) = zero_point (otherwise on/zero_point = 1 can still permit zero segments). File: flixopt/features.py (around lines 899–912) — update both branches that set sign = '<=' to '='; keep the else branch as '=' 1.

🤖 Prompt for AI Agents
In flixopt/features.py around lines 900 to 912, the constraint sign is set to
'<=' when self._zero_point is a Variable or True, which allows
sum(pieces.inside_piece) to be less than zero_point and can permit zero
segments; change both branches that currently set sign = '<=' to set sign = '='
so the constraint becomes sum(pieces.inside_piece) = zero_point (leave the
existing else branch that sets sign, rhs = '=', 1 unchanged). Ensure both
branches assign sign = '=' and keep rhs as the corresponding zero_point variable
or expression.

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',
Expand Down Expand Up @@ -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
Expand Down
Loading