Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -49,16 +49,16 @@ This uses the **bounds with state** pattern described in [Bounds and States](../

### Optional vs. Mandatory Investment

The `optional` parameter controls whether investment is required:
The `mandatory` parameter controls whether investment is required:

**Optional Investment** (`optional=True`):
**Optional Investment** (`mandatory=False`, default):
$$\label{eq:invest_optional}
s_\text{invest} \in \{0, 1\}
$$

The optimization can freely choose to invest or not.

**Mandatory Investment** (`optional=False`):
**Mandatory Investment** (`mandatory=True`):
$$\label{eq:invest_mandatory}
s_\text{invest} = 1
$$
Expand Down Expand Up @@ -228,7 +228,7 @@ $$
**Key Parameters:**
- `fixed_size`: For binary investments (mutually exclusive with continuous sizing)
- `minimum_size`, `maximum_size`: For continuous sizing
- `optional`: Whether investment can be skipped
- `mandatory`: Whether investment is required (default: `False`)
- `effects_of_investment`: Fixed effects incurred when investing (replaces deprecated `fix_effects`)
- `effects_of_investment_per_size`: Per-unit effects proportional to size (replaces deprecated `specific_effects`)
- `piecewise_effects_of_investment`: Non-linear effect modeling (replaces deprecated `piecewise_effects`)
Expand All @@ -250,7 +250,7 @@ See the [`InvestParameters`][flixopt.interface.InvestParameters] API documentati
```python
solar_investment = InvestParameters(
fixed_size=100, # 100 kW system
optional=True,
mandatory=False, # Optional investment (default)
effects_of_investment={'cost': 25000}, # Installation costs
effects_of_investment_per_size={'cost': 1200}, # €1200/kW
)
Expand All @@ -261,7 +261,7 @@ solar_investment = InvestParameters(
battery_investment = InvestParameters(
minimum_size=10, # kWh
maximum_size=1000,
optional=True,
mandatory=False, # Optional investment (default)
effects_of_investment={'cost': 5000}, # Grid connection
effects_of_investment_per_size={'cost': 600}, # €600/kWh
)
Expand All @@ -272,7 +272,7 @@ battery_investment = InvestParameters(
boiler_replacement = InvestParameters(
minimum_size=50, # kW
maximum_size=200,
optional=True,
mandatory=False, # Optional investment (default)
effects_of_investment={'cost': 15000},
effects_of_investment_per_size={'cost': 400},
effects_of_retirement={'cost': 8000}, # Demolition if not replaced
Expand Down
2 changes: 1 addition & 1 deletion examples/01_Simple/simple_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
label='Storage',
charging=fx.Flow('Q_th_load', bus='Fernwärme', size=1000),
discharging=fx.Flow('Q_th_unload', bus='Fernwärme', size=1000),
capacity_in_flow_hours=fx.InvestParameters(effects_of_investment=20, fixed_size=30, optional=False),
capacity_in_flow_hours=fx.InvestParameters(effects_of_investment=20, fixed_size=30, mandatory=True),
initial_charge_state=0, # Initial storage state: empty
relative_maximum_charge_state=1 / 100 * np.array([80, 70, 80, 80, 80, 80, 80, 80, 80]),
relative_maximum_final_charge_state=0.8,
Expand Down
4 changes: 2 additions & 2 deletions examples/02_Complex/complex_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
size=fx.InvestParameters(
effects_of_investment=1000, # Fixed investment costs
fixed_size=50, # Fixed size
optional=False, # Forced investment
mandatory=True, # Forced investment
effects_of_investment_per_size={Costs.label: 10, PE.label: 2}, # Specific costs
),
load_factor_max=1.0, # Maximum load factor (50 kW)
Expand Down Expand Up @@ -131,7 +131,7 @@
discharging=fx.Flow('Q_th_unload', bus='Fernwärme', size=1e4),
capacity_in_flow_hours=fx.InvestParameters(
piecewise_effects_of_investment=segmented_investment_effects, # Investment effects
optional=False, # Forced investment
mandatory=True, # Forced investment
minimum_size=0,
maximum_size=1000, # Optimizing between 0 and 1000 kWh
),
Expand Down
2 changes: 1 addition & 1 deletion examples/04_Scenarios/scenario_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@
label='Storage',
charging=fx.Flow('Q_th_load', bus='Fernwärme', size=1000),
discharging=fx.Flow('Q_th_unload', bus='Fernwärme', size=1000),
capacity_in_flow_hours=fx.InvestParameters(effects_of_investment=20, fixed_size=30, optional=False),
capacity_in_flow_hours=fx.InvestParameters(effects_of_investment=20, fixed_size=30, mandatory=True),
initial_charge_state=0, # Initial storage state: empty
relative_maximum_charge_state=np.array([80, 70, 80, 80, 80, 80, 80, 80, 80]) * 0.01,
relative_maximum_final_charge_state=0.8,
Expand Down
9 changes: 2 additions & 7 deletions flixopt/elements.py
Original file line number Diff line number Diff line change
Expand Up @@ -489,11 +489,6 @@ def size_is_fixed(self) -> bool:
# Wenn kein InvestParameters existiert --> True; Wenn Investparameter, den Wert davon nehmen
return False if (isinstance(self.size, InvestParameters) and self.size.fixed_size is None) else True

@property
def invest_is_optional(self) -> bool:
# Wenn kein InvestParameters existiert: # Investment ist nicht optional -> Keine Variable --> False
return False if (isinstance(self.size, InvestParameters) and not self.size.optional) else True


class FlowModel(ElementModel):
element: Flow # Type hint
Expand Down Expand Up @@ -678,8 +673,8 @@ def absolute_flow_rate_bounds(self) -> tuple[TemporalData, TemporalData]:
if not self.with_investment:
# Basic case without investment and without OnOff
lb = lb_relative * self.element.size
elif isinstance(self.element.size, InvestParameters) and not self.element.size.optional:
# With non-optional Investment
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:
Expand Down
7 changes: 4 additions & 3 deletions flixopt/features.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,13 @@ def _create_variables_and_constraints(self):
size_min, size_max = (self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size)
self.add_variables(
short_name='size',
lower=0 if self.parameters.optional else size_min,
lower=0 if not self.parameters.mandatory else size_min,
upper=size_max,
coords=self._model.get_coords(['period', 'scenario']),
)

if self.parameters.optional:
# Optional (not mandatory)
if not self.parameters.mandatory:
self.add_variables(
binary=True,
coords=self._model.get_coords(['period', 'scenario']),
Expand Down Expand Up @@ -100,7 +101,7 @@ def _add_effects(self):
target='periodic',
)

if self.parameters.effects_of_retirement and self.parameters.optional:
if self.parameters.effects_of_retirement and not self.parameters.mandatory:
self._model.effects.add_share_to_effects(
name=self.label_of_element,
expressions={
Expand Down
41 changes: 34 additions & 7 deletions flixopt/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -696,8 +696,9 @@ class InvestParameters(Interface):
Ignored if fixed_size is specified.
maximum_size: Upper bound for continuous sizing. Default: CONFIG.modeling.BIG.
Ignored if fixed_size is specified.
optional: If True, can choose not to invest. If False, investment is mandatory.
Default: True.
mandatory: Controls whether investment is required. When True, forces investment
to occur (useful for mandatory upgrades or replacement decisions).
When False (default), optimization can choose not to invest.
effects_of_investment: Fixed costs if investment is made, regardless of size.
Dict: {'effect_name': value} (e.g., {'cost': 10000}).
effects_of_investment_per_size: Variable costs proportional to size (per-unit costs).
Expand All @@ -716,6 +717,8 @@ class InvestParameters(Interface):
Will be removed in version 4.0.
piecewise_effects: **Deprecated**. Use `piecewise_effects_of_investment` instead.
Will be removed in version 4.0.
optional: DEPRECATED. Use `mandatory` instead. Opposite of `mandatory`.
Will be removed in version 4.0.

Cost Annualization Requirements:
All cost values must be properly weighted to match the optimization model's time horizon.
Expand All @@ -733,7 +736,7 @@ class InvestParameters(Interface):
```python
solar_investment = InvestParameters(
fixed_size=100, # 100 kW system (binary decision)
optional=True,
mandatory=False, # Investment is optional
effects_of_investment={
'cost': 25000, # Installation and permitting costs
'CO2': -50000, # Avoided emissions over lifetime
Expand All @@ -751,7 +754,7 @@ class InvestParameters(Interface):
battery_investment = InvestParameters(
minimum_size=10, # Minimum viable system size (kWh)
maximum_size=1000, # Maximum installable capacity
optional=True,
mandatory=False, # Investment is optional
effects_of_investment={
'cost': 5000, # Grid connection and control system
'installation_time': 2, # Days for fixed components
Expand Down Expand Up @@ -783,7 +786,7 @@ class InvestParameters(Interface):
boiler_replacement = InvestParameters(
minimum_size=50,
maximum_size=200,
optional=True, # Can choose not to replace
mandatory=False, # Can choose not to replace
effects_of_investment={
'cost': 15000, # Installation costs
'disruption': 3, # Days of downtime
Expand Down Expand Up @@ -867,7 +870,7 @@ def __init__(
fixed_size: PeriodicDataUser | None = None,
minimum_size: PeriodicDataUser | None = None,
maximum_size: PeriodicDataUser | None = None,
optional: bool = True, # Investition ist weglassbar
mandatory: bool = False,
effects_of_investment: PeriodicEffectsUser | None = None,
effects_of_investment_per_size: PeriodicEffectsUser | None = None,
effects_of_retirement: PeriodicEffectsUser | None = None,
Expand All @@ -887,6 +890,16 @@ def __init__(
piecewise_effects_of_investment = self._handle_deprecated_kwarg(
kwargs, 'piecewise_effects', 'piecewise_effects_of_investment', piecewise_effects_of_investment
)
# For mandatory parameter with non-None default, disable conflict checking
if 'optional' in kwargs:
warnings.warn(
'Deprecated parameter "optional" used. Check conflicts with new parameter "mandatory" manually!',
DeprecationWarning,
stacklevel=2,
)
mandatory = self._handle_deprecated_kwarg(
kwargs, 'optional', 'mandatory', mandatory, transform=lambda x: not x, check_conflict=False
)

# Validate any remaining unexpected kwargs
self._validate_kwargs(kwargs)
Expand All @@ -898,7 +911,7 @@ def __init__(
effects_of_retirement if effects_of_retirement is not None else {}
)
self.fixed_size = fixed_size
self.optional = optional
self.mandatory = mandatory
self.effects_of_investment_per_size: PeriodicEffectsUser = (
effects_of_investment_per_size if effects_of_investment_per_size is not None else {}
)
Expand Down Expand Up @@ -941,6 +954,20 @@ def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None
f'{name_prefix}|fixed_size', self.fixed_size, dims=['period', 'scenario']
)

@property
def optional(self) -> bool:
"""DEPRECATED: Use 'mandatory' property instead. Returns the opposite of 'mandatory'."""
import warnings

warnings.warn("Property 'optional' is deprecated. Use 'mandatory' instead.", DeprecationWarning, stacklevel=2)
return not self.mandatory

@optional.setter
def optional(self, value: bool):
"""DEPRECATED: Use 'mandatory' property instead. Sets the opposite of the given value to 'mandatory'."""
warnings.warn("Property 'optional' is deprecated. Use 'mandatory' instead.", DeprecationWarning, stacklevel=2)
self.mandatory = not value

@property
def fix_effects(self) -> PeriodicEffectsUser:
"""Deprecated property. Use effects_of_investment instead."""
Expand Down
17 changes: 16 additions & 1 deletion flixopt/structure.py
Original file line number Diff line number Diff line change
Expand Up @@ -387,13 +387,26 @@ def _handle_deprecated_kwarg(
new_name: Name of the replacement parameter
current_value: Current value of the new parameter (if already set)
transform: Optional callable to transform the old value before returning (e.g., lambda x: [x] to wrap in list)
check_conflict: Whether to check if both old and new parameters are specified (default: True)
check_conflict: Whether to check if both old and new parameters are specified (default: True).
Note: For parameters with non-None default values (e.g., bool parameters with default=False),
set check_conflict=False since we cannot distinguish between an explicit value and the default.

Returns:
The value to use (either from old parameter or current_value)

Raises:
ValueError: If both old and new parameters are specified and check_conflict is True

Example:
# For parameters where None is the default (conflict checking works):
value = self._handle_deprecated_kwarg(kwargs, 'old_param', 'new_param', current_value)

# For parameters with non-None defaults (disable conflict checking):
mandatory = self._handle_deprecated_kwarg(
kwargs, 'optional', 'mandatory', mandatory,
transform=lambda x: not x,
check_conflict=False # Cannot detect if mandatory was explicitly passed
)
"""
import warnings

Expand All @@ -404,13 +417,15 @@ def _handle_deprecated_kwarg(
DeprecationWarning,
stacklevel=3, # Stack: this method -> __init__ -> caller
)
# Check for conflicts: only raise error if both were explicitly provided
if check_conflict and current_value is not None:
raise ValueError(f'Either {old_name} or {new_name} can be specified, but not both.')

# Apply transformation if provided
if transform is not None:
return transform(old_value)
return old_value

return current_value

def _validate_kwargs(self, kwargs: dict, class_name: str = None) -> None:
Expand Down
6 changes: 3 additions & 3 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ def complex():
size=fx.InvestParameters(
effects_of_investment=1000,
fixed_size=50,
optional=False,
mandatory=True,
effects_of_investment_per_size={'costs': 10, 'PE': 2},
),
on_off_parameters=fx.OnOffParameters(
Expand Down Expand Up @@ -267,7 +267,7 @@ def simple(timesteps_length=9):
'Speicher',
charging=fx.Flow('Q_th_load', bus='Fernwärme', size=1e4),
discharging=fx.Flow('Q_th_unload', bus='Fernwärme', size=1e4),
capacity_in_flow_hours=fx.InvestParameters(effects_of_investment=20, fixed_size=30, optional=False),
capacity_in_flow_hours=fx.InvestParameters(effects_of_investment=20, fixed_size=30, mandatory=True),
initial_charge_state=0,
relative_maximum_charge_state=1 / 100 * np.array(charge_state_values),
relative_maximum_final_charge_state=0.8,
Expand All @@ -289,7 +289,7 @@ def complex():
'PE': fx.Piecewise([fx.Piece(5, 25), fx.Piece(25, 100)]),
},
),
optional=False,
mandatory=True,
effects_of_investment_per_size={'costs': 0.01, 'CO2': 0.01},
minimum_size=0,
maximum_size=1000,
Expand Down
2 changes: 1 addition & 1 deletion tests/test_component.py
Original file line number Diff line number Diff line change
Expand Up @@ -562,7 +562,7 @@ def test_transmission_unbalanced(self, basic_flow_system, highs_solver):
in2=fx.Flow(
'Rohr2a',
'Fernwärme',
size=fx.InvestParameters(effects_of_investment_per_size=100, minimum_size=10, optional=False),
size=fx.InvestParameters(effects_of_investment_per_size=100, minimum_size=10, mandatory=True),
),
out2=fx.Flow('Rohr2b', bus='Wärme lokal', size=1000),
balanced=False,
Expand Down
2 changes: 1 addition & 1 deletion tests/test_effect.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ def test_shares(self, basic_flow_system_linopy_coords, coords_config):
Q_th=fx.Flow(
'Q_th',
bus='Fernwärme',
size=fx.InvestParameters(effects_of_investment_per_size=10, minimum_size=20, optional=False),
size=fx.InvestParameters(effects_of_investment_per_size=10, minimum_size=20, mandatory=True),
),
Q_fu=fx.Flow('Q_fu', bus='Gas'),
),
Expand Down
Loading