Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
5cd1733
Change default size to None. Raise if not set and Status is used
FBumann Dec 8, 2025
7d0d715
Make maximum size mandatory (or fixed size). Update tests accordingly
FBumann Dec 8, 2025
9e9b0a1
Adding maximum sizes for FLows which get Status variables (also if on…
FBumann Dec 8, 2025
0208ca5
Update more tests
FBumann Dec 8, 2025
c6990dd
BUGFIX: Minimum or foixed size in storages
FBumann Dec 9, 2025
1b90714
Merge remote-tracking branch 'origin/feature/solution-storage-change'…
FBumann Dec 10, 2025
816297c
Added validation:
FBumann Dec 11, 2025
22a299f
In flixopt/elements.py - Flow._plausibility_checks():
FBumann Dec 11, 2025
b46f045
Added more validations
FBumann Dec 11, 2025
b2b65a5
Fix test
FBumann Dec 11, 2025
d2a7ceb
Merge branch 'feature/solution-storage-change' into feature/solution-…
FBumann Dec 12, 2025
8edd7c2
Fix tests to specify size if needed
FBumann Dec 12, 2025
bd9c1d0
Improve check verbosity
FBumann Dec 12, 2025
421125f
Fix type hint
FBumann Dec 12, 2025
16d2d24
Improve conftest.py
FBumann Dec 12, 2025
5faa457
Merge branch 'feature/solution-storage-change' into feature/solution-…
FBumann Dec 12, 2025
22a8bb1
Merge branch 'feature/solution-storage-change' into feature/solution-…
FBumann Dec 12, 2025
eb62cb6
Merge branch 'feature/solution-storage-change' into feature/solution-…
FBumann Dec 12, 2025
a01c2f9
Merge branch 'feature/solution-storage-change' into feature/solution-…
FBumann Dec 12, 2025
8d62ab3
Merge branch 'feature/solution-storage-change' into feature/solution-…
FBumann Dec 12, 2025
28ee53f
Merge remote-tracking branch 'origin/feature/solution-storage-change'…
FBumann Dec 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
101 changes: 68 additions & 33 deletions flixopt/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,9 @@ class Storage(Component):
charging: Incoming flow for loading the storage.
discharging: Outgoing flow for unloading the storage.
capacity_in_flow_hours: Storage capacity in flow-hours (kWh, m³, kg).
Scalar for fixed size or InvestParameters for optimization.
Scalar for fixed size, InvestParameters for optimization, or None (unbounded).
Default: None (unbounded capacity). When using InvestParameters,
maximum_size (or fixed_size) must be explicitly set for proper model scaling.
relative_minimum_charge_state: Minimum charge state (0-1). Default: 0.
relative_maximum_charge_state: Maximum charge state (0-1). Default: 1.
initial_charge_state: Charge at start. Numeric or 'equals_final'. Default: 0.
Expand Down Expand Up @@ -366,6 +368,11 @@ class Storage(Component):
variables enforce mutual exclusivity, increasing solution time but preventing unrealistic
simultaneous charging and discharging.

**Unbounded capacity**: When capacity_in_flow_hours is None (default), the storage has
unlimited capacity. Note that prevent_simultaneous_charge_and_discharge requires the
charging and discharging flows to have explicit sizes. Use prevent_simultaneous_charge_and_discharge=False
with unbounded storages, or set flow sizes explicitly.

**Units**: Flow rates and charge states are related by the concept of 'flow hours' (=flow_rate * time).
With flow rates in kW, the charge state is therefore (usually) kWh.
With flow rates in m3/h, the charge state is therefore in m3.
Expand All @@ -378,7 +385,7 @@ def __init__(
label: str,
charging: Flow,
discharging: Flow,
capacity_in_flow_hours: Numeric_PS | InvestParameters,
capacity_in_flow_hours: Numeric_PS | InvestParameters | None = None,
relative_minimum_charge_state: Numeric_TPS = 0,
relative_maximum_charge_state: Numeric_TPS = 1,
initial_charge_state: Numeric_PS | Literal['equals_final'] = 0,
Expand Down Expand Up @@ -485,31 +492,56 @@ def _plausibility_checks(self) -> None:
raise PlausibilityError(f'initial_charge_state has undefined value: {self.initial_charge_state}')
initial_equals_final = True

# Use new InvestParameters methods to get capacity bounds
if isinstance(self.capacity_in_flow_hours, InvestParameters):
minimum_capacity = self.capacity_in_flow_hours.minimum_or_fixed_size
maximum_capacity = self.capacity_in_flow_hours.maximum_or_fixed_size
else:
maximum_capacity = self.capacity_in_flow_hours
minimum_capacity = self.capacity_in_flow_hours

# Initial capacity should not constraint investment decision
minimum_initial_capacity = maximum_capacity * self.relative_minimum_charge_state.isel(time=0)
maximum_initial_capacity = minimum_capacity * self.relative_maximum_charge_state.isel(time=0)

# Only perform numeric comparisons if not using 'equals_final'
if not initial_equals_final:
if (self.initial_charge_state > maximum_initial_capacity).any():
# Capacity is required when using non-default relative bounds
if self.capacity_in_flow_hours is None:
if np.any(self.relative_minimum_charge_state > 0):
raise PlausibilityError(
f'Storage "{self.label_full}" has relative_minimum_charge_state > 0 but no capacity_in_flow_hours. '
f'A capacity is required because the lower bound is capacity * relative_minimum_charge_state.'
)
if np.any(self.relative_maximum_charge_state < 1):
raise PlausibilityError(
f'Storage "{self.label_full}" has relative_maximum_charge_state < 1 but no capacity_in_flow_hours. '
f'A capacity is required because the upper bound is capacity * relative_maximum_charge_state.'
)
if self.relative_minimum_final_charge_state is not None:
raise PlausibilityError(
f'{self.label_full}: {self.initial_charge_state=} '
f'is constraining the investment decision. Chosse a value above {maximum_initial_capacity}'
f'Storage "{self.label_full}" has relative_minimum_final_charge_state but no capacity_in_flow_hours. '
f'A capacity is required for relative final charge state constraints.'
)
if (self.initial_charge_state < minimum_initial_capacity).any():
if self.relative_maximum_final_charge_state is not None:
raise PlausibilityError(
f'{self.label_full}: {self.initial_charge_state=} '
f'is constraining the investment decision. Chosse a value below {minimum_initial_capacity}'
f'Storage "{self.label_full}" has relative_maximum_final_charge_state but no capacity_in_flow_hours. '
f'A capacity is required for relative final charge state constraints.'
)

# Skip capacity-related checks if capacity is None (unbounded)
if self.capacity_in_flow_hours is not None:
# Use new InvestParameters methods to get capacity bounds
if isinstance(self.capacity_in_flow_hours, InvestParameters):
minimum_capacity = self.capacity_in_flow_hours.minimum_or_fixed_size
maximum_capacity = self.capacity_in_flow_hours.maximum_or_fixed_size
else:
maximum_capacity = self.capacity_in_flow_hours
minimum_capacity = self.capacity_in_flow_hours

# Initial capacity should not constraint investment decision
minimum_initial_capacity = maximum_capacity * self.relative_minimum_charge_state.isel(time=0)
maximum_initial_capacity = minimum_capacity * self.relative_maximum_charge_state.isel(time=0)

# Only perform numeric comparisons if not using 'equals_final'
if not initial_equals_final:
if (self.initial_charge_state > maximum_initial_capacity).any():
raise PlausibilityError(
f'{self.label_full}: {self.initial_charge_state=} '
f'is constraining the investment decision. Chosse a value above {maximum_initial_capacity}'
)
if (self.initial_charge_state < minimum_initial_capacity).any():
raise PlausibilityError(
f'{self.label_full}: {self.initial_charge_state=} '
f'is constraining the investment decision. Chosse a value below {minimum_initial_capacity}'
)
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Bug: initial_charge_state feasibility checks use inverted min/max capacity multipliers (will raise incorrectly).

-            minimum_initial_capacity = maximum_capacity * self.relative_minimum_charge_state.isel(time=0)
-            maximum_initial_capacity = minimum_capacity * self.relative_maximum_charge_state.isel(time=0)
+            min_initial_allowed = minimum_capacity * self.relative_minimum_charge_state.isel(time=0)
+            max_initial_allowed = maximum_capacity * self.relative_maximum_charge_state.isel(time=0)

             # Only perform numeric comparisons if not using 'equals_final'
             if not initial_equals_final:
-                if (self.initial_charge_state > maximum_initial_capacity).any():
+                if (self.initial_charge_state > max_initial_allowed).any():
                     raise PlausibilityError(
                         f'{self.label_full}: {self.initial_charge_state=} '
-                        f'is constraining the investment decision. Chosse a value above {maximum_initial_capacity}'
+                        f'is infeasible w.r.t. the maximum capacity. Choose a value <= {max_initial_allowed}.'
                     )
-                if (self.initial_charge_state < minimum_initial_capacity).any():
+                if (self.initial_charge_state < min_initial_allowed).any():
                     raise PlausibilityError(
                         f'{self.label_full}: {self.initial_charge_state=} '
-                        f'is constraining the investment decision. Chosse a value below {minimum_initial_capacity}'
+                        f'is infeasible w.r.t. the minimum capacity. Choose a value >= {min_initial_allowed}.'
                     )
🤖 Prompt for AI Agents
In flixopt/components.py around lines 528 to 543 the computation of initial
capacity bounds is inverted: minimum_initial_capacity is calculated from
maximum_capacity and maximum_initial_capacity from minimum_capacity, causing
valid initial_charge_state values to be flagged as infeasible; change
minimum_initial_capacity to use minimum_capacity *
self.relative_minimum_charge_state.isel(time=0) and maximum_initial_capacity to
use maximum_capacity * self.relative_maximum_charge_state.isel(time=0), keep the
subsequent comparisons the same so that values above maximum_initial_capacity
and below minimum_initial_capacity raise PlausibilityError with the existing
messages.


if self.balanced:
if not isinstance(self.charging.size, InvestParameters) or not isinstance(
self.discharging.size, InvestParameters
Expand All @@ -518,13 +550,13 @@ def _plausibility_checks(self) -> None:
f'Balancing charging and discharging Flows in {self.label_full} is only possible with Investments.'
)

if (self.charging.size.minimum_size > self.discharging.size.maximum_size).any() or (
self.charging.size.maximum_size < self.discharging.size.minimum_size
if (self.charging.size.minimum_or_fixed_size > self.discharging.size.maximum_or_fixed_size).any() or (
self.charging.size.maximum_or_fixed_size < self.discharging.size.minimum_or_fixed_size
).any():
raise PlausibilityError(
f'Balancing charging and discharging Flows in {self.label_full} need compatible minimum and maximum sizes.'
f'Got: {self.charging.size.minimum_size=}, {self.charging.size.maximum_size=} and '
f'{self.discharging.size.minimum_size=}, {self.discharging.size.maximum_size=}.'
f'Got: {self.charging.size.minimum_or_fixed_size=}, {self.charging.size.maximum_or_fixed_size=} and '
f'{self.discharging.size.minimum_or_fixed_size=}, {self.discharging.size.maximum_or_fixed_size=}.'
)

def __repr__(self) -> str:
Expand Down Expand Up @@ -705,8 +737,8 @@ def _plausibility_checks(self):
).any():
raise ValueError(
f'Balanced Transmission needs compatible minimum and maximum sizes.'
f'Got: {self.in1.size.minimum_size=}, {self.in1.size.maximum_size=}, {self.in1.size.fixed_size=} and '
f'{self.in2.size.minimum_size=}, {self.in2.size.maximum_size=}, {self.in2.size.fixed_size=}.'
f'Got: {self.in1.size.minimum_or_fixed_size=}, {self.in1.size.maximum_or_fixed_size=} and '
f'{self.in2.size.minimum_or_fixed_size=}, {self.in2.size.maximum_or_fixed_size=}.'
)

def create_model(self, model) -> TransmissionModel:
Expand Down Expand Up @@ -938,15 +970,18 @@ def _initial_and_final_charge_state(self):
@property
def _absolute_charge_state_bounds(self) -> tuple[xr.DataArray, xr.DataArray]:
relative_lower_bound, relative_upper_bound = self._relative_charge_state_bounds
if not isinstance(self.element.capacity_in_flow_hours, InvestParameters):
if self.element.capacity_in_flow_hours is None:
# Unbounded storage: lower bound is 0, upper bound is infinite
return (0, np.inf)
elif isinstance(self.element.capacity_in_flow_hours, InvestParameters):
return (
relative_lower_bound * self.element.capacity_in_flow_hours,
relative_upper_bound * self.element.capacity_in_flow_hours,
relative_lower_bound * self.element.capacity_in_flow_hours.minimum_or_fixed_size,
relative_upper_bound * self.element.capacity_in_flow_hours.maximum_or_fixed_size,
)
else:
return (
relative_lower_bound * self.element.capacity_in_flow_hours.minimum_size,
relative_upper_bound * self.element.capacity_in_flow_hours.maximum_size,
relative_lower_bound * self.element.capacity_in_flow_hours,
relative_upper_bound * self.element.capacity_in_flow_hours,
)

@property
Expand Down
71 changes: 58 additions & 13 deletions flixopt/elements.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,17 @@ def _check_unique_flow_labels(self):
def _plausibility_checks(self) -> None:
self._check_unique_flow_labels()

# Component with status_parameters requires all flows to have sizes set
# (status_parameters are propagated to flows in _do_modeling, which need sizes for big-M constraints)
if self.status_parameters is not None:
flows_without_size = [flow.label for flow in self.inputs + self.outputs if flow.size is None]
if flows_without_size:
raise PlausibilityError(
f'Component "{self.label_full}" has status_parameters, but the following flows have no size: '
f'{flows_without_size}. All flows need explicit sizes when the component uses status_parameters '
f'(required for big-M constraints).'
)

def _connect_flows(self):
# Inputs
for flow in self.inputs:
Expand Down Expand Up @@ -344,7 +355,7 @@ class Flow(Element):
Args:
label: Unique flow identifier within its component.
bus: Bus label this flow connects to.
size: Flow capacity. Scalar, InvestParameters, or None (uses CONFIG.Modeling.big).
size: Flow capacity. Scalar, InvestParameters, or None (unbounded).
relative_minimum: Minimum flow rate as fraction of size (0-1). Default: 0.
relative_maximum: Maximum flow rate as fraction of size. Default: 1.
load_factor_min: Minimum average utilization (0-1). Default: 0.
Expand Down Expand Up @@ -445,7 +456,8 @@ class Flow(Element):
`relative_maximum` for upper bounds on optimization variables.

Notes:
- Default size (CONFIG.Modeling.big) is used when size=None
- size=None means unbounded (no capacity constraint)
- size must be set when using status_parameters or fixed_relative_profile
- list inputs for previous_flow_rate are converted to NumPy arrays
- Flow direction is determined by component input/output designation

Expand Down Expand Up @@ -476,7 +488,7 @@ def __init__(
meta_data: dict | None = None,
):
super().__init__(label, meta_data=meta_data)
self.size = CONFIG.Modeling.big if size is None else size
self.size = size
self.relative_minimum = relative_minimum
self.relative_maximum = relative_maximum
self.fixed_relative_profile = fixed_relative_profile
Expand Down Expand Up @@ -549,21 +561,51 @@ def transform_data(self) -> None:
self.status_parameters.transform_data()
if isinstance(self.size, InvestParameters):
self.size.transform_data()
else:
elif self.size is not None:
self.size = self._fit_coords(f'{self.prefix}|size', self.size, dims=['period', 'scenario'])

def _plausibility_checks(self) -> None:
# TODO: Incorporate into Variable? (Lower_bound can not be greater than upper bound
if (self.relative_minimum > self.relative_maximum).any():
raise PlausibilityError(self.label_full + ': Take care, that relative_minimum <= relative_maximum!')

if not isinstance(self.size, InvestParameters) and (
np.any(self.size == CONFIG.Modeling.big) and self.fixed_relative_profile is not None
): # Default Size --> Most likely by accident
logger.warning(
f'Flow "{self.label_full}" has no size assigned, but a "fixed_relative_profile". '
f'The default size is {CONFIG.Modeling.big}. As "flow_rate = size * fixed_relative_profile", '
f'the resulting flow_rate will be very high. To fix this, assign a size to the Flow {self}.'
# Size is required when using StatusParameters (for big-M constraints)
if self.status_parameters is not None and self.size is None:
raise PlausibilityError(
f'Flow "{self.label_full}" has status_parameters but no size defined. '
f'A size is required when using status_parameters to bound the flow rate.'
)

if self.size is None and self.fixed_relative_profile is not None:
raise PlausibilityError(
f'Flow "{self.label_full}" has a fixed_relative_profile but no size defined. '
f'A size is required because flow_rate = size * fixed_relative_profile.'
)

# Size is required when using non-default relative bounds (flow_rate = size * relative_bound)
if self.size is None and np.any(self.relative_minimum > 0):
raise PlausibilityError(
f'Flow "{self.label_full}" has relative_minimum > 0 but no size defined. '
f'A size is required because the lower bound is size * relative_minimum.'
)

if self.size is None and np.any(self.relative_maximum < 1):
raise PlausibilityError(
f'Flow "{self.label_full}" has relative_maximum != 1 but no size defined. '
f'A size is required because the upper bound is size * relative_maximum.'
)

# Size is required for load factor constraints (total_flow_hours / size)
if self.size is None and self.load_factor_min is not None:
raise PlausibilityError(
f'Flow "{self.label_full}" has load_factor_min but no size defined. '
f'A size is required because the constraint is total_flow_hours >= size * load_factor_min * hours.'
)

if self.size is None and self.load_factor_max is not None:
raise PlausibilityError(
f'Flow "{self.label_full}" has load_factor_max but no size defined. '
f'A size is required because the constraint is total_flow_hours <= size * load_factor_max * hours.'
)

if self.fixed_relative_profile is not None and self.status_parameters is not None:
Expand Down Expand Up @@ -829,15 +871,18 @@ def absolute_flow_rate_bounds(self) -> tuple[xr.DataArray, xr.DataArray]:
if not self.with_status:
if not self.with_investment:
# Basic case without investment and without Status
lb = lb_relative * self.element.size
if self.element.size is not None:
lb = lb_relative * self.element.size
elif self.with_investment and self.element.size.mandatory:
# With mandatory Investment
lb = lb_relative * self.element.size.minimum_or_fixed_size

if self.with_investment:
ub = ub_relative * self.element.size.maximum_or_fixed_size
else:
elif self.element.size is not None:
ub = ub_relative * self.element.size
else:
ub = np.inf # Unbounded when size is None

return lb, ub

Expand Down
10 changes: 8 additions & 2 deletions flixopt/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -882,7 +882,7 @@ class InvestParameters(Interface):
fixed_size: Creates binary decision at this exact size. None allows continuous sizing.
minimum_size: Lower bound for continuous sizing. Default: CONFIG.Modeling.epsilon.
Ignored if fixed_size is specified.
maximum_size: Upper bound for continuous sizing. Default: CONFIG.Modeling.big.
maximum_size: Upper bound for continuous sizing. Required if fixed_size is not set.
Ignored if fixed_size is specified.
mandatory: Controls whether investment is required. When True, forces investment
to occur (useful for mandatory upgrades or replacement decisions).
Expand Down Expand Up @@ -1065,7 +1065,7 @@ def __init__(
)
self.piecewise_effects_of_investment = piecewise_effects_of_investment
self.minimum_size = minimum_size if minimum_size is not None else CONFIG.Modeling.epsilon
self.maximum_size = maximum_size if maximum_size is not None else CONFIG.Modeling.big # default maximum
self.maximum_size = maximum_size
self.linked_periods = linked_periods

def link_to_flow_system(self, flow_system, prefix: str = '') -> None:
Expand All @@ -1075,6 +1075,12 @@ def link_to_flow_system(self, flow_system, prefix: str = '') -> None:
self.piecewise_effects_of_investment.link_to_flow_system(flow_system, self._sub_prefix('PiecewiseEffects'))

def transform_data(self) -> None:
# Validate that either fixed_size or maximum_size is set
if self.fixed_size is None and self.maximum_size is None:
raise ValueError(
f'InvestParameters in "{self.prefix}" requires either fixed_size or maximum_size to be set. '
f'An upper bound is needed to properly scale the optimization model.'
)
self.effects_of_investment = self._fit_effect_coords(
prefix=self.prefix,
effect_values=self.effects_of_investment,
Expand Down
2 changes: 1 addition & 1 deletion flixopt/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -1031,7 +1031,7 @@ def build_repr_from_init(
excluded_params: Set of parameter names to exclude (e.g., {'self', 'inputs', 'outputs'})
Default excludes 'self', 'label', and 'kwargs'
label_as_positional: If True and 'label' param exists, show it as first positional arg
skip_default_size: If True, skip 'size' parameter when it equals CONFIG.Modeling.big
skip_default_size: Deprecated. Previously skipped size=CONFIG.Modeling.big, now size=None is default.

Returns:
Formatted repr string like: ClassName("label", param=value)
Expand Down
Loading