diff --git a/docs/user-guide/mathematical-notation/features/InvestParameters.md b/docs/user-guide/mathematical-notation/features/InvestParameters.md index b6e1afe6b..77e485cb3 100644 --- a/docs/user-guide/mathematical-notation/features/InvestParameters.md +++ b/docs/user-guide/mathematical-notation/features/InvestParameters.md @@ -61,6 +61,26 @@ By default, investment is **optional** — the optimizer can choose $P = 0$ (don # → 50 ≤ P ≤ 200 (no zero option) ``` + `mandatory` can be set per period or scenario, forcing the investment only + where it applies: + + ```python + fx.InvestParameters( + minimum_size=50, + maximum_size=200, + mandatory=[True, False], # forced in the first period, optional in the second + ) + ``` + +In general the investment decision is bounded by + +$$ +mandatory \leq s_{inv} \leq \mathbb{1}[P^{max} \neq 0] +$$ + +so an investment is forced where `mandatory` applies, and impossible where the +maximum size is zero. + --- ## Investment Effects diff --git a/flixopt/elements.py b/flixopt/elements.py index 760883fa1..e490a10d7 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -938,9 +938,9 @@ def absolute_flow_rate_bounds(self) -> tuple[xr.DataArray, xr.DataArray]: # Basic case without investment and without Status if self.element.size is not None: lb = lb_relative * self.element.size - elif self.with_investment and self.element.size.mandatory: - # With mandatory Investment - lb = lb_relative * self.element.size.minimum_or_fixed_size + elif self.with_investment and self.element.size.ever_mandatory: + # With mandatory Investment (masked to the periods/scenarios it is mandatory in) + lb = lb_relative * self.element.size.minimum_or_fixed_size * self.element.size.mandatory if self.with_investment: ub = ub_relative * self.element.size.maximum_or_fixed_size diff --git a/flixopt/features.py b/flixopt/features.py index cf214ea15..48ae8cd5f 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -69,13 +69,13 @@ def _create_variables_and_constraints(self): self.add_variables( short_name='size', - lower=size_min if self.parameters.mandatory else 0, + lower=size_min * self.parameters.mandatory, upper=size_max, coords=self._model.get_coords(['period', 'scenario']), category=self._size_category, ) - if not self.parameters.mandatory: + if not self.parameters.always_mandatory: self.add_variables( binary=True, coords=self._model.get_coords(['period', 'scenario']), @@ -88,6 +88,18 @@ def _create_variables_and_constraints(self): state=self._variables['invested'], bounds=(self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size), ) + if self.parameters.ever_mandatory: + self.add_constraints( + self._variables['invested'] >= self.parameters.mandatory, + short_name='mandatory', + ) + + investable = (size_max != 0).astype(int) + if not bool(np.all(investable)): + self.add_constraints( + self._variables['invested'] <= investable, + short_name='investable', + ) if self.parameters.linked_periods is not None: masked_size = self.size.where(self.parameters.linked_periods, drop=True) @@ -108,7 +120,7 @@ def _add_effects(self): target='periodic', ) - if self.parameters.effects_of_retirement and not self.parameters.mandatory: + if self.parameters.effects_of_retirement and self.invested is not None: self._model.effects.add_share_to_effects( name=self.label_of_element, expressions={ diff --git a/flixopt/interface.py b/flixopt/interface.py index 227a63c7a..356b31ea6 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -982,11 +982,13 @@ class InvestParameters(Interface): minimum_size: Lower bound for continuous sizing. Default: CONFIG.Modeling.epsilon. Ignored if fixed_size is specified. maximum_size: Upper bound for continuous sizing. Required if fixed_size is not set. - Ignored if fixed_size is specified. + Ignored if fixed_size is specified. A maximum (or fixed) size of 0 forbids + the investment. 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. + Can be specified per period/scenario to force the investment only in some + of them (e.g. ``[True, False]`` for two periods). 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). @@ -1148,7 +1150,7 @@ def __init__( fixed_size: Numeric_PS | None = None, minimum_size: Numeric_PS | None = None, maximum_size: Numeric_PS | None = None, - mandatory: bool = False, + mandatory: Numeric_PS | bool = False, effects_of_investment: Effect_PS | Numeric_PS | None = None, effects_of_investment_per_size: Effect_PS | Numeric_PS | None = None, effects_of_retirement: Effect_PS | Numeric_PS | None = None, @@ -1237,6 +1239,21 @@ def transform_data(self) -> None: f'{self.prefix}|linked_periods', self.linked_periods, dims=['period', 'scenario'] ) self.fixed_size = self._fit_coords(f'{self.prefix}|fixed_size', self.fixed_size, dims=['period', 'scenario']) + self.mandatory = self._fit_coords( + f'{self.prefix}|mandatory', + self.mandatory if self.mandatory is not None else False, + dims=['period', 'scenario'], + ).astype(int) + + @property + def always_mandatory(self) -> bool: + """Whether the investment is forced in every period and scenario.""" + return bool(np.all(np.asarray(self.mandatory))) + + @property + def ever_mandatory(self) -> bool: + """Whether the investment is forced in at least one period or scenario.""" + return bool(np.any(np.asarray(self.mandatory))) @property def minimum_or_fixed_size(self) -> Numeric_PS: @@ -1256,7 +1273,12 @@ def format_for_repr(self) -> str: if self.fixed_size is not None: val = numeric_to_str_for_repr(self.fixed_size) - status = 'mandatory' if self.mandatory else 'optional' + if self.always_mandatory: + status = 'mandatory' + elif self.ever_mandatory: + status = 'partly mandatory' + else: + status = 'optional' return f'{val} ({status})' # Show range if available diff --git a/flixopt/transform_accessor.py b/flixopt/transform_accessor.py index 579d4597b..77c616aba 100644 --- a/flixopt/transform_accessor.py +++ b/flixopt/transform_accessor.py @@ -1010,9 +1010,11 @@ def fix_sizes( 2. Fix sizes and solve dispatch at full resolution The returned FlowSystem has InvestParameters with fixed_size set, - turning those sizes into constants rather than decision variables. A fixed - size of 0 keeps the investment optional so its fixed effects_of_investment - are not charged, letting the dispatch objective match the sizing run. + turning those sizes into constants rather than decision variables. The + investment decision itself is fixed along with the size: it is mandatory in + every period/scenario with a non-zero size, and impossible where the size is 0. + The solver can therefore neither drop an investment to avoid its fixed + effects_of_investment, nor claim them for a plant it does not build. Args: sizes: The sizes to fix. Can be: @@ -1087,11 +1089,10 @@ def fix_sizes( base_name = size_var[: -len('|size')] if size_var.endswith('|size') else size_var fixed_value = sizes[size_var] - # Only force the investment where every value is non-zero. A fixed size of - # 0 means "do not invest"; mandatory=True would still charge the flat - # effects_of_investment (no invested binary to gate it), so keep it - # optional whenever any period/scenario is 0. - mandatory = bool((fixed_value != 0).all()) + # A fixed size of 0 means "do not invest", every other size means "invest". + # A size of 0 also bounds the invested binary to 0, so the periods that do not + # build are not charged the flat effects_of_investment. + mandatory = (fixed_value != 0).astype(int) found = False for flow in new_fs.flows.values(): diff --git a/tests/test_math/test_multi_period.py b/tests/test_math/test_multi_period.py index 2df1341d7..b78146cc0 100644 --- a/tests/test_math/test_multi_period.py +++ b/tests/test_math/test_multi_period.py @@ -484,6 +484,118 @@ def test_fix_sizes_no_invest_reproduces_objective(self, optimize): assert_allclose(fs_dispatch.solution['Boiler(heat)|size'].item(), 0.0, atol=1e-6) assert_allclose(fs_dispatch.solution['objective'].item(), 160.0, rtol=1e-5) + def test_fix_sizes_enforces_investment_in_nonzero_periods(self, optimize): + """Proves: transform.fix_sizes() forces the investment in every period with a + non-zero size, even when skipping it would be cheaper. + + periods=[2020, 2021], weights [1, 1], 3 ts each. Demand is 0 in 2020 and + [10, 90, 10] in 2021. A DirectHeat source @1.5€ can serve 2021 for 165, which + beats building the Boiler (100 fixed invest + 1 per size + 1 per fuel unit = + 100 + 90 + 110 = 300). Sizes are fixed to [0, 90] from the outside. + + Stage 2 must honor the fixed size: sizes [0, 90] and objective 300, with the + 100€ fixed investment charged in 2021 only (2020 stays at size 0, uncharged). + + Sensitivity: mandatory used to be a single flag, set to False as soon as any + period had size 0. The invested binary was then free in 2021 too, so the solver + dropped the "fixed" 90 kW boiler and returned size [0, 0] with objective 165. + """ + fs = make_multi_period_flow_system(n_timesteps=3, periods=[2020, 2021], weight_of_last_period=1) + demand = xr.DataArray( + np.array([[0, 0, 0], [10, 90, 10]], dtype=float), + coords={'period': [2020, 2021], 'time': fs.timesteps}, + dims=['period', 'time'], + ) + fs.add_elements( + fx.Bus('Heat'), + fx.Bus('Gas'), + fx.Effect('costs', '€', is_standard=True, is_objective=True), + fx.Sink('Demand', inputs=[fx.Flow('heat', bus='Heat', size=1, fixed_relative_profile=demand)]), + fx.Source('DirectHeat', outputs=[fx.Flow('h', bus='Heat', size=200, effects_per_flow_hour=1.5)]), + fx.Source('GasSrc', outputs=[fx.Flow('gas', bus='Gas', effects_per_flow_hour=1)]), + fx.linear_converters.Boiler( + 'Boiler', + thermal_efficiency=1.0, + fuel_flow=fx.Flow('fuel', bus='Gas'), + thermal_flow=fx.Flow( + 'heat', + bus='Heat', + size=fx.InvestParameters( + maximum_size=200, + effects_of_investment=100, + effects_of_investment_per_size=1, + ), + ), + ), + ) + # Without fixed sizes, not investing (165) beats investing (300) + fs_free = optimize(fs) + assert_allclose(fs_free.solution['Boiler(heat)|size'].values, [0.0, 0.0], atol=1e-6) + assert_allclose(fs_free.solution['objective'].item(), 165.0, rtol=1e-5) + + sizes = xr.Dataset( + {'Boiler(heat)': xr.DataArray([0.0, 90.0], coords={'period': [2020, 2021]}, dims=['period'])} + ) + fs_dispatch = fs.transform.fix_sizes(sizes) + fs_dispatch.optimize(_SOLVER) + assert_allclose(fs_dispatch.solution['Boiler(heat)|size'].values, [0.0, 90.0], atol=1e-6) + assert_allclose(fs_dispatch.solution['objective'].item(), 300.0, rtol=1e-5) + # The fixed investment effect is charged in 2021 only + assert_allclose(fs_dispatch.solution['Boiler(heat)->costs(periodic)'].values, [0.0, 190.0], rtol=1e-5) + + def test_fix_sizes_forbids_investment_in_zero_size_periods(self, optimize): + """Proves: transform.fix_sizes() pins the invested binary, not just the size. + A period fixed to size 0 cannot "invest" to dodge effects_of_retirement. + + periods=[2020, 2021], weights [1, 1], 3 ts each. Demand is 0 in 2020 and + [10, 90, 10] in 2021. Boiler: 10€ fixed invest, 1€ per size, 50€ retirement + (charged when NOT investing). Sizes are fixed to [0, 90]. + + 2020 does not build, so it pays the 50€ retirement. 2021 builds 90 kW: + 10 + 90 periodic + 110 fuel = 210. Objective = 50 + 210 = 260. + + Sensitivity: with the size pinned to 0 but the binary left free, invested=1 + still satisfies size = 0 · invested, and buying the 10€ investment to avoid the + 50€ retirement is 40€ cheaper - the solver "invests" in a plant of size 0 and + the objective drops to 220. + """ + fs = make_multi_period_flow_system(n_timesteps=3, periods=[2020, 2021], weight_of_last_period=1) + demand = xr.DataArray( + np.array([[0, 0, 0], [10, 90, 10]], dtype=float), + coords={'period': [2020, 2021], 'time': fs.timesteps}, + dims=['period', 'time'], + ) + fs.add_elements( + fx.Bus('Heat'), + fx.Bus('Gas'), + fx.Effect('costs', '€', is_standard=True, is_objective=True), + fx.Sink('Demand', inputs=[fx.Flow('heat', bus='Heat', size=1, fixed_relative_profile=demand)]), + fx.Source('GasSrc', outputs=[fx.Flow('gas', bus='Gas', effects_per_flow_hour=1)]), + fx.linear_converters.Boiler( + 'Boiler', + thermal_efficiency=1.0, + fuel_flow=fx.Flow('fuel', bus='Gas'), + thermal_flow=fx.Flow( + 'heat', + bus='Heat', + size=fx.InvestParameters( + maximum_size=200, + effects_of_investment=10, + effects_of_investment_per_size=1, + effects_of_retirement=50, + ), + ), + ), + ) + sizes = xr.Dataset( + {'Boiler(heat)': xr.DataArray([0.0, 90.0], coords={'period': [2020, 2021]}, dims=['period'])} + ) + fs_dispatch = fs.transform.fix_sizes(sizes) + fs_dispatch.optimize(_SOLVER) + assert_allclose(fs_dispatch.solution['Boiler(heat)|size'].values, [0.0, 90.0], atol=1e-6) + assert_allclose(fs_dispatch.solution['Boiler(heat)|invested'].values, [0.0, 1.0], atol=1e-6) + assert_allclose(fs_dispatch.solution['objective'].item(), 260.0, rtol=1e-5) + def test_fix_sizes_mixed_period_invest_reproduces_objective(self, optimize): """Proves: transform.fix_sizes() charges investment per period, not globally.