From 1cf0b7b872445e6101683dcbc96f670617272e7a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 6 Oct 2025 16:44:29 +0200 Subject: [PATCH 01/19] Reorganize InvestmentParameters to always create the binary investment variable --- flixopt/features.py | 53 ++++++++++++++++++++++----------------------- 1 file changed, 26 insertions(+), 27 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index 9843a7f57..fa0985dcf 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -60,33 +60,19 @@ def _create_variables_and_constraints(self): upper=size_max, coords=self._model.get_coords(['period', 'scenario']), ) - - if self.parameters.optional: - self.add_variables( - binary=True, - coords=self._model.get_coords(['period', 'scenario']), - short_name='is_invested', - ) - - BoundingPatterns.bounds_with_state( - self, - variable=self.size, - variable_state=self.is_invested, - bounds=(self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size), - ) - - if self.parameters.piecewise_effects: - self.piecewise_effects = self.add_submodels( - PiecewiseEffectsModel( - model=self._model, - label_of_element=self.label_of_element, - label_of_model=f'{self.label_of_element}|PiecewiseEffects', - piecewise_origin=(self.size.name, self.parameters.piecewise_effects.piecewise_origin), - piecewise_shares=self.parameters.piecewise_effects.piecewise_shares, - zero_point=self.is_invested, - ), - short_name='segments', - ) + self.add_variables( + binary=True, + coords=self._model.get_coords(['period', 'scenario']), + short_name='is_invested', + ) + BoundingPatterns.bounds_with_state( + self, + variable=self.size, + variable_state=self.is_invested, + bounds=(self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size), + ) + if not self.parameters.optional: + self.add_constraints(self._variables['invested'] == 1, 'invest|fix') def _add_effects(self): """Add investment effects""" @@ -117,6 +103,19 @@ def _add_effects(self): target='periodic', ) + if self.parameters.piecewise_effects: + self.piecewise_effects = self.add_submodels( + PiecewiseEffectsModel( + model=self._model, + label_of_element=self.label_of_element, + label_of_model=f'{self.label_of_element}|PiecewiseEffects', + piecewise_origin=(self.size.name, self.parameters.piecewise_effects.piecewise_origin), + piecewise_shares=self.parameters.piecewise_effects.piecewise_shares, + zero_point=self.is_invested, + ), + short_name='segments', + ) + @property def size(self) -> linopy.Variable: """Investment size variable""" From c2c6d4c7c12f8f241e745fc494d84b8d604ffc3c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 6 Oct 2025 16:50:15 +0200 Subject: [PATCH 02/19] Add new variable that indicates wether investment was taken, independent of period and allow linked periods --- flixopt/features.py | 26 ++++++++++++++++++++++++++ flixopt/interface.py | 23 ++++++++++++++++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/flixopt/features.py b/flixopt/features.py index fa0985dcf..5abbc463e 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -54,6 +54,9 @@ def _do_modeling(self): def _create_variables_and_constraints(self): size_min, size_max = (self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size) + if self.parameters.linked_periods is not None: + size_min = size_min * self.parameters.linked_periods + self.add_variables( short_name='size', lower=0 if self.parameters.optional else size_min, @@ -74,6 +77,29 @@ def _create_variables_and_constraints(self): if not self.parameters.optional: self.add_constraints(self._variables['invested'] == 1, 'invest|fix') + self.add_variables( + short_name='invested', + lower=0, + upper=1, + coords=self._model.get_coords(['scenario']), + ) + self.add_constraints(self._variables['is_invested'] <= self._variables['invested'], 'invest|lb') + self.add_constraints( + self._variables['invested'] + <= self._variables['is_invested'].sum('period' if self._model.flow_system.periods is not None else None), + short_name='invest|ub', + ) + if self.parameters.linked_periods is not None: + self.add_constraints( + self.size.where(self.parameters.linked_periods == 1, drop=True).isel(period=slice(None, -1)) + == self.size.where(self.parameters.linked_periods == 1, drop=True).isel(period=slice(1, None)), + short_name='linked_periods', + ) + self.add_constraints( + self.size.where(self.parameters.linked_periods == 0, drop=True) == 0, + short_name='zeroed_periods', + ) + def _add_effects(self): """Add investment effects""" if self.parameters.fix_effects: diff --git a/flixopt/interface.py b/flixopt/interface.py index b3edffffc..159750a78 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -8,6 +8,10 @@ import logging from typing import TYPE_CHECKING, Literal, Optional +import numpy as np +import pandas as pd +import xarray as xr + from .config import CONFIG from .structure import Interface, register_class_for_io @@ -705,6 +709,7 @@ class InvestParameters(Interface): divest_effects: Costs incurred if the investment is NOT made, such as demolition of existing equipment, contractual penalties, or lost opportunities. Dictionary mapping effect names to values. + linked_periods: Describes which periods are linked. 1 means linked, 0 means size=0. None means no linked periods. Cost Annualization Requirements: All cost values must be properly weighted to match the optimization model's time horizon. @@ -861,6 +866,7 @@ def __init__( specific_effects: PeriodicEffectsUser | None = None, # costs per Flow-Unit/Storage-Size/... piecewise_effects: PiecewiseEffects | None = None, divest_effects: PeriodicEffectsUser | None = None, + linked_periods: PeriodicDataUser | tuple[int, int] | None = None, ): self.fix_effects: PeriodicEffectsUser = fix_effects or {} self.divest_effects: PeriodicEffectsUser = divest_effects or {} @@ -870,6 +876,7 @@ def __init__( self.piecewise_effects = piecewise_effects 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.linked_periods = linked_periods def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None: self.fix_effects = flow_system.fit_effects_to_model_coords( @@ -900,9 +907,12 @@ def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None self.maximum_size = flow_system.fit_to_model_coords( f'{name_prefix}|maximum_size', self.maximum_size, dims=['period', 'scenario'] ) + self.linked_periods = flow_system.fit_to_model_coords( + f'{name_prefix}|linked_periods', self.linked_periods, dims=['period', 'scenario'] + ) if self.fixed_size is not None: self.fixed_size = flow_system.fit_to_model_coords( - f'{name_prefix}|fixed_size', self.fixed_size, dims=['period', 'scenario'] + f'{name_prefix}|fixed_size', self.fixed_size, dims=['scenario'] ) @property @@ -913,6 +923,17 @@ def minimum_or_fixed_size(self) -> PeriodicData: def maximum_or_fixed_size(self) -> PeriodicData: return self.fixed_size if self.fixed_size is not None else self.maximum_size + @staticmethod + def compute_linked_periods(first_period: int, last_period: int, periods: pd.Index | list[int]) -> xr.DataArray: + return xr.DataArray( + xr.where( + (first_period <= np.array(periods)) & (np.array(periods) <= last_period), + 1, + 0, + ), + coords=(pd.Index(periods, name='period'),), + ).rename('linked_periods') + @register_class_for_io class OnOffParameters(Interface): From d004f4666911cc631d7fddd4f67493cb692974c2 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 7 Oct 2025 10:10:05 +0200 Subject: [PATCH 03/19] Improve Handling of linked periods --- flixopt/interface.py | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/flixopt/interface.py b/flixopt/interface.py index 159750a78..8677cb3f5 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -907,13 +907,31 @@ def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None self.maximum_size = flow_system.fit_to_model_coords( f'{name_prefix}|maximum_size', self.maximum_size, dims=['period', 'scenario'] ) + # Convert tuple (first_period, last_period) to DataArray if needed + if isinstance(self.linked_periods, (tuple, list)): + if len(self.linked_periods) != 2: + raise TypeError( + f'If you provide a tuple to "linked_periods", it needs to ben len=2. ' + f'Got {len(self.linked_periods)=}' + ) + logger.info(f'Computing linked_periods from {self.linked_periods}') + start, end = self.linked_periods + if start not in flow_system.periods.values: + logger.warning( + f'Start of linked periods ({start} not found in periods directly: {flow_system.periods.values}' + ) + if end not in flow_system.periods.values: + logger.warning( + f'Start of linked periods ({end} not found in periods directly: {flow_system.periods.values}' + ) + self.linked_periods = self.compute_linked_periods(start, end, flow_system.periods) + self.linked_periods = flow_system.fit_to_model_coords( f'{name_prefix}|linked_periods', self.linked_periods, dims=['period', 'scenario'] ) - if self.fixed_size is not None: - self.fixed_size = flow_system.fit_to_model_coords( - f'{name_prefix}|fixed_size', self.fixed_size, dims=['scenario'] - ) + self.fixed_size = flow_system.fit_to_model_coords( + f'{name_prefix}|fixed_size', self.fixed_size, dims=['scenario'] + ) @property def minimum_or_fixed_size(self) -> PeriodicData: From efd961c04892141f2b1bc50c75bd6d1a95d17457 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 7 Oct 2025 10:10:44 +0200 Subject: [PATCH 04/19] Improve Handling of linked periods --- flixopt/interface.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/flixopt/interface.py b/flixopt/interface.py index 8677cb3f5..b62a3e926 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -911,8 +911,7 @@ def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None if isinstance(self.linked_periods, (tuple, list)): if len(self.linked_periods) != 2: raise TypeError( - f'If you provide a tuple to "linked_periods", it needs to ben len=2. ' - f'Got {len(self.linked_periods)=}' + f'If you provide a tuple to "linked_periods", it needs to be len=2. Got {len(self.linked_periods)=}' ) logger.info(f'Computing linked_periods from {self.linked_periods}') start, end = self.linked_periods From 5b76192a316de09ce136c6f464e5b9a05c45b282 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 7 Oct 2025 10:11:53 +0200 Subject: [PATCH 05/19] Add examples --- examples/07_Investment_Periods/README.md | 124 ++++++++++ .../investment_periods_example.py | 203 ++++++++++++++++ .../linked_periods_advanced_example.py | 229 ++++++++++++++++++ 3 files changed, 556 insertions(+) create mode 100644 examples/07_Investment_Periods/README.md create mode 100644 examples/07_Investment_Periods/investment_periods_example.py create mode 100644 examples/07_Investment_Periods/linked_periods_advanced_example.py diff --git a/examples/07_Investment_Periods/README.md b/examples/07_Investment_Periods/README.md new file mode 100644 index 000000000..881c436ec --- /dev/null +++ b/examples/07_Investment_Periods/README.md @@ -0,0 +1,124 @@ +# Investment Periods Examples + +This directory contains examples demonstrating the use of the **period dimension** and **linked_periods parameter** in flixopt's `InvestParameters`. + +## Overview + +The period dimension enables multi-period optimization, allowing you to model investment decisions across multiple time horizons (e.g., years 2020, 2025, 2030). The `linked_periods` parameter controls how investment decisions are connected across these periods. + +## Examples + +### 1. `investment_periods_example.py` + +**Basic period-based investments** + +Demonstrates: +- Using InvestParameters with the period dimension +- Period-specific investment costs (e.g., technology learning curves) +- Period-varying constraints (maximum sizes) +- Basic linked_periods usage with `(0, 1)` - linking all periods together + +Key concepts: +- Investment costs that decrease over time +- Different maximum capacities per period +- Single investment decision shared across periods (linked_periods) + +### 2. `linked_periods_advanced_example.py` + +**Advanced linked_periods configurations** + +Demonstrates: +- `linked_periods=None`: Independent investment per period +- `linked_periods=(0, 1)`: All periods fully linked (single decision) +- Custom linking patterns with arrays like `[1, 1, 2, 2, 3]` +- Practical use cases: phased rollouts, technology generations, upgrade cycles + +Key concepts: +- Group-based linking (same group ID = linked periods) +- Sequential vs. persistent investments +- Technology replacement cycles +- Phased deployment strategies + +## Understanding linked_periods + +The `linked_periods` parameter accepts: + +| Value | Behavior | Use Case | +|-------|----------|----------| +| `None` | Independent decision per period | Equipment that can be installed/removed between periods | +| `(0, 1)` | All periods linked (1D array) | Long-lived infrastructure (buildings, major equipment) | +| `[1,1,2,2,3]` | Custom groups | Phased deployments, technology generations, upgrade cycles | + +### Custom Linking Examples + +```python +# Example: Two technology generations +linked_periods=np.array([1, 1, 1, 2, 2]) # Periods 0-2 linked, periods 3-4 linked separately + +# Example: Completely independent periods +linked_periods=np.array([1, 2, 3, 4, 5]) # Each period is its own group + +# Example: Early commitment, later flexibility +linked_periods=np.array([1, 1, 2, 3, 4]) # First two linked, rest independent +``` + +## Running the Examples + +```bash +# Basic period example +PYTHONPATH=/path/to/flixopt python examples/07_Investment_Periods/investment_periods_example.py + +# Advanced linked_periods example +PYTHONPATH=/path/to/flixopt python examples/07_Investment_Periods/linked_periods_advanced_example.py +``` + +## Key Parameters in InvestParameters + +```python +InvestParameters( + fixed_size=None, # Scalar or per-period numpy array + minimum_size=0, # Scalar or per-period numpy array + maximum_size=1000, # Scalar or per-period numpy array + optional=True, + fix_effects={'costs': 5000}, # Scalar or per-period numpy array/dict + specific_effects={ # Scalar or per-period numpy array/dict + 'costs': np.array([1000, 900, 800]) # Decreasing costs per period + }, + linked_periods=(0, 1), # None, (0,1), or numpy array +) +``` + +## Common Patterns + +### Technology Learning Curve +```python +# Costs decrease over time due to technological improvements +specific_effects={'costs': np.array([1200, 1000, 800, 650, 500])} +linked_periods=None # Can invest at different times +``` + +### Long-lived Infrastructure +```python +# Single investment that persists across all periods +linked_periods=(0, 1) # All periods linked +``` + +### Phased Rollout +```python +# Two deployment phases +linked_periods=np.array([1, 1, 1, 2, 2]) # Phase 1: periods 0-2, Phase 2: periods 3-4 +``` + +### Replacement Cycles +```python +# Equipment with 2-period lifetime, can be replaced +linked_periods=np.array([1, 1, 2, 2, 3]) # Gen 1, Gen 2, Gen 3 +``` + +## Tips + +1. **Start simple**: Use `linked_periods=(0, 1)` for most long-lived assets +2. **Model reality**: Match linking to actual equipment lifecycles +3. **Cost annualization**: Ensure investment costs are properly annualized to the period duration +4. **Check results**: Verify the `is_invested` binary variable to understand investment timing +5. **Solver settings**: Multi-period MIP problems may need longer solve times or relaxed MIP gaps diff --git a/examples/07_Investment_Periods/investment_periods_example.py b/examples/07_Investment_Periods/investment_periods_example.py new file mode 100644 index 000000000..f35ae207c --- /dev/null +++ b/examples/07_Investment_Periods/investment_periods_example.py @@ -0,0 +1,203 @@ +""" +This example demonstrates how to use the period dimension with InvestParameters. + +The period dimension allows modeling investment decisions across multiple time periods, +enabling multi-year planning and investment timing optimization. + +This example shows: +1. Basic InvestParameters with periods - different investment costs per period +2. Using linked_periods to link investment decisions across periods +3. Period-specific investment constraints +""" + +import numpy as np +import pandas as pd + +import flixopt as fx + +if __name__ == '__main__': + # --- Create Time Series Data --- + # Define timesteps for a single representative day + timesteps = pd.date_range('2020-01-01', periods=24, freq='h') + + # Define multiple periods (e.g., years 2020, 2025, 2030) + periods = pd.Index([2020, 2025, 2030], name='period') + + # Heat demand profile (kW) - same pattern for each period + heat_demand_per_h = np.array( + [30, 25, 20, 20, 25, 40, 60, 80, 90, 100, 95, 90, 85, 80, 75, 80, 85, 90, 80, 70, 60, 50, 40, 35] + ) + + # Power prices varying by period (€/kWh) - increasing over time + power_prices_per_period = np.array([0.08, 0.10, 0.12]) # 2020, 2025, 2030 + + # Create flow system with periods + flow_system = fx.FlowSystem(timesteps=timesteps, periods=periods) + + # --- Define Energy Buses --- + flow_system.add_elements(fx.Bus(label='Electricity'), fx.Bus(label='Heat'), fx.Bus(label='Gas')) + + # --- Define Effects --- + costs = fx.Effect( + label='costs', + unit='€', + description='Total costs', + is_standard=True, + is_objective=True, + ) + + CO2 = fx.Effect( + label='CO2', + unit='kg', + description='CO2 emissions', + ) + + # --- Example 1: Basic Investment with Period-Specific Costs --- + # Solar panels with decreasing costs over time (technology learning curve) + solar_panels = fx.Source( + label='Solar', + outputs=[ + fx.Flow( + label='P_solar', + bus='Electricity', + size=fx.InvestParameters( + minimum_size=0, + maximum_size=100, # kW + optional=True, + fix_effects={ + 'costs': np.array([10000, 8000, 6000]), # Fixed costs decrease over periods + }, + specific_effects={ + 'costs': np.array([1200, 1000, 800]), # €/kW decreases due to technology improvement + 'CO2': np.array([-500, -500, -500]), # Avoided emissions per kW (constant) + }, + ), + ) + ], + ) + + # --- Example 2: Investment with Linked Periods --- + # Battery storage - once invested in period 1, it's available in subsequent periods + # linked_periods controls this behavior + battery = fx.Storage( + label='Battery', + charging=fx.Flow('P_charge', bus='Electricity', size=50), + discharging=fx.Flow('P_discharge', bus='Electricity', size=50), + capacity_in_flow_hours=fx.InvestParameters( + minimum_size=10, # kWh + maximum_size=200, + optional=True, + fix_effects={ + 'costs': 5000, # Grid connection costs (same for all periods) + }, + specific_effects={ + 'costs': np.array([800, 650, 500]), # €/kWh decreases over time + }, + # linked_periods: Once invested in an early period, available in later periods + # This creates a binary investment variable that is shared across periods + linked_periods=(2020, 1), # Links all periods together (1D array with single link group) + ), + initial_charge_state=0, + eta_charge=0.95, + eta_discharge=0.95, + relative_loss_per_hour=0.001, + prevent_simultaneous_charge_and_discharge=True, + ) + + # --- Example 3: CHP with Period-Specific Maximum Size --- + # CHP can be expanded over time (different maximum in each period) + chp = fx.linear_converters.CHP( + label='CHP', + eta_th=0.5, + eta_el=0.4, + P_el=fx.Flow( + 'P_el', + bus='Electricity', + size=fx.InvestParameters( + minimum_size=0, + maximum_size=np.array([50, 75, 100]), # Maximum capacity increases per period + optional=True, + fix_effects={ + 'costs': 15000, + }, + specific_effects={ + 'costs': 1500, # €/kW + 'CO2': 200, # kg CO2 per kW (lifecycle) + }, + # No linked_periods - can invest independently in each period + ), + ), + Q_th=fx.Flow('Q_th', bus='Heat'), + Q_fu=fx.Flow('Q_fu', bus='Gas'), + ) + + # --- Supporting Components --- + # Heat demand + heat_sink = fx.Sink( + label='Heat Demand', + inputs=[fx.Flow(label='Q_th_demand', bus='Heat', size=1, fixed_relative_profile=heat_demand_per_h)], + ) + + # Gas source + gas_source = fx.Source( + label='Gas Supply', + outputs=[fx.Flow(label='Q_gas', bus='Gas', size=1000, effects_per_flow_hour={'costs': 0.06, 'CO2': 0.2})], + ) + + # Grid electricity (with period-varying prices) + grid_import = fx.Source( + label='Grid Import', + outputs=[ + fx.Flow( + label='P_import', bus='Electricity', size=200, effects_per_flow_hour={'costs': power_prices_per_period} + ) + ], + ) + + # Grid export + grid_export = fx.Sink( + label='Grid Export', + inputs=[ + fx.Flow( + label='P_export', + bus='Electricity', + size=200, + effects_per_flow_hour={'costs': -0.9 * power_prices_per_period}, # 90% of import price + ) + ], + ) + + # --- Build Flow System --- + flow_system.add_elements(costs, CO2, solar_panels, battery, chp, heat_sink, gas_source, grid_import, grid_export) + + # --- Visualize and Solve --- + flow_system.plot_network(show=True) + + calculation = fx.FullCalculation(name='InvestmentPeriods', flow_system=flow_system) + calculation.do_modeling() + calculation.solve(fx.solvers.HighsSolver(mip_gap=0.01, time_limit_seconds=60)) + + # --- Analyze Results --- + # The investment decisions are automatically printed in the calculation summary above + print('\n=== Additional Analysis ===') + + # Access investment variables directly from the solution + solar_var = 'Solar(P_solar)|size' + battery_var = 'Battery|capacity_in_flow_hours|size' + chp_var = 'CHP(P_el)|size' + + if solar_var in calculation.results.solution.data_vars: + print(f'\nSolar capacity per period: {calculation.results.solution[solar_var].values}') + + if battery_var in calculation.results.solution.data_vars: + print(f'\nBattery capacity (linked): {calculation.results.solution[battery_var].values}') + + if chp_var in calculation.results.solution.data_vars: + print(f'\nCHP capacity per period: {calculation.results.solution[chp_var].values}') + + # Plot results + calculation.results['Heat'].plot_node_balance() + calculation.results['Electricity'].plot_node_balance() + + # Save results + calculation.results.to_file() diff --git a/examples/07_Investment_Periods/linked_periods_advanced_example.py b/examples/07_Investment_Periods/linked_periods_advanced_example.py new file mode 100644 index 000000000..ea952074c --- /dev/null +++ b/examples/07_Investment_Periods/linked_periods_advanced_example.py @@ -0,0 +1,229 @@ +""" +Advanced example demonstrating the linked_periods parameter of InvestParameters. + +The linked_periods parameter controls how investment decisions are linked across periods: +- None: Independent investment decision for each period +- (0, 1): All periods linked - once invested, available in all periods +- Custom array: Fine-grained control over which periods are linked + +This example shows various linked_periods configurations and their use cases. +""" + +import numpy as np +import pandas as pd + +import flixopt as fx + +if __name__ == '__main__': + # --- Setup --- + timesteps = pd.date_range('2020-01-01', periods=24, freq='h') + periods = pd.Index([2020, 2025, 2030, 2035, 2040], name='period') + + heat_demand = np.array( + [40, 35, 30, 30, 35, 50, 70, 90, 100, 110, 105, 100, 95, 90, 85, 90, 95, 100, 90, 80, 70, 60, 50, 45] + ) + + flow_system = fx.FlowSystem(timesteps=timesteps, periods=periods) + + flow_system.add_elements(fx.Bus(label='Electricity'), fx.Bus(label='Heat'), fx.Bus(label='Gas')) + + costs = fx.Effect(label='costs', unit='€', description='Total costs', is_standard=True, is_objective=True) + + # --- Example 1: No Linking (Independent Decisions) --- + # Heat pump can be invested in independently for each period + # Use case: Technology can be installed/uninstalled between periods + heat_pump_independent = fx.linear_converters.HeatPump( + label='HeatPump_Independent', + COP=3.5, + P_el=fx.Flow( + 'P_el', + bus='Electricity', + size=fx.InvestParameters( + minimum_size=0, + maximum_size=50, + optional=True, + fix_effects={'costs': 5000}, + specific_effects={'costs': 1000}, + linked_periods=None, # No linking - independent per period + ), + ), + Q_th=fx.Flow('Q_th', bus='Heat'), + ) + + # --- Example 2: Full Linking (All Periods) --- + # Storage battery - once built in any period, it exists in all periods + # Use case: Long-lived infrastructure that persists across all periods + battery_fully_linked = fx.Storage( + label='Battery_FullyLinked', + charging=fx.Flow('P_charge', bus='Electricity', size=40), + discharging=fx.Flow('P_discharge', bus='Electricity', size=40), + capacity_in_flow_hours=fx.InvestParameters( + minimum_size=10, + maximum_size=150, + optional=True, + fix_effects={'costs': 8000}, + specific_effects={'costs': np.array([700, 600, 500, 450, 400])}, # Cost reduction over time + linked_periods=(0, 1), # All periods linked - single investment decision + ), + initial_charge_state=0, + eta_charge=0.93, + eta_discharge=0.93, + prevent_simultaneous_charge_and_discharge=True, + ) + + # --- Example 3: Custom Linking Pattern --- + # Solar panels with phased rollout + # First deployment period (2020-2030), second deployment (2030-2040) + # linked_periods array: [group_id for each period] + # Same group_id means periods are linked + solar_phased = fx.Source( + label='Solar_Phased', + outputs=[ + fx.Flow( + label='P_solar', + bus='Electricity', + size=fx.InvestParameters( + minimum_size=0, + maximum_size=80, + optional=True, + fix_effects={'costs': np.array([12000, 11000, 10000, 9000, 8000])}, + specific_effects={'costs': np.array([1100, 950, 800, 700, 600])}, + # Phase 1: 2020-2025-2030 linked (group 1) + # Phase 2: 2035-2040 linked (group 2) + linked_periods=np.array([1, 1, 1, 2, 2]), + ), + ) + ], + ) + + # --- Example 4: Incremental Upgrades --- + # Boiler with potential upgrades/expansions in later periods + # Early periods linked, then separate decision for final period + boiler_upgradeable = fx.linear_converters.Boiler( + label='Boiler_Upgradeable', + eta=0.92, + Q_th=fx.Flow( + label='Q_th', + bus='Heat', + size=fx.InvestParameters( + minimum_size=0, + maximum_size=np.array([60, 60, 80, 80, 100]), # Increasing max over time + optional=True, + fix_effects={'costs': 10000}, + specific_effects={'costs': 800}, + # Periods 0-1 linked (group 1), 2-3 linked (group 2), 4 independent (group 3) + linked_periods=np.array([1, 1, 2, 2, 3]), + ), + ), + Q_fu=fx.Flow('Q_fu', bus='Gas'), + ) + + # --- Example 5: Sequential Periods (No Group Overlap) --- + # CHP with replacement cycles - each period represents a separate lifecycle + # Once a CHP is installed in one "generation", it doesn't carry to the next + chp_sequential = fx.linear_converters.CHP( + label='CHP_Sequential', + eta_th=0.55, + eta_el=0.38, + P_el=fx.Flow( + 'P_el', + bus='Electricity', + size=fx.InvestParameters( + minimum_size=0, + maximum_size=70, + optional=True, + fix_effects={'costs': 20000}, + specific_effects={'costs': 1800}, + # Each period is its own group - completely independent + linked_periods=np.array([1, 2, 3, 4, 5]), + ), + ), + Q_th=fx.Flow('Q_th', bus='Heat'), + Q_fu=fx.Flow('Q_fu', bus='Gas'), + ) + + # --- Supporting Components --- + heat_sink = fx.Sink( + label='Heat Demand', + inputs=[fx.Flow(label='Q_demand', bus='Heat', size=1, fixed_relative_profile=heat_demand)], + ) + + gas_source = fx.Source( + label='Gas Supply', + outputs=[fx.Flow(label='Q_gas', bus='Gas', size=500, effects_per_flow_hour={'costs': 0.05})], + ) + + grid = fx.Source( + label='Grid', + outputs=[fx.Flow(label='P_grid', bus='Electricity', size=150, effects_per_flow_hour={'costs': 0.15})], + ) + + # --- Build and Solve --- + flow_system.add_elements( + costs, + heat_pump_independent, + battery_fully_linked, + solar_phased, + boiler_upgradeable, + chp_sequential, + heat_sink, + gas_source, + grid, + ) + + flow_system.plot_network(show=True) + + calculation = fx.FullCalculation(name='LinkedPeriodsAdvanced', flow_system=flow_system) + calculation.do_modeling() + calculation.solve(fx.solvers.HighsSolver(mip_gap=0.02, time_limit_seconds=120)) + + # --- Analyze Results --- + print('\n' + '=' * 60) + print('INVESTMENT DECISIONS ACROSS PERIODS') + print('=' * 60) + print('\nThe investment decisions are shown in the calculation summary above.') + print('Key observations from the results:') + + # Access size variables + hp_var = 'HeatPump_Independent(P_el)|size' + bat_var = 'Battery_FullyLinked|capacity_in_flow_hours|size' + sol_var = 'Solar_Phased(P_solar)|size' + boil_var = 'Boiler_Upgradeable(Q_th)|size' + chp_var = 'CHP_Sequential(P_el)|size' + + print('\n1. Independent Heat Pump (no linking):') + if hp_var in calculation.results.solution.data_vars: + values = calculation.results.solution[hp_var].values + print(f' Sizes per period: {values}') + + print('\n2. Fully Linked Battery (linked_periods=(0,1)):') + if bat_var in calculation.results.solution.data_vars: + values = calculation.results.solution[bat_var].values + print(f' Sizes (should be same): {values}') + + print('\n3. Phased Solar (groups [1,1,1,2,2]):') + if sol_var in calculation.results.solution.data_vars: + values = calculation.results.solution[sol_var].values + print(f' Sizes: {values}') + print(' Periods 0-2 should match, periods 3-4 should match') + + print('\n4. Upgradeable Boiler (groups [1,1,2,2,3]):') + if boil_var in calculation.results.solution.data_vars: + values = calculation.results.solution[boil_var].values + print(f' Sizes: {values}') + print(' Periods 0-1 match, 2-3 match, 4 independent') + + print('\n5. Sequential CHP (groups [1,2,3,4,5]):') + if chp_var in calculation.results.solution.data_vars: + values = calculation.results.solution[chp_var].values + print(f' Sizes (all independent): {values}') + + print('\n' + '=' * 60) + print('\nKey Insights:') + print('- linked_periods=None: Maximum flexibility, but may be unrealistic') + print('- linked_periods=(0,1): Single decision, realistic for long-lived assets') + print('- Custom arrays: Model technology generations, phased rollouts, upgrades') + print('=' * 60) + + # Save results + calculation.results.to_file() From 3f0cbeeb95b25c82ac7a213ab2009da8274aa398 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 8 Oct 2025 13:55:16 +0200 Subject: [PATCH 06/19] Typos --- examples/05_Two-stage-optimization/two_stage_optimization.py | 4 ++-- examples/07_Investment_Periods/investment_periods_example.py | 4 ++-- flixopt/interface.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/examples/05_Two-stage-optimization/two_stage_optimization.py b/examples/05_Two-stage-optimization/two_stage_optimization.py index 52c47f006..e9f6d192d 100644 --- a/examples/05_Two-stage-optimization/two_stage_optimization.py +++ b/examples/05_Two-stage-optimization/two_stage_optimization.py @@ -119,7 +119,7 @@ timer_sizing = timeit.default_timer() - start start = timeit.default_timer() - calculation_dispatch = fx.FullCalculation('Sizing', flow_system) + calculation_dispatch = fx.FullCalculation('Dispatch', flow_system) calculation_dispatch.do_modeling() calculation_dispatch.fix_sizes(calculation_sizing.results.solution) calculation_dispatch.solve(fx.solvers.HighsSolver(0.1 / 100, 600)) @@ -132,7 +132,7 @@ # Optimization of both flow sizes and dispatch together start = timeit.default_timer() - calculation_combined = fx.FullCalculation('Sizing', flow_system) + calculation_combined = fx.FullCalculation('Combined', flow_system) calculation_combined.do_modeling() calculation_combined.solve(fx.solvers.HighsSolver(0.1 / 100, 600)) timer_combined = timeit.default_timer() - start diff --git a/examples/07_Investment_Periods/investment_periods_example.py b/examples/07_Investment_Periods/investment_periods_example.py index f35ae207c..d124d8ffc 100644 --- a/examples/07_Investment_Periods/investment_periods_example.py +++ b/examples/07_Investment_Periods/investment_periods_example.py @@ -95,7 +95,7 @@ }, # linked_periods: Once invested in an early period, available in later periods # This creates a binary investment variable that is shared across periods - linked_periods=(2020, 1), # Links all periods together (1D array with single link group) + linked_periods=(2020, 2029), # Links all periods together (1D array with single link group) ), initial_charge_state=0, eta_charge=0.95, @@ -183,7 +183,7 @@ # Access investment variables directly from the solution solar_var = 'Solar(P_solar)|size' - battery_var = 'Battery|capacity_in_flow_hours|size' + battery_var = 'Battery|size' chp_var = 'CHP(P_el)|size' if solar_var in calculation.results.solution.data_vars: diff --git a/flixopt/interface.py b/flixopt/interface.py index b62a3e926..87649773d 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -921,7 +921,7 @@ def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None ) if end not in flow_system.periods.values: logger.warning( - f'Start of linked periods ({end} not found in periods directly: {flow_system.periods.values}' + f'End of linked periods ({end} not found in periods directly: {flow_system.periods.values}' ) self.linked_periods = self.compute_linked_periods(start, end, flow_system.periods) From 19ac9e7cfa47d23c7ff2df28bb79effe3682d1fe Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 10 Oct 2025 13:59:44 +0200 Subject: [PATCH 07/19] Fix: reference invested only after it exists --- flixopt/features.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index fbcfec7d3..b6ccb0e0e 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -74,8 +74,6 @@ def _create_variables_and_constraints(self): variable_state=self.is_invested, bounds=(self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size), ) - if self.parameters.mandatory: - self.add_constraints(self._variables['invested'] == 1, 'invest|fix') self.add_variables( short_name='invested', @@ -89,6 +87,10 @@ def _create_variables_and_constraints(self): <= self._variables['is_invested'].sum('period' if self._model.flow_system.periods is not None else None), short_name='invest|ub', ) + + if self.parameters.mandatory: + self.add_constraints(self._variables['invested'] == 1, 'invest|fix') + if self.parameters.linked_periods is not None: self.add_constraints( self.size.where(self.parameters.linked_periods == 1, drop=True).isel(period=slice(None, -1)) From 21c26e71b90736344070ea0ef5e5d6f01c00a7e0 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 10 Oct 2025 19:26:19 +0200 Subject: [PATCH 08/19] Improve readbility of equation --- flixopt/features.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index b6ccb0e0e..4507f1ff7 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -92,9 +92,9 @@ def _create_variables_and_constraints(self): self.add_constraints(self._variables['invested'] == 1, 'invest|fix') if self.parameters.linked_periods is not None: + masked_size = self.size.where(self.parameters.linked_periods, drop=True) self.add_constraints( - self.size.where(self.parameters.linked_periods == 1, drop=True).isel(period=slice(None, -1)) - == self.size.where(self.parameters.linked_periods == 1, drop=True).isel(period=slice(1, None)), + masked_size.isel(period=slice(None, -1)) == masked_size.isel(period=slice(1, None)), short_name='linked_periods', ) self.add_constraints( From 63f7ac08e640265d4eef3ee7d77e97fca5d90cd1 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 10 Oct 2025 19:38:19 +0200 Subject: [PATCH 09/19] Update from Merge --- flixopt/features.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index 4507f1ff7..a6b1352ac 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -134,14 +134,14 @@ def _add_effects(self): target='periodic', ) - if self.parameters.piecewise_effects: + if self.parameters.piecewise_effects_of_investment: self.piecewise_effects = self.add_submodels( PiecewiseEffectsModel( model=self._model, label_of_element=self.label_of_element, label_of_model=f'{self.label_of_element}|PiecewiseEffects', - piecewise_origin=(self.size.name, self.parameters.piecewise_effects.piecewise_origin), - piecewise_shares=self.parameters.piecewise_effects.piecewise_shares, + piecewise_origin=(self.size.name, self.parameters.piecewise_effects_of_investment.piecewise_origin), + piecewise_shares=self.parameters.piecewise_effects_of_investment.piecewise_shares, zero_point=self.is_invested, ), short_name='segments', From 2f0c6a5df1f0ffd94357c80f24751657505032dc Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 10 Oct 2025 19:55:12 +0200 Subject: [PATCH 10/19] Improve InvestmentModel --- flixopt/features.py | 35 +++++++++++++++++------------------ 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index a6b1352ac..a08904a29 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -66,31 +66,30 @@ def _create_variables_and_constraints(self): self.add_variables( binary=True, coords=self._model.get_coords(['period', 'scenario']), - short_name='is_invested', + short_name='do_invest', ) + if self.parameters.mandatory: + self.add_constraints( + (self._variables['do_invest'].sum('period') == 1) + if self._model.flow_system.periods is not None + else (self._variables['do_invest'] == 1), + 'single_invest|mandatory', + ) + else: + self.add_constraints( + (self._variables['do_invest'].sum('period') <= 1) + if self._model.flow_system.periods is not None + else (self._variables['do_invest'] <= 1), + 'single_invest', + ) + BoundingPatterns.bounds_with_state( self, variable=self.size, - variable_state=self.is_invested, + variable_state=self._variables['do_invest'], bounds=(self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size), ) - self.add_variables( - short_name='invested', - lower=0, - upper=1, - coords=self._model.get_coords(['scenario']), - ) - self.add_constraints(self._variables['is_invested'] <= self._variables['invested'], 'invest|lb') - self.add_constraints( - self._variables['invested'] - <= self._variables['is_invested'].sum('period' if self._model.flow_system.periods is not None else None), - short_name='invest|ub', - ) - - if self.parameters.mandatory: - self.add_constraints(self._variables['invested'] == 1, 'invest|fix') - if self.parameters.linked_periods is not None: masked_size = self.size.where(self.parameters.linked_periods, drop=True) self.add_constraints( From 98440e7bae8f4d453516ceb46928b323ec55f07e Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 12 Oct 2025 23:21:38 +0200 Subject: [PATCH 11/19] Improve readability --- flixopt/features.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixopt/features.py b/flixopt/features.py index a08904a29..1e9594228 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -59,7 +59,7 @@ def _create_variables_and_constraints(self): self.add_variables( short_name='size', - lower=0 if not self.parameters.mandatory else size_min, + lower=size_min if self.parameters.mandatory else 0, upper=size_max, coords=self._model.get_coords(['period', 'scenario']), ) From 491e283164a8a086eb242ede56f20766a0e24d52 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 12 Oct 2025 23:25:04 +0200 Subject: [PATCH 12/19] Improve readability and reorder methods --- flixopt/features.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index 1e9594228..faf09f954 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -59,7 +59,7 @@ def _create_variables_and_constraints(self): self.add_variables( short_name='size', - lower=size_min if self.parameters.mandatory else 0, + lower=size_min if self.parameters.mandatory and self.parameters.linked_periods is None else 0, upper=size_max, coords=self._model.get_coords(['period', 'scenario']), ) @@ -68,6 +68,13 @@ def _create_variables_and_constraints(self): coords=self._model.get_coords(['period', 'scenario']), short_name='do_invest', ) + BoundingPatterns.bounds_with_state( + self, + variable=self.size, + variable_state=self._variables['do_invest'], + bounds=(self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size), + ) + if self.parameters.mandatory: self.add_constraints( (self._variables['do_invest'].sum('period') == 1) @@ -83,13 +90,6 @@ def _create_variables_and_constraints(self): 'single_invest', ) - BoundingPatterns.bounds_with_state( - self, - variable=self.size, - variable_state=self._variables['do_invest'], - bounds=(self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size), - ) - if self.parameters.linked_periods is not None: masked_size = self.size.where(self.parameters.linked_periods, drop=True) self.add_constraints( From 9337113699c7faf601963cf81ea4f5bada7606f9 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 12 Oct 2025 23:41:19 +0200 Subject: [PATCH 13/19] Improve logging --- flixopt/interface.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/flixopt/interface.py b/flixopt/interface.py index ff6e15623..f9141ca2a 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -962,7 +962,7 @@ def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None raise TypeError( f'If you provide a tuple to "linked_periods", it needs to be len=2. Got {len(self.linked_periods)=}' ) - logger.info(f'Computing linked_periods from {self.linked_periods}') + logger.debug(f'Computing linked_periods from {self.linked_periods}') start, end = self.linked_periods if start not in flow_system.periods.values: logger.warning( @@ -973,6 +973,7 @@ def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None f'End of linked periods ({end} not found in periods directly: {flow_system.periods.values}' ) self.linked_periods = self.compute_linked_periods(start, end, flow_system.periods) + logger.debug(f'Computed {self.linked_periods=}') self.linked_periods = flow_system.fit_to_model_coords( f'{name_prefix}|linked_periods', self.linked_periods, dims=['period', 'scenario'] From f37c855815abb05868ee894b4ed31467c00a17fe Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 12 Oct 2025 23:41:31 +0200 Subject: [PATCH 14/19] Improve InvestmentModel --- flixopt/features.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index faf09f954..477d4c9e8 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -56,37 +56,38 @@ def _create_variables_and_constraints(self): size_min, size_max = (self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size) if self.parameters.linked_periods is not None: size_min = size_min * self.parameters.linked_periods + size_max = size_max * self.parameters.linked_periods self.add_variables( short_name='size', - lower=size_min if self.parameters.mandatory and self.parameters.linked_periods is None else 0, + lower=size_min if self.parameters.mandatory else 0, upper=size_max, coords=self._model.get_coords(['period', 'scenario']), ) self.add_variables( binary=True, coords=self._model.get_coords(['period', 'scenario']), - short_name='do_invest', + short_name='unit_installed', ) BoundingPatterns.bounds_with_state( self, variable=self.size, - variable_state=self._variables['do_invest'], + variable_state=self._variables['unit_installed'], bounds=(self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size), ) if self.parameters.mandatory: self.add_constraints( - (self._variables['do_invest'].sum('period') == 1) + (self._variables['unit_installed'].sum('period') >= 1) if self._model.flow_system.periods is not None - else (self._variables['do_invest'] == 1), + else (self._variables['unit_installed'] == 1), 'single_invest|mandatory', ) else: self.add_constraints( - (self._variables['do_invest'].sum('period') <= 1) + (self._variables['unit_installed'].sum('period') <= 1) if self._model.flow_system.periods is not None - else (self._variables['do_invest'] <= 1), + else (self._variables['unit_installed'] <= 1), 'single_invest', ) @@ -96,10 +97,6 @@ def _create_variables_and_constraints(self): masked_size.isel(period=slice(None, -1)) == masked_size.isel(period=slice(1, None)), short_name='linked_periods', ) - self.add_constraints( - self.size.where(self.parameters.linked_periods == 0, drop=True) == 0, - short_name='zeroed_periods', - ) def _add_effects(self): """Add investment effects""" From 3de3d15ff234c9881f87a77a0212fd981809638e Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 12 Oct 2025 23:48:12 +0200 Subject: [PATCH 15/19] Rename to "invested" --- flixopt/features.py | 24 ++++++++++++------------ tests/test_flow.py | 32 +++++++++++++++----------------- tests/test_functional.py | 12 ++++++------ tests/test_storage.py | 24 +++++++++++------------- 4 files changed, 44 insertions(+), 48 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index 477d4c9e8..06de48e53 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -67,27 +67,27 @@ def _create_variables_and_constraints(self): self.add_variables( binary=True, coords=self._model.get_coords(['period', 'scenario']), - short_name='unit_installed', + short_name='invested', ) BoundingPatterns.bounds_with_state( self, variable=self.size, - variable_state=self._variables['unit_installed'], + variable_state=self._variables['invested'], bounds=(self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size), ) if self.parameters.mandatory: self.add_constraints( - (self._variables['unit_installed'].sum('period') >= 1) + (self._variables['invested'].sum('period') >= 1) if self._model.flow_system.periods is not None - else (self._variables['unit_installed'] == 1), + else (self._variables['invested'] == 1), 'single_invest|mandatory', ) else: self.add_constraints( - (self._variables['unit_installed'].sum('period') <= 1) + (self._variables['invested'].sum('period') <= 1) if self._model.flow_system.periods is not None - else (self._variables['unit_installed'] <= 1), + else (self._variables['invested'] <= 1), 'single_invest', ) @@ -104,7 +104,7 @@ def _add_effects(self): self._model.effects.add_share_to_effects( name=self.label_of_element, expressions={ - effect: self.is_invested * factor if self.is_invested is not None else factor + effect: self.invested * factor if self.invested is not None else factor for effect, factor in self.parameters.effects_of_investment.items() }, target='periodic', @@ -114,7 +114,7 @@ def _add_effects(self): self._model.effects.add_share_to_effects( name=self.label_of_element, expressions={ - effect: -self.is_invested * factor + factor + effect: -self.invested * factor + factor for effect, factor in self.parameters.effects_of_retirement.items() }, target='periodic', @@ -138,7 +138,7 @@ def _add_effects(self): label_of_model=f'{self.label_of_element}|PiecewiseEffects', piecewise_origin=(self.size.name, self.parameters.piecewise_effects_of_investment.piecewise_origin), piecewise_shares=self.parameters.piecewise_effects_of_investment.piecewise_shares, - zero_point=self.is_invested, + zero_point=self.invested, ), short_name='segments', ) @@ -149,11 +149,11 @@ def size(self) -> linopy.Variable: return self._variables['size'] @property - def is_invested(self) -> linopy.Variable | None: + def invested(self) -> linopy.Variable | None: """Binary investment decision variable""" - if 'is_invested' not in self._variables: + if 'invested' not in self._variables: return None - return self._variables['is_invested'] + return self._variables['invested'] class OnOffModel(Submodel): diff --git a/tests/test_flow.py b/tests/test_flow.py index 5c8420137..8a011939f 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -222,7 +222,7 @@ def test_flow_invest_optional(self, basic_flow_system_linopy_coords, coords_conf assert_sets_equal( set(flow.submodel.variables), - {'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate', 'Sink(Wärme)|size', 'Sink(Wärme)|is_invested'}, + {'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate', 'Sink(Wärme)|size', 'Sink(Wärme)|invested'}, msg='Incorrect variables', ) assert_sets_equal( @@ -243,7 +243,7 @@ def test_flow_invest_optional(self, basic_flow_system_linopy_coords, coords_conf ) assert_var_equal( - model['Sink(Wärme)|is_invested'], + model['Sink(Wärme)|invested'], model.add_variables(binary=True, coords=model.get_coords(['period', 'scenario'])), ) @@ -273,11 +273,11 @@ def test_flow_invest_optional(self, basic_flow_system_linopy_coords, coords_conf # Is invested assert_conequal( model.constraints['Sink(Wärme)|size|ub'], - flow.submodel.variables['Sink(Wärme)|size'] <= flow.submodel.variables['Sink(Wärme)|is_invested'] * 100, + flow.submodel.variables['Sink(Wärme)|size'] <= flow.submodel.variables['Sink(Wärme)|invested'] * 100, ) assert_conequal( model.constraints['Sink(Wärme)|size|lb'], - flow.submodel.variables['Sink(Wärme)|size'] >= flow.submodel.variables['Sink(Wärme)|is_invested'] * 20, + flow.submodel.variables['Sink(Wärme)|size'] >= flow.submodel.variables['Sink(Wärme)|invested'] * 20, ) def test_flow_invest_optional_wo_min_size(self, basic_flow_system_linopy_coords, coords_config): @@ -297,7 +297,7 @@ def test_flow_invest_optional_wo_min_size(self, basic_flow_system_linopy_coords, assert_sets_equal( set(flow.submodel.variables), - {'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate', 'Sink(Wärme)|size', 'Sink(Wärme)|is_invested'}, + {'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate', 'Sink(Wärme)|size', 'Sink(Wärme)|invested'}, msg='Incorrect variables', ) assert_sets_equal( @@ -318,7 +318,7 @@ def test_flow_invest_optional_wo_min_size(self, basic_flow_system_linopy_coords, ) assert_var_equal( - model['Sink(Wärme)|is_invested'], + model['Sink(Wärme)|invested'], model.add_variables(binary=True, coords=model.get_coords(['period', 'scenario'])), ) @@ -348,11 +348,11 @@ def test_flow_invest_optional_wo_min_size(self, basic_flow_system_linopy_coords, # Is invested assert_conequal( model.constraints['Sink(Wärme)|size|ub'], - flow.submodel.variables['Sink(Wärme)|size'] <= flow.submodel.variables['Sink(Wärme)|is_invested'] * 100, + flow.submodel.variables['Sink(Wärme)|size'] <= flow.submodel.variables['Sink(Wärme)|invested'] * 100, ) assert_conequal( model.constraints['Sink(Wärme)|size|lb'], - flow.submodel.variables['Sink(Wärme)|size'] >= flow.submodel.variables['Sink(Wärme)|is_invested'] * 1e-5, + flow.submodel.variables['Sink(Wärme)|size'] >= flow.submodel.variables['Sink(Wärme)|invested'] * 1e-5, ) def test_flow_invest_wo_min_size_non_optional(self, basic_flow_system_linopy_coords, coords_config): @@ -471,19 +471,18 @@ def test_flow_invest_with_effects(self, basic_flow_system_linopy_coords, coords_ assert 'Sink(Wärme)->costs(periodic)' in model.variables assert 'Sink(Wärme)->CO2(periodic)' in model.variables - # Check fix effects (applied only when is_invested=1) + # Check fix effects (applied only when invested=1) assert_conequal( model.constraints['Sink(Wärme)->costs(periodic)'], model.variables['Sink(Wärme)->costs(periodic)'] - == flow.submodel.variables['Sink(Wärme)|is_invested'] * 1000 + == flow.submodel.variables['Sink(Wärme)|invested'] * 1000 + flow.submodel.variables['Sink(Wärme)|size'] * 500, ) assert_conequal( model.constraints['Sink(Wärme)->CO2(periodic)'], model.variables['Sink(Wärme)->CO2(periodic)'] - == flow.submodel.variables['Sink(Wärme)|is_invested'] * 5 - + flow.submodel.variables['Sink(Wärme)|size'] * 0.1, + == flow.submodel.variables['Sink(Wärme)|invested'] * 5 + flow.submodel.variables['Sink(Wärme)|size'] * 0.1, ) def test_flow_invest_divest_effects(self, basic_flow_system_linopy_coords, coords_config): @@ -509,8 +508,7 @@ def test_flow_invest_divest_effects(self, basic_flow_system_linopy_coords, coord assert_conequal( model.constraints['Sink(Wärme)->costs(periodic)'], - model.variables['Sink(Wärme)->costs(periodic)'] + (model.variables['Sink(Wärme)|is_invested'] - 1) * 500 - == 0, + model.variables['Sink(Wärme)->costs(periodic)'] + (model.variables['Sink(Wärme)|invested'] - 1) * 500 == 0, ) @@ -1089,7 +1087,7 @@ def test_flow_on_invest_optional(self, basic_flow_system_linopy_coords, coords_c { 'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate', - 'Sink(Wärme)|is_invested', + 'Sink(Wärme)|invested', 'Sink(Wärme)|size', 'Sink(Wärme)|on', 'Sink(Wärme)|on_hours_total', @@ -1133,11 +1131,11 @@ def test_flow_on_invest_optional(self, basic_flow_system_linopy_coords, coords_c ) assert_conequal( model.constraints['Sink(Wärme)|size|lb'], - flow.submodel.variables['Sink(Wärme)|size'] >= flow.submodel.variables['Sink(Wärme)|is_invested'] * 20, + flow.submodel.variables['Sink(Wärme)|size'] >= flow.submodel.variables['Sink(Wärme)|invested'] * 20, ) assert_conequal( model.constraints['Sink(Wärme)|size|ub'], - flow.submodel.variables['Sink(Wärme)|size'] <= flow.submodel.variables['Sink(Wärme)|is_invested'] * 200, + flow.submodel.variables['Sink(Wärme)|size'] <= flow.submodel.variables['Sink(Wärme)|invested'] * 200, ) assert_conequal( model.constraints['Sink(Wärme)|flow_rate|lb1'], diff --git a/tests/test_functional.py b/tests/test_functional.py index cd872e84c..0f9fe02ef 100644 --- a/tests/test_functional.py +++ b/tests/test_functional.py @@ -169,11 +169,11 @@ def test_fixed_size(solver_fixture, time_steps_fixture): err_msg='"Boiler__Q_th__Investment_size" does not have the right value', ) assert_allclose( - boiler.Q_th.submodel._investment.is_invested.solution.item(), + boiler.Q_th.submodel._investment.invested.solution.item(), 1, rtol=1e-5, atol=1e-10, - err_msg='"Boiler__Q_th__Investment_size" does not have the right value', + err_msg='"Boiler__Q_th__invested" does not have the right value', ) @@ -210,7 +210,7 @@ def test_optimize_size(solver_fixture, time_steps_fixture): err_msg='"Boiler__Q_th__Investment_size" does not have the right value', ) assert_allclose( - boiler.Q_th.submodel._investment.is_invested.solution.item(), + boiler.Q_th.submodel._investment.invested.solution.item(), 1, rtol=1e-5, atol=1e-10, @@ -251,7 +251,7 @@ def test_size_bounds(solver_fixture, time_steps_fixture): err_msg='"Boiler__Q_th__Investment_size" does not have the right value', ) assert_allclose( - boiler.Q_th.submodel._investment.is_invested.solution.item(), + boiler.Q_th.submodel._investment.invested.solution.item(), 1, rtol=1e-5, atol=1e-10, @@ -307,7 +307,7 @@ def test_optional_invest(solver_fixture, time_steps_fixture): err_msg='"Boiler__Q_th__Investment_size" does not have the right value', ) assert_allclose( - boiler.Q_th.submodel._investment.is_invested.solution.item(), + boiler.Q_th.submodel._investment.invested.solution.item(), 1, rtol=1e-5, atol=1e-10, @@ -322,7 +322,7 @@ def test_optional_invest(solver_fixture, time_steps_fixture): err_msg='"Boiler__Q_th__Investment_size" does not have the right value', ) assert_allclose( - boiler_optional.Q_th.submodel._investment.is_invested.solution.item(), + boiler_optional.Q_th.submodel._investment.invested.solution.item(), 0, rtol=1e-5, atol=1e-10, diff --git a/tests/test_storage.py b/tests/test_storage.py index 9252556cc..8d0c495c2 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -281,7 +281,7 @@ def test_storage_with_investment(self, basic_flow_system_linopy_coords, coords_c for var_name in { 'InvestStorage|charge_state', 'InvestStorage|size', - 'InvestStorage|is_invested', + 'InvestStorage|invested', }: assert var_name in model.variables, f'Missing investment variable: {var_name}' @@ -295,16 +295,16 @@ def test_storage_with_investment(self, basic_flow_system_linopy_coords, coords_c model.add_variables(lower=0, upper=100, coords=model.get_coords(['period', 'scenario'])), ) assert_var_equal( - model['InvestStorage|is_invested'], + model['InvestStorage|invested'], model.add_variables(binary=True, coords=model.get_coords(['period', 'scenario'])), ) assert_conequal( model.constraints['InvestStorage|size|ub'], - model.variables['InvestStorage|size'] <= model.variables['InvestStorage|is_invested'] * 100, + model.variables['InvestStorage|size'] <= model.variables['InvestStorage|invested'] * 100, ) assert_conequal( model.constraints['InvestStorage|size|lb'], - model.variables['InvestStorage|size'] >= model.variables['InvestStorage|is_invested'] * 20, + model.variables['InvestStorage|size'] >= model.variables['InvestStorage|invested'] * 20, ) def test_storage_with_final_state_constraints(self, basic_flow_system_linopy_coords, coords_config): @@ -427,8 +427,8 @@ def test_simultaneous_charge_discharge(self, basic_flow_system_linopy_coords, co @pytest.mark.parametrize( 'mandatory,minimum_size,expected_vars,expected_constraints', [ - (False, None, {'InvestStorage|is_invested'}, {'InvestStorage|size|lb'}), - (False, 20, {'InvestStorage|is_invested'}, {'InvestStorage|size|lb'}), + (False, None, {'InvestStorage|invested'}, {'InvestStorage|size|lb'}), + (False, 20, {'InvestStorage|invested'}, {'InvestStorage|size|lb'}), (True, None, set(), set()), (True, 20, set(), set()), ], @@ -479,12 +479,10 @@ def test_investment_parameters( if not mandatory: # Optional investment (mandatory=False) assert constraint_name in model.constraints, f'Expected constraint {constraint_name} not found' - # If mandatory is True, is_invested should be fixed to 1 + # If mandatory is True, invested should be fixed to 1 if mandatory: - # Check that the is_invested variable exists and is fixed to 1 - if 'InvestStorage|is_invested' in model.variables: - var = model.variables['InvestStorage|is_invested'] + # Check that the invested variable exists and is fixed to 1 + if 'InvestStorage|invested' in model.variables: + var = model.variables['InvestStorage|invested'] # Check if the lower and upper bounds are both 1 - assert var.upper == 1 and var.lower == 1, ( - 'is_invested variable should be fixed to 1 when mandatory=True' - ) + assert var.upper == 1 and var.lower == 1, 'invested variable should be fixed to 1 when mandatory=True' From 3fbcb5595a524930a3f9d5a26eee7758c9d4dfc6 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 12 Oct 2025 23:54:48 +0200 Subject: [PATCH 16/19] Update CHANGELOG.md --- CHANGELOG.md | 1 + examples/07_Investment_Periods/README.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40e12ec42..3387eba98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -87,6 +87,7 @@ This replaces `specific_share_to_other_effects_*` parameters and inverts the dir - Renamed class `SystemModel` to `FlowSystemModel` - Renamed class `Model` to `Submodel` - Renamed `mode` parameter in plotting methods to `style` +- Renamed investment binary variable `is_invested` to `invested` in `InvestmentModel` - `Calculation.do_modeling()` now returns the `Calculation` object instead of its `linopy.Model`. Callers that previously accessed the linopy model directly should now use `calculation.do_modeling().model` instead of `calculation.do_modeling()`. ### ♻️ Changed diff --git a/examples/07_Investment_Periods/README.md b/examples/07_Investment_Periods/README.md index 881c436ec..e0f3c9b90 100644 --- a/examples/07_Investment_Periods/README.md +++ b/examples/07_Investment_Periods/README.md @@ -120,5 +120,5 @@ linked_periods=np.array([1, 1, 2, 2, 3]) # Gen 1, Gen 2, Gen 3 1. **Start simple**: Use `linked_periods=(0, 1)` for most long-lived assets 2. **Model reality**: Match linking to actual equipment lifecycles 3. **Cost annualization**: Ensure investment costs are properly annualized to the period duration -4. **Check results**: Verify the `is_invested` binary variable to understand investment timing +4. **Check results**: Verify the `invested` binary variable to understand investment timing 5. **Solver settings**: Multi-period MIP problems may need longer solve times or relaxed MIP gaps From 97b9672c3593805ca9f95413aff5ab4acdb40886 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 13 Oct 2025 00:06:56 +0200 Subject: [PATCH 17/19] Bugfix --- flixopt/interface.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixopt/interface.py b/flixopt/interface.py index f9141ca2a..2f3828e11 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -979,7 +979,7 @@ def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None f'{name_prefix}|linked_periods', self.linked_periods, dims=['period', 'scenario'] ) self.fixed_size = flow_system.fit_to_model_coords( - f'{name_prefix}|fixed_size', self.fixed_size, dims=['scenario'] + f'{name_prefix}|fixed_size', self.fixed_size, dims=['period', 'scenario'] ) @property From 0a34b984dd69e015214332f2c8c7eacf30e5a386 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 13 Oct 2025 00:07:06 +0200 Subject: [PATCH 18/19] Improve docstring --- flixopt/interface.py | 1 + 1 file changed, 1 insertion(+) diff --git a/flixopt/interface.py b/flixopt/interface.py index 2f3828e11..ab47c2522 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -703,6 +703,7 @@ class InvestParameters(Interface): 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. + With multiple periods, at least one period has to have an investment. 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). From 777332485bfece9a12f175d20cc0edccbfe7cc51 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 13 Oct 2025 00:08:13 +0200 Subject: [PATCH 19/19] Improve InvestmentModel to be more inline with the previous Version --- flixopt/features.py | 33 ++++++++++----------------------- 1 file changed, 10 insertions(+), 23 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index 06de48e53..ebec1cfc0 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -64,31 +64,18 @@ def _create_variables_and_constraints(self): upper=size_max, coords=self._model.get_coords(['period', 'scenario']), ) - self.add_variables( - binary=True, - coords=self._model.get_coords(['period', 'scenario']), - short_name='invested', - ) - BoundingPatterns.bounds_with_state( - self, - variable=self.size, - variable_state=self._variables['invested'], - bounds=(self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size), - ) - if self.parameters.mandatory: - self.add_constraints( - (self._variables['invested'].sum('period') >= 1) - if self._model.flow_system.periods is not None - else (self._variables['invested'] == 1), - 'single_invest|mandatory', + if not self.parameters.mandatory: + self.add_variables( + binary=True, + coords=self._model.get_coords(['period', 'scenario']), + short_name='invested', ) - else: - self.add_constraints( - (self._variables['invested'].sum('period') <= 1) - if self._model.flow_system.periods is not None - else (self._variables['invested'] <= 1), - 'single_invest', + BoundingPatterns.bounds_with_state( + self, + variable=self.size, + variable_state=self._variables['invested'], + bounds=(self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size), ) if self.parameters.linked_periods is not None: