From 3e9952433522f18382caa5a6957f808e372b8d20 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 30 Nov 2025 14:33:21 +0100 Subject: [PATCH 1/7] =?UTF-8?q?=E2=8F=BA=20The=20vintage=20dimension=20imp?= =?UTF-8?q?lementation=20is=20complete=20and=20all=20tests=20pass.=20Here'?= =?UTF-8?q?s=20a=20summary=20of=20what=20was=20implemented:?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary of Changes 1. core.py - Added 'vintage' to FlowSystemDimensions - Updated FlowSystemDimensions type to include 'vintage' 2. types.py - Added vintage type aliases - Numeric_VS - Vintage, Scenario dimensions - Numeric_PVS - Period, Vintage, Scenario dimensions - Numeric_TPVS - Time, Period, Vintage, Scenario dimensions - Bool_VS, Bool_PVS - Boolean variants - Effect_VS, Effect_PVS - Effect dict variants 3. flow_system.py - Added vintages and vintage_weights - vintages property derived from periods (same values, different name) - vintage_weights defaults to 1.0 for all vintages (one-time costs not scaled) - all_coords property that includes vintage 4. structure.py - Added vintage_weights property - vintage_weights property on FlowSystemModel - Updated get_coords to work with vintage dimension when explicitly requested 5. effects.py - Major updates - Added vintage parameters: vintage_weights, share_from_vintage, minimum_vintage, maximum_vintage, minimum_over_vintages, maximum_over_vintages, minimum_overall, maximum_overall - Added minimum_total/maximum_total for per-period total bounds - Added vintage ShareAllocationModel (only when periods are defined) - Updated objective function to weight temporal/periodic and vintage separately - Added vintage target to add_share_to_effects - Added vintage cross-effect shares in _add_share_between_effects - Kept total variable for backwards compatibility (temporal + periodic only) The remaining tasks (SizingParameters and InvestmentParameters) are separate from the vintage dimension work and would require further implementation to split the current InvestParameters into distinct sizing vs investment classes with the active matrix logic. --- flixopt/core.py | 2 +- flixopt/effects.py | 316 +++++++++++++++++++++++++++++++++++----- flixopt/flow_system.py | 34 +++++ flixopt/structure.py | 25 +++- flixopt/types.py | 58 +++++++- tests/test_effect.py | 125 ++++++++++------ tests/test_scenarios.py | 33 +++-- 7 files changed, 490 insertions(+), 103 deletions(-) diff --git a/flixopt/core.py b/flixopt/core.py index a14aa6654..d2cabc8f4 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -15,7 +15,7 @@ logger = logging.getLogger('flixopt') -FlowSystemDimensions = Literal['time', 'period', 'scenario'] +FlowSystemDimensions = Literal['time', 'period', 'vintage', 'scenario'] """Possible dimensions of a FlowSystem.""" diff --git a/flixopt/effects.py b/flixopt/effects.py index 5dd53258f..216d747a4 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -22,7 +22,7 @@ if TYPE_CHECKING: from collections.abc import Iterator - from .types import Effect_PS, Effect_TPS, Numeric_PS, Numeric_S, Numeric_TPS, Scalar + from .types import Effect_PS, Effect_TPS, Effect_VS, Numeric_PS, Numeric_S, Numeric_TPS, Numeric_VS, Scalar logger = logging.getLogger('flixopt') @@ -55,24 +55,40 @@ class Effect(Element): If provided, overrides the FlowSystem's default period weights for this effect. Useful for effect-specific weighting (e.g., discounting for costs vs equal weights for CO2). If None, uses FlowSystem's default weights. + vintage_weights: Optional custom weights for vintages and scenarios (Numeric_VS). + If provided, overrides the FlowSystem's default vintage weights for this effect. + Useful for discounting one-time investment costs. + If None, uses FlowSystem's default weights (typically 1). share_from_temporal: Temporal cross-effect contributions. Maps temporal contributions from other effects to this effect. share_from_periodic: Periodic cross-effect contributions. Maps periodic contributions from other effects to this effect. + share_from_vintage: Vintage cross-effect contributions. + Maps vintage contributions from other effects to this effect. minimum_temporal: Minimum allowed total contribution across all timesteps (per period). maximum_temporal: Maximum allowed total contribution across all timesteps (per period). minimum_per_hour: Minimum allowed contribution per hour. maximum_per_hour: Maximum allowed contribution per hour. minimum_periodic: Minimum allowed total periodic contribution (per period). maximum_periodic: Maximum allowed total periodic contribution (per period). - minimum_total: Minimum allowed total effect (temporal + periodic combined) per period. - maximum_total: Maximum allowed total effect (temporal + periodic combined) per period. - minimum_over_periods: Minimum allowed weighted sum of total effect across ALL periods. + minimum_vintage: Minimum allowed total vintage contribution (per vintage). + maximum_vintage: Maximum allowed total vintage contribution (per vintage). + minimum_over_periods: Minimum allowed weighted sum of (temporal + periodic) across ALL periods. Weighted by effect-specific weights if defined, otherwise by FlowSystem period weights. Requires FlowSystem to have a 'period' dimension (i.e., periods must be defined). - maximum_over_periods: Maximum allowed weighted sum of total effect across ALL periods. + maximum_over_periods: Maximum allowed weighted sum of (temporal + periodic) across ALL periods. Weighted by effect-specific weights if defined, otherwise by FlowSystem period weights. Requires FlowSystem to have a 'period' dimension (i.e., periods must be defined). + minimum_over_vintages: Minimum allowed weighted sum of vintage across ALL vintages. + Weighted by vintage_weights if defined, otherwise by FlowSystem vintage weights. + Requires FlowSystem to have a 'vintage' dimension. + maximum_over_vintages: Maximum allowed weighted sum of vintage across ALL vintages. + Weighted by vintage_weights if defined, otherwise by FlowSystem vintage weights. + Requires FlowSystem to have a 'vintage' dimension. + minimum_overall: Minimum allowed sum of over_periods + over_vintages. + This is the grand total across all effect categories. + maximum_overall: Maximum allowed sum of over_periods + over_vintages. + This is the grand total across all effect categories. meta_data: Used to store additional information. Not used internally but saved in results. Only use Python native types. @@ -192,18 +208,26 @@ def __init__( is_standard: bool = False, is_objective: bool = False, period_weights: Numeric_PS | None = None, + vintage_weights: Numeric_VS | None = None, share_from_temporal: Effect_TPS | Numeric_TPS | None = None, share_from_periodic: Effect_PS | Numeric_PS | None = None, + share_from_vintage: Effect_VS | Numeric_VS | None = None, minimum_temporal: Numeric_PS | None = None, maximum_temporal: Numeric_PS | None = None, minimum_periodic: Numeric_PS | None = None, maximum_periodic: Numeric_PS | None = None, + minimum_vintage: Numeric_VS | None = None, + maximum_vintage: Numeric_VS | None = None, minimum_per_hour: Numeric_TPS | None = None, maximum_per_hour: Numeric_TPS | None = None, minimum_total: Numeric_PS | None = None, maximum_total: Numeric_PS | None = None, minimum_over_periods: Numeric_S | None = None, maximum_over_periods: Numeric_S | None = None, + minimum_over_vintages: Numeric_S | None = None, + maximum_over_vintages: Numeric_S | None = None, + minimum_overall: Numeric_S | None = None, + maximum_overall: Numeric_S | None = None, ): super().__init__(label, meta_data=meta_data) self.unit = unit @@ -219,29 +243,38 @@ def __init__( self.is_objective = is_objective self.period_weights = period_weights + self.vintage_weights = vintage_weights # Share parameters accept Effect_* | Numeric_* unions (dict or single value). # Store as-is here; transform_data() will normalize via fit_effects_to_model_coords(). # Default to {} when None (no shares defined). self.share_from_temporal = share_from_temporal if share_from_temporal is not None else {} self.share_from_periodic = share_from_periodic if share_from_periodic is not None else {} + self.share_from_vintage = share_from_vintage if share_from_vintage is not None else {} # Set attributes directly self.minimum_temporal = minimum_temporal self.maximum_temporal = maximum_temporal self.minimum_periodic = minimum_periodic self.maximum_periodic = maximum_periodic + self.minimum_vintage = minimum_vintage + self.maximum_vintage = maximum_vintage self.minimum_per_hour = minimum_per_hour self.maximum_per_hour = maximum_per_hour - self.minimum_total = minimum_total - self.maximum_total = maximum_total self.minimum_over_periods = minimum_over_periods self.maximum_over_periods = maximum_over_periods + self.minimum_over_vintages = minimum_over_vintages + self.maximum_over_vintages = maximum_over_vintages + self.minimum_overall = minimum_overall + self.maximum_overall = maximum_overall + self.minimum_total = minimum_total + self.maximum_total = maximum_total def transform_data(self, name_prefix: str = '') -> None: prefix = '|'.join(filter(None, [name_prefix, self.label_full])) self.minimum_per_hour = self._fit_coords(f'{prefix}|minimum_per_hour', self.minimum_per_hour) self.maximum_per_hour = self._fit_coords(f'{prefix}|maximum_per_hour', self.maximum_per_hour) + # Cross-effect shares self.share_from_temporal = self._fit_effect_coords( prefix=None, effect_values=self.share_from_temporal, @@ -254,34 +287,68 @@ def transform_data(self, name_prefix: str = '') -> None: suffix=f'(periodic)->{prefix}(periodic)', dims=['period', 'scenario'], ) + self.share_from_vintage = self._fit_effect_coords( + prefix=None, + effect_values=self.share_from_vintage, + suffix=f'(vintage)->{prefix}(vintage)', + dims=['vintage', 'scenario'], + ) + # Temporal bounds self.minimum_temporal = self._fit_coords( f'{prefix}|minimum_temporal', self.minimum_temporal, dims=['period', 'scenario'] ) self.maximum_temporal = self._fit_coords( f'{prefix}|maximum_temporal', self.maximum_temporal, dims=['period', 'scenario'] ) + + # Periodic bounds self.minimum_periodic = self._fit_coords( f'{prefix}|minimum_periodic', self.minimum_periodic, dims=['period', 'scenario'] ) self.maximum_periodic = self._fit_coords( f'{prefix}|maximum_periodic', self.maximum_periodic, dims=['period', 'scenario'] ) + + # Total bounds (temporal + periodic per period) self.minimum_total = self._fit_coords( f'{prefix}|minimum_total', self.minimum_total, dims=['period', 'scenario'] ) self.maximum_total = self._fit_coords( f'{prefix}|maximum_total', self.maximum_total, dims=['period', 'scenario'] ) + + # Vintage bounds + self.minimum_vintage = self._fit_coords( + f'{prefix}|minimum_vintage', self.minimum_vintage, dims=['vintage', 'scenario'] + ) + self.maximum_vintage = self._fit_coords( + f'{prefix}|maximum_vintage', self.maximum_vintage, dims=['vintage', 'scenario'] + ) + + # Aggregated bounds self.minimum_over_periods = self._fit_coords( f'{prefix}|minimum_over_periods', self.minimum_over_periods, dims=['scenario'] ) self.maximum_over_periods = self._fit_coords( f'{prefix}|maximum_over_periods', self.maximum_over_periods, dims=['scenario'] ) + self.minimum_over_vintages = self._fit_coords( + f'{prefix}|minimum_over_vintages', self.minimum_over_vintages, dims=['scenario'] + ) + self.maximum_over_vintages = self._fit_coords( + f'{prefix}|maximum_over_vintages', self.maximum_over_vintages, dims=['scenario'] + ) + self.minimum_overall = self._fit_coords(f'{prefix}|minimum_overall', self.minimum_overall, dims=['scenario']) + self.maximum_overall = self._fit_coords(f'{prefix}|maximum_overall', self.maximum_overall, dims=['scenario']) + + # Weights self.period_weights = self._fit_coords( f'{prefix}|period_weights', self.period_weights, dims=['period', 'scenario'] ) + self.vintage_weights = self._fit_coords( + f'{prefix}|vintage_weights', self.vintage_weights, dims=['vintage', 'scenario'] + ) def create_model(self, model: FlowSystemModel) -> EffectModel: self._plausibility_checks() @@ -299,14 +366,38 @@ def _plausibility_checks(self) -> None: f'the FlowSystem, or remove these constraints.' ) + # Check that minimum_over_vintages and maximum_over_vintages require a vintage dimension + if ( + self.minimum_over_vintages is not None or self.maximum_over_vintages is not None + ) and self.flow_system.vintages is None: + raise PlausibilityError( + f"Effect '{self.label}': minimum_over_vintages and maximum_over_vintages require " + f"the FlowSystem to have a 'vintage' dimension. Please define periods when creating " + f'the FlowSystem (vintages are derived from periods), or remove these constraints.' + ) + + # Check that minimum_overall and maximum_overall require both dimensions + if (self.minimum_overall is not None or self.maximum_overall is not None) and self.flow_system.periods is None: + raise PlausibilityError( + f"Effect '{self.label}': minimum_overall and maximum_overall require " + f"the FlowSystem to have a 'period' dimension. Please define periods when creating " + f'the FlowSystem, or remove these constraints.' + ) + class EffectModel(ElementModel): """Mathematical model implementation for Effects. Creates optimization variables and constraints for effect aggregation, - including periodic and temporal tracking, cross-effect contributions, + including temporal, periodic, and vintage tracking, cross-effect contributions, and effect bounds. + The three effect categories have different dimensions and are only combined + in the objective function after proper weighting: + - temporal: (time, period, scenario) - operational costs over time + - periodic: (period, scenario) - recurring costs per period + - vintage: (vintage, scenario) - one-time investment costs + Mathematical Formulation: See """ @@ -336,23 +427,30 @@ def period_weights(self) -> xr.DataArray: return default_weights return self.element._fit_coords(name='period_weights', data=1, dims=['period']) + @property + def vintage_weights(self) -> xr.DataArray: + """ + Get vintage weights for this effect. + + Returns effect-specific weights if defined, otherwise falls back to FlowSystem vintage weights. + Default vintage weights are 1 (one-time costs not scaled by period duration). + + Returns: + Weights with vintage dimensions (if applicable) + """ + effect_weights = self.element.vintage_weights + default_weights = self.element._flow_system.vintage_weights + if effect_weights is not None: # Use effect-specific weights + return effect_weights + elif default_weights is not None: # Fall back to FlowSystem weights + return default_weights + return self.element._fit_coords(name='vintage_weights', data=1, dims=['vintage']) + def _do_modeling(self): """Create variables, constraints, and nested submodels""" super()._do_modeling() - self.total: linopy.Variable | None = None - self.periodic: ShareAllocationModel = self.add_submodels( - ShareAllocationModel( - model=self._model, - dims=('period', 'scenario'), - label_of_element=self.label_of_element, - label_of_model=f'{self.label_of_model}(periodic)', - total_max=self.element.maximum_periodic, - total_min=self.element.minimum_periodic, - ), - short_name='periodic', - ) - + # Temporal effects: operational costs over time self.temporal: ShareAllocationModel = self.add_submodels( ShareAllocationModel( model=self._model, @@ -367,21 +465,79 @@ def _do_modeling(self): short_name='temporal', ) + # Periodic effects: recurring costs per period + self.periodic: ShareAllocationModel = self.add_submodels( + ShareAllocationModel( + model=self._model, + dims=('period', 'scenario'), + label_of_element=self.label_of_element, + label_of_model=f'{self.label_of_model}(periodic)', + total_max=self.element.maximum_periodic, + total_min=self.element.minimum_periodic, + ), + short_name='periodic', + ) + + # Vintage effects: one-time investment costs + # Only create if FlowSystem has vintages defined + self.vintage: ShareAllocationModel | None = None + if self._model.flow_system.vintages is not None: + self.vintage = self.add_submodels( + ShareAllocationModel( + model=self._model, + dims=('vintage', 'scenario'), + label_of_element=self.label_of_element, + label_of_model=f'{self.label_of_model}(vintage)', + total_max=self.element.maximum_vintage, + total_min=self.element.minimum_vintage, + ), + short_name='vintage', + ) + + # NOTE: Vintage has a different dimension than temporal/periodic, so we cannot have a + # unified 'total' that includes all three. However, we keep total = temporal + periodic + # for backwards compatibility and per-period constraints. + + # Add total variable and constraint for temporal + periodic (same period dimension) + self._add_total_constraint() + + # Add weighted sum over all periods constraint for temporal + periodic + self._add_over_periods_constraints() + + # Add weighted sum over all vintages constraint + self._add_over_vintages_constraints() + + # Add overall constraint (over_periods + over_vintages) + self._add_overall_constraints() + + def _add_total_constraint(self): + """Add total = temporal + periodic constraint (per period, does not include vintage).""" + # Only create if bounds are specified or for backwards compatibility tracking + # For now, always create to maintain backwards compatibility + lower = self.element.minimum_total if self.element.minimum_total is not None else -np.inf + upper = self.element.maximum_total if self.element.maximum_total is not None else np.inf + self.total = self.add_variables( - lower=self.element.minimum_total if self.element.minimum_total is not None else -np.inf, - upper=self.element.maximum_total if self.element.maximum_total is not None else np.inf, + lower=lower, + upper=upper, coords=self._model.get_coords(['period', 'scenario']), name=self.label_full, ) self.add_constraints( - self.total == self.temporal.total + self.periodic.total, name=self.label_full, short_name='total' + self.total == self.temporal.total + self.periodic.total, + name=self.label_full, + short_name='total', ) - # Add weighted sum over all periods constraint if minimum_over_periods or maximum_over_periods is defined + def _add_over_periods_constraints(self): + """Add constraints for weighted sum of temporal + periodic over all periods.""" if self.element.minimum_over_periods is not None or self.element.maximum_over_periods is not None: + # Sum temporal and periodic (they share the same period dimension) + period_total = self.temporal.total + self.periodic.total + # Calculate weighted sum over all periods - weighted_total = (self.total * self.period_weights).sum('period') + weighted_total = (period_total * self.period_weights).sum('period') # Create tracking variable for the weighted sum self.total_over_periods = self.add_variables( @@ -393,6 +549,51 @@ def _do_modeling(self): self.add_constraints(self.total_over_periods == weighted_total, short_name='total_over_periods') + def _add_over_vintages_constraints(self): + """Add constraints for weighted sum of vintage over all vintages.""" + if self.vintage is None: + return + + if self.element.minimum_over_vintages is not None or self.element.maximum_over_vintages is not None: + # Calculate weighted sum over all vintages + weighted_total = (self.vintage.total * self.vintage_weights).sum('vintage') + + # Create tracking variable for the weighted sum + self.total_over_vintages = self.add_variables( + lower=self.element.minimum_over_vintages if self.element.minimum_over_vintages is not None else -np.inf, + upper=self.element.maximum_over_vintages if self.element.maximum_over_vintages is not None else np.inf, + coords=self._model.get_coords(['scenario']), + short_name='total_over_vintages', + ) + + self.add_constraints(self.total_over_vintages == weighted_total, short_name='total_over_vintages') + + def _add_overall_constraints(self): + """Add constraints for overall sum (over_periods + over_vintages).""" + if self.element.minimum_overall is None and self.element.maximum_overall is None: + return + + # Calculate period contribution (temporal + periodic weighted sum) + period_total = self.temporal.total + self.periodic.total + weighted_periods = (period_total * self.period_weights).sum('period') + + # Calculate vintage contribution (if exists) + if self.vintage is not None: + weighted_vintages = (self.vintage.total * self.vintage_weights).sum('vintage') + overall_total = weighted_periods + weighted_vintages + else: + overall_total = weighted_periods + + # Create tracking variable for overall + self.total_overall = self.add_variables( + lower=self.element.minimum_overall if self.element.minimum_overall is not None else -np.inf, + upper=self.element.maximum_overall if self.element.maximum_overall is not None else np.inf, + coords=self._model.get_coords(['scenario']), + short_name='total_overall', + ) + + self.add_constraints(self.total_overall == overall_total, short_name='total_overall') + EffectExpr = dict[str, linopy.LinearExpression] # Used to create Shares @@ -643,7 +844,7 @@ def add_share_to_effects( self, name: str, expressions: EffectExpr, - target: Literal['temporal', 'periodic'], + target: Literal['temporal', 'periodic', 'vintage'], ) -> None: for effect, expression in expressions.items(): if target == 'temporal': @@ -658,8 +859,19 @@ def add_share_to_effects( expression, dims=('period', 'scenario'), ) + elif target == 'vintage': + if self.effects[effect].submodel.vintage is None: + raise ValueError( + f"Cannot add vintage share to effect '{effect}': " + f'FlowSystem has no vintage dimension (periods not defined).' + ) + self.effects[effect].submodel.vintage.add_share( + name, + expression, + dims=('vintage', 'scenario'), + ) else: - raise ValueError(f'Target {target} not supported!') + raise ValueError(f'Target {target} not supported! Use "temporal", "periodic", or "vintage".') def _do_modeling(self): """Create variables, constraints, and nested submodels""" @@ -679,11 +891,35 @@ def _do_modeling(self): # Add cross-effect shares self._add_share_between_effects() - # Use objective weights with objective effect and penalty effect - self._model.add_objective( - (self.effects.objective_effect.submodel.total * self._model.objective_weights).sum() - + (self.effects.penalty_effect.submodel.total * self._model.objective_weights).sum() - ) + # Build objective function with separate weighting for each effect category + self._add_objective() + + def _add_objective(self): + """Build objective function combining temporal, periodic, and vintage effects.""" + obj_effect = self.effects.objective_effect.submodel + pen_effect = self.effects.penalty_effect.submodel + + # Period-weighted components: (temporal + periodic) × period_weights × scenario_weights + period_weights = obj_effect.period_weights + scenario_weights = self._model.scenario_weights + + obj_temporal_weighted = (obj_effect.temporal.total * period_weights * scenario_weights).sum() + obj_periodic_weighted = (obj_effect.periodic.total * period_weights * scenario_weights).sum() + + pen_temporal_weighted = (pen_effect.temporal.total * period_weights * scenario_weights).sum() + pen_periodic_weighted = (pen_effect.periodic.total * period_weights * scenario_weights).sum() + + objective = obj_temporal_weighted + obj_periodic_weighted + pen_temporal_weighted + pen_periodic_weighted + + # Vintage-weighted component: vintage × vintage_weights × scenario_weights + # Only if vintage dimension exists + if obj_effect.vintage is not None: + vintage_weights = obj_effect.vintage_weights + obj_vintage_weighted = (obj_effect.vintage.total * vintage_weights * scenario_weights).sum() + pen_vintage_weighted = (pen_effect.vintage.total * vintage_weights * scenario_weights).sum() + objective = objective + obj_vintage_weighted + pen_vintage_weighted + + self._model.add_objective(objective) def _add_share_between_effects(self): for target_effect in self.effects.values(): @@ -701,6 +937,20 @@ def _add_share_between_effects(self): self.effects[source_effect].submodel.periodic.total * factor, dims=('period', 'scenario'), ) + # 3. vintage: <- receiving vintage shares from other effects + for source_effect, factor in target_effect.share_from_vintage.items(): + source_vintage = self.effects[source_effect].submodel.vintage + target_vintage = target_effect.submodel.vintage + if source_vintage is None or target_vintage is None: + raise ValueError( + f"Cannot add vintage cross-effect share from '{source_effect}' to '{target_effect.label}': " + f'FlowSystem has no vintage dimension (periods not defined).' + ) + target_vintage.add_share( + source_vintage.label_full, + source_vintage.total * factor, + dims=('vintage', 'scenario'), + ) def calculate_all_conversion_paths( diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 9015de3e4..bec5eda51 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -177,6 +177,12 @@ def __init__( self.periods = None if periods is None else self._validate_periods(periods) self.scenarios = None if scenarios is None else self._validate_scenarios(scenarios) + # Vintage dimension: same coordinate values as period, but different semantics + # Used for tracking one-time investment costs at decision points + self.vintages: pd.Index | None = None + if self.periods is not None: + self.vintages = self.periods.rename('vintage') + self.hours_per_timestep = self.fit_to_model_coords('hours_per_timestep', hours_per_timestep) self.scenario_weights = scenario_weights # Use setter @@ -188,6 +194,17 @@ def __init__( self.period_weights: xr.DataArray | None = weight_per_period + # Vintage weights: default to 1 (one-time costs not scaled by period duration) + # Can be used for discounting investment costs + self.vintage_weights: xr.DataArray | None = None + if self.vintages is not None: + self.vintage_weights = xr.DataArray( + np.ones(len(self.vintages)), + coords={'vintage': self.vintages}, + dims='vintage', + name='vintage_weights', + ) + # Element collections self.components: ElementContainer[Component] = ElementContainer( element_type_name='components', truncate_repr=10 @@ -1069,9 +1086,26 @@ def flows(self) -> ElementContainer[Flow]: @property def coords(self) -> dict[FlowSystemDimensions, pd.Index]: + """Default coordinates for variables (time, period, scenario). + + Note: vintage is NOT included by default as it's a separate dimension + for one-time investment costs. Use `all_coords` to include vintage. + """ + active_coords = {'time': self.timesteps} + if self.periods is not None: + active_coords['period'] = self.periods + if self.scenarios is not None: + active_coords['scenario'] = self.scenarios + return active_coords + + @property + def all_coords(self) -> dict[FlowSystemDimensions, pd.Index]: + """All coordinates including vintage dimension.""" active_coords = {'time': self.timesteps} if self.periods is not None: active_coords['period'] = self.periods + if self.vintages is not None: + active_coords['vintage'] = self.vintages if self.scenarios is not None: active_coords['scenario'] = self.scenarios return active_coords diff --git a/flixopt/structure.py b/flixopt/structure.py index 62067e2ba..0e1121259 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -214,6 +214,24 @@ def scenario_weights(self) -> xr.DataArray: raise ValueError('FlowSystemModel.scenario_weights: weights sum to 0; cannot normalize.') return scenario_weights / norm + @property + def vintage_weights(self) -> xr.DataArray: + """ + Vintage weights for one-time investment costs. Default is 1 (no scaling). + Can be used for discounting investment costs. + """ + if self.flow_system.vintages is None: + return xr.DataArray(1) + + if self.flow_system.vintage_weights is None: + return xr.DataArray( + np.ones(self.flow_system.vintages.size, dtype=float), + coords={'vintage': self.flow_system.vintages}, + dims=['vintage'], + name='vintage_weights', + ) + return self.flow_system.vintage_weights + @property def objective_weights(self) -> xr.DataArray: """ @@ -233,7 +251,8 @@ def get_coords( Returns the coordinates of the model Args: - dims: The dimensions to include in the coordinates. If None, includes all dimensions + dims: The dimensions to include in the coordinates. If None, includes default dimensions + (time, period, scenario). To include vintage, explicitly pass it in dims. extra_timestep: If True, uses extra timesteps instead of regular timesteps Returns: @@ -246,9 +265,11 @@ def get_coords( raise ValueError('extra_timestep=True requires "time" to be included in dims') if dims is None: + # Default: use coords without vintage coords = dict(self.flow_system.coords) else: - coords = {k: v for k, v in self.flow_system.coords.items() if k in dims} + # When dims specified, use all_coords to allow vintage selection + coords = {k: v for k, v in self.flow_system.all_coords.items() if k in dims} if extra_timestep and coords: coords['time'] = self.flow_system.timesteps_extra diff --git a/flixopt/types.py b/flixopt/types.py index 998639dcf..6019a82d2 100644 --- a/flixopt/types.py +++ b/flixopt/types.py @@ -3,19 +3,22 @@ Type aliases use suffix notation to indicate maximum dimensions. Data can have any subset of these dimensions (including scalars, which are broadcast to all dimensions). -| Suffix | Dimensions | Use Case | -|--------|------------|----------| -| `_TPS` | Time, Period, Scenario | Time-varying data across all dimensions | -| `_PS` | Period, Scenario | Investment parameters (no time variation) | -| `_S` | Scenario | Scenario-specific parameters | -| (none) | Scalar only | Single numeric values | +| Suffix | Dimensions | Use Case | +|---------|---------------------------------|----------| +| `_TPVS` | Time, Period, Vintage, Scenario | Time-varying vintage-dependent data | +| `_TPS` | Time, Period, Scenario | Time-varying data across all dimensions | +| `_PVS` | Period, Vintage, Scenario | Vintage-dependent period parameters | +| `_VS` | Vintage, Scenario | One-time investment parameters | +| `_PS` | Period, Scenario | Recurring investment parameters | +| `_S` | Scenario | Scenario-specific parameters | +| (none) | Scalar only | Single numeric values | All dimensioned types accept: scalars (`int`, `float`), arrays (`ndarray`), Series (`pd.Series`), DataFrames (`pd.DataFrame`), or DataArrays (`xr.DataArray`). Example: ```python - from flixopt.types import Numeric_TPS, Numeric_PS, Scalar + from flixopt.types import Numeric_TPS, Numeric_PS, Numeric_VS, Scalar def create_flow( @@ -68,6 +71,16 @@ def create_flow( Numeric_S: TypeAlias = int | float | np.integer | np.floating | np.ndarray | pd.Series | pd.DataFrame | xr.DataArray """Scenario dimension. For scenario-specific parameters (e.g., discount rates).""" +# Vintage-related numeric types +Numeric_VS: TypeAlias = int | float | np.integer | np.floating | np.ndarray | pd.Series | pd.DataFrame | xr.DataArray +"""Vintage, Scenario dimensions. For one-time investment parameters.""" + +Numeric_PVS: TypeAlias = int | float | np.integer | np.floating | np.ndarray | pd.Series | pd.DataFrame | xr.DataArray +"""Period, Vintage, Scenario dimensions. For vintage-dependent parameters that vary by period (e.g., age-dependent efficiency).""" + +Numeric_TPVS: TypeAlias = int | float | np.integer | np.floating | np.ndarray | pd.Series | pd.DataFrame | xr.DataArray +"""Time, Period, Vintage, Scenario dimensions. For time-varying vintage-dependent data.""" + # Boolean data types - Repeating types instead of using common var for better docs rendering Bool_TPS: TypeAlias = bool | np.bool_ | np.ndarray | pd.Series | pd.DataFrame | xr.DataArray @@ -79,6 +92,13 @@ def create_flow( Bool_S: TypeAlias = bool | np.bool_ | np.ndarray | pd.Series | pd.DataFrame | xr.DataArray """Scenario dimension. For scenario-specific binary flags.""" +# Vintage-related boolean types +Bool_VS: TypeAlias = bool | np.bool_ | np.ndarray | pd.Series | pd.DataFrame | xr.DataArray +"""Vintage, Scenario dimensions. For vintage-specific binary decisions.""" + +Bool_PVS: TypeAlias = bool | np.bool_ | np.ndarray | pd.Series | pd.DataFrame | xr.DataArray +"""Period, Vintage, Scenario dimensions. For period+vintage binary flags.""" + # Effect data types Effect_TPS: TypeAlias = dict[ @@ -99,6 +119,19 @@ def create_flow( """Scenario dimension. Dict mapping effect names to numeric values. For scenario-specific effects (carbon prices). Use `Effect_S | Numeric_S` to accept single values.""" +# Vintage-related effect types +Effect_VS: TypeAlias = dict[ + str, int | float | np.integer | np.floating | np.ndarray | pd.Series | pd.DataFrame | xr.DataArray +] +"""Vintage, Scenario dimensions. Dict mapping effect names to numeric values. +For one-time investment effects (purchase costs, permits).""" + +Effect_PVS: TypeAlias = dict[ + str, int | float | np.integer | np.floating | np.ndarray | pd.Series | pd.DataFrame | xr.DataArray +] +"""Period, Vintage, Scenario dimensions. Dict mapping effect names to numeric values. +For vintage-dependent effects that vary by period.""" + # Scalar type (no dimensions) Scalar: TypeAlias = int | float | np.integer | np.floating @@ -106,15 +139,26 @@ def create_flow( # Export public API __all__ = [ + # Numeric types 'Numeric_TPS', 'Numeric_PS', 'Numeric_S', + 'Numeric_VS', + 'Numeric_PVS', + 'Numeric_TPVS', + # Boolean types 'Bool_TPS', 'Bool_PS', 'Bool_S', + 'Bool_VS', + 'Bool_PVS', + # Effect types 'Effect_TPS', 'Effect_PS', 'Effect_S', + 'Effect_VS', + 'Effect_PVS', + # Other 'Scalar', 'NumericOrBool', ] diff --git a/tests/test_effect.py b/tests/test_effect.py index 33ce59f9e..ee2f69b2e 100644 --- a/tests/test_effect.py +++ b/tests/test_effect.py @@ -22,31 +22,39 @@ def test_minimal(self, basic_flow_system_linopy_coords, coords_config): flow_system.add_elements(effect) model = create_linopy_model(flow_system) + # Expected variables: temporal, periodic, total (temporal + periodic) + # vintage only created if periods are defined + expected_vars = { + 'Effect1(periodic)', + 'Effect1(temporal)', + 'Effect1(temporal)|per_timestep', + 'Effect1', # total = temporal + periodic + } + if flow_system.vintages is not None: + expected_vars.add('Effect1(vintage)') + assert_sets_equal( set(effect.submodel.variables), - { - 'Effect1(periodic)', - 'Effect1(temporal)', - 'Effect1(temporal)|per_timestep', - 'Effect1', - }, + expected_vars, msg='Incorrect variables', ) + # Expected constraints: temporal, periodic, total + expected_cons = { + 'Effect1(periodic)', + 'Effect1(temporal)', + 'Effect1(temporal)|per_timestep', + 'Effect1', # total = temporal + periodic + } + if flow_system.vintages is not None: + expected_cons.add('Effect1(vintage)') + assert_sets_equal( set(effect.submodel.constraints), - { - 'Effect1(periodic)', - 'Effect1(temporal)', - 'Effect1(temporal)|per_timestep', - 'Effect1', - }, + expected_cons, msg='Incorrect constraints', ) - assert_var_equal( - model.variables['Effect1'], model.add_variables(coords=model.get_coords(['period', 'scenario'])) - ) assert_var_equal( model.variables['Effect1(periodic)'], model.add_variables(coords=model.get_coords(['period', 'scenario'])) ) @@ -55,13 +63,15 @@ def test_minimal(self, basic_flow_system_linopy_coords, coords_config): model.add_variables(coords=model.get_coords(['period', 'scenario'])), ) assert_var_equal( - model.variables['Effect1(temporal)|per_timestep'], model.add_variables(coords=model.get_coords()) + model.variables['Effect1(temporal)|per_timestep'], + model.add_variables(coords=model.get_coords(['time', 'period', 'scenario'])), ) - assert_conequal( - model.constraints['Effect1'], - model.variables['Effect1'] == model.variables['Effect1(temporal)'] + model.variables['Effect1(periodic)'], + # Check total variable (temporal + periodic) + assert_var_equal( + model.variables['Effect1'], model.add_variables(coords=model.get_coords(['period', 'scenario'])) ) + # In minimal/bounds tests with no contributing components, periodic totals should be zero assert_conequal(model.constraints['Effect1(periodic)'], model.variables['Effect1(periodic)'] == 0) assert_conequal( @@ -72,6 +82,11 @@ def test_minimal(self, basic_flow_system_linopy_coords, coords_config): model.constraints['Effect1(temporal)|per_timestep'], model.variables['Effect1(temporal)|per_timestep'] == 0, ) + # Check total constraint: total = temporal + periodic + assert_conequal( + model.constraints['Effect1'], + model.variables['Effect1'] == model.variables['Effect1(temporal)'] + model.variables['Effect1(periodic)'], + ) def test_bounds(self, basic_flow_system_linopy_coords, coords_config): flow_system, coords_config = basic_flow_system_linopy_coords, coords_config @@ -92,25 +107,33 @@ def test_bounds(self, basic_flow_system_linopy_coords, coords_config): flow_system.add_elements(effect) model = create_linopy_model(flow_system) + expected_vars = { + 'Effect1(periodic)', + 'Effect1(temporal)', + 'Effect1(temporal)|per_timestep', + 'Effect1', + } + if flow_system.vintages is not None: + expected_vars.add('Effect1(vintage)') + assert_sets_equal( set(effect.submodel.variables), - { - 'Effect1(periodic)', - 'Effect1(temporal)', - 'Effect1(temporal)|per_timestep', - 'Effect1', - }, + expected_vars, msg='Incorrect variables', ) + expected_cons = { + 'Effect1(periodic)', + 'Effect1(temporal)', + 'Effect1(temporal)|per_timestep', + 'Effect1', + } + if flow_system.vintages is not None: + expected_cons.add('Effect1(vintage)') + assert_sets_equal( set(effect.submodel.constraints), - { - 'Effect1(periodic)', - 'Effect1(temporal)', - 'Effect1(temporal)|per_timestep', - 'Effect1', - }, + expected_cons, msg='Incorrect constraints', ) @@ -174,29 +197,37 @@ def test_shares(self, basic_flow_system_linopy_coords, coords_config): flow_system.add_elements(effect1, effect2, effect3) model = create_linopy_model(flow_system) + expected_vars = { + 'Effect2(periodic)', + 'Effect2(temporal)', + 'Effect2(temporal)|per_timestep', + 'Effect2', + 'Effect1(periodic)->Effect2(periodic)', + 'Effect1(temporal)->Effect2(temporal)', + } + if flow_system.vintages is not None: + expected_vars.add('Effect2(vintage)') + assert_sets_equal( set(effect2.submodel.variables), - { - 'Effect2(periodic)', - 'Effect2(temporal)', - 'Effect2(temporal)|per_timestep', - 'Effect2', - 'Effect1(periodic)->Effect2(periodic)', - 'Effect1(temporal)->Effect2(temporal)', - }, + expected_vars, msg='Incorrect variables for effect2', ) + expected_cons = { + 'Effect2(periodic)', + 'Effect2(temporal)', + 'Effect2(temporal)|per_timestep', + 'Effect2', + 'Effect1(periodic)->Effect2(periodic)', + 'Effect1(temporal)->Effect2(temporal)', + } + if flow_system.vintages is not None: + expected_cons.add('Effect2(vintage)') + assert_sets_equal( set(effect2.submodel.constraints), - { - 'Effect2(periodic)', - 'Effect2(temporal)', - 'Effect2(temporal)|per_timestep', - 'Effect2', - 'Effect1(periodic)->Effect2(periodic)', - 'Effect1(temporal)->Effect2(temporal)', - }, + expected_cons, msg='Incorrect constraints for effect2', ) diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index a5eb3d6a2..83c6c1692 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -251,12 +251,16 @@ def test_weights(flow_system_piecewise_conversion_scenarios): model = create_linopy_model(flow_system_piecewise_conversion_scenarios) normalized_weights = scenario_weights / sum(scenario_weights) np.testing.assert_allclose(model.objective_weights.values, normalized_weights) - # Penalty is now an effect with temporal and periodic components - penalty_total = flow_system_piecewise_conversion_scenarios.effects.penalty_effect.submodel.total - assert_linequal( - model.objective.expression, - (model.variables['costs'] * normalized_weights).sum() + (penalty_total * normalized_weights).sum(), - ) + # Objective uses temporal.total + periodic.total separately weighted + costs_submodel = flow_system_piecewise_conversion_scenarios.effects.objective_effect.submodel + penalty_submodel = flow_system_piecewise_conversion_scenarios.effects.penalty_effect.submodel + expected_obj = ( + (costs_submodel.temporal.total * normalized_weights).sum() + + (costs_submodel.periodic.total * normalized_weights).sum() + + (penalty_submodel.temporal.total * normalized_weights).sum() + + (penalty_submodel.periodic.total * normalized_weights).sum() + ) + assert_linequal(model.objective.expression, expected_obj) assert np.isclose(model.objective_weights.sum().item(), 1) @@ -274,13 +278,16 @@ def test_weights_io(flow_system_piecewise_conversion_scenarios): model = create_linopy_model(flow_system_piecewise_conversion_scenarios) np.testing.assert_allclose(model.objective_weights.values, normalized_scenario_weights_da) - # Penalty is now an effect with temporal and periodic components - penalty_total = flow_system_piecewise_conversion_scenarios.effects.penalty_effect.submodel.total - assert_linequal( - model.objective.expression, - (model.variables['costs'] * normalized_scenario_weights_da).sum() - + (penalty_total * normalized_scenario_weights_da).sum(), - ) + # Objective uses temporal.total + periodic.total separately weighted + costs_submodel = flow_system_piecewise_conversion_scenarios.effects.objective_effect.submodel + penalty_submodel = flow_system_piecewise_conversion_scenarios.effects.penalty_effect.submodel + expected_obj = ( + (costs_submodel.temporal.total * normalized_scenario_weights_da).sum() + + (costs_submodel.periodic.total * normalized_scenario_weights_da).sum() + + (penalty_submodel.temporal.total * normalized_scenario_weights_da).sum() + + (penalty_submodel.periodic.total * normalized_scenario_weights_da).sum() + ) + assert_linequal(model.objective.expression, expected_obj) assert np.isclose(model.objective_weights.sum().item(), 1.0) From 85a588cc96c9614a580e8a617e1b9e9bc2dfd978 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 30 Nov 2025 14:45:43 +0100 Subject: [PATCH 2/7] Temp --- flixopt/__init__.py | 13 +- flixopt/features.py | 188 +++++++-------- flixopt/interface.py | 533 ++++++++++++++++++++++--------------------- flixopt/types.py | 20 ++ 4 files changed, 404 insertions(+), 350 deletions(-) diff --git a/flixopt/__init__.py b/flixopt/__init__.py index 8874811b3..c96b06857 100644 --- a/flixopt/__init__.py +++ b/flixopt/__init__.py @@ -28,7 +28,16 @@ from .effects import PENALTY_EFFECT_LABEL, Effect from .elements import Bus, Flow from .flow_system import FlowSystem -from .interface import InvestParameters, Piece, Piecewise, PiecewiseConversion, PiecewiseEffects, StatusParameters +from .interface import ( + InvestmentParameters, + InvestParameters, + Piece, + Piecewise, + PiecewiseConversion, + PiecewiseEffects, + SizingParameters, + StatusParameters, +) from .optimization import ClusteredOptimization, Optimization, SegmentedOptimization __all__ = [ @@ -49,6 +58,8 @@ 'ClusteredOptimization', 'SegmentedOptimization', 'InvestParameters', + 'SizingParameters', + 'InvestmentParameters', 'StatusParameters', 'Piece', 'Piecewise', diff --git a/flixopt/features.py b/flixopt/features.py index cd9e07151..4aceaa4d3 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -5,6 +5,8 @@ from __future__ import annotations +import logging +import warnings from typing import TYPE_CHECKING import linopy @@ -13,140 +15,142 @@ from .modeling import BoundingPatterns, ModelingPrimitives, ModelingUtilities from .structure import FlowSystemModel, Submodel +logger = logging.getLogger('flixopt') + if TYPE_CHECKING: from collections.abc import Collection import xarray as xr from .core import FlowSystemDimensions - from .interface import InvestParameters, Piecewise, StatusParameters - from .types import Numeric_PS, Numeric_TPS - - -class InvestmentModel(Submodel): - """Mathematical model implementation for investment decisions. + from .interface import Piecewise, SizingParameters, StatusParameters + from .types import Numeric_PS, Numeric_TPS, PeriodicData, PeriodicEffects - Creates optimization variables and constraints for investment sizing decisions, - supporting both binary and continuous sizing with comprehensive effect modeling. - - Mathematical Formulation: - See - - Args: - model: The optimization model instance - label_of_element: The label of the parent (Element). Used to construct the full label of the model. - parameters: The parameters of the feature model. - label_of_model: The label of the model. This is needed to construct the full label of the model. - """ - parameters: InvestParameters +class _SizeModel(Submodel): + """A model that creates the size variable together with an optional binary availability variable.""" - def __init__( + def _create_sizing_variables_and_constraints( self, - model: FlowSystemModel, - label_of_element: str, - parameters: InvestParameters, - label_of_model: str | None = None, + size_min: PeriodicData, + size_max: PeriodicData, + mandatory: PeriodicData, + dims: list[FlowSystemDimensions], + force_available: bool = False, ): - self.piecewise_effects: PiecewiseEffectsModel | None = None - self.parameters = parameters - super().__init__(model, label_of_element=label_of_element, label_of_model=label_of_model) + """Create sizing variables and constraints. - def _do_modeling(self): - super()._do_modeling() - self._create_variables_and_constraints() - self._add_effects() - - 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: - # Mask size bounds: linked_periods is a binary DataArray that zeros out non-linked periods - size_min = size_min * self.parameters.linked_periods - size_max = size_max * self.parameters.linked_periods + Args: + size_min: Minimum size bound + size_max: Maximum size bound + mandatory: Whether sizing is mandatory (forces available=1) + dims: Dimensions for the variables + force_available: If True, always create the available binary variable + """ + if not np.issubdtype(mandatory.dtype, np.bool_): + raise TypeError(f'Expected all bool values, got {mandatory.dtype=}: {mandatory}') - self.add_variables( + size = self.add_variables( short_name='size', - lower=size_min if self.parameters.mandatory else 0, + lower=size_min.where(mandatory, 0), upper=size_max, - coords=self._model.get_coords(['period', 'scenario']), + coords=self._model.get_coords(dims), ) - if not self.parameters.mandatory: + if force_available or mandatory.any(): self.add_variables( binary=True, - coords=self._model.get_coords(['period', 'scenario']), - short_name='invested', - ) - BoundingPatterns.bounds_with_state( - self, - variable=self.size, - state=self._variables['invested'], - bounds=(self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size), + coords=self._model.get_coords(dims), + short_name='available', ) - - 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', + self.available.where(mandatory) == 1, + short_name='mandatory', ) - - def _add_effects(self): - """Add investment effects""" - if self.parameters.effects_of_investment: - self._model.effects.add_share_to_effects( - name=self.label_of_element, - expressions={ - effect: self.invested * factor if self.invested is not None else factor - for effect, factor in self.parameters.effects_of_investment.items() - }, - target='periodic', + BoundingPatterns.bounds_with_state( + self, + variable=size, + state=self._variables['available'], + bounds=(size_min, size_max), ) - if self.parameters.effects_of_retirement and not self.parameters.mandatory: + def _add_sizing_effects(self, effects_per_size: PeriodicEffects, effects_of_size: PeriodicEffects): + """Add sizing-related effects to the model.""" + if effects_per_size: self._model.effects.add_share_to_effects( name=self.label_of_element, - expressions={ - effect: -self.invested * factor + factor - for effect, factor in self.parameters.effects_of_retirement.items() - }, + expressions={effect: self.size * factor for effect, factor in effects_per_size.items()}, target='periodic', ) - if self.parameters.effects_of_investment_per_size: + if effects_of_size: self._model.effects.add_share_to_effects( name=self.label_of_element, expressions={ - effect: self.size * factor - for effect, factor in self.parameters.effects_of_investment_per_size.items() + effect: self.available * factor if self.available is not None else factor + for effect, factor in effects_of_size.items() }, 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""" + """Capacity size variable""" return self._variables['size'] + @property + def available(self) -> linopy.Variable | None: + """Binary availability variable (None if not created)""" + return self._variables.get('available') + + +class SizingModel(_SizeModel): + """Model for capacity sizing decisions. + + This feature model handles capacity sizing with optional binary availability. + It applies bounds to the size variable and creates effects based on size. + + Args: + model: The optimization model instance + label_of_element: The label of the parent element + parameters: The sizing parameters + label_of_model: Optional custom label for the model + """ + + parameters: SizingParameters + + def __init__( + self, + model: FlowSystemModel, + label_of_element: str, + parameters: SizingParameters, + label_of_model: str | None = None, + ): + self.parameters = parameters + super().__init__(model, label_of_element=label_of_element, label_of_model=label_of_model) + + def _do_modeling(self): + super()._do_modeling() + self._create_sizing_variables_and_constraints( + size_min=self.parameters.minimum_or_fixed_size, + size_max=self.parameters.maximum_or_fixed_size, + mandatory=self.parameters.mandatory, + dims=['period', 'scenario'], + ) + self._add_sizing_effects( + effects_per_size=self.parameters.effects_per_size, + effects_of_size=self.parameters.effects_of_size, + ) + @property def invested(self) -> linopy.Variable | None: - """Binary investment decision variable""" - if 'invested' not in self._variables: - return None - return self._variables['invested'] + """Deprecated: Use 'available' instead.""" + warnings.warn('Deprecated, use available instead', DeprecationWarning, stacklevel=2) + return self.available + + +# Alias for backwards compatibility +InvestmentModel = SizingModel class StatusModel(Submodel): diff --git a/flixopt/interface.py b/flixopt/interface.py index 7995d5e78..d52d2dd5c 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -6,19 +6,22 @@ from __future__ import annotations import logging +import warnings from typing import TYPE_CHECKING -import numpy as np -import pandas as pd -import xarray as xr - from .config import CONFIG from .structure import Interface, register_class_for_io +from .types import Bool_PS, Numeric_PS, PeriodicData, PeriodicEffectsUser if TYPE_CHECKING: # for type checking and preventing circular imports from collections.abc import Iterator - from .types import Effect_PS, Effect_TPS, Numeric_PS, Numeric_TPS + import xarray as xr + + from .types import Effect_PS, Effect_TPS, Numeric_TPS + +# Backwards compatibility alias +PeriodicDataUser = Numeric_PS logger = logging.getLogger('flixopt') @@ -689,240 +692,315 @@ def transform_data(self, name_prefix: str = '') -> None: piecewise.transform_data(f'{name_prefix}|PiecewiseEffects|{effect}') +class _SizeParameters(Interface): + """Base class for sizing and investment parameters.""" + + def __init__( + self, + fixed_size: Numeric_PS | None = None, + minimum_size: Numeric_PS | None = None, + maximum_size: Numeric_PS | None = None, + mandatory: bool | Bool_PS = False, + effects_of_size: Effect_PS | Numeric_PS | None = None, + effects_per_size: Effect_PS | Numeric_PS | None = None, + piecewise_effects_per_size: PiecewiseEffects | None = None, + ): + self.effects_of_size: PeriodicEffectsUser = effects_of_size if effects_of_size is not None else {} + self.fixed_size = fixed_size + self.mandatory = mandatory + self.effects_per_size: PeriodicEffectsUser = effects_per_size if effects_per_size is not None else {} + self.piecewise_effects_per_size = piecewise_effects_per_size + 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 + + def _set_flow_system(self, flow_system) -> None: + """Propagate flow_system reference to nested PiecewiseEffects object if present.""" + super()._set_flow_system(flow_system) + if self.piecewise_effects_per_size is not None: + self.piecewise_effects_per_size._set_flow_system(flow_system) + + def transform_data(self, name_prefix: str = '') -> None: + self.effects_of_size = self._fit_effect_coords( + prefix=name_prefix, + effect_values=self.effects_of_size, + suffix='effects_of_size', + dims=['period', 'scenario'], + ) + self.effects_per_size = self._fit_effect_coords( + prefix=name_prefix, + effect_values=self.effects_per_size, + suffix='effects_per_size', + dims=['period', 'scenario'], + ) + + if self.piecewise_effects_per_size is not None: + self.piecewise_effects_per_size.has_time_dim = False + self.piecewise_effects_per_size.transform_data(f'{name_prefix}|PiecewiseEffects') + + self.minimum_size = self._fit_coords( + f'{name_prefix}|minimum_size', self.minimum_size, dims=['period', 'scenario'] + ) + self.maximum_size = self._fit_coords( + f'{name_prefix}|maximum_size', self.maximum_size, dims=['period', 'scenario'] + ) + self.fixed_size = self._fit_coords(f'{name_prefix}|fixed_size', self.fixed_size, dims=['period', 'scenario']) + self.mandatory = self._fit_coords(f'{name_prefix}|mandatory', self.mandatory, dims=['period', 'scenario']) + + @property + def minimum_or_fixed_size(self) -> PeriodicData: + return self.fixed_size if self.fixed_size is not None else self.minimum_size + + @property + def maximum_or_fixed_size(self) -> PeriodicData: + return self.fixed_size if self.fixed_size is not None else self.maximum_size + + def format_for_repr(self) -> str: + """Format SizingParameters for display in repr methods. + + Returns: + Formatted string showing size information + """ + from .io import numeric_to_str_for_repr + + if self.fixed_size is not None: + val = numeric_to_str_for_repr(self.fixed_size) + status = 'mandatory' if self.mandatory else 'optional' + return f'{val} ({status})' + + # Show range if available + parts = [] + if self.minimum_size is not None: + parts.append(f'min: {numeric_to_str_for_repr(self.minimum_size)}') + if self.maximum_size is not None: + parts.append(f'max: {numeric_to_str_for_repr(self.maximum_size)}') + return ', '.join(parts) if parts else 'sizing' + + @register_class_for_io -class InvestParameters(Interface): - """Define investment decision parameters with flexible sizing and effect modeling. +class SizingParameters(_SizeParameters): + """Define sizing parameters with flexible capacity bounds and effect modeling. - This class models investment decisions in optimization problems, supporting - both binary (invest/don't invest) and continuous sizing choices with - comprehensive cost structures. It enables realistic representation of - investment economics including fixed costs, scale effects, and divestment penalties. + This class models sizing decisions in optimization problems, supporting + both binary (yes/no) and continuous sizing choices with comprehensive cost structures. - Investment Decision Types: - **Binary Investments**: Fixed size investments creating yes/no decisions - (e.g., install a specific generator, build a particular facility) + Sizing Decision Types: + **Binary Sizing**: Fixed size creating yes/no decisions + (e.g., install a 100 kW system or not) - **Continuous Sizing**: Variable size investments with minimum/maximum bounds - (e.g., battery capacity from 10-1000 kWh, pipeline diameter optimization) + **Continuous Sizing**: Variable size with minimum/maximum bounds + (e.g., battery capacity from 10-1000 kWh) Cost Modeling Approaches: - - **Fixed Effects**: One-time costs independent of size (permits, connections) - - **Specific Effects**: Linear costs proportional to size (€/kW, €/m²) - - **Piecewise Effects**: Non-linear relationships (bulk discounts, learning curves) - - **Divestment Effects**: Penalties for not investing (demolition, opportunity costs) + - **Fixed Effects**: Costs independent of size (permits, connections) + - **Specific Effects**: Linear costs proportional to size (€/kW) + - **Piecewise Effects**: Non-linear relationships (bulk discounts) Mathematical Formulation: - See + See Args: fixed_size: Creates binary decision at this exact size. None allows continuous sizing. minimum_size: Lower bound for continuous sizing. Default: CONFIG.Modeling.epsilon. - Ignored if fixed_size is specified. maximum_size: Upper bound for continuous sizing. Default: CONFIG.Modeling.big. - Ignored if fixed_size is specified. - mandatory: Controls whether investment is required. When True, forces investment - to occur (useful for mandatory upgrades or replacement decisions). - 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. + mandatory: Controls whether sizing is required. When True, forces capacity > 0. + effects_of_size: Fixed costs if capacity is available, regardless of size. Dict: {'effect_name': value} (e.g., {'cost': 10000}). - effects_of_investment_per_size: Variable costs proportional to size (per-unit costs). + effects_per_size: Variable costs proportional to size (per-unit costs). Dict: {'effect_name': value/unit} (e.g., {'cost': 1200}). - piecewise_effects_of_investment: Non-linear costs using PiecewiseEffects. - Combinable with effects_of_investment and effects_of_investment_per_size. - effects_of_retirement: Costs incurred if NOT investing (demolition, penalties). - Dict: {'effect_name': value}. - linked_periods: Describes which periods are linked. 1 means linked, 0 means size=0. None means no linked periods. - For convenience, pass a tuple containing the first and last period (2025, 2039), linking them and those in between - - Cost Annualization Requirements: - All cost values must be properly weighted to match the optimization model's time horizon. - For long-term investments, the cost values should be annualized to the corresponding operation time (annuity). - - - Use equivalent annual cost (capital cost / equipment lifetime) - - Apply appropriate discount rates for present value optimizations - - Account for inflation, escalation, and financing costs - - Example: €1M equipment with 20-year life → €50k/year fixed cost + piecewise_effects_per_size: Non-linear costs using PiecewiseEffects. Examples: - Simple binary investment (solar panels): + Simple binary sizing: ```python - solar_investment = InvestParameters( - fixed_size=100, # 100 kW system (binary decision) - mandatory=False, # Investment is optional - effects_of_investment={ - 'cost': 25000, # Installation and permitting costs - 'CO2': -50000, # Avoided emissions over lifetime - }, - effects_of_investment_per_size={ - 'cost': 1200, # €1200/kW for panels (annualized) - 'CO2': -800, # kg CO2 avoided per kW annually - }, + solar_sizing = SizingParameters( + fixed_size=100, # 100 kW system + effects_of_size={'cost': 25000}, + effects_per_size={'cost': 1200}, ) ``` - Flexible sizing with economies of scale: + Flexible sizing: ```python - battery_investment = InvestParameters( - minimum_size=10, # Minimum viable system size (kWh) - maximum_size=1000, # Maximum installable capacity - mandatory=False, # Investment is optional - effects_of_investment={ - 'cost': 5000, # Grid connection and control system - 'installation_time': 2, # Days for fixed components - }, - piecewise_effects_of_investment=PiecewiseEffects( - piecewise_origin=Piecewise( - [ - Piece(0, 100), # Small systems - Piece(100, 500), # Medium systems - Piece(500, 1000), # Large systems - ] - ), - piecewise_shares={ - 'cost': Piecewise( - [ - Piece(800, 750), # High cost/kWh for small systems - Piece(750, 600), # Medium cost/kWh - Piece(600, 500), # Bulk discount for large systems - ] - ) - }, - ), + battery_sizing = SizingParameters( + minimum_size=10, + maximum_size=1000, + effects_of_size={'cost': 5000}, + effects_per_size={'cost': 600}, ) ``` + """ - Mandatory replacement with retirement costs: + # SizingParameters inherits all functionality from _SizeParameters + pass - ```python - boiler_replacement = InvestParameters( - minimum_size=50, - maximum_size=200, - mandatory=False, # Can choose not to replace - effects_of_investment={ - 'cost': 15000, # Installation costs - 'disruption': 3, # Days of downtime - }, - effects_of_investment_per_size={ - 'cost': 400, # €400/kW capacity - 'maintenance': 25, # Annual maintenance per kW - }, - effects_of_retirement={ - 'cost': 8000, # Demolition if not replaced - 'environmental': 100, # Disposal fees - }, + +@register_class_for_io +class InvestParameters(SizingParameters): + """Deprecated: Use SizingParameters instead.""" + + def __init__(self, **kwargs): + # Map old parameter names to new ones for backwards compatibility + if 'effects_of_investment' in kwargs: + kwargs['effects_of_size'] = kwargs.pop('effects_of_investment') + if 'effects_of_investment_per_size' in kwargs: + kwargs['effects_per_size'] = kwargs.pop('effects_of_investment_per_size') + if 'piecewise_effects_of_investment' in kwargs: + kwargs['piecewise_effects_per_size'] = kwargs.pop('piecewise_effects_of_investment') + # Remove deprecated parameters + kwargs.pop('effects_of_retirement', None) + kwargs.pop('linked_periods', None) + + warnings.warn( + 'InvestParameters is deprecated, use SizingParameters instead. ' + 'Parameter names have changed: effects_of_investment -> effects_of_size, ' + 'effects_of_investment_per_size -> effects_per_size, ' + 'piecewise_effects_of_investment -> piecewise_effects_per_size. ' + 'effects_of_retirement and linked_periods are no longer supported.', + DeprecationWarning, + stacklevel=2, ) - ``` + super().__init__(**kwargs) - Multi-technology comparison: - ```python - # Gas turbine option - gas_turbine = InvestParameters( - fixed_size=50, # MW - effects_of_investment={'cost': 2500000, 'CO2': 1250000}, - effects_of_investment_per_size={'fuel_cost': 45, 'maintenance': 12}, - ) +InvestmentPeriodData = PeriodicDataUser +"""This datatype is used to define things related to the period of investment.""" +InvestmentPeriodDataBool = bool | InvestmentPeriodData +"""This datatype is used to define things with boolean data related to the period of investment.""" + + +@register_class_for_io +class InvestmentParameters(_SizeParameters): + """Define investment timing parameters with fixed lifetime. + + This class models WHEN to invest with a fixed lifetime duration. + It includes all sizing parameters (capacity bounds, effects) plus timing controls. + + InvestmentParameters combines both TIMING (when to invest) and CAPACITY (how much) + aspects in a single class, optimizing when to make an investment that will last + for a fixed duration. - # Wind farm option - wind_farm = InvestParameters( - minimum_size=20, - maximum_size=100, - effects_of_investment={'cost': 1000000, 'CO2': -5000000}, - effects_of_investment_per_size={'cost': 1800000, 'land_use': 0.5}, + Investment Timing Features: + **Single Investment Decision**: Decide which period to invest in (at most once) + **Fixed Lifetime**: Investment lasts for a specified number of periods + **Timing-Dependent Effects**: Effects that vary based on when investment occurs + (e.g., technology learning curves, time-varying costs) + + Mathematical Formulation: + See + + Args: + lifetime: REQUIRED. The investment lifetime in number of periods. + Once invested, the asset operates for this many periods. + allow_investment: Allow investment in specific periods. Default: True (all periods). + force_investment: Force investment to occur in a specific period. Default: False. + effects_of_investment: Effects that depend on when investment occurs. + Dict mapping effect names to values. + effects_of_investment_per_size: Size-dependent effects that also depend on investment period. + Dict mapping effect names to values. + previous_lifetime: Remaining lifetime of existing capacity from previous periods. Default: 0. + fixed_size: Creates binary decision at this exact size. None allows continuous sizing. + minimum_size: Lower bound for continuous sizing. Default: CONFIG.Modeling.epsilon. + maximum_size: Upper bound for continuous sizing. Default: CONFIG.Modeling.big. + mandatory: Controls whether investment is required. When True, forces investment. + effects_of_size: Fixed costs if investment is made, regardless of size. + effects_per_size: Variable costs proportional to size (per-unit costs). + piecewise_effects_per_size: Non-linear costs using PiecewiseEffects. + + Examples: + Basic investment timing: + + ```python + timing = InvestmentParameters( + lifetime=10, # Investment lasts 10 periods + allow_investment=True, # Can invest in any period ) ``` - Technology learning curve: + Force investment in specific period: ```python - hydrogen_electrolyzer = InvestParameters( - minimum_size=1, - maximum_size=50, # MW - piecewise_effects_of_investment=PiecewiseEffects( - piecewise_origin=Piecewise( - [ - Piece(0, 5), # Small scale: early adoption - Piece(5, 20), # Medium scale: cost reduction - Piece(20, 50), # Large scale: mature technology - ] - ), - piecewise_shares={ - 'capex': Piecewise( - [ - Piece(2000, 1800), # Learning reduces costs - Piece(1800, 1400), # Continued cost reduction - Piece(1400, 1200), # Technology maturity - ] - ), - 'efficiency': Piecewise( - [ - Piece(65, 68), # Improving efficiency - Piece(68, 72), # with scale and experience - Piece(72, 75), # Best efficiency at scale - ] - ), - }, + timing = InvestmentParameters( + lifetime=10, # Must operate for 10 periods + force_investment=xr.DataArray( + [0, 0, 1, 0, 0], # Force in period 3 (2030) + coords=[('period', [2020, 2025, 2030, 2035, 2040])], ), ) ``` Common Use Cases: - - Power generation: Plant sizing, technology selection, retrofit decisions - - Industrial equipment: Capacity expansion, efficiency upgrades, replacements - - Infrastructure: Network expansion, facility construction, system upgrades - - Energy storage: Battery sizing, pumped hydro, compressed air systems - - Transportation: Fleet expansion, charging infrastructure, modal shifts - - Buildings: HVAC systems, insulation upgrades, renewable integration - + - Technology learning: Model cost reductions over time + - Multi-period optimization: Optimize investment timing across periods + - Regulatory changes: Model period-specific incentives or constraints + - Strategic timing: Find optimal investment timing considering future conditions """ def __init__( self, - fixed_size: Numeric_PS | None = None, - minimum_size: Numeric_PS | None = None, - maximum_size: Numeric_PS | None = None, - mandatory: 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, - piecewise_effects_of_investment: PiecewiseEffects | None = None, - linked_periods: Numeric_PS | tuple[int, int] | None = None, + lifetime: InvestmentPeriodData, + allow_investment: InvestmentPeriodDataBool = True, + force_investment: InvestmentPeriodDataBool = False, + effects_of_investment: PeriodicEffectsUser | None = None, + effects_of_investment_per_size: PeriodicEffectsUser | None = None, + previous_lifetime: int = 0, + # Sizing parameters (inherited from _SizeParameters) + fixed_size: PeriodicDataUser | None = None, + minimum_size: PeriodicDataUser | None = None, + maximum_size: PeriodicDataUser | None = None, + mandatory: bool | xr.DataArray = False, + effects_of_size: PeriodicEffectsUser | None = None, + effects_per_size: PeriodicEffectsUser | None = None, + piecewise_effects_per_size: PiecewiseEffects | None = None, ): - self.effects_of_investment = effects_of_investment if effects_of_investment is not None else {} - self.effects_of_retirement = effects_of_retirement if effects_of_retirement is not None else {} - self.fixed_size = fixed_size - self.mandatory = mandatory - self.effects_of_investment_per_size = ( + if lifetime is None: + raise ValueError('InvestmentParameters requires lifetime to be specified.') + + # Initialize investment-specific attributes + self.lifetime = lifetime + self.allow_investment = allow_investment + self.force_investment = force_investment + self.previous_lifetime = previous_lifetime + + self.effects_of_investment: dict[str, xr.DataArray] = ( + effects_of_investment if effects_of_investment is not None else {} + ) + self.effects_of_investment_per_size: dict[str, xr.DataArray] = ( effects_of_investment_per_size if effects_of_investment_per_size is not None else {} ) - 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 _set_flow_system(self, flow_system) -> None: - """Propagate flow_system reference to nested PiecewiseEffects object if present.""" - super()._set_flow_system(flow_system) - if self.piecewise_effects_of_investment is not None: - self.piecewise_effects_of_investment._set_flow_system(flow_system) + # Initialize base sizing parameters + super().__init__( + fixed_size=fixed_size, + minimum_size=minimum_size, + maximum_size=maximum_size, + mandatory=mandatory, + effects_of_size=effects_of_size, + effects_per_size=effects_per_size, + piecewise_effects_per_size=piecewise_effects_per_size, + ) def transform_data(self, name_prefix: str = '') -> None: + """Transform user data into internal model coordinates.""" + super().transform_data(name_prefix) + # Transform boolean/data flags to DataArrays + self.allow_investment = self._fit_coords( + f'{name_prefix}|allow_investment', self.allow_investment, dims=['period', 'scenario'] + ) + self.force_investment = self._fit_coords( + f'{name_prefix}|force_investment', self.force_investment, dims=['period', 'scenario'] + ) + self.previous_lifetime = self._fit_coords( + f'{name_prefix}|previous_lifetime', self.previous_lifetime, dims=['scenario'] + ) + self.lifetime = self._fit_coords(f'{name_prefix}|lifetime', self.lifetime, dims=['scenario']) self.effects_of_investment = self._fit_effect_coords( prefix=name_prefix, effect_values=self.effects_of_investment, suffix='effects_of_investment', dims=['period', 'scenario'], ) - self.effects_of_retirement = self._fit_effect_coords( - prefix=name_prefix, - effect_values=self.effects_of_retirement, - suffix='effects_of_retirement', - dims=['period', 'scenario'], - ) self.effects_of_investment_per_size = self._fit_effect_coords( prefix=name_prefix, effect_values=self.effects_of_investment_per_size, @@ -930,84 +1008,25 @@ def transform_data(self, name_prefix: str = '') -> None: dims=['period', 'scenario'], ) - if self.piecewise_effects_of_investment is not None: - self.piecewise_effects_of_investment.has_time_dim = False - self.piecewise_effects_of_investment.transform_data(f'{name_prefix}|PiecewiseEffects') - - self.minimum_size = self._fit_coords( - f'{name_prefix}|minimum_size', self.minimum_size, dims=['period', 'scenario'] - ) - self.maximum_size = self._fit_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 be len=2. Got {len(self.linked_periods)=}' - ) - if self.flow_system.periods is None: - raise ValueError( - f'Cannot use linked_periods={self.linked_periods} when FlowSystem has no periods defined. ' - f'Please define periods in FlowSystem or use linked_periods=None.' - ) - logger.debug(f'Computing linked_periods from {self.linked_periods}') - start, end = self.linked_periods - if start not in self.flow_system.periods.values: - logger.warning( - f'Start of linked periods ({start} not found in periods directly: {self.flow_system.periods.values}' - ) - if end not in self.flow_system.periods.values: + def _plausibility_checks(self) -> None: + """Validate parameter consistency.""" + super()._plausibility_checks() + if self.flow_system.periods is None: + raise ValueError("InvestmentParameters requires the flow_system to have a 'periods' dimension.") + + # Check force_investment uniqueness (can only force in one period per scenario) + if (self.force_investment.sum('period') > 1).any(): + raise ValueError('force_investment can only be True for a single period per scenario.') + + # Check lifetime feasibility + _periods = self.flow_system.periods.values + if len(_periods) > 1: + # Warn if investment in late periods would extend beyond model horizon + max_horizon = _periods[-1] - _periods[0] + if (self.lifetime > max_horizon).any(): logger.warning( - f'End of linked periods ({end} not found in periods directly: {self.flow_system.periods.values}' + f'Fixed lifetime ({self.lifetime.values}) if Investment exceeds model horizon ({max_horizon}). ' ) - self.linked_periods = self.compute_linked_periods(start, end, self.flow_system.periods) - logger.debug(f'Computed {self.linked_periods=}') - - self.linked_periods = self._fit_coords( - f'{name_prefix}|linked_periods', self.linked_periods, dims=['period', 'scenario'] - ) - self.fixed_size = self._fit_coords(f'{name_prefix}|fixed_size', self.fixed_size, dims=['period', 'scenario']) - - @property - def minimum_or_fixed_size(self) -> Numeric_PS: - return self.fixed_size if self.fixed_size is not None else self.minimum_size - - @property - def maximum_or_fixed_size(self) -> Numeric_PS: - return self.fixed_size if self.fixed_size is not None else self.maximum_size - - def format_for_repr(self) -> str: - """Format InvestParameters for display in repr methods. - - Returns: - Formatted string showing size information - """ - from .io import numeric_to_str_for_repr - - if self.fixed_size is not None: - val = numeric_to_str_for_repr(self.fixed_size) - status = 'mandatory' if self.mandatory else 'optional' - return f'{val} ({status})' - - # Show range if available - parts = [] - if self.minimum_size is not None: - parts.append(f'min: {numeric_to_str_for_repr(self.minimum_size)}') - if self.maximum_size is not None: - parts.append(f'max: {numeric_to_str_for_repr(self.maximum_size)}') - return ', '.join(parts) if parts else 'invest' - - @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 diff --git a/flixopt/types.py b/flixopt/types.py index 6019a82d2..297100d1a 100644 --- a/flixopt/types.py +++ b/flixopt/types.py @@ -137,6 +137,22 @@ def create_flow( Scalar: TypeAlias = int | float | np.integer | np.floating """Scalar numeric values only. Not converted to DataArray (unlike dimensioned types).""" + +# Transformed data types (after fit_to_model_coords) +PeriodicData: TypeAlias = xr.DataArray +"""Periodic data (after transformation). Always an xr.DataArray with period, scenario dims.""" + +PeriodicEffects: TypeAlias = dict[str, xr.DataArray] +"""Periodic effects (after transformation). Dict mapping effect names to DataArrays.""" + + +# User-facing types for effects (before transformation) +PeriodicEffectsUser: TypeAlias = dict[ + str, int | float | np.integer | np.floating | np.ndarray | pd.Series | pd.DataFrame | xr.DataArray +] +"""User input for periodic effects. Dict mapping effect names to numeric values (any format).""" + + # Export public API __all__ = [ # Numeric types @@ -161,4 +177,8 @@ def create_flow( # Other 'Scalar', 'NumericOrBool', + # Transformed data types + 'PeriodicData', + 'PeriodicEffects', + 'PeriodicEffectsUser', ] From d9a2a0bb5c635a272c8197f15bfcdd779907f0fc Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 30 Nov 2025 17:55:12 +0100 Subject: [PATCH 3/7] Temp --- flixopt/elements.py | 2 +- flixopt/features.py | 73 +++++++++++++++++++++++++++++--------------- flixopt/interface.py | 19 +++++++++--- 3 files changed, 64 insertions(+), 30 deletions(-) diff --git a/flixopt/elements.py b/flixopt/elements.py index 74ed7bde4..371e05b91 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -817,7 +817,7 @@ def absolute_flow_rate_bounds(self) -> tuple[xr.DataArray, xr.DataArray]: if not self.with_investment: # Basic case without investment and without Status lb = lb_relative * self.element.size - elif self.with_investment and self.element.size.mandatory: + elif self.with_investment and self.element.size.mandatory.all(): # With mandatory Investment lb = lb_relative * self.element.size.minimum_or_fixed_size diff --git a/flixopt/features.py b/flixopt/features.py index 4aceaa4d3..e43cdaf7c 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -6,7 +6,6 @@ from __future__ import annotations import logging -import warnings from typing import TYPE_CHECKING import linopy @@ -57,39 +56,67 @@ def _create_sizing_variables_and_constraints( coords=self._model.get_coords(dims), ) - if force_available or mandatory.any(): + # Create invested binary variable only when sizing is optional (not all mandatory) + # When mandatory=True everywhere, no binary variable is needed since size is forced anyway + sizing_is_optional = not mandatory.all() + if force_available or sizing_is_optional: self.add_variables( binary=True, coords=self._model.get_coords(dims), - short_name='available', - ) - self.add_constraints( - self.available.where(mandatory) == 1, - short_name='mandatory', + short_name='invested', ) + # Constrain invested=1 where mandatory + if mandatory.any(): + self.add_constraints( + self.invested.where(mandatory) == 1, + short_name='mandatory', + ) BoundingPatterns.bounds_with_state( self, variable=size, - state=self._variables['available'], + state=self._variables['invested'], bounds=(size_min, size_max), ) - def _add_sizing_effects(self, effects_per_size: PeriodicEffects, effects_of_size: PeriodicEffects): + def _add_sizing_effects( + self, + effects_per_size: PeriodicEffects, + effects_of_size: PeriodicEffects, + effects_of_retirement: PeriodicEffects | None = None, + mandatory: PeriodicData | None = None, + ): """Add sizing-related effects to the model.""" - if effects_per_size: + # Add fixed effects (effects_of_size) - must come before effects_per_size to match test expectations + if effects_of_size: self._model.effects.add_share_to_effects( name=self.label_of_element, - expressions={effect: self.size * factor for effect, factor in effects_per_size.items()}, + expressions={ + effect: self.invested * factor if self.invested is not None else factor + for effect, factor in effects_of_size.items() + }, target='periodic', ) - if effects_of_size: + # Add retirement effects (only when not mandatory and invested variable exists) + # Formula: -invested * factor + factor = factor * (1 - invested) + # This means: when invested=0, cost is factor; when invested=1, cost is 0 + if effects_of_retirement and self.invested is not None: + # Only apply retirement effects where not all mandatory + is_all_mandatory = mandatory.all() if mandatory is not None else False + if not is_all_mandatory: + self._model.effects.add_share_to_effects( + name=self.label_of_element, + expressions={ + effect: -self.invested * factor + factor for effect, factor in effects_of_retirement.items() + }, + target='periodic', + ) + + # Add per-size effects + if effects_per_size: self._model.effects.add_share_to_effects( name=self.label_of_element, - expressions={ - effect: self.available * factor if self.available is not None else factor - for effect, factor in effects_of_size.items() - }, + expressions={effect: self.size * factor for effect, factor in effects_per_size.items()}, target='periodic', ) @@ -99,9 +126,9 @@ def size(self) -> linopy.Variable: return self._variables['size'] @property - def available(self) -> linopy.Variable | None: - """Binary availability variable (None if not created)""" - return self._variables.get('available') + def invested(self) -> linopy.Variable | None: + """Binary investment decision variable (None if not created)""" + return self._variables.get('invested') class SizingModel(_SizeModel): @@ -140,14 +167,10 @@ def _do_modeling(self): self._add_sizing_effects( effects_per_size=self.parameters.effects_per_size, effects_of_size=self.parameters.effects_of_size, + effects_of_retirement=self.parameters.effects_of_retirement, + mandatory=self.parameters.mandatory, ) - @property - def invested(self) -> linopy.Variable | None: - """Deprecated: Use 'available' instead.""" - warnings.warn('Deprecated, use available instead', DeprecationWarning, stacklevel=2) - return self.available - # Alias for backwards compatibility InvestmentModel = SizingModel diff --git a/flixopt/interface.py b/flixopt/interface.py index d52d2dd5c..8926f88b3 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -703,12 +703,16 @@ def __init__( mandatory: bool | Bool_PS = False, effects_of_size: Effect_PS | Numeric_PS | None = None, effects_per_size: Effect_PS | Numeric_PS | None = None, + effects_of_retirement: Effect_PS | Numeric_PS | None = None, piecewise_effects_per_size: PiecewiseEffects | None = None, ): self.effects_of_size: PeriodicEffectsUser = effects_of_size if effects_of_size is not None else {} self.fixed_size = fixed_size self.mandatory = mandatory self.effects_per_size: PeriodicEffectsUser = effects_per_size if effects_per_size is not None else {} + self.effects_of_retirement: PeriodicEffectsUser = ( + effects_of_retirement if effects_of_retirement is not None else {} + ) self.piecewise_effects_per_size = piecewise_effects_per_size 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 @@ -732,6 +736,12 @@ def transform_data(self, name_prefix: str = '') -> None: suffix='effects_per_size', dims=['period', 'scenario'], ) + self.effects_of_retirement = self._fit_effect_coords( + prefix=name_prefix, + effect_values=self.effects_of_retirement, + suffix='effects_of_retirement', + dims=['period', 'scenario'], + ) if self.piecewise_effects_per_size is not None: self.piecewise_effects_per_size.has_time_dim = False @@ -764,7 +774,9 @@ 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' + # Handle both bool and DataArray cases (before/after transform_data) + is_mandatory = self.mandatory.all() if hasattr(self.mandatory, 'all') else self.mandatory + status = 'mandatory' if is_mandatory else 'optional' return f'{val} ({status})' # Show range if available @@ -848,8 +860,7 @@ def __init__(self, **kwargs): kwargs['effects_per_size'] = kwargs.pop('effects_of_investment_per_size') if 'piecewise_effects_of_investment' in kwargs: kwargs['piecewise_effects_per_size'] = kwargs.pop('piecewise_effects_of_investment') - # Remove deprecated parameters - kwargs.pop('effects_of_retirement', None) + # Remove unsupported parameters kwargs.pop('linked_periods', None) warnings.warn( @@ -857,7 +868,7 @@ def __init__(self, **kwargs): 'Parameter names have changed: effects_of_investment -> effects_of_size, ' 'effects_of_investment_per_size -> effects_per_size, ' 'piecewise_effects_of_investment -> piecewise_effects_per_size. ' - 'effects_of_retirement and linked_periods are no longer supported.', + 'linked_periods is no longer supported.', DeprecationWarning, stacklevel=2, ) From ad34f61c0ef6a1bf04c36806fb9aac95c4a262c0 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 30 Nov 2025 17:59:05 +0100 Subject: [PATCH 4/7] Temp --- flixopt/features.py | 213 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 210 insertions(+), 3 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index e43cdaf7c..7d0f7da1c 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -22,7 +22,7 @@ import xarray as xr from .core import FlowSystemDimensions - from .interface import Piecewise, SizingParameters, StatusParameters + from .interface import InvestmentParameters, Piecewise, SizingParameters, StatusParameters from .types import Numeric_PS, Numeric_TPS, PeriodicData, PeriodicEffects @@ -172,8 +172,215 @@ def _do_modeling(self): ) -# Alias for backwards compatibility -InvestmentModel = SizingModel +class InvestmentModel(_SizeModel): + """Model investment timing with fixed lifetime. + + This feature works in conjunction with SizingModel to provide full investment modeling: + - SizingModel: Determines HOW MUCH capacity to install + - InvestmentModel: Determines WHEN to invest + + The model creates binary variables to track: + - When the investment occurs (one period) + - Which periods the investment is active (based on fixed lifetime) + + The investment capacity (from SizingModel) is only active during the investment's lifetime. + + Args: + model: The optimization model instance + label_of_element: The label of the parent element + parameters: InvestmentParameters defining timing constraints + label_of_model: Optional custom label for the model + """ + + parameters: InvestmentParameters + + def __init__( + self, + model: FlowSystemModel, + label_of_element: str, + parameters: InvestmentParameters, + label_of_model: str | None = None, + ): + self.parameters = parameters + super().__init__(model, label_of_element=label_of_element, label_of_model=label_of_model) + + def _do_modeling(self): + super()._do_modeling() + self._create_variables_and_constraints() + self._add_effects() + + def _create_variables_and_constraints(self): + """Create timing variables and constraints.""" + # Regular sizing + self._create_sizing_variables_and_constraints( + size_min=self.parameters.minimum_or_fixed_size, + size_max=self.parameters.maximum_or_fixed_size, + mandatory=self.parameters.mandatory, + dims=['period', 'scenario'], + force_available=True, + ) + + self._track_investment_and_decommissioning_period() + self._track_investment_and_decommissioning_size() + self._track_lifetime() + self._apply_investment_period_constraints() + + def _track_investment_and_decommissioning_period(self): + """Track investment and decommissioning period based on binary state variable.""" + self.add_variables( + binary=True, + coords=self._model.get_coords(['period', 'scenario']), + short_name='size|investment_occurs', + ) + self.add_constraints( + self.investment_occurs.sum('period') <= 1, + short_name='invest_once', + ) + + self.add_variables( + binary=True, + coords=self._model.get_coords(['period', 'scenario']), + short_name='size|decommissioning_occurs', + ) + self.add_constraints( + self.decommissioning_occurs.sum('period') <= 1, + short_name='decommission_once', + ) + + BoundingPatterns.state_transition_bounds( + self, + state=self.invested, + activate=self.investment_occurs, + deactivate=self.decommissioning_occurs, + name=self.invested.name, + previous_state=0, + coord='period', + ) + + def _track_investment_and_decommissioning_size(self): + """Track size changes when investment/decommissioning occurs.""" + self.add_variables( + coords=self._model.get_coords(['period', 'scenario']), + short_name='size|increase', + lower=0, + upper=self.parameters.maximum_or_fixed_size, + ) + self.add_variables( + coords=self._model.get_coords(['period', 'scenario']), + short_name='size|decrease', + lower=0, + upper=self.parameters.maximum_or_fixed_size, + ) + BoundingPatterns.link_changes_to_level_with_binaries( + self, + level_variable=self.size, + increase_variable=self.size_increase, + decrease_variable=self.size_decrease, + increase_binary=self.investment_occurs, + decrease_binary=self.decommissioning_occurs, + name=f'{self.label_of_element}|size|changes', + max_change=self.parameters.maximum_or_fixed_size, + previous_level=0 if self.parameters.previous_lifetime == 0 else self.size.isel(period=0), + coord='period', + ) + + def _track_lifetime(self): + """Create constraints that link investment period to decommissioning period based on lifetime.""" + periods = self._model.flow_system._fit_coords( + 'periods', self._model.flow_system.periods.values, dims=['period', 'scenario'] + ) + + # Calculate decommissioning periods (vectorized) + import xarray as xr + + is_first = periods == periods.isel(period=0) + decom_period = periods + self.parameters.lifetime - xr.where(is_first, self.parameters.previous_lifetime, 0) + + # Map to available periods (drop invalid ones for sel to work) + valid = decom_period.where(decom_period <= self._model.flow_system.periods.values[-1], drop=True) + avail_decom = periods.sel(period=valid, method='bfill').assign_coords(period=valid.period) + + # One constraint per unique decommissioning period + for decom_val in np.unique(avail_decom.values): + mask = (avail_decom == decom_val).reindex_like(periods).fillna(0) + self.add_constraints( + self.investment_occurs.where(mask).sum('period') == self.decommissioning_occurs.sel(period=decom_val), + short_name=f'size|lifetime{int(decom_val)}', + ) + + def _apply_investment_period_constraints(self): + """Apply constraints on when investment can/must occur.""" + # Constraint: Apply allow_investment restrictions + if (self.parameters.allow_investment == 0).any(): + if (self.parameters.allow_investment == 0).all('period'): + logger.error(f'In "{self.label_full}": Need to allow Investment in at least one period.') + self.add_constraints( + self.investment_occurs <= self.parameters.allow_investment, + short_name='allow_investment', + ) + + # If a specific period is forced, investment must occur there + if (self.parameters.force_investment == 1).any(): + if (self.parameters.force_investment.sum('period') > 1).any(): + raise ValueError('Can not force Investment in more than one period') + self.add_constraints( + self.investment_occurs == self.parameters.force_investment, + short_name='force_investment', + ) + + def _add_effects(self): + """Add investment effects to the model.""" + self._add_sizing_effects( + self.parameters.effects_per_size, + self.parameters.effects_of_size, + ) + + # Investment-timing dependent effects + if self.parameters.effects_of_investment: + # Effects depending on when the investment is made + remapped_variable = self.investment_occurs.rename({'period': 'period_of_investment'}) + + self._model.effects.add_share_to_effects( + name=self.label_of_element, + expressions={ + effect: (remapped_variable * factor).sum('period_of_investment') + for effect, factor in self.parameters.effects_of_investment.items() + }, + target='periodic', + ) + + if self.parameters.effects_of_investment_per_size: + # Effects depending on when the investment is made proportional to investment size + remapped_variable = self.size_increase.rename({'period': 'period_of_investment'}) + + self._model.effects.add_share_to_effects( + name=self.label_of_element, + expressions={ + effect: (remapped_variable * factor).sum('period_of_investment') + for effect, factor in self.parameters.effects_of_investment_per_size.items() + }, + target='periodic', + ) + + @property + def investment_occurs(self) -> linopy.Variable: + """Binary variable indicating when investment occurs (at most one period)""" + return self._variables['size|investment_occurs'] + + @property + def decommissioning_occurs(self) -> linopy.Variable: + """Binary variable indicating when decommissioning occurs""" + return self._variables['size|decommissioning_occurs'] + + @property + def size_decrease(self) -> linopy.Variable: + """Size decrease variable""" + return self._variables['size|decrease'] + + @property + def size_increase(self) -> linopy.Variable: + """Size increase variable""" + return self._variables['size|increase'] class StatusModel(Submodel): From 374a535a92a1e505b0a1506b0bbb8569976f02a8 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 30 Nov 2025 18:02:25 +0100 Subject: [PATCH 5/7] Temp --- .../features/InvestmentParameters.md | 151 ++++++++++++++++++ .../features/SizingParameters.md | 147 +++++++++++++++++ 2 files changed, 298 insertions(+) create mode 100644 docs/user-guide/mathematical-notation/features/InvestmentParameters.md create mode 100644 docs/user-guide/mathematical-notation/features/SizingParameters.md diff --git a/docs/user-guide/mathematical-notation/features/InvestmentParameters.md b/docs/user-guide/mathematical-notation/features/InvestmentParameters.md new file mode 100644 index 000000000..3570fd958 --- /dev/null +++ b/docs/user-guide/mathematical-notation/features/InvestmentParameters.md @@ -0,0 +1,151 @@ +# InvestmentParameters + +InvestmentParameters extend [SizingParameters](SizingParameters.md) to model WHEN to invest, not just how much. + +!!! info "Relationship to SizingParameters" + - **SizingParameters**: Determines capacity size (how much to build) + - **InvestmentParameters**: Adds timing decisions (when to invest) with fixed lifetime + +## Key Concept: Investment Timing + +InvestmentParameters tracks: + +1. **When** the investment occurs (at most once) +2. **How long** the investment is active (fixed lifetime) +3. **When** decommissioning occurs (lifetime periods after investment) + +$$ +\sum_{p} x^{invest}_p \leq 1 \quad \text{(invest at most once)} +$$ + +$$ +x^{active}_p = 1 \iff \exists p' \leq p : x^{invest}_{p'} = 1 \land p - p' < L +$$ + +where $L$ is the lifetime in periods. + +--- + +## Basic Usage + +```python +solar_timing = fx.InvestmentParameters( + lifetime=10, # Investment lasts 10 periods + minimum_size=50, + maximum_size=200, + effects_of_size={'costs': 15000}, + effects_per_size={'costs': 1200}, +) +``` + +--- + +## Timing Controls + +=== "Allow Investment" + + Restrict when investment can occur: + + ```python + import xarray as xr + + fx.InvestmentParameters( + lifetime=10, + allow_investment=xr.DataArray( + [1, 1, 1, 0, 0], # Only allow in first 3 periods + coords=[('period', [2020, 2025, 2030, 2035, 2040])], + ), + ) + ``` + +=== "Force Investment" + + Force investment in a specific period: + + ```python + fx.InvestmentParameters( + lifetime=10, + force_investment=xr.DataArray( + [0, 0, 1, 0, 0], # Force in 2030 + coords=[('period', [2020, 2025, 2030, 2035, 2040])], + ), + ) + ``` + +=== "Previous Lifetime" + + Model existing capacity from before the optimization horizon: + + ```python + fx.InvestmentParameters( + lifetime=10, + previous_lifetime=3, # 3 periods of lifetime remaining + ) + ``` + +--- + +## Investment-Period Effects + +Effects that depend on WHEN the investment is made (technology learning curves, time-varying subsidies): + +=== "Fixed by Investment Period" + + Effects that vary by investment timing: + + ```python + # Cost decreases over time (learning curve) + fx.InvestmentParameters( + lifetime=10, + effects_of_investment={ + 'costs': xr.DataArray( + [50000, 45000, 40000, 35000, 30000], + coords=[('period', [2020, 2025, 2030, 2035, 2040])], + ) + }, + ) + ``` + +=== "Per-Size by Investment Period" + + Size-dependent effects that vary by investment timing: + + ```python + # Cost per kW decreases (technology improvement) + fx.InvestmentParameters( + lifetime=10, + effects_of_investment_per_size={ + 'costs': xr.DataArray( + [1500, 1200, 1000, 900, 800], # €/kW over time + coords=[('period', [2020, 2025, 2030, 2035, 2040])], + ) + }, + ) + ``` + +--- + +## Variables Created + +| Variable | Description | +|----------|-------------| +| `size` | Capacity size | +| `invested` | Binary: is investment currently active? | +| `investment_occurs` | Binary: does investment happen this period? | +| `decommissioning_occurs` | Binary: does decommissioning happen this period? | +| `size_increase` | Size increase when investing | +| `size_decrease` | Size decrease when decommissioning | + +--- + +## Reference + +| Symbol | Type | Description | +|--------|------|-------------| +| $P$ | $\mathbb{R}_{\geq 0}$ | Investment size (capacity) | +| $x^{invest}_p$ | $\{0, 1\}$ | Investment occurs in period $p$ | +| $x^{decom}_p$ | $\{0, 1\}$ | Decommissioning occurs in period $p$ | +| $x^{active}_p$ | $\{0, 1\}$ | Investment is active in period $p$ | +| $L$ | $\mathbb{Z}_{>0}$ | Lifetime in periods | + +**Classes:** [`InvestmentParameters`][flixopt.interface.InvestmentParameters], [`InvestmentModel`][flixopt.features.InvestmentModel] diff --git a/docs/user-guide/mathematical-notation/features/SizingParameters.md b/docs/user-guide/mathematical-notation/features/SizingParameters.md new file mode 100644 index 000000000..d882bd2b2 --- /dev/null +++ b/docs/user-guide/mathematical-notation/features/SizingParameters.md @@ -0,0 +1,147 @@ +# SizingParameters + +SizingParameters make capacity a decision variable — should we build this? How big? + +!!! note "Naming Change" + `SizingParameters` replaces the deprecated `InvestParameters`. + For investment timing with fixed lifetime (when to invest), see [InvestmentParameters](InvestmentParameters.md). + +## Basic: Size as Variable + +$$ +P^{min} \leq P \leq P^{max} +$$ + +```python +battery = fx.Storage( + ..., + capacity_in_flow_hours=fx.SizingParameters( + minimum_size=10, + maximum_size=1000, + effects_per_size={'costs': 600}, # €600/kWh + ), +) +``` + +--- + +## Sizing Modes + +By default, sizing is **optional** — the optimizer can choose $P = 0$ (don't build). + +=== "Continuous" + + Choose size within range (or zero): + + ```python + fx.SizingParameters( + minimum_size=10, + maximum_size=1000, + ) + # → P = 0 OR 10 ≤ P ≤ 1000 + ``` + +=== "Binary" + + Fixed size or nothing: + + ```python + fx.SizingParameters( + fixed_size=100, # 100 kW or 0 + ) + # → P ∈ {0, 100} + ``` + +=== "Mandatory" + + Force sizing with `mandatory=True` — zero not allowed: + + ```python + fx.SizingParameters( + minimum_size=50, + maximum_size=200, + mandatory=True, + ) + # → 50 ≤ P ≤ 200 (no zero option) + ``` + +--- + +## Sizing Effects + +=== "Per-Size Cost" + + Cost proportional to capacity (€/kW): + + $E = P \cdot c_{spec}$ + + ```python + fx.SizingParameters( + effects_per_size={'costs': 1200}, # €1200/kW + ) + ``` + +=== "Fixed Cost" + + One-time cost if sizing: + + $E = s_{sized} \cdot c_{fix}$ + + ```python + fx.SizingParameters( + effects_of_size={'costs': 25000}, # €25k + ) + ``` + +=== "Retirement Cost" + + Cost if NOT sizing (demolition, opportunity cost): + + $E = (1 - s_{sized}) \cdot c_{ret}$ + + ```python + fx.SizingParameters( + effects_of_retirement={'costs': 8000}, # Demolition + ) + ``` + +=== "Piecewise Cost" + + Non-linear cost curves (e.g., economies of scale): + + $E = f_{piecewise}(P)$ + + ```python + fx.SizingParameters( + piecewise_effects_per_size=fx.PiecewiseEffects( + piecewise_origin=fx.Piecewise([ + fx.Piece(0, 100), + fx.Piece(100, 500), + ]), + piecewise_shares={ + 'costs': fx.Piecewise([ + fx.Piece(0, 80_000), # €800/kW for 0-100 + fx.Piece(80_000, 280_000), # €500/kW for 100-500 + ]) + }, + ), + ) + ``` + + See [Piecewise](Piecewise.md) for details on the formulation. + +--- + +## Reference + +| Symbol | Type | Description | +|--------|------|-------------| +| $P$ | $\mathbb{R}_{\geq 0}$ | Size (capacity) | +| $s_{sized}$ | $\{0, 1\}$ | Binary sizing decision (0=no, 1=yes) | +| $P^{min}$ | $\mathbb{R}_{\geq 0}$ | Minimum size (`minimum_size`) | +| $P^{max}$ | $\mathbb{R}_{\geq 0}$ | Maximum size (`maximum_size`) | +| $c_{spec}$ | $\mathbb{R}$ | Per-size effect (`effects_per_size`) | +| $c_{fix}$ | $\mathbb{R}$ | Fixed effect (`effects_of_size`) | +| $c_{ret}$ | $\mathbb{R}$ | Retirement effect (`effects_of_retirement`) | + +**Classes:** [`SizingParameters`][flixopt.interface.SizingParameters], [`SizingModel`][flixopt.features.SizingModel] From da43796063689714f47e1d6cd5a7a797d67e6e51 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 30 Nov 2025 18:02:28 +0100 Subject: [PATCH 6/7] Temp --- .../features/InvestParameters.md | 143 ------------------ 1 file changed, 143 deletions(-) delete mode 100644 docs/user-guide/mathematical-notation/features/InvestParameters.md diff --git a/docs/user-guide/mathematical-notation/features/InvestParameters.md b/docs/user-guide/mathematical-notation/features/InvestParameters.md deleted file mode 100644 index b6e1afe6b..000000000 --- a/docs/user-guide/mathematical-notation/features/InvestParameters.md +++ /dev/null @@ -1,143 +0,0 @@ -# InvestParameters - -InvestParameters make capacity a decision variable — should we build this? How big? - -## Basic: Size as Variable - -$$ -P^{min} \leq P \leq P^{max} -$$ - -```python -battery = fx.Storage( - ..., - capacity_in_flow_hours=fx.InvestParameters( - minimum_size=10, - maximum_size=1000, - specific_effects={'costs': 600}, # €600/kWh - ), -) -``` - ---- - -## Investment Modes - -By default, investment is **optional** — the optimizer can choose $P = 0$ (don't invest). - -=== "Continuous" - - Choose size within range (or zero): - - ```python - fx.InvestParameters( - minimum_size=10, - maximum_size=1000, - ) - # → P = 0 OR 10 ≤ P ≤ 1000 - ``` - -=== "Binary" - - Fixed size or nothing: - - ```python - fx.InvestParameters( - fixed_size=100, # 100 kW or 0 - ) - # → P ∈ {0, 100} - ``` - -=== "Mandatory" - - Force investment with `mandatory=True` — zero not allowed: - - ```python - fx.InvestParameters( - minimum_size=50, - maximum_size=200, - mandatory=True, - ) - # → 50 ≤ P ≤ 200 (no zero option) - ``` - ---- - -## Investment Effects - -=== "Per-Size Cost" - - Cost proportional to capacity (€/kW): - - $E = P \cdot c_{spec}$ - - ```python - fx.InvestParameters( - specific_effects={'costs': 1200}, # €1200/kW - ) - ``` - -=== "Fixed Cost" - - One-time cost if investing: - - $E = s_{inv} \cdot c_{fix}$ - - ```python - fx.InvestParameters( - effects_of_investment={'costs': 25000}, # €25k - ) - ``` - -=== "Retirement Cost" - - Cost if NOT investing: - - $E = (1 - s_{inv}) \cdot c_{ret}$ - - ```python - fx.InvestParameters( - effects_of_retirement={'costs': 8000}, # Demolition - ) - ``` - -=== "Piecewise Cost" - - Non-linear cost curves (e.g., economies of scale): - - $E = f_{piecewise}(P)$ - - ```python - fx.InvestParameters( - piecewise_effects_of_investment=fx.PiecewiseEffects( - piecewise_origin=fx.Piecewise([ - fx.Piece(0, 100), - fx.Piece(100, 500), - ]), - piecewise_shares={ - 'costs': fx.Piecewise([ - fx.Piece(0, 80_000), # €800/kW for 0-100 - fx.Piece(80_000, 280_000), # €500/kW for 100-500 - ]) - }, - ), - ) - ``` - - See [Piecewise](Piecewise.md) for details on the formulation. - ---- - -## Reference - -| Symbol | Type | Description | -|--------|------|-------------| -| $P$ | $\mathbb{R}_{\geq 0}$ | Investment size (capacity) | -| $s_{inv}$ | $\{0, 1\}$ | Binary investment decision (0=no, 1=yes) | -| $P^{min}$ | $\mathbb{R}_{\geq 0}$ | Minimum size (`minimum_size`) | -| $P^{max}$ | $\mathbb{R}_{\geq 0}$ | Maximum size (`maximum_size`) | -| $c_{spec}$ | $\mathbb{R}$ | Per-size effect (`effects_of_investment_per_size`) | -| $c_{fix}$ | $\mathbb{R}$ | Fixed effect (`effects_of_investment`) | -| $c_{ret}$ | $\mathbb{R}$ | Retirement effect (`effects_of_retirement`) | - -**Classes:** [`InvestParameters`][flixopt.interface.InvestParameters], [`InvestmentModel`][flixopt.features.InvestmentModel] From 61489801e83df09b72328206d61cfa0aee165fdd Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 30 Nov 2025 20:42:09 +0100 Subject: [PATCH 7/7] _track_lifetime() method in InvestmentModel had complex coordinate handling that needed clarification and test coverage. Changes Made 1. Simplified _track_lifetime() logic (flixopt/features.py:287-336): - Rewrote from vectorized xarray operations to explicit for-loop logic - Added comprehensive docstring with examples - Made the investment-to-decommissioning mapping explicit and readable 2. Fixed _SizeParameters isinstance checks: - Updated elements.py to use _SizeParameters base class for all size parameter checks - Updated components.py similarly - This ensures InvestmentParameters is properly recognized alongside SizingParameters and deprecated InvestParameters 3. Added differentiated model selection: - _create_investment_model() now selects: - InvestmentModel for InvestmentParameters (with lifetime tracking) - SizingModel for SizingParameters or deprecated InvestParameters 4. Fixed parameter name: Changed previous_level to initial_level in BoundingPatterns.link_changes_to_level_with_binaries() call 5. Added test coverage (tests/test_functional.py:736-831): - New test_investment_parameters_with_lifetime test - Tests multi-period optimization with InvestmentParameters - Verifies investment_occurs and decommissioning_occurs variables exist - Validates investment occurs at most once --- flixopt/components.py | 46 +++++++++++--------- flixopt/elements.py | 18 +++++--- flixopt/features.py | 60 ++++++++++++++++++-------- flixopt/optimization.py | 11 ++--- tests/test_functional.py | 93 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 179 insertions(+), 49 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index 0cfed39eb..f974526b5 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -13,8 +13,8 @@ from . import io as fx_io from .core import PlausibilityError from .elements import Component, ComponentModel, Flow -from .features import InvestmentModel, PiecewiseModel -from .interface import InvestParameters, PiecewiseConversion, StatusParameters +from .features import InvestmentModel, PiecewiseModel, SizingModel +from .interface import InvestmentParameters, InvestParameters, PiecewiseConversion, StatusParameters, _SizeParameters from .modeling import BoundingPatterns from .structure import FlowSystemModel, register_class_for_io @@ -209,9 +209,9 @@ def _plausibility_checks(self) -> None: ) if self.piecewise_conversion: for flow in self.flows.values(): - if isinstance(flow.size, InvestParameters) and flow.size.fixed_size is None: + if isinstance(flow.size, _SizeParameters) and flow.size.fixed_size is None: logger.warning( - f'Using a Flow with variable size (InvestParameters without fixed_size) ' + f'Using a Flow with variable size (SizingParameters without fixed_size) ' f'and a piecewise_conversion in {self.label_full} is uncommon. Please verify intent ' f'({flow.label_full}).' ) @@ -428,9 +428,9 @@ def create_model(self, model: FlowSystemModel) -> StorageModel: return self.submodel def _set_flow_system(self, flow_system) -> None: - """Propagate flow_system reference to parent Component and capacity_in_flow_hours if it's InvestParameters.""" + """Propagate flow_system reference to parent Component and capacity_in_flow_hours if it's a SizeParameters.""" super()._set_flow_system(flow_system) - if isinstance(self.capacity_in_flow_hours, InvestParameters): + if isinstance(self.capacity_in_flow_hours, _SizeParameters): self.capacity_in_flow_hours._set_flow_system(flow_system) def transform_data(self, name_prefix: str = '') -> None: @@ -465,8 +465,8 @@ def transform_data(self, name_prefix: str = '') -> None: self.relative_maximum_final_charge_state, dims=['period', 'scenario'], ) - if isinstance(self.capacity_in_flow_hours, InvestParameters): - self.capacity_in_flow_hours.transform_data(f'{prefix}|InvestParameters') + if isinstance(self.capacity_in_flow_hours, _SizeParameters): + self.capacity_in_flow_hours.transform_data(f'{prefix}|SizeParameters') else: self.capacity_in_flow_hours = self._fit_coords( f'{prefix}|capacity_in_flow_hours', self.capacity_in_flow_hours, dims=['period', 'scenario'] @@ -485,8 +485,8 @@ def _plausibility_checks(self) -> None: raise PlausibilityError(f'initial_charge_state has undefined value: {self.initial_charge_state}') initial_equals_final = True - # Use new InvestParameters methods to get capacity bounds - if isinstance(self.capacity_in_flow_hours, InvestParameters): + # Use SizeParameters methods to get capacity bounds + if isinstance(self.capacity_in_flow_hours, _SizeParameters): minimum_capacity = self.capacity_in_flow_hours.minimum_or_fixed_size maximum_capacity = self.capacity_in_flow_hours.maximum_or_fixed_size else: @@ -511,11 +511,11 @@ def _plausibility_checks(self) -> None: ) if self.balanced: - if not isinstance(self.charging.size, InvestParameters) or not isinstance( - self.discharging.size, InvestParameters + if not isinstance(self.charging.size, _SizeParameters) or not isinstance( + self.discharging.size, _SizeParameters ): raise PlausibilityError( - f'Balancing charging and discharging Flows in {self.label_full} is only possible with Investments.' + f'Balancing charging and discharging Flows in {self.label_full} is only possible with SizingParameters.' ) if (self.charging.size.minimum_size > self.discharging.size.maximum_size).any() or ( @@ -697,9 +697,9 @@ def _plausibility_checks(self): if self.balanced: if self.in2 is None: - raise ValueError('Balanced Transmission needs InvestParameters in both in-Flows') - if not isinstance(self.in1.size, InvestParameters) or not isinstance(self.in2.size, InvestParameters): - raise ValueError('Balanced Transmission needs InvestParameters in both in-Flows') + raise ValueError('Balanced Transmission needs SizingParameters in both in-Flows') + if not isinstance(self.in1.size, _SizeParameters) or not isinstance(self.in2.size, _SizeParameters): + raise ValueError('Balanced Transmission needs SizingParameters in both in-Flows') if (self.in1.size.minimum_or_fixed_size > self.in2.size.maximum_or_fixed_size).any() or ( self.in1.size.maximum_or_fixed_size < self.in2.size.minimum_or_fixed_size ).any(): @@ -879,10 +879,16 @@ def _do_modeling(self): short_name='charge_state', ) - # Create InvestmentModel and bounding constraints for investment - if isinstance(self.element.capacity_in_flow_hours, InvestParameters): + # Create investment/sizing model and bounding constraints for investment + if isinstance(self.element.capacity_in_flow_hours, _SizeParameters): + # Use InvestmentModel only for InvestmentParameters (with lifetime/timing) + # Use SizingModel for SizingParameters or deprecated InvestParameters + if isinstance(self.element.capacity_in_flow_hours, InvestmentParameters): + model_class = InvestmentModel + else: + model_class = SizingModel self.add_submodels( - InvestmentModel( + model_class( model=self._model, label_of_element=self.label_of_element, label_of_model=self.label_of_element, @@ -936,7 +942,7 @@ def _initial_and_final_charge_state(self): @property def _absolute_charge_state_bounds(self) -> tuple[xr.DataArray, xr.DataArray]: relative_lower_bound, relative_upper_bound = self._relative_charge_state_bounds - if not isinstance(self.element.capacity_in_flow_hours, InvestParameters): + if not isinstance(self.element.capacity_in_flow_hours, _SizeParameters): return ( relative_lower_bound * self.element.capacity_in_flow_hours, relative_upper_bound * self.element.capacity_in_flow_hours, diff --git a/flixopt/elements.py b/flixopt/elements.py index 371e05b91..64950aa7e 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -13,8 +13,8 @@ from . import io as fx_io from .config import CONFIG from .core import PlausibilityError -from .features import InvestmentModel, StatusModel -from .interface import InvestParameters, StatusParameters +from .features import InvestmentModel, SizingModel, StatusModel +from .interface import InvestmentParameters, InvestParameters, StatusParameters, _SizeParameters from .modeling import BoundingPatterns, ModelingPrimitives, ModelingUtilitiesAbstract from .structure import ( Element, @@ -534,7 +534,7 @@ def transform_data(self, name_prefix: str = '') -> None: if self.status_parameters is not None: self.status_parameters.transform_data(prefix) - if isinstance(self.size, InvestParameters): + if isinstance(self.size, _SizeParameters): self.size.transform_data(prefix) else: self.size = self._fit_coords(f'{prefix}|size', self.size, dims=['period', 'scenario']) @@ -544,7 +544,7 @@ def _plausibility_checks(self) -> None: if (self.relative_minimum > self.relative_maximum).any(): raise PlausibilityError(self.label_full + ': Take care, that relative_minimum <= relative_maximum!') - if not isinstance(self.size, InvestParameters) and ( + if not isinstance(self.size, _SizeParameters) and ( np.any(self.size == CONFIG.Modeling.big) and self.fixed_relative_profile is not None ): # Default Size --> Most likely by accident logger.warning( @@ -685,8 +685,14 @@ def _create_status_model(self): ) def _create_investment_model(self): + # Use InvestmentModel only for InvestmentParameters (with lifetime/timing) + # Use SizingModel for SizingParameters or deprecated InvestParameters + if isinstance(self.element.size, InvestmentParameters): + model_class = InvestmentModel + else: + model_class = SizingModel self.add_submodels( - InvestmentModel( + model_class( model=self._model, label_of_element=self.label_of_element, parameters=self.element.size, @@ -744,7 +750,7 @@ def with_status(self) -> bool: @property def with_investment(self) -> bool: - return isinstance(self.element.size, InvestParameters) + return isinstance(self.element.size, _SizeParameters) # Properties for clean access to variables @property diff --git a/flixopt/features.py b/flixopt/features.py index 7d0f7da1c..19b52ffe6 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -280,32 +280,56 @@ def _track_investment_and_decommissioning_size(self): decrease_binary=self.decommissioning_occurs, name=f'{self.label_of_element}|size|changes', max_change=self.parameters.maximum_or_fixed_size, - previous_level=0 if self.parameters.previous_lifetime == 0 else self.size.isel(period=0), + initial_level=0 if self.parameters.previous_lifetime == 0 else self.size.isel(period=0), coord='period', ) def _track_lifetime(self): - """Create constraints that link investment period to decommissioning period based on lifetime.""" - periods = self._model.flow_system._fit_coords( - 'periods', self._model.flow_system.periods.values, dims=['period', 'scenario'] - ) - - # Calculate decommissioning periods (vectorized) - import xarray as xr + """ + Create constraints linking investment periods to decommissioning periods based on lifetime. - is_first = periods == periods.isel(period=0) - decom_period = periods + self.parameters.lifetime - xr.where(is_first, self.parameters.previous_lifetime, 0) + The constraint enforces: if investment occurs in period p, decommissioning must occur + in period p + lifetime. For the first period, previous_lifetime is subtracted + (modeling existing capacity with remaining lifetime). - # Map to available periods (drop invalid ones for sel to work) - valid = decom_period.where(decom_period <= self._model.flow_system.periods.values[-1], drop=True) - avail_decom = periods.sel(period=valid, method='bfill').assign_coords(period=valid.period) + Example with periods [2020, 2025, 2030, 2035, 2040] and lifetime=2: + - Invest in 2020 → decommission in 2030 (2020 + 2*5 years) + - Invest in 2025 → decommission in 2035 (2025 + 2*5 years) + - Invest in 2030 → decommission in 2040 (2030 + 2*5 years) + - Invest in 2035/2040 → decommission beyond horizon (no constraint needed) - # One constraint per unique decommissioning period - for decom_val in np.unique(avail_decom.values): - mask = (avail_decom == decom_val).reindex_like(periods).fillna(0) + If previous_lifetime=1 (existing capacity with 1 period left): + - First period 2020: decommission in 2025 (2020 + 2 - 1 = 2021 → mapped to 2025) + """ + period_values = self._model.flow_system.periods.values + + # Build investment-to-decommissioning mapping for each period + invest_to_decom = {} + for i, invest_period in enumerate(period_values): + # Calculate lifetime offset: full lifetime for new investment, + # reduced by previous_lifetime for first period (existing capacity) + effective_lifetime = self.parameters.lifetime + if i == 0 and self.parameters.previous_lifetime > 0: + effective_lifetime -= self.parameters.previous_lifetime + + # Decommissioning period = invest period + lifetime (in period units) + decom_period_idx = i + effective_lifetime + if decom_period_idx < len(period_values): + invest_to_decom[invest_period] = period_values[decom_period_idx] + # Else: decommissioning is beyond optimization horizon, no constraint needed + + # Group investments by their decommissioning period + decom_to_invest_periods: dict = {} + for invest_p, decom_p in invest_to_decom.items(): + decom_to_invest_periods.setdefault(decom_p, []).append(invest_p) + + # Create constraint for each decommissioning period: + # sum of investments leading to this decom period == decom occurs in this period + for decom_period, invest_periods in decom_to_invest_periods.items(): + invest_sum = self.investment_occurs.sel(period=invest_periods).sum('period') self.add_constraints( - self.investment_occurs.where(mask).sum('period') == self.decommissioning_occurs.sel(period=decom_val), - short_name=f'size|lifetime{int(decom_val)}', + invest_sum == self.decommissioning_occurs.sel(period=decom_period), + short_name=f'size|lifetime{int(decom_period)}', ) def _apply_investment_period_constraints(self): diff --git a/flixopt/optimization.py b/flixopt/optimization.py index 529975df7..a2a975ea2 100644 --- a/flixopt/optimization.py +++ b/flixopt/optimization.py @@ -27,7 +27,7 @@ from .config import CONFIG, SUCCESS_LEVEL from .core import DataConverter, TimeSeriesData, drop_constant_arrays from .effects import PENALTY_EFFECT_LABEL -from .features import InvestmentModel +from .features import InvestmentModel, SizingModel from .flow_system import FlowSystem from .results import Results, SegmentedResults @@ -296,14 +296,15 @@ def main_results(self) -> dict[str, int | float | dict]: model.label_of_element: model.size.solution for component in self.flow_system.components.values() for model in component.submodel.all_submodels - if isinstance(model, InvestmentModel) + if isinstance(model, (SizingModel, InvestmentModel)) and model.size.solution.max().item() >= CONFIG.Modeling.epsilon }, 'Not invested': { model.label_of_element: model.size.solution for component in self.flow_system.components.values() for model in component.submodel.all_submodels - if isinstance(model, InvestmentModel) and model.size.solution.max().item() < CONFIG.Modeling.epsilon + if isinstance(model, (SizingModel, InvestmentModel)) + and model.size.solution.max().item() < CONFIG.Modeling.epsilon }, }, 'Buses with excess': [ @@ -701,12 +702,12 @@ def _solve_single_segment( model.label_full for component in optimization.flow_system.components.values() for model in component.submodel.all_submodels - if isinstance(model, InvestmentModel) + if isinstance(model, (SizingModel, InvestmentModel)) ] if invest_elements: raise ValueError( f'Investments are not supported in SegmentedOptimization. ' - f'Found InvestmentModels: {invest_elements}. ' + f'Found sizing/investment models: {invest_elements}. ' f'Please use Optimization instead for problems with investments.' ) diff --git a/tests/test_functional.py b/tests/test_functional.py index f351deef5..17784f270 100644 --- a/tests/test_functional.py +++ b/tests/test_functional.py @@ -733,5 +733,98 @@ def test_consecutive_off(solver_fixture, time_steps_fixture): ) +def test_investment_parameters_with_lifetime(solver_fixture): + """Tests InvestmentParameters with lifetime tracking in a multi-period optimization. + + This test verifies that: + 1. Investment timing is correctly tracked (investment_occurs, decommissioning_occurs) + 2. Lifetime constraints link investment period to decommissioning period + 3. The component is only active during its lifetime + """ + # Set up multi-period flow system with 4 periods + timesteps = pd.date_range('2020-01-01', periods=5, freq='h', name='time') + periods = pd.Index([2020, 2025, 2030, 2035], name='period') + + flow_system = fx.FlowSystem(timesteps=timesteps, periods=periods) + + # Create buses and effects + flow_system.add_elements( + fx.Bus('Fernwärme'), + fx.Bus('Gas'), + fx.Effect('costs', '€', 'Costs', is_objective=True), + ) + + # Add heat load (constant across periods) + flow_system.add_elements( + fx.Sink( + 'Wärmelast', + inputs=[fx.Flow('Q_th', bus='Fernwärme', size=100, fixed_relative_profile=0.3)], + ), + fx.Source( + 'Gasquelle', + outputs=[fx.Flow('Q_fu', bus='Gas', size=1000, effects_per_flow_hour={'costs': 2})], + ), + ) + + # Add boiler with InvestmentParameters (lifetime = 2 periods) + # Invest in 2020 → active in 2020, 2025 → decommission in 2030 + flow_system.add_elements( + fx.linear_converters.Boiler( + 'Boiler_Invest', + thermal_efficiency=0.5, + fuel_flow=fx.Flow('Q_fu', bus='Gas'), + thermal_flow=fx.Flow( + 'Q_th', + bus='Fernwärme', + size=fx.InvestmentParameters( + lifetime=2, # Active for 2 periods after investment + minimum_size=50, + maximum_size=200, + effects_per_size={'costs': 10}, # €10/kW investment cost + ), + ), + ), + ) + + # Add backup boiler (always available) + flow_system.add_elements( + fx.linear_converters.Boiler( + 'Boiler_Backup', + thermal_efficiency=0.3, # Less efficient, more expensive + fuel_flow=fx.Flow('Q_fu', bus='Gas'), + thermal_flow=fx.Flow('Q_th', bus='Fernwärme', size=200), + ), + ) + + # Create and solve optimization + optimization = fx.Optimization('test_investment_lifetime', flow_system) + optimization.do_modeling() + optimization.solve(solver_fixture) + + # Get the investment model + boiler = flow_system['Boiler_Invest'] + investment = boiler.thermal_flow.submodel.investment + + # Verify investment model is InvestmentModel (not SizingModel) + from flixopt.features import InvestmentModel + + assert isinstance(investment, InvestmentModel), 'Should use InvestmentModel for InvestmentParameters' + + # Verify investment_occurs variable exists + assert hasattr(investment, 'investment_occurs'), 'InvestmentModel should have investment_occurs variable' + assert hasattr(investment, 'decommissioning_occurs'), 'InvestmentModel should have decommissioning_occurs variable' + + # Verify the investment occurs in exactly one period (or zero) + investment_sum = investment.investment_occurs.solution.sum('period').item() + assert investment_sum <= 1, f'Investment should occur at most once, got sum={investment_sum}' + + # If investment happened, check that decommissioning occurs at most as many times + if investment_sum > 0: + # The sum of decommissioning_occurs should not exceed investment_occurs + # (decommissioning can be 0 if it's beyond the optimization horizon) + decom_sum = investment.decommissioning_occurs.solution.sum('period').item() + assert decom_sum <= investment_sum, 'Decommissioning should not exceed investment count' + + if __name__ == '__main__': pytest.main(['-v', '--disable-warnings'])