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 new file mode 100644 index 000000000..e0f3c9b90 --- /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 `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..d124d8ffc --- /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, 2029), # 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|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() diff --git a/flixopt/features.py b/flixopt/features.py index 7be0c5f30..ebec1cfc0 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -54,39 +54,35 @@ 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 + size_max = size_max * self.parameters.linked_periods + 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']), ) - # Optional (not mandatory) if not self.parameters.mandatory: self.add_variables( binary=True, coords=self._model.get_coords(['period', 'scenario']), - short_name='is_invested', + short_name='invested', ) - BoundingPatterns.bounds_with_state( self, variable=self.size, - variable_state=self.is_invested, + variable_state=self._variables['invested'], bounds=(self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size), ) - 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_of_investment.piecewise_origin), - piecewise_shares=self.parameters.piecewise_effects_of_investment.piecewise_shares, - zero_point=self.is_invested, - ), - short_name='segments', + if self.parameters.linked_periods is not None: + masked_size = self.size.where(self.parameters.linked_periods, drop=True) + self.add_constraints( + masked_size.isel(period=slice(None, -1)) == masked_size.isel(period=slice(1, None)), + short_name='linked_periods', ) def _add_effects(self): @@ -95,7 +91,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', @@ -105,7 +101,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', @@ -121,17 +117,30 @@ def _add_effects(self): target='periodic', ) + 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_of_investment.piecewise_origin), + piecewise_shares=self.parameters.piecewise_effects_of_investment.piecewise_shares, + zero_point=self.invested, + ), + short_name='segments', + ) + @property def size(self) -> linopy.Variable: """Investment size 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/flixopt/interface.py b/flixopt/interface.py index 72bcd5b77..ab47c2522 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -9,6 +9,10 @@ import warnings 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 @@ -699,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). @@ -719,6 +724,7 @@ class InvestParameters(Interface): Will be removed in version 4.0. optional: DEPRECATED. Use `mandatory` instead. Opposite of `mandatory`. Will be removed in version 4.0. + 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. @@ -875,6 +881,7 @@ def __init__( effects_of_investment_per_size: PeriodicEffectsUser | None = None, effects_of_retirement: PeriodicEffectsUser | None = None, piecewise_effects_of_investment: PiecewiseEffects | None = None, + linked_periods: PeriodicDataUser | tuple[int, int] | None = None, **kwargs, ): # Handle deprecated parameters using centralized helper @@ -918,6 +925,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.linked_periods = linked_periods def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None: self.effects_of_investment = flow_system.fit_effects_to_model_coords( @@ -949,10 +957,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'] ) - 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'] - ) + # 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 be len=2. Got {len(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( + 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'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'] + ) + self.fixed_size = flow_system.fit_to_model_coords( + f'{name_prefix}|fixed_size', self.fixed_size, dims=['period', 'scenario'] + ) @property def optional(self) -> bool: @@ -1016,6 +1045,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): 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'