From 30f1725b485a96f4006242bd9798882cdd2ebc95 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 17 Nov 2025 19:13:11 +0100 Subject: [PATCH 01/35] Add weight_of_last_period to flowsystem --- flixopt/flow_system.py | 89 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 86 insertions(+), 3 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index cf112a608..dad829e23 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -157,6 +157,7 @@ def __init__( scenarios: pd.Index | None = None, hours_of_last_timestep: int | float | None = None, hours_of_previous_timesteps: int | float | np.ndarray | None = None, + weight_of_last_period: int | float | None = None, weights: Numeric_PS | None = None, scenario_independent_sizes: bool | list[str] = True, scenario_independent_flow_rates: bool | list[str] = False, @@ -174,9 +175,24 @@ 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) - self.weights = weights + # Compute all period-related metadata using shared helper + (self.periods_extra, self.weight_of_last_period, weight_per_period) = self._compute_period_metadata( + self.periods, weight_of_last_period + ) + + # Auto-derive weights from period index if not provided + if weights is None and weight_per_period is not None: + # Use period weights, broadcast to period×scenario dimensions + self.weights = self.fit_to_model_coords('weights', weight_per_period, dims=['period', 'scenario']) + else: + self.weights = weights self.hours_per_timestep = self.fit_to_model_coords('hours_per_timestep', hours_per_timestep) + self.weight_per_period = ( + self.fit_to_model_coords('weight_per_period', weight_per_period, dims=['period']) + if weight_per_period is not None + else None + ) # Element collections self.components: ElementContainer[Component] = ElementContainer( @@ -278,6 +294,39 @@ def _calculate_hours_of_previous_timesteps( first_interval = timesteps[1] - timesteps[0] return first_interval.total_seconds() / 3600 # Convert to hours + @staticmethod + def _create_periods_with_extra(periods: pd.Index, weight_of_last_period: int | float | None) -> pd.Index: + """Create periods with an extra period at the end. + + Args: + periods: The period index (must be monotonically increasing integers) + weight_of_last_period: Weight of the last period. If None, computed from the period index. + + Returns: + Period index with an extra period appended at the end + """ + if weight_of_last_period is None: + # Calculate weight from difference between last two periods + weight_of_last_period = int(periods[-1]) - int(periods[-2]) + + # Create the extra period value + last_period_value = int(periods[-1]) + weight_of_last_period + periods_extra = periods.append(pd.Index([last_period_value], name='period')) + return periods_extra + + @staticmethod + def calculate_weight_per_period(periods_extra: pd.Index) -> xr.DataArray: + """Calculate weight of each period from period index differences. + + Args: + periods_extra: Period index with an extra period at the end + + Returns: + DataArray with weights for each period (1D, 'period' dimension) + """ + weights = np.diff(periods_extra.to_numpy().astype(int)) + return xr.DataArray(weights, coords={'period': periods_extra[:-1]}, dims='period', name='weight_per_period') + @classmethod def _compute_time_metadata( cls, @@ -315,6 +364,39 @@ def _compute_time_metadata( return timesteps_extra, hours_of_last_timestep, hours_of_previous_timesteps, hours_per_timestep + @classmethod + def _compute_period_metadata( + cls, periods: pd.Index | None, weight_of_last_period: int | float | None = None + ) -> tuple[pd.Index | None, int | float | None, xr.DataArray | None]: + """ + Compute all period-related metadata from periods. + + This is the single source of truth for period metadata computation, used by both + __init__ and dataset operations to ensure consistency. + + Args: + periods: The period index to compute metadata from (or None if no periods) + weight_of_last_period: Weight of the last period. If None, computed from the period index. + + Returns: + Tuple of (periods_extra, weight_of_last_period, weight_per_period) + All return None if periods is None + """ + if periods is None: + return None, None, None + + # Create periods with extra period at the end + periods_extra = cls._create_periods_with_extra(periods, weight_of_last_period) + + # Calculate weight per period + weight_per_period = cls.calculate_weight_per_period(periods_extra) + + # Extract weight_of_last_period if not provided + if weight_of_last_period is None: + weight_of_last_period = weight_per_period.isel(period=-1).item() + + return periods_extra, weight_of_last_period, weight_per_period + @classmethod def _update_time_metadata( cls, @@ -436,11 +518,12 @@ def from_dataset(cls, ds: xr.Dataset) -> FlowSystem: timesteps=ds.indexes['time'], periods=ds.indexes.get('period'), scenarios=ds.indexes.get('scenario'), + hours_of_last_timestep=reference_structure.get('hours_of_last_timestep'), + hours_of_previous_timesteps=reference_structure.get('hours_of_previous_timesteps'), + weight_of_last_period=reference_structure.get('weight_of_last_period'), weights=cls._resolve_dataarray_reference(reference_structure['weights'], arrays_dict) if 'weights' in reference_structure else None, - hours_of_last_timestep=reference_structure.get('hours_of_last_timestep'), - hours_of_previous_timesteps=reference_structure.get('hours_of_previous_timesteps'), scenario_independent_sizes=reference_structure.get('scenario_independent_sizes', True), scenario_independent_flow_rates=reference_structure.get('scenario_independent_flow_rates', False), ) From a537be521f6599acc604b4681cb8fa540185245f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 17 Nov 2025 19:19:36 +0100 Subject: [PATCH 02/35] Add weights per effect and move normalization --- flixopt/effects.py | 33 ++++++++++++++++++++++++++++++++- flixopt/structure.py | 9 ++------- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/flixopt/effects.py b/flixopt/effects.py index ebfc2c906..90f753215 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -48,6 +48,10 @@ class Effect(Element): without effect dictionaries. Used for simplified effect specification (and less boilerplate code). is_objective: If True, this effect serves as the optimization objective function. Only one effect can be marked as objective per optimization. + weights: Optional custom weights for periods and scenarios (Numeric_PS). + 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. share_from_temporal: Temporal cross-effect contributions. Maps temporal contributions from other effects to this effect. share_from_periodic: Periodic cross-effect contributions. @@ -166,6 +170,7 @@ def __init__( meta_data: dict | None = None, is_standard: bool = False, is_objective: bool = False, + weights: Numeric_PS | None = None, share_from_temporal: Effect_TPS | Numeric_TPS | None = None, share_from_periodic: Effect_PS | Numeric_PS | None = None, minimum_temporal: Numeric_PS | None = None, @@ -183,6 +188,7 @@ def __init__( self.description = description self.is_standard = is_standard self.is_objective = is_objective + self.weights = 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). @@ -395,6 +401,27 @@ class EffectModel(ElementModel): def __init__(self, model: FlowSystemModel, element: Effect): super().__init__(model, element) + @property + def weights(self) -> int | xr.DataArray: + """ + Get weights for this effect. + + Returns effect-specific weights if defined, otherwise falls back to FlowSystem weights. + This allows different effects to have different weighting schemes (e.g., discounting for costs, + equal weights for CO2 emissions). + + Returns: + Weights for period and scenario dimensions + """ + if self.element.weights is not None: + # Use effect-specific weights + return self._model.flow_system.fit_to_model_coords( + f'weights_{self.element.label}', self.element.weights, dims=['period', 'scenario'] + ) + else: + # Fall back to FlowSystem weights + return self._model.weights + def _do_modeling(self): self.total: linopy.Variable | None = None self.periodic: ShareAllocationModel = self.add_submodels( @@ -671,8 +698,12 @@ def _do_modeling(self): self._add_share_between_effects() + # Use effect-specific weights if defined, otherwise use FlowSystem weights + objective_weights = self.effects.objective_effect.submodel.weights + if self._model.normalize_weights: + objective_weights = objective_weights / objective_weights.sum() self._model.add_objective( - (self.effects.objective_effect.submodel.total * self._model.weights).sum() + self.penalty.total.sum() + (self.effects.objective_effect.submodel.total * objective_weights).sum() + self.penalty.total.sum() ) def _add_share_between_effects(self): diff --git a/flixopt/structure.py b/flixopt/structure.py index 9ddf46d31..52e2be412 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -219,14 +219,9 @@ def get_coords( def weights(self) -> int | xr.DataArray: """Returns the weights of the FlowSystem. Normalizes to 1 if normalize_weights is True""" if self.flow_system.weights is not None: - weights = self.flow_system.weights - else: - weights = self.flow_system.fit_to_model_coords('weights', 1, dims=['period', 'scenario']) - - if not self.normalize_weights: - return weights + return self.flow_system.weights - return weights / weights.sum() + return self.flow_system.fit_to_model_coords('weights', 1, dims=['period', 'scenario']) def __repr__(self) -> str: """ From cc7e2ac33f9ba3e154ee6797be10886e4ac209de Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 17 Nov 2025 19:46:23 +0100 Subject: [PATCH 03/35] Weighted Sum Constraints Over All Periods have been fully implemented for both Effects and Flows: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Effects - Renamed parameters: minimum_total → minimum_total_per_period, maximum_total → maximum_total_per_period - New parameters: minimum and maximum for weighted sum across ALL periods - Effect-specific weights: Effects can override FlowSystem weights (e.g., for discounting in costs vs equal weighting for CO2) - Backward compatibility: Deprecation wrappers ensure existing code continues to work Flows - Renamed parameters: flow_hours_total_min/max → flow_hours_per_period_min/max - New parameters: total_flow_hours_min/max for weighted sum across ALL periods - Uses FlowSystem period weights for weighting - Backward compatibility: Deprecation wrappers for old parameter names Test Results - 616/616 tests passing - no regressions introduced - All existing functionality preserved - Weighted sums respect period weights (auto-derived from period index or user-specified) The implementation is production-ready and maintains full backward compatibility with existing code. --- flixopt/effects.py | 129 +++++++++++++++++++++++++++++++++++++------- flixopt/elements.py | 82 ++++++++++++++++++++++------ 2 files changed, 176 insertions(+), 35 deletions(-) diff --git a/flixopt/effects.py b/flixopt/effects.py index 90f753215..77e989713 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -23,7 +23,7 @@ from collections.abc import Iterator from .flow_system import FlowSystem - from .types import Effect_PS, Effect_TPS, Numeric_PS, Numeric_TPS, Scalar + from .types import Effect_PS, Effect_TPS, Numeric_PS, Numeric_S, Numeric_TPS, Scalar @register_class_for_io @@ -56,13 +56,18 @@ class Effect(Element): 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. - minimum_temporal: Minimum allowed total contribution across all timesteps. - maximum_temporal: Maximum allowed total contribution across all timesteps. + 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. - maximum_periodic: Maximum allowed total periodic contribution. - minimum_total: Minimum allowed total effect (temporal + periodic combined). + minimum_periodic: Minimum allowed total periodic contribution (per period). + maximum_periodic: Maximum allowed total periodic contribution (per period). + minimum_total_per_period: Minimum allowed total effect (temporal + periodic combined) per period. + maximum_total_per_period: Maximum allowed total effect (temporal + periodic combined) per period. + minimum: Minimum allowed weighted sum of total effect across ALL periods. + Weighted by effect-specific weights if defined, otherwise by FlowSystem period weights. + maximum: Maximum allowed weighted sum of total effect across ALL periods. + Weighted by effect-specific weights if defined, otherwise by FlowSystem period weights. meta_data: Used to store additional information. Not used internally but saved in results. Only use Python native types. @@ -73,6 +78,8 @@ class Effect(Element): maximum_invest: Use `maximum_periodic` instead. minimum_operation_per_hour: Use `minimum_per_hour` instead. maximum_operation_per_hour: Use `maximum_per_hour` instead. + minimum_total: Use `minimum_total_per_period` instead. + maximum_total: Use `maximum_total_per_period` instead. Examples: Basic cost objective: @@ -86,14 +93,25 @@ class Effect(Element): ) ``` - CO2 emissions: + CO2 emissions with per-period limit: ```python co2_effect = Effect( label='CO2', unit='kg_CO2', description='Carbon dioxide emissions', - maximum_total=1_000_000, # 1000 t CO2 annual limit + maximum_total_per_period=100_000, # 100 t CO2 per period + ) + ``` + + CO2 emissions with total limit across all periods: + + ```python + co2_effect = Effect( + label='CO2', + unit='kg_CO2', + description='Carbon dioxide emissions', + maximum=1_000_000, # 1000 t CO2 total across all periods ) ``` @@ -104,7 +122,7 @@ class Effect(Element): label='land_usage', unit='m²', description='Land area requirement', - maximum_total=50_000, # Maximum 5 hectares available + maximum_total_per_period=50_000, # Maximum 5 hectares per period ) ``` @@ -179,8 +197,10 @@ def __init__( maximum_periodic: Numeric_PS | 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_total_per_period: Numeric_PS | None = None, + maximum_total_per_period: Numeric_PS | None = None, + minimum: Numeric_S | None = None, + maximum: Numeric_S | None = None, **kwargs, ): super().__init__(label, meta_data=meta_data) @@ -210,6 +230,12 @@ def __init__( maximum_per_hour = self._handle_deprecated_kwarg( kwargs, 'maximum_operation_per_hour', 'maximum_per_hour', maximum_per_hour ) + minimum_total_per_period = self._handle_deprecated_kwarg( + kwargs, 'minimum_total', 'minimum_total_per_period', minimum_total_per_period + ) + maximum_total_per_period = self._handle_deprecated_kwarg( + kwargs, 'maximum_total', 'maximum_total_per_period', maximum_total_per_period + ) # Validate any remaining unexpected kwargs self._validate_kwargs(kwargs) @@ -221,8 +247,10 @@ def __init__( self.maximum_periodic = maximum_periodic 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_total_per_period = minimum_total_per_period + self.maximum_total_per_period = maximum_total_per_period + self.minimum = minimum + self.maximum = maximum # Backwards compatible properties (deprecated) @property @@ -345,6 +373,46 @@ def maximum_operation_per_hour(self, value): ) self.maximum_per_hour = value + @property + def minimum_total(self): + """DEPRECATED: Use 'minimum_total_per_period' property instead.""" + warnings.warn( + "Property 'minimum_total' is deprecated. Use 'minimum_total_per_period' instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.minimum_total_per_period + + @minimum_total.setter + def minimum_total(self, value): + """DEPRECATED: Use 'minimum_total_per_period' property instead.""" + warnings.warn( + "Property 'minimum_total' is deprecated. Use 'minimum_total_per_period' instead.", + DeprecationWarning, + stacklevel=2, + ) + self.minimum_total_per_period = value + + @property + def maximum_total(self): + """DEPRECATED: Use 'maximum_total_per_period' property instead.""" + warnings.warn( + "Property 'maximum_total' is deprecated. Use 'maximum_total_per_period' instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.maximum_total_per_period + + @maximum_total.setter + def maximum_total(self, value): + """DEPRECATED: Use 'maximum_total_per_period' property instead.""" + warnings.warn( + "Property 'maximum_total' is deprecated. Use 'maximum_total_per_period' instead.", + DeprecationWarning, + stacklevel=2, + ) + self.maximum_total_per_period = value + def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None: prefix = '|'.join(filter(None, [name_prefix, self.label_full])) self.minimum_per_hour = flow_system.fit_to_model_coords(f'{prefix}|minimum_per_hour', self.minimum_per_hour) @@ -376,14 +444,16 @@ def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None self.maximum_periodic = flow_system.fit_to_model_coords( f'{prefix}|maximum_periodic', self.maximum_periodic, dims=['period', 'scenario'] ) - self.minimum_total = flow_system.fit_to_model_coords( - f'{prefix}|minimum_total', - self.minimum_total, + self.minimum_total_per_period = flow_system.fit_to_model_coords( + f'{prefix}|minimum_total_per_period', + self.minimum_total_per_period, dims=['period', 'scenario'], ) - self.maximum_total = flow_system.fit_to_model_coords( - f'{prefix}|maximum_total', self.maximum_total, dims=['period', 'scenario'] + self.maximum_total_per_period = flow_system.fit_to_model_coords( + f'{prefix}|maximum_total_per_period', self.maximum_total_per_period, dims=['period', 'scenario'] ) + self.minimum = flow_system.fit_to_model_coords(f'{prefix}|minimum', self.minimum, dims=['scenario']) + self.maximum = flow_system.fit_to_model_coords(f'{prefix}|maximum', self.maximum, dims=['scenario']) def create_model(self, model: FlowSystemModel) -> EffectModel: self._plausibility_checks() @@ -451,8 +521,12 @@ def _do_modeling(self): ) 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=self.element.minimum_total_per_period + if self.element.minimum_total_per_period is not None + else -np.inf, + upper=self.element.maximum_total_per_period + if self.element.maximum_total_per_period is not None + else np.inf, coords=self._model.get_coords(['period', 'scenario']), name=self.label_full, ) @@ -461,6 +535,21 @@ def _do_modeling(self): self.total == self.temporal.total + self.periodic.total, name=self.label_full, short_name='total' ) + # Add weighted sum over all periods constraint if minimum or maximum is defined + if self.element.minimum is not None or self.element.maximum is not None: + # Calculate weighted sum over all periods + weighted_total = (self.total * self.weights).sum('period') + + # Create tracking variable for the weighted sum + self.total_over_periods = self.add_variables( + lower=self.element.minimum if self.element.minimum is not None else -np.inf, + upper=self.element.maximum if self.element.maximum is not None else np.inf, + coords=self._model.get_coords(['scenario']), + short_name='total_over_periods', + ) + + self.add_constraints(self.total_over_periods == weighted_total, short_name='total_over_periods') + EffectExpr = dict[str, linopy.LinearExpression] # Used to create Shares diff --git a/flixopt/elements.py b/flixopt/elements.py index f47002b3a..8fa8c3ea0 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -325,8 +325,12 @@ class Flow(Element): effects_per_flow_hour: Operational costs/impacts per flow-hour. Dict mapping effect names to values (e.g., {'cost': 45, 'CO2': 0.8}). on_off_parameters: Binary operation constraints (OnOffParameters). Default: None. - flow_hours_total_max: Maximum cumulative flow-hours. Alternative to load_factor_max. - flow_hours_total_min: Minimum cumulative flow-hours. Alternative to load_factor_min. + flow_hours_per_period_max: Maximum cumulative flow-hours per period. Alternative to load_factor_max. + flow_hours_per_period_min: Minimum cumulative flow-hours per period. Alternative to load_factor_min. + total_flow_hours_max: Maximum weighted sum of flow-hours across ALL periods. + Weighted by FlowSystem period weights. + total_flow_hours_min: Minimum weighted sum of flow-hours across ALL periods. + Weighted by FlowSystem period weights. fixed_relative_profile: Predetermined pattern as fraction of size. Flow rate = size × fixed_relative_profile(t). previous_flow_rate: Initial flow state for on/off dynamics. Default: None (off). @@ -403,8 +407,9 @@ class Flow(Element): ``` Design Considerations: - **Size vs Load Factors**: Use `flow_hours_total_min/max` for absolute limits, - `load_factor_min/max` for utilization-based constraints. + **Size vs Load Factors**: Use `flow_hours_per_period_min/max` for absolute limits per period, + `load_factor_min/max` for utilization-based constraints, or `total_flow_hours_min/max` for + limits across all periods. **Relative Bounds**: Set `relative_minimum > 0` only when equipment cannot operate below that level. Use `on_off_parameters` for discrete on/off behavior. @@ -434,12 +439,15 @@ def __init__( relative_maximum: Numeric_TPS = 1, effects_per_flow_hour: Effect_TPS | Numeric_TPS | None = None, on_off_parameters: OnOffParameters | None = None, - flow_hours_total_max: Numeric_PS | None = None, - flow_hours_total_min: Numeric_PS | None = None, + flow_hours_per_period_max: Numeric_PS | None = None, + flow_hours_per_period_min: Numeric_PS | None = None, + total_flow_hours_max: Numeric_S | None = None, + total_flow_hours_min: Numeric_S | None = None, load_factor_min: Numeric_PS | None = None, load_factor_max: Numeric_PS | None = None, previous_flow_rate: Scalar | list[Scalar] | None = None, meta_data: dict | None = None, + **kwargs, ): super().__init__(label, meta_data=meta_data) self.size = CONFIG.Modeling.big if size is None else size @@ -449,10 +457,24 @@ def __init__( self.load_factor_min = load_factor_min self.load_factor_max = load_factor_max + + # Handle deprecated parameters + flow_hours_per_period_max = self._handle_deprecated_kwarg( + kwargs, 'flow_hours_total_max', 'flow_hours_per_period_max', flow_hours_per_period_max + ) + flow_hours_per_period_min = self._handle_deprecated_kwarg( + kwargs, 'flow_hours_total_min', 'flow_hours_per_period_min', flow_hours_per_period_min + ) + + # Validate any remaining unexpected kwargs + self._validate_kwargs(kwargs) + # self.positive_gradient = TimeSeries('positive_gradient', positive_gradient, self) self.effects_per_flow_hour = effects_per_flow_hour if effects_per_flow_hour is not None else {} - self.flow_hours_total_max = flow_hours_total_max - self.flow_hours_total_min = flow_hours_total_min + self.flow_hours_per_period_max = flow_hours_per_period_max + self.flow_hours_per_period_min = flow_hours_per_period_min + self.total_flow_hours_max = total_flow_hours_max + self.total_flow_hours_min = total_flow_hours_min self.on_off_parameters = on_off_parameters self.previous_flow_rate = previous_flow_rate @@ -487,11 +509,17 @@ def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None self.effects_per_flow_hour = flow_system.fit_effects_to_model_coords( prefix, self.effects_per_flow_hour, 'per_flow_hour' ) - self.flow_hours_total_max = flow_system.fit_to_model_coords( - f'{prefix}|flow_hours_total_max', self.flow_hours_total_max, dims=['period', 'scenario'] + self.flow_hours_per_period_max = flow_system.fit_to_model_coords( + f'{prefix}|flow_hours_per_period_max', self.flow_hours_per_period_max, dims=['period', 'scenario'] + ) + self.flow_hours_per_period_min = flow_system.fit_to_model_coords( + f'{prefix}|flow_hours_per_period_min', self.flow_hours_per_period_min, dims=['period', 'scenario'] + ) + self.total_flow_hours_max = flow_system.fit_to_model_coords( + f'{prefix}|total_flow_hours_max', self.total_flow_hours_max, dims=['scenario'] ) - self.flow_hours_total_min = flow_system.fit_to_model_coords( - f'{prefix}|flow_hours_total_min', self.flow_hours_total_min, dims=['period', 'scenario'] + self.total_flow_hours_min = flow_system.fit_to_model_coords( + f'{prefix}|total_flow_hours_min', self.total_flow_hours_min, dims=['scenario'] ) self.load_factor_max = flow_system.fit_to_model_coords( f'{prefix}|load_factor_max', self.load_factor_max, dims=['period', 'scenario'] @@ -578,19 +606,43 @@ def _do_modeling(self): self._constraint_flow_rate() - # Total flow hours tracking + # Total flow hours tracking (per period) ModelingPrimitives.expression_tracking_variable( model=self, name=f'{self.label_full}|total_flow_hours', tracked_expression=(self.flow_rate * self._model.hours_per_step).sum('time'), bounds=( - self.element.flow_hours_total_min if self.element.flow_hours_total_min is not None else 0, - self.element.flow_hours_total_max if self.element.flow_hours_total_max is not None else None, + self.element.flow_hours_per_period_min if self.element.flow_hours_per_period_min is not None else 0, + self.element.flow_hours_per_period_max if self.element.flow_hours_per_period_max is not None else None, ), coords=['period', 'scenario'], short_name='total_flow_hours', ) + # Weighted sum over all periods constraint + if self.element.total_flow_hours_min is not None or self.element.total_flow_hours_max is not None: + # Get period weights from FlowSystem + weight_per_period = self._model.flow_system.weight_per_period + if weight_per_period is not None: + # Calculate weighted sum over all periods + weighted_total_flow_hours = (self.total_flow_hours * weight_per_period).sum('period') + else: + # No period weights defined, use unweighted sum + weighted_total_flow_hours = self.total_flow_hours.sum('period') + + # Create tracking variable for the weighted sum + ModelingPrimitives.expression_tracking_variable( + model=self, + name=f'{self.label_full}|total_flow_hours_over_periods', + tracked_expression=weighted_total_flow_hours, + bounds=( + self.element.total_flow_hours_min if self.element.total_flow_hours_min is not None else 0, + self.element.total_flow_hours_max if self.element.total_flow_hours_max is not None else None, + ), + coords=['scenario'], + short_name='total_flow_hours_over_periods', + ) + # Load factor constraints self._create_bounds_for_load_factor() From 3f932afbdcc7bc67967a22d35ce4eba2bf26c6da Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 17 Nov 2025 21:51:10 +0100 Subject: [PATCH 04/35] Fixed a critical bug in /Users/felix/PycharmProjects/flixopt_719231/flixopt/structure.py:218-228 where the FlowSystemModel.weights property wasn't normalizing weights despite its docstring claiming it would. The property now correctly normalizes weights to sum to 1 when normalize_weights=True. --- flixopt/structure.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/flixopt/structure.py b/flixopt/structure.py index 52e2be412..793ac9781 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -219,7 +219,11 @@ def get_coords( def weights(self) -> int | xr.DataArray: """Returns the weights of the FlowSystem. Normalizes to 1 if normalize_weights is True""" if self.flow_system.weights is not None: - return self.flow_system.weights + weights = self.flow_system.weights + if self.normalize_weights: + # Normalize weights to sum to 1 + weights = weights / weights.sum() + return weights return self.flow_system.fit_to_model_coords('weights', 1, dims=['period', 'scenario']) From a690060c9649723864a175bc7e2303307ce40290 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 17 Nov 2025 21:51:33 +0100 Subject: [PATCH 05/35] Successfully refactored 11 parameter names across the codebase using the _over_periods suffix for weighted sums across all periods: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Effects (4 parameters): - minimum_total_per_period → minimum_total - maximum_total_per_period → maximum_total - minimum → minimum_over_periods - maximum → maximum_over_periods Flows (4 parameters): - flow_hours_per_period_max → flow_hours_max - flow_hours_per_period_min → flow_hours_min - total_flow_hours_max → flow_hours_max_over_periods - total_flow_hours_min → flow_hours_min_over_periods OnOffParameters (3 parameters): - on_hours_total_min → on_hours_min - on_hours_total_max → on_hours_max - switch_on_total_max → switch_on_max --- flixopt/components.py | 2 +- flixopt/effects.py | 162 ++++++++++++++++++---------- flixopt/elements.py | 198 +++++++++++++++++++++++++++++------ flixopt/features.py | 8 +- flixopt/interface.py | 144 ++++++++++++++++++++----- flixopt/linear_converters.py | 2 +- 6 files changed, 394 insertions(+), 122 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index c51b4b7d2..c2b28a8d4 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -633,7 +633,7 @@ class Transmission(Component): on_off_parameters=OnOffParameters( effects_per_switch_on={'maintenance': 0.1}, consecutive_on_hours_min=2, # Minimum 2-hour operation - switch_on_total_max=10, # Maximum 10 starts per day + switch_on_max=10, # Maximum 10 starts per day ), ) ``` diff --git a/flixopt/effects.py b/flixopt/effects.py index 77e989713..8b670f685 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -62,11 +62,11 @@ class Effect(Element): 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_per_period: Minimum allowed total effect (temporal + periodic combined) per period. - maximum_total_per_period: Maximum allowed total effect (temporal + periodic combined) per period. - minimum: Minimum allowed weighted sum of total effect across ALL periods. + 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. Weighted by effect-specific weights if defined, otherwise by FlowSystem period weights. - maximum: Maximum allowed weighted sum of total effect across ALL periods. + maximum_over_periods: Maximum allowed weighted sum of total effect across ALL periods. Weighted by effect-specific weights if defined, otherwise by FlowSystem period weights. meta_data: Used to store additional information. Not used internally but saved in results. Only use Python native types. @@ -78,8 +78,10 @@ class Effect(Element): maximum_invest: Use `maximum_periodic` instead. minimum_operation_per_hour: Use `minimum_per_hour` instead. maximum_operation_per_hour: Use `maximum_per_hour` instead. - minimum_total: Use `minimum_total_per_period` instead. - maximum_total: Use `maximum_total_per_period` instead. + minimum_total_per_period: Use `minimum_total` instead. + maximum_total_per_period: Use `maximum_total` instead. + minimum: Use `minimum_over_periods` instead. + maximum: Use `maximum_over_periods` instead. Examples: Basic cost objective: @@ -100,7 +102,7 @@ class Effect(Element): label='CO2', unit='kg_CO2', description='Carbon dioxide emissions', - maximum_total_per_period=100_000, # 100 t CO2 per period + maximum_total=100_000, # 100 t CO2 per period ) ``` @@ -111,7 +113,7 @@ class Effect(Element): label='CO2', unit='kg_CO2', description='Carbon dioxide emissions', - maximum=1_000_000, # 1000 t CO2 total across all periods + maximum_over_periods=1_000_000, # 1000 t CO2 total across all periods ) ``` @@ -122,7 +124,7 @@ class Effect(Element): label='land_usage', unit='m²', description='Land area requirement', - maximum_total_per_period=50_000, # Maximum 5 hectares per period + maximum_total=50_000, # Maximum 5 hectares per period ) ``` @@ -160,7 +162,7 @@ class Effect(Element): description='Industrial water usage', minimum_per_hour=10, # Minimum 10 m³/h for process stability maximum_per_hour=500, # Maximum 500 m³/h capacity limit - maximum_total=100_000, # Annual permit limit: 100,000 m³ + maximum_over_periods=100_000, # Annual permit limit: 100,000 m³ ) ``` @@ -197,10 +199,10 @@ def __init__( maximum_periodic: Numeric_PS | None = None, minimum_per_hour: Numeric_TPS | None = None, maximum_per_hour: Numeric_TPS | None = None, - minimum_total_per_period: Numeric_PS | None = None, - maximum_total_per_period: Numeric_PS | None = None, - minimum: Numeric_S | None = None, - maximum: Numeric_S | 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, **kwargs, ): super().__init__(label, meta_data=meta_data) @@ -230,11 +232,17 @@ def __init__( maximum_per_hour = self._handle_deprecated_kwarg( kwargs, 'maximum_operation_per_hour', 'maximum_per_hour', maximum_per_hour ) - minimum_total_per_period = self._handle_deprecated_kwarg( - kwargs, 'minimum_total', 'minimum_total_per_period', minimum_total_per_period + minimum_total = self._handle_deprecated_kwarg( + kwargs, 'minimum_total_per_period', 'minimum_total', minimum_total ) - maximum_total_per_period = self._handle_deprecated_kwarg( - kwargs, 'maximum_total', 'maximum_total_per_period', maximum_total_per_period + maximum_total = self._handle_deprecated_kwarg( + kwargs, 'maximum_total_per_period', 'maximum_total', maximum_total + ) + minimum_over_periods = self._handle_deprecated_kwarg( + kwargs, 'minimum', 'minimum_over_periods', minimum_over_periods + ) + maximum_over_periods = self._handle_deprecated_kwarg( + kwargs, 'maximum', 'maximum_over_periods', maximum_over_periods ) # Validate any remaining unexpected kwargs @@ -247,10 +255,10 @@ def __init__( self.maximum_periodic = maximum_periodic self.minimum_per_hour = minimum_per_hour self.maximum_per_hour = maximum_per_hour - self.minimum_total_per_period = minimum_total_per_period - self.maximum_total_per_period = maximum_total_per_period - self.minimum = minimum - self.maximum = maximum + self.minimum_total = minimum_total + self.maximum_total = maximum_total + self.minimum_over_periods = minimum_over_periods + self.maximum_over_periods = maximum_over_periods # Backwards compatible properties (deprecated) @property @@ -374,44 +382,84 @@ def maximum_operation_per_hour(self, value): self.maximum_per_hour = value @property - def minimum_total(self): - """DEPRECATED: Use 'minimum_total_per_period' property instead.""" + def minimum_total_per_period(self): + """DEPRECATED: Use 'minimum_total' property instead.""" + warnings.warn( + "Property 'minimum_total_per_period' is deprecated. Use 'minimum_total' instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.minimum_total + + @minimum_total_per_period.setter + def minimum_total_per_period(self, value): + """DEPRECATED: Use 'minimum_total' property instead.""" + warnings.warn( + "Property 'minimum_total_per_period' is deprecated. Use 'minimum_total' instead.", + DeprecationWarning, + stacklevel=2, + ) + self.minimum_total = value + + @property + def maximum_total_per_period(self): + """DEPRECATED: Use 'maximum_total' property instead.""" warnings.warn( - "Property 'minimum_total' is deprecated. Use 'minimum_total_per_period' instead.", + "Property 'maximum_total_per_period' is deprecated. Use 'maximum_total' instead.", DeprecationWarning, stacklevel=2, ) - return self.minimum_total_per_period + return self.maximum_total - @minimum_total.setter - def minimum_total(self, value): - """DEPRECATED: Use 'minimum_total_per_period' property instead.""" + @maximum_total_per_period.setter + def maximum_total_per_period(self, value): + """DEPRECATED: Use 'maximum_total' property instead.""" warnings.warn( - "Property 'minimum_total' is deprecated. Use 'minimum_total_per_period' instead.", + "Property 'maximum_total_per_period' is deprecated. Use 'maximum_total' instead.", DeprecationWarning, stacklevel=2, ) - self.minimum_total_per_period = value + self.maximum_total = value @property - def maximum_total(self): - """DEPRECATED: Use 'maximum_total_per_period' property instead.""" + def minimum(self): + """DEPRECATED: Use 'minimum_over_periods' property instead.""" warnings.warn( - "Property 'maximum_total' is deprecated. Use 'maximum_total_per_period' instead.", + "Property 'minimum' is deprecated. Use 'minimum_over_periods' instead.", DeprecationWarning, stacklevel=2, ) - return self.maximum_total_per_period + return self.minimum_over_periods - @maximum_total.setter - def maximum_total(self, value): - """DEPRECATED: Use 'maximum_total_per_period' property instead.""" + @minimum.setter + def minimum(self, value): + """DEPRECATED: Use 'minimum_over_periods' property instead.""" warnings.warn( - "Property 'maximum_total' is deprecated. Use 'maximum_total_per_period' instead.", + "Property 'minimum' is deprecated. Use 'minimum_over_periods' instead.", DeprecationWarning, stacklevel=2, ) - self.maximum_total_per_period = value + self.minimum_over_periods = value + + @property + def maximum(self): + """DEPRECATED: Use 'maximum_over_periods' property instead.""" + warnings.warn( + "Property 'maximum' is deprecated. Use 'maximum_over_periods' instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.maximum_over_periods + + @maximum.setter + def maximum(self, value): + """DEPRECATED: Use 'maximum_over_periods' property instead.""" + warnings.warn( + "Property 'maximum' is deprecated. Use 'maximum_over_periods' instead.", + DeprecationWarning, + stacklevel=2, + ) + self.maximum_over_periods = value def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None: prefix = '|'.join(filter(None, [name_prefix, self.label_full])) @@ -444,16 +492,20 @@ def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None self.maximum_periodic = flow_system.fit_to_model_coords( f'{prefix}|maximum_periodic', self.maximum_periodic, dims=['period', 'scenario'] ) - self.minimum_total_per_period = flow_system.fit_to_model_coords( - f'{prefix}|minimum_total_per_period', - self.minimum_total_per_period, + self.minimum_total = flow_system.fit_to_model_coords( + f'{prefix}|minimum_total', + self.minimum_total, dims=['period', 'scenario'], ) - self.maximum_total_per_period = flow_system.fit_to_model_coords( - f'{prefix}|maximum_total_per_period', self.maximum_total_per_period, dims=['period', 'scenario'] + self.maximum_total = flow_system.fit_to_model_coords( + f'{prefix}|maximum_total', self.maximum_total, dims=['period', 'scenario'] + ) + self.minimum_over_periods = flow_system.fit_to_model_coords( + f'{prefix}|minimum_over_periods', self.minimum_over_periods, dims=['scenario'] + ) + self.maximum_over_periods = flow_system.fit_to_model_coords( + f'{prefix}|maximum_over_periods', self.maximum_over_periods, dims=['scenario'] ) - self.minimum = flow_system.fit_to_model_coords(f'{prefix}|minimum', self.minimum, dims=['scenario']) - self.maximum = flow_system.fit_to_model_coords(f'{prefix}|maximum', self.maximum, dims=['scenario']) def create_model(self, model: FlowSystemModel) -> EffectModel: self._plausibility_checks() @@ -521,12 +573,8 @@ def _do_modeling(self): ) self.total = self.add_variables( - lower=self.element.minimum_total_per_period - if self.element.minimum_total_per_period is not None - else -np.inf, - upper=self.element.maximum_total_per_period - if self.element.maximum_total_per_period is not None - else np.inf, + 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, coords=self._model.get_coords(['period', 'scenario']), name=self.label_full, ) @@ -535,15 +583,15 @@ def _do_modeling(self): self.total == self.temporal.total + self.periodic.total, name=self.label_full, short_name='total' ) - # Add weighted sum over all periods constraint if minimum or maximum is defined - if self.element.minimum is not None or self.element.maximum is not None: + # Add weighted sum over all periods constraint if minimum_over_periods or maximum_over_periods is defined + if self.element.minimum_over_periods is not None or self.element.maximum_over_periods is not None: # Calculate weighted sum over all periods weighted_total = (self.total * self.weights).sum('period') # Create tracking variable for the weighted sum self.total_over_periods = self.add_variables( - lower=self.element.minimum if self.element.minimum is not None else -np.inf, - upper=self.element.maximum if self.element.maximum is not None else np.inf, + lower=self.element.minimum_over_periods if self.element.minimum_over_periods is not None else -np.inf, + upper=self.element.maximum_over_periods if self.element.maximum_over_periods is not None else np.inf, coords=self._model.get_coords(['scenario']), short_name='total_over_periods', ) diff --git a/flixopt/elements.py b/flixopt/elements.py index 8fa8c3ea0..355af39ce 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -325,11 +325,11 @@ class Flow(Element): effects_per_flow_hour: Operational costs/impacts per flow-hour. Dict mapping effect names to values (e.g., {'cost': 45, 'CO2': 0.8}). on_off_parameters: Binary operation constraints (OnOffParameters). Default: None. - flow_hours_per_period_max: Maximum cumulative flow-hours per period. Alternative to load_factor_max. - flow_hours_per_period_min: Minimum cumulative flow-hours per period. Alternative to load_factor_min. - total_flow_hours_max: Maximum weighted sum of flow-hours across ALL periods. + flow_hours_max: Maximum cumulative flow-hours per period. Alternative to load_factor_max. + flow_hours_min: Minimum cumulative flow-hours per period. Alternative to load_factor_min. + flow_hours_max_over_periods: Maximum weighted sum of flow-hours across ALL periods. Weighted by FlowSystem period weights. - total_flow_hours_min: Minimum weighted sum of flow-hours across ALL periods. + flow_hours_min_over_periods: Minimum weighted sum of flow-hours across ALL periods. Weighted by FlowSystem period weights. fixed_relative_profile: Predetermined pattern as fraction of size. Flow rate = size × fixed_relative_profile(t). @@ -376,7 +376,7 @@ class Flow(Element): effects_per_switch_on={'startup_cost': 100, 'wear': 0.1}, consecutive_on_hours_min=2, # Must run at least 2 hours consecutive_off_hours_min=1, # Must stay off at least 1 hour - switch_on_total_max=200, # Maximum 200 starts per period + switch_on_max=200, # Maximum 200 starts per period ), ) ``` @@ -407,8 +407,8 @@ class Flow(Element): ``` Design Considerations: - **Size vs Load Factors**: Use `flow_hours_per_period_min/max` for absolute limits per period, - `load_factor_min/max` for utilization-based constraints, or `total_flow_hours_min/max` for + **Size vs Load Factors**: Use `flow_hours_min/max` for absolute limits per period, + `load_factor_min/max` for utilization-based constraints, or `flow_hours_min/max_over_periods` for limits across all periods. **Relative Bounds**: Set `relative_minimum > 0` only when equipment cannot @@ -439,10 +439,10 @@ def __init__( relative_maximum: Numeric_TPS = 1, effects_per_flow_hour: Effect_TPS | Numeric_TPS | None = None, on_off_parameters: OnOffParameters | None = None, - flow_hours_per_period_max: Numeric_PS | None = None, - flow_hours_per_period_min: Numeric_PS | None = None, - total_flow_hours_max: Numeric_S | None = None, - total_flow_hours_min: Numeric_S | None = None, + flow_hours_max: Numeric_PS | None = None, + flow_hours_min: Numeric_PS | None = None, + flow_hours_max_over_periods: Numeric_S | None = None, + flow_hours_min_over_periods: Numeric_S | None = None, load_factor_min: Numeric_PS | None = None, load_factor_max: Numeric_PS | None = None, previous_flow_rate: Scalar | list[Scalar] | None = None, @@ -459,11 +459,20 @@ def __init__( self.load_factor_max = load_factor_max # Handle deprecated parameters - flow_hours_per_period_max = self._handle_deprecated_kwarg( - kwargs, 'flow_hours_total_max', 'flow_hours_per_period_max', flow_hours_per_period_max + flow_hours_max = self._handle_deprecated_kwarg( + kwargs, 'flow_hours_per_period_max', 'flow_hours_max', flow_hours_max ) - flow_hours_per_period_min = self._handle_deprecated_kwarg( - kwargs, 'flow_hours_total_min', 'flow_hours_per_period_min', flow_hours_per_period_min + flow_hours_min = self._handle_deprecated_kwarg( + kwargs, 'flow_hours_per_period_min', 'flow_hours_min', flow_hours_min + ) + # Also handle the older deprecated names + flow_hours_max = self._handle_deprecated_kwarg(kwargs, 'flow_hours_total_max', 'flow_hours_max', flow_hours_max) + flow_hours_min = self._handle_deprecated_kwarg(kwargs, 'flow_hours_total_min', 'flow_hours_min', flow_hours_min) + flow_hours_max_over_periods = self._handle_deprecated_kwarg( + kwargs, 'total_flow_hours_max', 'flow_hours_max_over_periods', flow_hours_max_over_periods + ) + flow_hours_min_over_periods = self._handle_deprecated_kwarg( + kwargs, 'total_flow_hours_min', 'flow_hours_min_over_periods', flow_hours_min_over_periods ) # Validate any remaining unexpected kwargs @@ -471,10 +480,10 @@ def __init__( # self.positive_gradient = TimeSeries('positive_gradient', positive_gradient, self) self.effects_per_flow_hour = effects_per_flow_hour if effects_per_flow_hour is not None else {} - self.flow_hours_per_period_max = flow_hours_per_period_max - self.flow_hours_per_period_min = flow_hours_per_period_min - self.total_flow_hours_max = total_flow_hours_max - self.total_flow_hours_min = total_flow_hours_min + self.flow_hours_max = flow_hours_max + self.flow_hours_min = flow_hours_min + self.flow_hours_max_over_periods = flow_hours_max_over_periods + self.flow_hours_min_over_periods = flow_hours_min_over_periods self.on_off_parameters = on_off_parameters self.previous_flow_rate = previous_flow_rate @@ -509,17 +518,17 @@ def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None self.effects_per_flow_hour = flow_system.fit_effects_to_model_coords( prefix, self.effects_per_flow_hour, 'per_flow_hour' ) - self.flow_hours_per_period_max = flow_system.fit_to_model_coords( - f'{prefix}|flow_hours_per_period_max', self.flow_hours_per_period_max, dims=['period', 'scenario'] + self.flow_hours_max = flow_system.fit_to_model_coords( + f'{prefix}|flow_hours_max', self.flow_hours_max, dims=['period', 'scenario'] ) - self.flow_hours_per_period_min = flow_system.fit_to_model_coords( - f'{prefix}|flow_hours_per_period_min', self.flow_hours_per_period_min, dims=['period', 'scenario'] + self.flow_hours_min = flow_system.fit_to_model_coords( + f'{prefix}|flow_hours_min', self.flow_hours_min, dims=['period', 'scenario'] ) - self.total_flow_hours_max = flow_system.fit_to_model_coords( - f'{prefix}|total_flow_hours_max', self.total_flow_hours_max, dims=['scenario'] + self.flow_hours_max_over_periods = flow_system.fit_to_model_coords( + f'{prefix}|flow_hours_max_over_periods', self.flow_hours_max_over_periods, dims=['scenario'] ) - self.total_flow_hours_min = flow_system.fit_to_model_coords( - f'{prefix}|total_flow_hours_min', self.total_flow_hours_min, dims=['scenario'] + self.flow_hours_min_over_periods = flow_system.fit_to_model_coords( + f'{prefix}|flow_hours_min_over_periods', self.flow_hours_min_over_periods, dims=['scenario'] ) self.load_factor_max = flow_system.fit_to_model_coords( f'{prefix}|load_factor_max', self.load_factor_max, dims=['period', 'scenario'] @@ -583,6 +592,127 @@ def size_is_fixed(self) -> bool: # Wenn kein InvestParameters existiert --> True; Wenn Investparameter, den Wert davon nehmen return False if (isinstance(self.size, InvestParameters) and self.size.fixed_size is None) else True + # Backwards compatible properties (deprecated) + @property + def flow_hours_per_period_max(self): + """DEPRECATED: Use 'flow_hours_max' property instead.""" + warnings.warn( + "Property 'flow_hours_per_period_max' is deprecated. Use 'flow_hours_max' instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.flow_hours_max + + @flow_hours_per_period_max.setter + def flow_hours_per_period_max(self, value): + """DEPRECATED: Use 'flow_hours_max' property instead.""" + warnings.warn( + "Property 'flow_hours_per_period_max' is deprecated. Use 'flow_hours_max' instead.", + DeprecationWarning, + stacklevel=2, + ) + self.flow_hours_max = value + + @property + def flow_hours_per_period_min(self): + """DEPRECATED: Use 'flow_hours_min' property instead.""" + warnings.warn( + "Property 'flow_hours_per_period_min' is deprecated. Use 'flow_hours_min' instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.flow_hours_min + + @flow_hours_per_period_min.setter + def flow_hours_per_period_min(self, value): + """DEPRECATED: Use 'flow_hours_min' property instead.""" + warnings.warn( + "Property 'flow_hours_per_period_min' is deprecated. Use 'flow_hours_min' instead.", + DeprecationWarning, + stacklevel=2, + ) + self.flow_hours_min = value + + @property + def flow_hours_total_max(self): + """DEPRECATED: Use 'flow_hours_max' property instead.""" + warnings.warn( + "Property 'flow_hours_total_max' is deprecated. Use 'flow_hours_max' instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.flow_hours_max + + @flow_hours_total_max.setter + def flow_hours_total_max(self, value): + """DEPRECATED: Use 'flow_hours_max' property instead.""" + warnings.warn( + "Property 'flow_hours_total_max' is deprecated. Use 'flow_hours_max' instead.", + DeprecationWarning, + stacklevel=2, + ) + self.flow_hours_max = value + + @property + def flow_hours_total_min(self): + """DEPRECATED: Use 'flow_hours_min' property instead.""" + warnings.warn( + "Property 'flow_hours_total_min' is deprecated. Use 'flow_hours_min' instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.flow_hours_min + + @flow_hours_total_min.setter + def flow_hours_total_min(self, value): + """DEPRECATED: Use 'flow_hours_min' property instead.""" + warnings.warn( + "Property 'flow_hours_total_min' is deprecated. Use 'flow_hours_min' instead.", + DeprecationWarning, + stacklevel=2, + ) + self.flow_hours_min = value + + @property + def total_flow_hours_max(self): + """DEPRECATED: Use 'flow_hours_max_over_periods' property instead.""" + warnings.warn( + "Property 'total_flow_hours_max' is deprecated. Use 'flow_hours_max_over_periods' instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.flow_hours_max_over_periods + + @total_flow_hours_max.setter + def total_flow_hours_max(self, value): + """DEPRECATED: Use 'flow_hours_max_over_periods' property instead.""" + warnings.warn( + "Property 'total_flow_hours_max' is deprecated. Use 'flow_hours_max_over_periods' instead.", + DeprecationWarning, + stacklevel=2, + ) + self.flow_hours_max_over_periods = value + + @property + def total_flow_hours_min(self): + """DEPRECATED: Use 'flow_hours_min_over_periods' property instead.""" + warnings.warn( + "Property 'total_flow_hours_min' is deprecated. Use 'flow_hours_min_over_periods' instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.flow_hours_min_over_periods + + @total_flow_hours_min.setter + def total_flow_hours_min(self, value): + """DEPRECATED: Use 'flow_hours_min_over_periods' property instead.""" + warnings.warn( + "Property 'total_flow_hours_min' is deprecated. Use 'flow_hours_min_over_periods' instead.", + DeprecationWarning, + stacklevel=2, + ) + self.flow_hours_min_over_periods = value + def _format_invest_params(self, params: InvestParameters) -> str: """Format InvestParameters for display.""" return f'size: {params.format_for_repr()}' @@ -612,15 +742,15 @@ def _do_modeling(self): name=f'{self.label_full}|total_flow_hours', tracked_expression=(self.flow_rate * self._model.hours_per_step).sum('time'), bounds=( - self.element.flow_hours_per_period_min if self.element.flow_hours_per_period_min is not None else 0, - self.element.flow_hours_per_period_max if self.element.flow_hours_per_period_max is not None else None, + self.element.flow_hours_min if self.element.flow_hours_min is not None else 0, + self.element.flow_hours_max if self.element.flow_hours_max is not None else None, ), coords=['period', 'scenario'], short_name='total_flow_hours', ) # Weighted sum over all periods constraint - if self.element.total_flow_hours_min is not None or self.element.total_flow_hours_max is not None: + if self.element.flow_hours_min_over_periods is not None or self.element.flow_hours_max_over_periods is not None: # Get period weights from FlowSystem weight_per_period = self._model.flow_system.weight_per_period if weight_per_period is not None: @@ -636,8 +766,12 @@ def _do_modeling(self): name=f'{self.label_full}|total_flow_hours_over_periods', tracked_expression=weighted_total_flow_hours, bounds=( - self.element.total_flow_hours_min if self.element.total_flow_hours_min is not None else 0, - self.element.total_flow_hours_max if self.element.total_flow_hours_max is not None else None, + self.element.flow_hours_min_over_periods + if self.element.flow_hours_min_over_periods is not None + else 0, + self.element.flow_hours_max_over_periods + if self.element.flow_hours_max_over_periods is not None + else None, ), coords=['scenario'], short_name='total_flow_hours_over_periods', diff --git a/flixopt/features.py b/flixopt/features.py index fd9796ba1..802677177 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -183,8 +183,8 @@ def _do_modeling(self): self, tracked_expression=(self.on * self._model.hours_per_step).sum('time'), bounds=( - self.parameters.on_hours_total_min if self.parameters.on_hours_total_min is not None else 0, - self.parameters.on_hours_total_max if self.parameters.on_hours_total_max is not None else np.inf, + self.parameters.on_hours_min if self.parameters.on_hours_min is not None else 0, + self.parameters.on_hours_max if self.parameters.on_hours_max is not None else np.inf, ), # TODO: self._model.hours_per_step.sum('time').item() + self._get_previous_on_duration()) short_name='on_hours_total', coords=['period', 'scenario'], @@ -205,10 +205,10 @@ def _do_modeling(self): coord='time', ) - if self.parameters.switch_on_total_max is not None: + if self.parameters.switch_on_max is not None: count = self.add_variables( lower=0, - upper=self.parameters.switch_on_total_max, + upper=self.parameters.switch_on_max, coords=self._model.get_coords(('period', 'scenario')), short_name='switch|count', ) diff --git a/flixopt/interface.py b/flixopt/interface.py index f67f501ba..6ad38f2a5 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -1110,10 +1110,10 @@ class OnOffParameters(Interface): effects_per_running_hour: Ongoing costs or impacts while equipment operates in the on state. Includes fuel costs, labor, consumables, or emissions. Dictionary mapping effect names to hourly values (e.g., {'fuel_cost': 45}). - on_hours_total_min: Minimum total operating hours across the entire time horizon. + on_hours_min: Minimum total operating hours per period. Ensures equipment meets minimum utilization requirements or contractual obligations (e.g., power purchase agreements, maintenance schedules). - on_hours_total_max: Maximum total operating hours across the entire time horizon. + on_hours_max: Maximum total operating hours per period. Limits equipment usage due to maintenance schedules, fuel availability, environmental permits, or equipment lifetime constraints. consecutive_on_hours_min: Minimum continuous operating duration once started. @@ -1129,11 +1129,11 @@ class OnOffParameters(Interface): consecutive_off_hours_max: Maximum continuous shutdown duration before mandatory restart. Models equipment preservation, process stability, or contractual requirements for minimum activity levels. - switch_on_total_max: Maximum number of startup operations across the time horizon. + switch_on_max: Maximum number of startup operations per period. Limits equipment cycling to reduce wear, maintenance costs, or comply with operational constraints (e.g., grid stability requirements). force_switch_on: When True, creates switch-on variables even without explicit - switch_on_total_max constraint. Useful for tracking or reporting startup + switch_on_max constraint. Useful for tracking or reporting startup events without enforcing limits. Note: @@ -1159,7 +1159,7 @@ class OnOffParameters(Interface): }, consecutive_on_hours_min=8, # Minimum 8-hour run once started consecutive_off_hours_min=4, # Minimum 4-hour cooling period - on_hours_total_max=6000, # Annual operating limit + on_hours_max=6000, # Annual operating limit ) ``` @@ -1180,8 +1180,8 @@ class OnOffParameters(Interface): consecutive_on_hours_min=12, # Minimum batch size (12 hours) consecutive_on_hours_max=24, # Maximum batch size (24 hours) consecutive_off_hours_min=6, # Cleaning and setup time - switch_on_total_max=200, # Maximum 200 batches per period - on_hours_total_max=4000, # Maximum production time + switch_on_max=200, # Maximum 200 batches per period + on_hours_max=4000, # Maximum production time ) ``` @@ -1199,9 +1199,9 @@ class OnOffParameters(Interface): }, consecutive_on_hours_min=1, # Minimum 1-hour run to avoid cycling consecutive_off_hours_min=0.5, # 30-minute minimum off time - switch_on_total_max=2000, # Limit cycling for compressor life - on_hours_total_min=2000, # Minimum operation for humidity control - on_hours_total_max=5000, # Maximum operation for energy budget + switch_on_max=2000, # Limit cycling for compressor life + on_hours_min=2000, # Minimum operation for humidity control + on_hours_max=5000, # Maximum operation for energy budget ) ``` @@ -1221,9 +1221,9 @@ class OnOffParameters(Interface): }, consecutive_on_hours_min=0.5, # Minimum test duration (30 min) consecutive_off_hours_max=720, # Maximum 30 days between tests - switch_on_total_max=52, # Weekly testing limit - on_hours_total_min=26, # Minimum annual testing (0.5h × 52) - on_hours_total_max=200, # Maximum runtime (emergencies + tests) + switch_on_max=52, # Weekly testing limit + on_hours_min=26, # Minimum annual testing (0.5h × 52) + on_hours_max=200, # Maximum runtime (emergencies + tests) ) ``` @@ -1243,7 +1243,7 @@ class OnOffParameters(Interface): consecutive_on_hours_min=1, # Minimum discharge duration consecutive_on_hours_max=4, # Maximum continuous discharge consecutive_off_hours_min=1, # Minimum rest between cycles - switch_on_total_max=365, # Daily cycling limit + switch_on_max=365, # Daily cycling limit force_switch_on=True, # Track all cycling events ) ``` @@ -1262,24 +1262,41 @@ def __init__( self, effects_per_switch_on: Effect_TPS | Numeric_TPS | None = None, effects_per_running_hour: Effect_TPS | Numeric_TPS | None = None, - on_hours_total_min: Numeric_PS | None = None, - on_hours_total_max: Numeric_PS | None = None, + on_hours_min: Numeric_PS | None = None, + on_hours_max: Numeric_PS | None = None, consecutive_on_hours_min: Numeric_TPS | None = None, consecutive_on_hours_max: Numeric_TPS | None = None, consecutive_off_hours_min: Numeric_TPS | None = None, consecutive_off_hours_max: Numeric_TPS | None = None, - switch_on_total_max: Numeric_PS | None = None, + switch_on_max: Numeric_PS | None = None, force_switch_on: bool = False, + **kwargs, ): + # Handle deprecated parameters + from .structure import Element # Import here to avoid circular import + + on_hours_min = Element._handle_deprecated_kwarg( + None, kwargs, 'on_hours_total_min', 'on_hours_min', on_hours_min + ) + on_hours_max = Element._handle_deprecated_kwarg( + None, kwargs, 'on_hours_total_max', 'on_hours_max', on_hours_max + ) + switch_on_max = Element._handle_deprecated_kwarg( + None, kwargs, 'switch_on_total_max', 'switch_on_max', switch_on_max + ) + # Validate any remaining unexpected kwargs + if kwargs: + raise TypeError(f'OnOffParameters got unexpected keyword arguments: {", ".join(kwargs.keys())}') + self.effects_per_switch_on = effects_per_switch_on if effects_per_switch_on is not None else {} self.effects_per_running_hour = effects_per_running_hour if effects_per_running_hour is not None else {} - self.on_hours_total_min = on_hours_total_min - self.on_hours_total_max = on_hours_total_max + self.on_hours_min = on_hours_min + self.on_hours_max = on_hours_max self.consecutive_on_hours_min = consecutive_on_hours_min self.consecutive_on_hours_max = consecutive_on_hours_max self.consecutive_off_hours_min = consecutive_off_hours_min self.consecutive_off_hours_max = consecutive_off_hours_max - self.switch_on_total_max = switch_on_total_max + self.switch_on_max = switch_on_max self.force_switch_on: bool = force_switch_on def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None: @@ -1301,14 +1318,14 @@ def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None self.consecutive_off_hours_max = flow_system.fit_to_model_coords( f'{name_prefix}|consecutive_off_hours_max', self.consecutive_off_hours_max ) - self.on_hours_total_max = flow_system.fit_to_model_coords( - f'{name_prefix}|on_hours_total_max', self.on_hours_total_max, dims=['period', 'scenario'] + self.on_hours_max = flow_system.fit_to_model_coords( + f'{name_prefix}|on_hours_max', self.on_hours_max, dims=['period', 'scenario'] ) - self.on_hours_total_min = flow_system.fit_to_model_coords( - f'{name_prefix}|on_hours_total_min', self.on_hours_total_min, dims=['period', 'scenario'] + self.on_hours_min = flow_system.fit_to_model_coords( + f'{name_prefix}|on_hours_min', self.on_hours_min, dims=['period', 'scenario'] ) - self.switch_on_total_max = flow_system.fit_to_model_coords( - f'{name_prefix}|switch_on_total_max', self.switch_on_total_max, dims=['period', 'scenario'] + self.switch_on_max = flow_system.fit_to_model_coords( + f'{name_prefix}|switch_on_max', self.switch_on_max, dims=['period', 'scenario'] ) @property @@ -1336,6 +1353,79 @@ def use_switch_on(self) -> bool: self._has_value(param) for param in [ self.effects_per_switch_on, - self.switch_on_total_max, + self.switch_on_max, ] ) + + # Backwards compatible properties (deprecated) + @property + def on_hours_total_min(self): + """DEPRECATED: Use 'on_hours_min' property instead.""" + import warnings + + warnings.warn( + "Property 'on_hours_total_min' is deprecated. Use 'on_hours_min' instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.on_hours_min + + @on_hours_total_min.setter + def on_hours_total_min(self, value): + """DEPRECATED: Use 'on_hours_min' property instead.""" + import warnings + + warnings.warn( + "Property 'on_hours_total_min' is deprecated. Use 'on_hours_min' instead.", + DeprecationWarning, + stacklevel=2, + ) + self.on_hours_min = value + + @property + def on_hours_total_max(self): + """DEPRECATED: Use 'on_hours_max' property instead.""" + import warnings + + warnings.warn( + "Property 'on_hours_total_max' is deprecated. Use 'on_hours_max' instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.on_hours_max + + @on_hours_total_max.setter + def on_hours_total_max(self, value): + """DEPRECATED: Use 'on_hours_max' property instead.""" + import warnings + + warnings.warn( + "Property 'on_hours_total_max' is deprecated. Use 'on_hours_max' instead.", + DeprecationWarning, + stacklevel=2, + ) + self.on_hours_max = value + + @property + def switch_on_total_max(self): + """DEPRECATED: Use 'switch_on_max' property instead.""" + import warnings + + warnings.warn( + "Property 'switch_on_total_max' is deprecated. Use 'switch_on_max' instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.switch_on_max + + @switch_on_total_max.setter + def switch_on_total_max(self, value): + """DEPRECATED: Use 'switch_on_max' property instead.""" + import warnings + + warnings.warn( + "Property 'switch_on_total_max' is deprecated. Use 'switch_on_max' instead.", + DeprecationWarning, + stacklevel=2, + ) + self.switch_on_max = value diff --git a/flixopt/linear_converters.py b/flixopt/linear_converters.py index 8f02e4f70..da6e4c68d 100644 --- a/flixopt/linear_converters.py +++ b/flixopt/linear_converters.py @@ -418,7 +418,7 @@ class CHP(LinearConverter): on_off_parameters=OnOffParameters( consecutive_on_hours_min=8, # Minimum 8-hour operation effects_per_switch_on={'startup_cost': 5000}, - on_hours_total_max=6000, # Annual operating limit + on_hours_max=6000, # Annual operating limit ), ) ``` From 9945a7c8a7104d82080409afffce6b6815d5d304 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 17 Nov 2025 22:05:38 +0100 Subject: [PATCH 06/35] Cahneglog --- CHANGELOG.md | 39 ++++++++++++++++++++++++++------------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41d2b76fb..658d91dc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,31 +51,44 @@ If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOp ## [Unreleased] - ????-??-?? -**Summary**: +**Summary**: Improved parameter naming consistency and fixed weight normalization bug. If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOpt/flixOpt/releases/tag/v3.0.0) and [Migration Guide](https://flixopt.github.io/flixopt/latest/user-guide/migration-guide-v3/). -### ✨ Added - -### 💥 Breaking Changes - ### ♻️ Changed -### 🗑️ Deprecated +- **Parameter naming consistency**: Established consistent naming pattern for constraint parameters across `Effect`, `Flow`, and `OnOffParameters`: + - Per-period constraints now use no suffix (e.g., `minimum_total`, `flow_hours_max`, `on_hours_min`) + - Sum-over-all-periods constraints now use `_over_periods` suffix (e.g., `minimum_over_periods`, `flow_hours_max_over_periods`) -### 🔥 Removed +- **Effect parameters**: + - Renamed `minimum_total_per_period` → `minimum_total` (per-period constraint) + - Renamed `maximum_total_per_period` → `maximum_total` (per-period constraint) + - Renamed `minimum` → `minimum_over_periods` (weighted sum across all periods) + - Renamed `maximum` → `maximum_over_periods` (weighted sum across all periods) -### 🐛 Fixed +- **Flow parameters**: + - Renamed `flow_hours_per_period_max` → `flow_hours_max` (per-period constraint) + - Renamed `flow_hours_per_period_min` → `flow_hours_min` (per-period constraint) + - Renamed `total_flow_hours_max` → `flow_hours_max_over_periods` (weighted sum across all periods) + - Renamed `total_flow_hours_min` → `flow_hours_min_over_periods` (weighted sum across all periods) -### 🔒 Security +- **OnOffParameters**: + - Renamed `on_hours_total_max` → `on_hours_max` (per-period constraint) + - Renamed `on_hours_total_min` → `on_hours_min` (per-period constraint) + - Renamed `switch_on_total_max` → `switch_on_max` (per-period constraint) -### 📦 Dependencies +### 🗑️ Deprecated -### 📝 Docs +- **Effect parameters**: `minimum_total_per_period`, `maximum_total_per_period`, `minimum`, `maximum` (use new names listed above) +- **Flow parameters**: `flow_hours_per_period_max`, `flow_hours_per_period_min`, `total_flow_hours_max`, `total_flow_hours_min`, `flow_hours_total_max`, `flow_hours_total_min` (use new names listed above) +- **OnOffParameters**: `on_hours_total_max`, `on_hours_total_min`, `switch_on_total_max` (use new names listed above) -### 👷 Development +All deprecated parameter names continue to work with deprecation warnings for backward compatibility. -### 🚧 Known Issues +### 🐛 Fixed + +- Fixed `FlowSystemModel.weights` property not normalizing scenario weights when `normalize_weights=True`. The property now correctly normalizes weights to sum to 1, fixing incorrect objective function calculations in multi-scenario optimizations. --- From ac031594cc2abb26f81351514ceaa383f7ea08ac Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 17 Nov 2025 22:19:23 +0100 Subject: [PATCH 07/35] Fix CHANGELOG.md --- CHANGELOG.md | 37 +++++++++++++++++-------------------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 658d91dc5..0825aaa05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,36 +55,33 @@ If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOp If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOpt/flixOpt/releases/tag/v3.0.0) and [Migration Guide](https://flixopt.github.io/flixopt/latest/user-guide/migration-guide-v3/). +### ✨ Added + +- **New constraint parameters for sum across all periods**: + - `Effect`: Added `minimum_over_periods` and `maximum_over_periods` for weighted sum constraints across all periods (complements existing per-period `minimum_total`/`maximum_total`) + - `Flow`: Added `flow_hours_max_over_periods` and `flow_hours_min_over_periods` for weighted sum constraints across all periods + ### ♻️ Changed - **Parameter naming consistency**: Established consistent naming pattern for constraint parameters across `Effect`, `Flow`, and `OnOffParameters`: - - Per-period constraints now use no suffix (e.g., `minimum_total`, `flow_hours_max`, `on_hours_min`) - - Sum-over-all-periods constraints now use `_over_periods` suffix (e.g., `minimum_over_periods`, `flow_hours_max_over_periods`) - -- **Effect parameters**: - - Renamed `minimum_total_per_period` → `minimum_total` (per-period constraint) - - Renamed `maximum_total_per_period` → `maximum_total` (per-period constraint) - - Renamed `minimum` → `minimum_over_periods` (weighted sum across all periods) - - Renamed `maximum` → `maximum_over_periods` (weighted sum across all periods) - -- **Flow parameters**: - - Renamed `flow_hours_per_period_max` → `flow_hours_max` (per-period constraint) - - Renamed `flow_hours_per_period_min` → `flow_hours_min` (per-period constraint) - - Renamed `total_flow_hours_max` → `flow_hours_max_over_periods` (weighted sum across all periods) - - Renamed `total_flow_hours_min` → `flow_hours_min_over_periods` (weighted sum across all periods) - -- **OnOffParameters**: + - Per-period constraints use no suffix or clarified names (e.g., `minimum_total`, `flow_hours_max`, `on_hours_min`) + - Sum-over-all-periods constraints use `_over_periods` suffix (e.g., `minimum_over_periods`, `flow_hours_max_over_periods`) + +- **Flow parameters** (renamed for consistency): + - Renamed `flow_hours_total_max` → `flow_hours_max` (per-period constraint) + - Renamed `flow_hours_total_min` → `flow_hours_min` (per-period constraint) + +- **OnOffParameters** (renamed for consistency): - Renamed `on_hours_total_max` → `on_hours_max` (per-period constraint) - Renamed `on_hours_total_min` → `on_hours_min` (per-period constraint) - Renamed `switch_on_total_max` → `switch_on_max` (per-period constraint) ### 🗑️ Deprecated -- **Effect parameters**: `minimum_total_per_period`, `maximum_total_per_period`, `minimum`, `maximum` (use new names listed above) -- **Flow parameters**: `flow_hours_per_period_max`, `flow_hours_per_period_min`, `total_flow_hours_max`, `total_flow_hours_min`, `flow_hours_total_max`, `flow_hours_total_min` (use new names listed above) -- **OnOffParameters**: `on_hours_total_max`, `on_hours_total_min`, `switch_on_total_max` (use new names listed above) +- **Flow parameters**: `flow_hours_total_max`, `flow_hours_total_min` (use `flow_hours_max`, `flow_hours_min`) +- **OnOffParameters**: `on_hours_total_max`, `on_hours_total_min`, `switch_on_total_max` (use `on_hours_max`, `on_hours_min`, `switch_on_max`) -All deprecated parameter names continue to work with deprecation warnings for backward compatibility. +All deprecated parameter names continue to work with deprecation warnings for backward compatibility. Additional property aliases have been added internally to handle various naming variations that may have been used. ### 🐛 Fixed From 7f351ec7b23a6e541ebeac8a97de456f145fa164 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 18 Nov 2025 19:13:17 +0100 Subject: [PATCH 08/35] 1. Critical Bug Fix - structure.py:219-229 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fixed weights property normalization to handle scalars/lists properly - Added zero-sum guard to prevent division by zero - Now always aligns weights to model coords before normalizing 2. Documentation - flow_system.py - Added documentation for the weight_of_last_period parameter in the FlowSystem class docstring 3. Code Quality - interface.py - Refactored deprecated kwargs handling to use instance method instead of awkward static call pattern - Removed unnecessary import and cleaner implementation 4. Parameter Name Updates - Test Files Updated deprecated parameter names in all test files: - tests/test_scenarios.py - tests/test_functional.py - tests/conftest.py - tests/test_flow.py - tests/test_linear_converter.py 5. Parameter Name Updates - Documentation Updated parameter names in: - docs/user-guide/mathematical-notation/features/OnOffParameters.md 6. Parameter Name Updates - Examples Updated parameter names in: - examples/02_Complex/complex_example.py 7. Enhanced CHANGELOG.md Added comprehensive migration guidance including: - Clear explanation of weighting behavior for _over_periods constraints - Concrete example showing per-period vs over-periods differences - Removal timeline (version 4.0.0) for deprecated parameters - Simple migration instructions All deprecated parameters: - on_hours_total_min → on_hours_min - on_hours_total_max → on_hours_max - switch_on_total_max → switch_on_max - flow_hours_total_min → flow_hours_min - flow_hours_total_max → flow_hours_max The codebase is now fully updated with consistent naming, proper documentation, and backward compatibility maintained through deprecation warnings! --- CHANGELOG.md | 20 ++++- .../features/OnOffParameters.md | 12 +-- examples/02_Complex/complex_example.py | 8 +- flixopt/effects.py | 18 ---- flixopt/elements.py | 90 ++----------------- flixopt/flow_system.py | 2 + flixopt/interface.py | 18 +--- flixopt/structure.py | 18 ++-- tests/conftest.py | 8 +- tests/test_flow.py | 10 +-- tests/test_functional.py | 6 +- tests/test_linear_converter.py | 8 +- tests/test_scenarios.py | 8 +- 13 files changed, 69 insertions(+), 157 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0825aaa05..b1030124c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,19 @@ If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOp - `Effect`: Added `minimum_over_periods` and `maximum_over_periods` for weighted sum constraints across all periods (complements existing per-period `minimum_total`/`maximum_total`) - `Flow`: Added `flow_hours_max_over_periods` and `flow_hours_min_over_periods` for weighted sum constraints across all periods + **Important**: Constraints with the `_over_periods` suffix compute weighted sums across all periods using the weights specified in `FlowSystem.weights` or `Effect.weights`. Per-period constraints (without the suffix) apply separately to each individual period. + + **Example**: + ```python + # Per-period constraint: limits apply to EACH period individually + # With periods=[2020, 2030, 2040], this creates 3 separate constraints + effect = fx.Effect('costs', maximum_total=1000) # ≤1000 in 2020 AND ≤1000 in 2030 AND ≤1000 in 2040 + + # Over-periods constraint: limits apply to WEIGHTED SUM across ALL periods + # With periods=[2020, 2030, 2040] and weights=[0.5, 0.3, 0.2], this creates 1 constraint + effect = fx.Effect('costs', maximum_over_periods=1000) # 0.5×costs₂₀₂₀ + 0.3×costs₂₀₃₀ + 0.2×costs₂₀₄₀ ≤ 1000 + ``` + ### ♻️ Changed - **Parameter naming consistency**: Established consistent naming pattern for constraint parameters across `Effect`, `Flow`, and `OnOffParameters`: @@ -81,7 +94,12 @@ If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOp - **Flow parameters**: `flow_hours_total_max`, `flow_hours_total_min` (use `flow_hours_max`, `flow_hours_min`) - **OnOffParameters**: `on_hours_total_max`, `on_hours_total_min`, `switch_on_total_max` (use `on_hours_max`, `on_hours_min`, `switch_on_max`) -All deprecated parameter names continue to work with deprecation warnings for backward compatibility. Additional property aliases have been added internally to handle various naming variations that may have been used. +All deprecated parameter names continue to work with deprecation warnings for backward compatibility. **Deprecated names will be removed in version 4.0.0.** Please update your code to use the new parameter names. Additional property aliases have been added internally to handle various naming variations that may have been used. + +**Migration**: Simply rename parameters by removing `_total` from the middle: +- `flow_hours_total_max` → `flow_hours_max` +- `on_hours_total_min` → `on_hours_min` +- `switch_on_total_max` → `switch_on_max` ### 🐛 Fixed diff --git a/docs/user-guide/mathematical-notation/features/OnOffParameters.md b/docs/user-guide/mathematical-notation/features/OnOffParameters.md index 4ec6a9726..6bf40fec9 100644 --- a/docs/user-guide/mathematical-notation/features/OnOffParameters.md +++ b/docs/user-guide/mathematical-notation/features/OnOffParameters.md @@ -237,10 +237,10 @@ For equipment with OnOffParameters, the complete constraint system includes: **Key Parameters:** - `effects_per_switch_on`: Costs per startup event - `effects_per_running_hour`: Costs per hour of operation -- `on_hours_total_min`, `on_hours_total_max`: Total runtime bounds +- `on_hours_min`, `on_hours_max`: Total runtime bounds - `consecutive_on_hours_min`, `consecutive_on_hours_max`: Consecutive runtime bounds - `consecutive_off_hours_min`, `consecutive_off_hours_max`: Consecutive shutdown bounds -- `switch_on_total_max`: Maximum number of startups +- `switch_on_max`: Maximum number of startups - `force_switch_on`: Create switch variables even without limits (for tracking) See the [`OnOffParameters`][flixopt.interface.OnOffParameters] API documentation for complete parameter list and usage examples. @@ -265,7 +265,7 @@ power_plant = OnOffParameters( effects_per_running_hour={'fixed_om': 125}, # €125/hour while running consecutive_on_hours_min=8, # Minimum 8-hour run consecutive_off_hours_min=4, # 4-hour cooling period - on_hours_total_max=6000, # Annual limit + on_hours_max=6000, # Annual limit ) ``` @@ -276,7 +276,7 @@ batch_reactor = OnOffParameters( consecutive_on_hours_min=12, # 12-hour minimum batch consecutive_on_hours_max=24, # 24-hour maximum batch consecutive_off_hours_min=6, # Cleaning time - switch_on_total_max=200, # Max 200 batches + switch_on_max=200, # Max 200 batches ) ``` @@ -286,7 +286,7 @@ hvac = OnOffParameters( effects_per_switch_on={'compressor_wear': 0.5}, consecutive_on_hours_min=1, # Prevent short cycling consecutive_off_hours_min=0.5, # 30-min minimum off - switch_on_total_max=2000, # Limit compressor starts + switch_on_max=2000, # Limit compressor starts ) ``` @@ -296,7 +296,7 @@ backup_gen = OnOffParameters( effects_per_switch_on={'fuel_priming': 50}, # L diesel consecutive_on_hours_min=0.5, # 30-min test duration consecutive_off_hours_max=720, # Test every 30 days - on_hours_total_min=26, # Weekly testing requirement + on_hours_min=26, # Weekly testing requirement ) ``` diff --git a/examples/02_Complex/complex_example.py b/examples/02_Complex/complex_example.py index 3ff5b251c..74463ffd2 100644 --- a/examples/02_Complex/complex_example.py +++ b/examples/02_Complex/complex_example.py @@ -68,15 +68,15 @@ relative_minimum=5 / 50, # Minimum part load relative_maximum=1, # Maximum part load previous_flow_rate=50, # Previous flow rate - flow_hours_total_max=1e6, # Total energy flow limit + flow_hours_max=1e6, # Total energy flow limit on_off_parameters=fx.OnOffParameters( - on_hours_total_min=0, # Minimum operating hours - on_hours_total_max=1000, # Maximum operating hours + on_hours_min=0, # Minimum operating hours + on_hours_max=1000, # Maximum operating hours consecutive_on_hours_max=10, # Max consecutive operating hours consecutive_on_hours_min=np.array([1, 1, 1, 1, 1, 2, 2, 2, 2]), # min consecutive operation hours consecutive_off_hours_max=10, # Max consecutive off hours effects_per_switch_on=0.01, # Cost per switch-on - switch_on_total_max=1000, # Max number of starts + switch_on_max=1000, # Max number of starts ), ), Q_fu=fx.Flow(label='Q_fu', bus='Gas', size=200), diff --git a/flixopt/effects.py b/flixopt/effects.py index 8b670f685..9d765820b 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -78,10 +78,6 @@ class Effect(Element): maximum_invest: Use `maximum_periodic` instead. minimum_operation_per_hour: Use `minimum_per_hour` instead. maximum_operation_per_hour: Use `maximum_per_hour` instead. - minimum_total_per_period: Use `minimum_total` instead. - maximum_total_per_period: Use `maximum_total` instead. - minimum: Use `minimum_over_periods` instead. - maximum: Use `maximum_over_periods` instead. Examples: Basic cost objective: @@ -232,20 +228,6 @@ def __init__( maximum_per_hour = self._handle_deprecated_kwarg( kwargs, 'maximum_operation_per_hour', 'maximum_per_hour', maximum_per_hour ) - minimum_total = self._handle_deprecated_kwarg( - kwargs, 'minimum_total_per_period', 'minimum_total', minimum_total - ) - maximum_total = self._handle_deprecated_kwarg( - kwargs, 'maximum_total_per_period', 'maximum_total', maximum_total - ) - minimum_over_periods = self._handle_deprecated_kwarg( - kwargs, 'minimum', 'minimum_over_periods', minimum_over_periods - ) - maximum_over_periods = self._handle_deprecated_kwarg( - kwargs, 'maximum', 'maximum_over_periods', maximum_over_periods - ) - - # Validate any remaining unexpected kwargs self._validate_kwargs(kwargs) # Set attributes directly diff --git a/flixopt/elements.py b/flixopt/elements.py index 355af39ce..92fe1f27a 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -593,46 +593,6 @@ def size_is_fixed(self) -> bool: return False if (isinstance(self.size, InvestParameters) and self.size.fixed_size is None) else True # Backwards compatible properties (deprecated) - @property - def flow_hours_per_period_max(self): - """DEPRECATED: Use 'flow_hours_max' property instead.""" - warnings.warn( - "Property 'flow_hours_per_period_max' is deprecated. Use 'flow_hours_max' instead.", - DeprecationWarning, - stacklevel=2, - ) - return self.flow_hours_max - - @flow_hours_per_period_max.setter - def flow_hours_per_period_max(self, value): - """DEPRECATED: Use 'flow_hours_max' property instead.""" - warnings.warn( - "Property 'flow_hours_per_period_max' is deprecated. Use 'flow_hours_max' instead.", - DeprecationWarning, - stacklevel=2, - ) - self.flow_hours_max = value - - @property - def flow_hours_per_period_min(self): - """DEPRECATED: Use 'flow_hours_min' property instead.""" - warnings.warn( - "Property 'flow_hours_per_period_min' is deprecated. Use 'flow_hours_min' instead.", - DeprecationWarning, - stacklevel=2, - ) - return self.flow_hours_min - - @flow_hours_per_period_min.setter - def flow_hours_per_period_min(self, value): - """DEPRECATED: Use 'flow_hours_min' property instead.""" - warnings.warn( - "Property 'flow_hours_per_period_min' is deprecated. Use 'flow_hours_min' instead.", - DeprecationWarning, - stacklevel=2, - ) - self.flow_hours_min = value - @property def flow_hours_total_max(self): """DEPRECATED: Use 'flow_hours_max' property instead.""" @@ -673,46 +633,6 @@ def flow_hours_total_min(self, value): ) self.flow_hours_min = value - @property - def total_flow_hours_max(self): - """DEPRECATED: Use 'flow_hours_max_over_periods' property instead.""" - warnings.warn( - "Property 'total_flow_hours_max' is deprecated. Use 'flow_hours_max_over_periods' instead.", - DeprecationWarning, - stacklevel=2, - ) - return self.flow_hours_max_over_periods - - @total_flow_hours_max.setter - def total_flow_hours_max(self, value): - """DEPRECATED: Use 'flow_hours_max_over_periods' property instead.""" - warnings.warn( - "Property 'total_flow_hours_max' is deprecated. Use 'flow_hours_max_over_periods' instead.", - DeprecationWarning, - stacklevel=2, - ) - self.flow_hours_max_over_periods = value - - @property - def total_flow_hours_min(self): - """DEPRECATED: Use 'flow_hours_min_over_periods' property instead.""" - warnings.warn( - "Property 'total_flow_hours_min' is deprecated. Use 'flow_hours_min_over_periods' instead.", - DeprecationWarning, - stacklevel=2, - ) - return self.flow_hours_min_over_periods - - @total_flow_hours_min.setter - def total_flow_hours_min(self, value): - """DEPRECATED: Use 'flow_hours_min_over_periods' property instead.""" - warnings.warn( - "Property 'total_flow_hours_min' is deprecated. Use 'flow_hours_min_over_periods' instead.", - DeprecationWarning, - stacklevel=2, - ) - self.flow_hours_min_over_periods = value - def _format_invest_params(self, params: InvestParameters) -> str: """Format InvestParameters for display.""" return f'size: {params.format_for_repr()}' @@ -755,16 +675,16 @@ def _do_modeling(self): weight_per_period = self._model.flow_system.weight_per_period if weight_per_period is not None: # Calculate weighted sum over all periods - weighted_total_flow_hours = (self.total_flow_hours * weight_per_period).sum('period') + weighted_flow_hours_over_periods = (self.total_flow_hours * weight_per_period).sum('period') else: # No period weights defined, use unweighted sum - weighted_total_flow_hours = self.total_flow_hours.sum('period') + weighted_flow_hours_over_periods = self.total_flow_hours.sum('period') # Create tracking variable for the weighted sum ModelingPrimitives.expression_tracking_variable( model=self, - name=f'{self.label_full}|total_flow_hours_over_periods', - tracked_expression=weighted_total_flow_hours, + name=f'{self.label_full}|flow_hours_over_periods', + tracked_expression=weighted_flow_hours_over_periods, bounds=( self.element.flow_hours_min_over_periods if self.element.flow_hours_min_over_periods is not None @@ -774,7 +694,7 @@ def _do_modeling(self): else None, ), coords=['scenario'], - short_name='total_flow_hours_over_periods', + short_name='flow_hours_over_periods', ) # Load factor constraints diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index dad829e23..629eb2810 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -51,6 +51,8 @@ class FlowSystem(Interface, CompositeContainerMixin[Element]): hours_of_previous_timesteps: Duration of previous timesteps. If None, computed from the first time interval. Can be a scalar (all previous timesteps have same duration) or array (different durations). Used to calculate previous values (e.g., consecutive_on_hours). + weight_of_last_period: Weight/duration of the last period. If None, computed from the last period interval. + Used for calculating sums over periods in multi-period models. weights: The weights of each period and scenario. If None, all scenarios have the same weight (normalized to 1). Its recommended to normalize the weights to sum up to 1. scenario_independent_sizes: Controls whether investment sizes are equalized across scenarios. diff --git a/flixopt/interface.py b/flixopt/interface.py index 6ad38f2a5..1f9156fc4 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -1273,20 +1273,10 @@ def __init__( **kwargs, ): # Handle deprecated parameters - from .structure import Element # Import here to avoid circular import - - on_hours_min = Element._handle_deprecated_kwarg( - None, kwargs, 'on_hours_total_min', 'on_hours_min', on_hours_min - ) - on_hours_max = Element._handle_deprecated_kwarg( - None, kwargs, 'on_hours_total_max', 'on_hours_max', on_hours_max - ) - switch_on_max = Element._handle_deprecated_kwarg( - None, kwargs, 'switch_on_total_max', 'switch_on_max', switch_on_max - ) - # Validate any remaining unexpected kwargs - if kwargs: - raise TypeError(f'OnOffParameters got unexpected keyword arguments: {", ".join(kwargs.keys())}') + on_hours_min = self._handle_deprecated_kwarg(kwargs, 'on_hours_total_min', 'on_hours_min', on_hours_min) + on_hours_max = self._handle_deprecated_kwarg(kwargs, 'on_hours_total_max', 'on_hours_max', on_hours_max) + switch_on_max = self._handle_deprecated_kwarg(kwargs, 'switch_on_total_max', 'switch_on_max', switch_on_max) + self._validate_kwargs(kwargs) self.effects_per_switch_on = effects_per_switch_on if effects_per_switch_on is not None else {} self.effects_per_running_hour = effects_per_running_hour if effects_per_running_hour is not None else {} diff --git a/flixopt/structure.py b/flixopt/structure.py index 793ac9781..ca8d3580d 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -216,16 +216,20 @@ def get_coords( return xr.Coordinates(coords) if coords else None @property - def weights(self) -> int | xr.DataArray: + def weights(self) -> xr.DataArray: """Returns the weights of the FlowSystem. Normalizes to 1 if normalize_weights is True""" - if self.flow_system.weights is not None: - weights = self.flow_system.weights - if self.normalize_weights: - # Normalize weights to sum to 1 - weights = weights / weights.sum() + weights = self.flow_system.fit_to_model_coords( + name='weights', + data=self.flow_system.weights if self.flow_system.weights is not None else 1, + dims=['period', 'scenario'], + ) + if not self.normalize_weights: return weights - return self.flow_system.fit_to_model_coords('weights', 1, dims=['period', 'scenario']) + total = weights.sum() + if np.isclose(total, 0): + raise ValueError('FlowSystemModel.weights: weights sum to 0; cannot normalize.') + return weights / total def __repr__(self) -> str: """ diff --git a/tests/conftest.py b/tests/conftest.py index bd940b843..047f3882b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -165,15 +165,15 @@ def complex(): effects_of_investment_per_size={'costs': 10, 'PE': 2}, ), on_off_parameters=fx.OnOffParameters( - on_hours_total_min=0, - on_hours_total_max=1000, + on_hours_min=0, + on_hours_max=1000, consecutive_on_hours_max=10, consecutive_on_hours_min=1, consecutive_off_hours_max=10, effects_per_switch_on=0.01, - switch_on_total_max=1000, + switch_on_max=1000, ), - flow_hours_total_max=1e6, + flow_hours_max=1e6, ), Q_fu=fx.Flow('Q_fu', bus='Gas', size=200, relative_minimum=0, relative_maximum=1), ) diff --git a/tests/test_flow.py b/tests/test_flow.py index 8a011939f..3017b25dd 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -48,8 +48,8 @@ def test_flow(self, basic_flow_system_linopy_coords, coords_config): size=100, relative_minimum=np.linspace(0, 0.5, timesteps.size), relative_maximum=np.linspace(0.5, 1, timesteps.size), - flow_hours_total_max=1000, - flow_hours_total_min=10, + flow_hours_max=1000, + flow_hours_min=10, load_factor_min=0.1, load_factor_max=0.9, ) @@ -976,7 +976,7 @@ def test_switch_on_constraints(self, basic_flow_system_linopy_coords, coords_con bus='Fernwärme', size=100, on_off_parameters=fx.OnOffParameters( - switch_on_total_max=5, # Maximum 5 startups + switch_on_max=5, # Maximum 5 startups effects_per_switch_on={'costs': 100}, # 100 EUR startup cost ), ) @@ -1038,8 +1038,8 @@ def test_on_hours_limits(self, basic_flow_system_linopy_coords, coords_config): bus='Fernwärme', size=100, on_off_parameters=fx.OnOffParameters( - on_hours_total_min=20, # Minimum 20 hours of operation - on_hours_total_max=100, # Maximum 100 hours of operation + on_hours_min=20, # Minimum 20 hours of operation + on_hours_max=100, # Maximum 100 hours of operation ), ) diff --git a/tests/test_functional.py b/tests/test_functional.py index a83bf112f..cb2a247c1 100644 --- a/tests/test_functional.py +++ b/tests/test_functional.py @@ -490,7 +490,7 @@ def test_on_total_max(solver_fixture, time_steps_fixture): 'Q_th', bus='Fernwärme', size=100, - on_off_parameters=fx.OnOffParameters(on_hours_total_max=1), + on_off_parameters=fx.OnOffParameters(on_hours_max=1), ), ), fx.linear_converters.Boiler( @@ -540,7 +540,7 @@ def test_on_total_bounds(solver_fixture, time_steps_fixture): 'Q_th', bus='Fernwärme', size=100, - on_off_parameters=fx.OnOffParameters(on_hours_total_max=2), + on_off_parameters=fx.OnOffParameters(on_hours_max=2), ), ), fx.linear_converters.Boiler( @@ -551,7 +551,7 @@ def test_on_total_bounds(solver_fixture, time_steps_fixture): 'Q_th', bus='Fernwärme', size=100, - on_off_parameters=fx.OnOffParameters(on_hours_total_min=3), + on_off_parameters=fx.OnOffParameters(on_hours_min=3), ), ), ) diff --git a/tests/test_linear_converter.py b/tests/test_linear_converter.py index 1884c8d72..02aa792f3 100644 --- a/tests/test_linear_converter.py +++ b/tests/test_linear_converter.py @@ -143,9 +143,7 @@ def test_linear_converter_with_on_off(self, basic_flow_system_linopy_coords, coo output_flow = fx.Flow('output', bus='output_bus', size=100) # Create OnOffParameters - on_off_params = fx.OnOffParameters( - on_hours_total_min=10, on_hours_total_max=40, effects_per_running_hour={'costs': 5} - ) + on_off_params = fx.OnOffParameters(on_hours_min=10, on_hours_max=40, effects_per_running_hour={'costs': 5}) # Create a linear converter with OnOffParameters converter = fx.LinearConverter( @@ -396,9 +394,7 @@ def test_piecewise_conversion_with_onoff(self, basic_flow_system_linopy_coords, ) # Create OnOffParameters - on_off_params = fx.OnOffParameters( - on_hours_total_min=10, on_hours_total_max=40, effects_per_running_hour={'costs': 5} - ) + on_off_params = fx.OnOffParameters(on_hours_min=10, on_hours_max=40, effects_per_running_hour={'costs': 5}) # Create a linear converter with piecewise conversion and on/off parameters converter = fx.LinearConverter( diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index 928eb88c6..88cad32ed 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -156,15 +156,15 @@ def flow_system_complex_scenarios() -> fx.FlowSystem: effects_of_investment_per_size={'costs': 10, 'PE': 2}, ), on_off_parameters=fx.OnOffParameters( - on_hours_total_min=0, - on_hours_total_max=1000, + on_hours_min=0, + on_hours_max=1000, consecutive_on_hours_max=10, consecutive_on_hours_min=1, consecutive_off_hours_max=10, effects_per_switch_on=0.01, - switch_on_total_max=1000, + switch_on_max=1000, ), - flow_hours_total_max=1e6, + flow_hours_max=1e6, ), Q_fu=fx.Flow('Q_fu', bus='Gas', size=200, relative_minimum=0, relative_maximum=1), ) From 4675da69797ddf31d40fd1d76ee0421a1299a9e4 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 18 Nov 2025 19:32:49 +0100 Subject: [PATCH 09/35] 1. Critical Bug Fix - structure.py:219-229 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fixed weights property normalization to handle scalars/lists properly - Added zero-sum guard to prevent division by zero - Now always aligns weights to model coords before normalizing 2. Documentation - flow_system.py - Added documentation for the weight_of_last_period parameter in the FlowSystem class docstring 3. Code Quality - interface.py - Refactored deprecated kwargs handling to use instance method instead of awkward static call pattern - Removed unnecessary import and cleaner implementation 4. Parameter Name Updates - Test Files Updated deprecated parameter names in all test files: - tests/test_scenarios.py - tests/test_functional.py - tests/conftest.py - tests/test_flow.py - tests/test_linear_converter.py 5. Parameter Name Updates - Documentation Updated parameter names in: - docs/user-guide/mathematical-notation/features/OnOffParameters.md 6. Parameter Name Updates - Examples Updated parameter names in: - examples/02_Complex/complex_example.py 7. Enhanced CHANGELOG.md Added comprehensive migration guidance including: - Clear explanation of weighting behavior for _over_periods constraints - Concrete example showing per-period vs over-periods differences - Removal timeline (version 4.0.0) for deprecated parameters - Simple migration instructions All deprecated parameters: - on_hours_total_min → on_hours_min - on_hours_total_max → on_hours_max - switch_on_total_max → switch_on_max - flow_hours_total_min → flow_hours_min - flow_hours_total_max → flow_hours_max The codebase is now fully updated with consistent naming, proper documentation, and backward compatibility maintained through deprecation warnings! --- examples/00_Minmal/minimal_example.py | 6 +- examples/01_Simple/simple_example.py | 16 +- examples/02_Complex/complex_example.py | 16 +- .../example_calculation_types.py | 16 +- examples/04_Scenarios/scenario_example.py | 18 +- .../two_stage_optimization.py | 18 +- flixopt/calculation.py | 7 +- flixopt/components.py | 103 ++- flixopt/features.py | 30 +- flixopt/flow_system.py | 89 +- flixopt/linear_converters.py | 834 ++++++++++++++---- flixopt/structure.py | 93 +- tests/conftest.py | 48 +- tests/test_component.py | 27 +- tests/test_effect.py | 6 +- tests/test_flow_system_resample.py | 4 +- tests/test_functional.py | 168 ++-- tests/test_integration.py | 6 +- tests/test_scenarios.py | 8 +- tests/test_storage.py | 2 +- 20 files changed, 1095 insertions(+), 420 deletions(-) diff --git a/examples/00_Minmal/minimal_example.py b/examples/00_Minmal/minimal_example.py index 92e6801b2..9756396b3 100644 --- a/examples/00_Minmal/minimal_example.py +++ b/examples/00_Minmal/minimal_example.py @@ -18,9 +18,9 @@ fx.Effect('Costs', '€', 'Cost', is_standard=True, is_objective=True), fx.linear_converters.Boiler( 'Boiler', - eta=0.5, - Q_th=fx.Flow(label='Heat', bus='Heat', size=50), - Q_fu=fx.Flow(label='Gas', bus='Gas'), + thermal_efficiency=0.5, + thermal_flow=fx.Flow(label='Heat', bus='Heat', size=50), + fuel_flow=fx.Flow(label='Gas', bus='Gas'), ), fx.Sink( 'Sink', diff --git a/examples/01_Simple/simple_example.py b/examples/01_Simple/simple_example.py index fd5a3d9b7..d9737cf7b 100644 --- a/examples/01_Simple/simple_example.py +++ b/examples/01_Simple/simple_example.py @@ -46,19 +46,19 @@ # Boiler: Converts fuel (gas) into thermal energy (heat) boiler = fx.linear_converters.Boiler( label='Boiler', - eta=0.5, - Q_th=fx.Flow(label='Q_th', bus='Fernwärme', size=50, relative_minimum=0.1, relative_maximum=1), - Q_fu=fx.Flow(label='Q_fu', bus='Gas'), + thermal_efficiency=0.5, + thermal_flow=fx.Flow(label='Q_th', bus='Fernwärme', size=50, relative_minimum=0.1, relative_maximum=1), + fuel_flow=fx.Flow(label='Q_fu', bus='Gas'), ) # Combined Heat and Power (CHP): Generates both electricity and heat from fuel chp = fx.linear_converters.CHP( label='CHP', - eta_th=0.5, - eta_el=0.4, - P_el=fx.Flow('P_el', bus='Strom', size=60, relative_minimum=5 / 60), - Q_th=fx.Flow('Q_th', bus='Fernwärme'), - Q_fu=fx.Flow('Q_fu', bus='Gas'), + thermal_efficiency=0.5, + electrical_efficiency=0.4, + electrical_flow=fx.Flow('P_el', bus='Strom', size=60, relative_minimum=5 / 60), + thermal_flow=fx.Flow('Q_th', bus='Fernwärme'), + fuel_flow=fx.Flow('Q_fu', bus='Gas'), ) # Storage: Energy storage system with charging and discharging capabilities diff --git a/examples/02_Complex/complex_example.py b/examples/02_Complex/complex_example.py index 74463ffd2..cad938cb2 100644 --- a/examples/02_Complex/complex_example.py +++ b/examples/02_Complex/complex_example.py @@ -50,11 +50,11 @@ # A gas boiler that converts fuel into thermal output, with investment and on-off parameters Gaskessel = fx.linear_converters.Boiler( 'Kessel', - eta=0.5, # Efficiency ratio + thermal_efficiency=0.5, # Efficiency ratio on_off_parameters=fx.OnOffParameters( effects_per_running_hour={Costs.label: 0, CO2.label: 1000} ), # CO2 emissions per hour - Q_th=fx.Flow( + thermal_flow=fx.Flow( label='Q_th', # Thermal output bus='Fernwärme', # Linked bus size=fx.InvestParameters( @@ -79,19 +79,19 @@ switch_on_max=1000, # Max number of starts ), ), - Q_fu=fx.Flow(label='Q_fu', bus='Gas', size=200), + fuel_flow=fx.Flow(label='Q_fu', bus='Gas', size=200), ) # 2. Define CHP Unit # Combined Heat and Power unit that generates both electricity and heat from fuel bhkw = fx.linear_converters.CHP( 'BHKW2', - eta_th=0.5, - eta_el=0.4, + thermal_efficiency=0.5, + electrical_efficiency=0.4, on_off_parameters=fx.OnOffParameters(effects_per_switch_on=0.01), - P_el=fx.Flow('P_el', bus='Strom', size=60, relative_minimum=5 / 60), - Q_th=fx.Flow('Q_th', bus='Fernwärme', size=1e3), - Q_fu=fx.Flow('Q_fu', bus='Gas', size=1e3, previous_flow_rate=20), # The CHP was ON previously + electrical_flow=fx.Flow('P_el', bus='Strom', size=60, relative_minimum=5 / 60), + thermal_flow=fx.Flow('Q_th', bus='Fernwärme', size=1e3), + fuel_flow=fx.Flow('Q_fu', bus='Gas', size=1e3, previous_flow_rate=20), # The CHP was ON previously ) # 3. Define CHP with Piecewise Conversion diff --git a/examples/03_Calculation_types/example_calculation_types.py b/examples/03_Calculation_types/example_calculation_types.py index e339c1c24..fa57e6f9a 100644 --- a/examples/03_Calculation_types/example_calculation_types.py +++ b/examples/03_Calculation_types/example_calculation_types.py @@ -71,9 +71,9 @@ # 1. Boiler a_gaskessel = fx.linear_converters.Boiler( 'Kessel', - eta=0.85, - Q_th=fx.Flow(label='Q_th', bus='Fernwärme'), - Q_fu=fx.Flow( + thermal_efficiency=0.85, + thermal_flow=fx.Flow(label='Q_th', bus='Fernwärme'), + fuel_flow=fx.Flow( label='Q_fu', bus='Gas', size=95, @@ -86,12 +86,12 @@ # 2. CHP a_kwk = fx.linear_converters.CHP( 'BHKW2', - eta_th=0.58, - eta_el=0.22, + thermal_efficiency=0.58, + electrical_efficiency=0.22, on_off_parameters=fx.OnOffParameters(effects_per_switch_on=24000), - P_el=fx.Flow('P_el', bus='Strom', size=200), - Q_th=fx.Flow('Q_th', bus='Fernwärme', size=200), - Q_fu=fx.Flow('Q_fu', bus='Kohle', size=288, relative_minimum=87 / 288, previous_flow_rate=100), + electrical_flow=fx.Flow('P_el', bus='Strom', size=200), + thermal_flow=fx.Flow('Q_th', bus='Fernwärme', size=200), + fuel_flow=fx.Flow('Q_fu', bus='Kohle', size=288, relative_minimum=87 / 288, previous_flow_rate=100), ) # 3. Storage diff --git a/examples/04_Scenarios/scenario_example.py b/examples/04_Scenarios/scenario_example.py index bf4f24617..ca50876c7 100644 --- a/examples/04_Scenarios/scenario_example.py +++ b/examples/04_Scenarios/scenario_example.py @@ -114,8 +114,8 @@ # Modern condensing gas boiler with realistic efficiency boiler = fx.linear_converters.Boiler( label='Boiler', - eta=0.92, # Realistic efficiency for modern condensing gas boiler (92%) - Q_th=fx.Flow( + thermal_efficiency=0.92, # Realistic efficiency for modern condensing gas boiler (92%) + thermal_flow=fx.Flow( label='Q_th', bus='Fernwärme', size=50, @@ -123,18 +123,20 @@ relative_maximum=1, on_off_parameters=fx.OnOffParameters(), ), - Q_fu=fx.Flow(label='Q_fu', bus='Gas'), + fuel_flow=fx.Flow(label='Q_fu', bus='Gas'), ) # Combined Heat and Power (CHP): Generates both electricity and heat from fuel # Modern CHP unit with realistic efficiencies (total efficiency ~88%) chp = fx.linear_converters.CHP( label='CHP', - eta_th=0.48, # Realistic thermal efficiency (48%) - eta_el=0.40, # Realistic electrical efficiency (40%) - P_el=fx.Flow('P_el', bus='Strom', size=60, relative_minimum=5 / 60, on_off_parameters=fx.OnOffParameters()), - Q_th=fx.Flow('Q_th', bus='Fernwärme'), - Q_fu=fx.Flow('Q_fu', bus='Gas'), + thermal_efficiency=0.48, # Realistic thermal efficiency (48%) + electrical_efficiency=0.40, # Realistic electrical efficiency (40%) + electrical_flow=fx.Flow( + 'P_el', bus='Strom', size=60, relative_minimum=5 / 60, on_off_parameters=fx.OnOffParameters() + ), + thermal_flow=fx.Flow('Q_th', bus='Fernwärme'), + fuel_flow=fx.Flow('Q_fu', bus='Gas'), ) # Storage: Thermal energy storage system with charging and discharging capabilities diff --git a/examples/05_Two-stage-optimization/two_stage_optimization.py b/examples/05_Two-stage-optimization/two_stage_optimization.py index 7354cb877..6c7b20276 100644 --- a/examples/05_Two-stage-optimization/two_stage_optimization.py +++ b/examples/05_Two-stage-optimization/two_stage_optimization.py @@ -45,9 +45,9 @@ fx.Effect('PE', 'kWh_PE', 'Primärenergie'), fx.linear_converters.Boiler( 'Kessel', - eta=0.85, - Q_th=fx.Flow(label='Q_th', bus='Fernwärme'), - Q_fu=fx.Flow( + thermal_efficiency=0.85, + thermal_flow=fx.Flow(label='Q_th', bus='Fernwärme'), + fuel_flow=fx.Flow( label='Q_fu', bus='Gas', size=fx.InvestParameters( @@ -60,14 +60,14 @@ ), fx.linear_converters.CHP( 'BHKW2', - eta_th=0.58, - eta_el=0.22, + thermal_efficiency=0.58, + electrical_efficiency=0.22, on_off_parameters=fx.OnOffParameters( effects_per_switch_on=1_000, consecutive_on_hours_min=10, consecutive_off_hours_min=10 ), - P_el=fx.Flow('P_el', bus='Strom'), - Q_th=fx.Flow('Q_th', bus='Fernwärme'), - Q_fu=fx.Flow( + electrical_flow=fx.Flow('P_el', bus='Strom'), + thermal_flow=fx.Flow('Q_th', bus='Fernwärme'), + fuel_flow=fx.Flow( 'Q_fu', bus='Kohle', size=fx.InvestParameters( @@ -82,7 +82,7 @@ capacity_in_flow_hours=fx.InvestParameters( minimum_size=10, maximum_size=1000, effects_of_investment_per_size={'costs': 60} ), - initial_charge_state='lastValueOfSim', + initial_charge_state='equals_final', eta_charge=1, eta_discharge=1, relative_loss_per_hour=0.001, diff --git a/flixopt/calculation.py b/flixopt/calculation.py index 64c589e3a..fcde018b7 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -98,8 +98,6 @@ def __init__( raise NotADirectoryError(f'Path {self.folder} exists and is not a directory.') self.folder.mkdir(parents=False, exist_ok=True) - self._modeled = False - @property def main_results(self) -> dict[str, int | float | dict]: from flixopt.features import InvestmentModel @@ -228,6 +226,11 @@ def fix_sizes(self, ds: xr.Dataset, decimal_rounding: int | None = 5) -> FullCal def solve( self, solver: _Solver, log_file: pathlib.Path | None = None, log_main_results: bool | None = None ) -> FullCalculation: + # Auto-call do_modeling() if not already done + if not self.modeled: + logger.info('Model not yet created. Calling do_modeling() automatically.') + self.do_modeling() + t_start = timeit.default_timer() self.model.solve( diff --git a/flixopt/components.py b/flixopt/components.py index c2b28a8d4..5b0854240 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -181,6 +181,12 @@ def create_model(self, model: FlowSystemModel) -> LinearConverterModel: self.submodel = LinearConverterModel(model, self) return self.submodel + def _set_flow_system(self, flow_system) -> None: + """Propagate flow_system reference to parent Component and piecewise_conversion.""" + super()._set_flow_system(flow_system) + if self.piecewise_conversion is not None: + self.piecewise_conversion._set_flow_system(flow_system) + def _plausibility_checks(self) -> None: super()._plausibility_checks() if not self.conversion_factors and not self.piecewise_conversion: @@ -211,23 +217,23 @@ def _plausibility_checks(self) -> None: f'({flow.label_full}).' ) - def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None: + def transform_data(self, name_prefix: str = '') -> None: prefix = '|'.join(filter(None, [name_prefix, self.label_full])) - super().transform_data(flow_system, prefix) + super().transform_data(prefix) if self.conversion_factors: - self.conversion_factors = self._transform_conversion_factors(flow_system) + self.conversion_factors = self._transform_conversion_factors() if self.piecewise_conversion: self.piecewise_conversion.has_time_dim = True - self.piecewise_conversion.transform_data(flow_system, f'{prefix}|PiecewiseConversion') + self.piecewise_conversion.transform_data(f'{prefix}|PiecewiseConversion') - def _transform_conversion_factors(self, flow_system: FlowSystem) -> list[dict[str, xr.DataArray]]: + def _transform_conversion_factors(self) -> list[dict[str, xr.DataArray]]: """Converts all conversion factors to internal datatypes""" list_of_conversion_factors = [] for idx, conversion_factor in enumerate(self.conversion_factors): transformed_dict = {} for flow, values in conversion_factor.items(): # TODO: Might be better to use the label of the component instead of the flow - ts = flow_system.fit_to_model_coords(f'{self.flows[flow].label_full}|conversion_factor{idx}', values) + ts = self._fit_coords(f'{self.flows[flow].label_full}|conversion_factor{idx}', values) if ts is None: raise PlausibilityError(f'{self.label_full}: conversion factor for flow "{flow}" must not be None') transformed_dict[flow] = ts @@ -275,7 +281,7 @@ class Storage(Component): Scalar for fixed size or InvestParameters for optimization. relative_minimum_charge_state: Minimum charge state (0-1). Default: 0. relative_maximum_charge_state: Maximum charge state (0-1). Default: 1. - initial_charge_state: Charge at start. Numeric or 'lastValueOfSim'. Default: 0. + initial_charge_state: Charge at start. Numeric or 'equals_final'. Default: 0. minimal_final_charge_state: Minimum absolute charge required at end (optional). maximal_final_charge_state: Maximum absolute charge allowed at end (optional). relative_minimum_final_charge_state: Minimum relative charge at end. @@ -339,7 +345,7 @@ class Storage(Component): ), eta_charge=0.85, # Pumping efficiency eta_discharge=0.90, # Turbine efficiency - initial_charge_state='lastValueOfSim', # Ensuring no deficit compared to start + initial_charge_state='equals_final', # Ensuring no deficit compared to start relative_loss_per_hour=0.0001, # Minimal evaporation ) ``` @@ -388,7 +394,7 @@ def __init__( capacity_in_flow_hours: Numeric_PS | InvestParameters, relative_minimum_charge_state: Numeric_TPS = 0, relative_maximum_charge_state: Numeric_TPS = 1, - initial_charge_state: Numeric_PS | Literal['lastValueOfSim'] = 0, + initial_charge_state: Numeric_PS | Literal['equals_final'] = 0, minimal_final_charge_state: Numeric_PS | None = None, maximal_final_charge_state: Numeric_PS | None = None, relative_minimum_final_charge_state: Numeric_PS | None = None, @@ -408,6 +414,13 @@ def __init__( prevent_simultaneous_flows=[charging, discharging] if prevent_simultaneous_charge_and_discharge else None, meta_data=meta_data, ) + if isinstance(initial_charge_state, str) and initial_charge_state == 'lastValueOfSim': + warnings.warn( + f'{initial_charge_state=} is deprecated. Use "equals_final" instead.', + DeprecationWarning, + stacklevel=2, + ) + initial_charge_state = 'equals_final' self.charging = charging self.discharging = discharging @@ -433,46 +446,48 @@ def create_model(self, model: FlowSystemModel) -> StorageModel: self.submodel = StorageModel(model, self) return self.submodel - def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None: + def _set_flow_system(self, flow_system) -> None: + """Propagate flow_system reference to parent Component and capacity_in_flow_hours if it's InvestParameters.""" + super()._set_flow_system(flow_system) + if isinstance(self.capacity_in_flow_hours, InvestParameters): + self.capacity_in_flow_hours._set_flow_system(flow_system) + + def transform_data(self, name_prefix: str = '') -> None: prefix = '|'.join(filter(None, [name_prefix, self.label_full])) - super().transform_data(flow_system, prefix) - self.relative_minimum_charge_state = flow_system.fit_to_model_coords( - f'{prefix}|relative_minimum_charge_state', - self.relative_minimum_charge_state, + super().transform_data(prefix) + self.relative_minimum_charge_state = self._fit_coords( + f'{prefix}|relative_minimum_charge_state', self.relative_minimum_charge_state ) - self.relative_maximum_charge_state = flow_system.fit_to_model_coords( - f'{prefix}|relative_maximum_charge_state', - self.relative_maximum_charge_state, - ) - self.eta_charge = flow_system.fit_to_model_coords(f'{prefix}|eta_charge', self.eta_charge) - self.eta_discharge = flow_system.fit_to_model_coords(f'{prefix}|eta_discharge', self.eta_discharge) - self.relative_loss_per_hour = flow_system.fit_to_model_coords( - f'{prefix}|relative_loss_per_hour', self.relative_loss_per_hour + self.relative_maximum_charge_state = self._fit_coords( + f'{prefix}|relative_maximum_charge_state', self.relative_maximum_charge_state ) + self.eta_charge = self._fit_coords(f'{prefix}|eta_charge', self.eta_charge) + self.eta_discharge = self._fit_coords(f'{prefix}|eta_discharge', self.eta_discharge) + self.relative_loss_per_hour = self._fit_coords(f'{prefix}|relative_loss_per_hour', self.relative_loss_per_hour) if not isinstance(self.initial_charge_state, str): - self.initial_charge_state = flow_system.fit_to_model_coords( + self.initial_charge_state = self._fit_coords( f'{prefix}|initial_charge_state', self.initial_charge_state, dims=['period', 'scenario'] ) - self.minimal_final_charge_state = flow_system.fit_to_model_coords( + self.minimal_final_charge_state = self._fit_coords( f'{prefix}|minimal_final_charge_state', self.minimal_final_charge_state, dims=['period', 'scenario'] ) - self.maximal_final_charge_state = flow_system.fit_to_model_coords( + self.maximal_final_charge_state = self._fit_coords( f'{prefix}|maximal_final_charge_state', self.maximal_final_charge_state, dims=['period', 'scenario'] ) - self.relative_minimum_final_charge_state = flow_system.fit_to_model_coords( + self.relative_minimum_final_charge_state = self._fit_coords( f'{prefix}|relative_minimum_final_charge_state', self.relative_minimum_final_charge_state, dims=['period', 'scenario'], ) - self.relative_maximum_final_charge_state = flow_system.fit_to_model_coords( + self.relative_maximum_final_charge_state = self._fit_coords( f'{prefix}|relative_maximum_final_charge_state', self.relative_maximum_final_charge_state, dims=['period', 'scenario'], ) if isinstance(self.capacity_in_flow_hours, InvestParameters): - self.capacity_in_flow_hours.transform_data(flow_system, f'{prefix}|InvestParameters') + self.capacity_in_flow_hours.transform_data(f'{prefix}|InvestParameters') else: - self.capacity_in_flow_hours = flow_system.fit_to_model_coords( + self.capacity_in_flow_hours = self._fit_coords( f'{prefix}|capacity_in_flow_hours', self.capacity_in_flow_hours, dims=['period', 'scenario'] ) @@ -483,12 +498,11 @@ def _plausibility_checks(self) -> None: super()._plausibility_checks() # Validate string values and set flag - initial_is_last = False + initial_equals_final = False if isinstance(self.initial_charge_state, str): - if self.initial_charge_state == 'lastValueOfSim': - initial_is_last = True - else: + if not self.initial_charge_state == 'equals_final': 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): @@ -502,8 +516,8 @@ def _plausibility_checks(self) -> None: minimum_initial_capacity = maximum_capacity * self.relative_minimum_charge_state.isel(time=0) maximum_initial_capacity = minimum_capacity * self.relative_maximum_charge_state.isel(time=0) - # Only perform numeric comparisons if not using 'lastValueOfSim' - if not initial_is_last: + # Only perform numeric comparisons if not using 'equals_final' + if not initial_equals_final: if (self.initial_charge_state > maximum_initial_capacity).any(): raise PlausibilityError( f'{self.label_full}: {self.initial_charge_state=} ' @@ -719,11 +733,11 @@ def create_model(self, model) -> TransmissionModel: self.submodel = TransmissionModel(model, self) return self.submodel - def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None: + def transform_data(self, name_prefix: str = '') -> None: prefix = '|'.join(filter(None, [name_prefix, self.label_full])) - super().transform_data(flow_system, prefix) - self.relative_losses = flow_system.fit_to_model_coords(f'{prefix}|relative_losses', self.relative_losses) - self.absolute_losses = flow_system.fit_to_model_coords(f'{prefix}|absolute_losses', self.absolute_losses) + super().transform_data(prefix) + self.relative_losses = self._fit_coords(f'{prefix}|relative_losses', self.relative_losses) + self.absolute_losses = self._fit_coords(f'{prefix}|absolute_losses', self.absolute_losses) class TransmissionModel(ComponentModel): @@ -738,7 +752,7 @@ def __init__(self, model: FlowSystemModel, element: Transmission): super().__init__(model, element) def _do_modeling(self): - """Initiates all FlowModels""" + """Create transmission efficiency equations and optional absolute loss constraints for both flow directions""" super()._do_modeling() # first direction @@ -779,8 +793,10 @@ def __init__(self, model: FlowSystemModel, element: LinearConverter): super().__init__(model, element) def _do_modeling(self): + """Create linear conversion equations or piecewise conversion constraints between input and output flows""" super()._do_modeling() - # conversion_factors: + + # Create conversion factor constraints if specified if self.element.conversion_factors: all_input_flows = set(self.element.inputs) all_output_flows = set(self.element.outputs) @@ -826,8 +842,10 @@ def __init__(self, model: FlowSystemModel, element: Storage): super().__init__(model, element) def _do_modeling(self): + """Create charge state variables, energy balance equations, and optional investment submodels""" super()._do_modeling() + # Create variables lb, ub = self._absolute_charge_state_bounds self.add_variables( lower=lb, @@ -838,6 +856,7 @@ def _do_modeling(self): self.add_variables(coords=self._model.get_coords(), short_name='netto_discharge') + # Create constraints (can now access flow.submodel.flow_rate) # netto_discharge: # eq: nettoFlow(t) - discharging(t) + charging(t) = 0 self.add_constraints( @@ -862,6 +881,7 @@ def _do_modeling(self): short_name='charge_state', ) + # Create InvestmentModel and bounding constraints for investment if isinstance(self.element.capacity_in_flow_hours, InvestParameters): self.add_submodels( InvestmentModel( @@ -883,6 +903,7 @@ def _do_modeling(self): # Initial charge state self._initial_and_final_charge_state() + # Balanced sizes if self.element.balanced: self.add_constraints( self.element.charging.submodel._investment.size * 1 diff --git a/flixopt/features.py b/flixopt/features.py index 802677177..8c4bf7c70 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -14,6 +14,8 @@ from .structure import FlowSystemModel, Submodel if TYPE_CHECKING: + from collections.abc import Collection + from .core import FlowSystemDimensions from .interface import InvestParameters, OnOffParameters, Piecewise from .types import Numeric_PS, Numeric_TPS @@ -172,6 +174,7 @@ def __init__( super().__init__(model, label_of_element, label_of_model=label_of_model) def _do_modeling(self): + """Create variables, constraints, and nested submodels""" super()._do_modeling() if self.parameters.use_off: @@ -326,7 +329,7 @@ def __init__( model: FlowSystemModel, label_of_element: str, label_of_model: str, - dims: FlowSystemDimensions | None, + dims: Collection[FlowSystemDimensions] | None, ): self.inside_piece: linopy.Variable | None = None self.lambda0: linopy.Variable | None = None @@ -336,7 +339,10 @@ def __init__( super().__init__(model, label_of_element, label_of_model) def _do_modeling(self): + """Create variables, constraints, and nested submodels""" super()._do_modeling() + + # Create variables self.inside_piece = self.add_variables( binary=True, short_name='inside_piece', @@ -356,6 +362,7 @@ def _do_modeling(self): coords=self._model.get_coords(dims=self.dims), ) + # Create constraints # eq: lambda0(t) + lambda1(t) = inside_piece(t) self.add_constraints(self.inside_piece == self.lambda0 + self.lambda1, short_name='inside_piece') @@ -368,7 +375,7 @@ def __init__( label_of_model: str, piecewise_variables: dict[str, Piecewise], zero_point: bool | linopy.Variable | None, - dims: FlowSystemDimensions | None, + dims: Collection[FlowSystemDimensions] | None, ): """ Modeling a Piecewise relation between miultiple variables. @@ -392,12 +399,15 @@ def __init__( super().__init__(model, label_of_element=label_of_element, label_of_model=label_of_model) def _do_modeling(self): + """Create variables, constraints, and nested submodels""" super()._do_modeling() + # Validate all piecewise variables have the same number of segments segment_counts = [len(pw) for pw in self._piecewise_variables.values()] if not all(count == segment_counts[0] for count in segment_counts): raise ValueError(f'All piecewises must have the same number of pieces, got {segment_counts}') + # Create PieceModel submodels (which creates their variables and constraints) for i in range(len(list(self._piecewise_variables.values())[0])): new_piece = self.add_submodels( PieceModel( @@ -441,6 +451,10 @@ def _do_modeling(self): else: rhs = 1 + # This constraint ensures at most one segment is active at a time. + # When zero_point is a binary variable, it acts as a gate: + # - zero_point=1: at most one segment can be active (normal piecewise operation) + # - zero_point=0: all segments must be inactive (effectively disables the piecewise) self.add_constraints( sum([piece.inside_piece for piece in self.pieces]) <= rhs, name=f'{self.label_full}|{variable.name}|single_segment', @@ -475,6 +489,10 @@ def __init__( super().__init__(model, label_of_element=label_of_element, label_of_model=label_of_model) def _do_modeling(self): + """Create variables, constraints, and nested submodels""" + super()._do_modeling() + + # Create variables self.shares = { effect: self.add_variables(coords=self._model.get_coords(['period', 'scenario']), short_name=effect) for effect in self._piecewise_shares @@ -488,6 +506,7 @@ def _do_modeling(self): }, } + # Create piecewise model (which creates its variables and constraints) self.piecewise_model = self.add_submodels( PiecewiseModel( model=self._model, @@ -500,7 +519,7 @@ def _do_modeling(self): short_name='PiecewiseEffects', ) - # Shares + # Add shares to effects self._model.effects.add_share_to_effects( name=self.label_of_element, expressions={effect: variable * 1 for effect, variable in self.shares.items()}, @@ -521,7 +540,7 @@ def __init__( min_per_hour: Numeric_TPS | None = None, ): if 'time' not in dims and (max_per_hour is not None or min_per_hour is not None): - raise ValueError('Both max_per_hour and min_per_hour cannot be used when has_time_dim is False') + raise ValueError("max_per_hour and min_per_hour require 'time' dimension in dims") self._dims = dims self.total_per_timestep: linopy.Variable | None = None @@ -541,7 +560,10 @@ def __init__( super().__init__(model, label_of_element=label_of_element, label_of_model=label_of_model) def _do_modeling(self): + """Create variables, constraints, and nested submodels""" super()._do_modeling() + + # Create variables self.total = self.add_variables( lower=self._total_min if self._total_min is not None else -np.inf, upper=self._total_max if self._total_max is not None else np.inf, diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 629eb2810..a1c213f63 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -678,7 +678,11 @@ def connect_and_transform(self): self._connect_network() for element in chain(self.components.values(), self.effects.values(), self.buses.values()): - element.transform_data(self) + element.transform_data() + + # Validate cross-element references immediately after transformation + self._validate_system_integrity() + self._connected_and_transformed = True def add_elements(self, *elements: Element) -> None: @@ -695,17 +699,29 @@ def add_elements(self, *elements: Element) -> None: stacklevel=2, ) self._connected_and_transformed = False + for new_element in list(elements): + # Validate element type first + if not isinstance(new_element, (Component, Effect, Bus)): + raise TypeError( + f'Tried to add incompatible object to FlowSystem: {type(new_element)=}: {new_element=} ' + ) + + # Common validations for all element types (before any state changes) + self._check_if_element_already_assigned(new_element) + self._check_if_element_is_unique(new_element) + + # Dispatch to type-specific handlers if isinstance(new_element, Component): self._add_components(new_element) elif isinstance(new_element, Effect): self._add_effects(new_element) elif isinstance(new_element, Bus): self._add_buses(new_element) - else: - raise TypeError( - f'Tried to add incompatible object to FlowSystem: {type(new_element)=}: {new_element=} ' - ) + + # Log registration + element_type = type(new_element).__name__ + logger.info(f'Registered new {element_type}: {new_element.label_full}') def create_model(self, normalize_weights: bool = True) -> FlowSystemModel: """ @@ -718,6 +734,7 @@ def create_model(self, normalize_weights: bool = True) -> FlowSystemModel: raise RuntimeError( 'FlowSystem is not connected_and_transformed. Call FlowSystem.connect_and_transform() first.' ) + # System integrity was already validated in connect_and_transform() self.model = FlowSystemModel(self, normalize_weights) return self.model @@ -851,22 +868,67 @@ def _check_if_element_is_unique(self, element: Element) -> None: if element.label_full in self: raise ValueError(f'Label of Element {element.label_full} already used in another element!') + def _check_if_element_already_assigned(self, element: Element) -> None: + """ + Check if element already belongs to another FlowSystem. + + Args: + element: Element to check + + Raises: + ValueError: If element is already assigned to a different FlowSystem + """ + if element._flow_system is not None and element._flow_system is not self: + raise ValueError( + f'Element "{element.label_full}" is already assigned to another FlowSystem. ' + f'Each element can only belong to one FlowSystem at a time. ' + f'To use this element in multiple systems, create a copy: ' + f'flow_system.add_elements(element.copy())' + ) + + def _validate_system_integrity(self) -> None: + """ + Validate cross-element references to ensure system consistency. + + This performs system-level validation that requires knowledge of multiple elements: + - Validates that all Flow.bus references point to existing buses + - Can be extended for other cross-element validations + + Should be called after connect_and_transform and before create_model. + + Raises: + ValueError: If any cross-element reference is invalid + """ + # Validate bus references in flows + for flow in self.flows.values(): + if flow.bus not in self.buses: + available_buses = list(self.buses.keys()) + raise ValueError( + f'Flow "{flow.label_full}" references bus "{flow.bus}" which does not exist in FlowSystem. ' + f'Available buses: {available_buses}. ' + f'Did you forget to add the bus using flow_system.add_elements(Bus("{flow.bus}"))?' + ) + def _add_effects(self, *args: Effect) -> None: + for effect in args: + effect._set_flow_system(self) # Link element to FlowSystem self.effects.add_effects(*args) def _add_components(self, *components: Component) -> None: for new_component in list(components): - logger.info(f'Registered new Component: {new_component.label_full}') - self._check_if_element_is_unique(new_component) # check if already exists: + new_component._set_flow_system(self) # Link element to FlowSystem self.components.add(new_component) # Add to existing components - self._flows_cache = None # Invalidate flows cache + # Invalidate cache once after all additions + if components: + self._flows_cache = None def _add_buses(self, *buses: Bus): for new_bus in list(buses): - logger.info(f'Registered new Bus: {new_bus.label_full}') - self._check_if_element_is_unique(new_bus) # check if already exists: + new_bus._set_flow_system(self) # Link element to FlowSystem self.buses.add(new_bus) # Add to existing buses - self._flows_cache = None # Invalidate flows cache + # Invalidate cache once after all additions + if buses: + self._flows_cache = None def _connect_network(self): """Connects the network of components and buses. Can be rerun without changes if no elements were added""" @@ -897,9 +959,12 @@ def _connect_network(self): bus.outputs.append(flow) elif not flow.is_input_in_component and flow not in bus.inputs: bus.inputs.append(flow) + + # Count flows manually to avoid triggering cache rebuild + flow_count = sum(len(c.inputs) + len(c.outputs) for c in self.components.values()) logger.debug( f'Connected {len(self.buses)} Buses and {len(self.components)} ' - f'via {len(self.flows)} Flows inside the FlowSystem.' + f'via {flow_count} Flows inside the FlowSystem.' ) def __repr__(self) -> str: diff --git a/flixopt/linear_converters.py b/flixopt/linear_converters.py index da6e4c68d..19b5f6d83 100644 --- a/flixopt/linear_converters.py +++ b/flixopt/linear_converters.py @@ -4,6 +4,7 @@ from __future__ import annotations +import warnings from typing import TYPE_CHECKING import numpy as np @@ -30,13 +31,16 @@ class Boiler(LinearConverter): Args: label: The label of the Element. Used to identify it in the FlowSystem. - eta: Thermal efficiency factor (0-1 range). Defines the ratio of thermal + thermal_efficiency: Thermal efficiency factor (0-1 range). Defines the ratio of thermal output to fuel input energy content. - Q_fu: Fuel input-flow representing fuel consumption. - Q_th: Thermal output-flow representing heat generation. + fuel_flow: Fuel input-flow representing fuel consumption. + thermal_flow: Thermal output-flow representing heat generation. on_off_parameters: Parameters defining binary operation constraints and costs. meta_data: Used to store additional information. Not used internally but saved in results. Only use Python native types. + eta: *Deprecated*. Use `thermal_efficiency` instead. + Q_fu: *Deprecated*. Use `fuel_flow` instead. + Q_th: *Deprecated*. Use `thermal_flow` instead. Examples: Natural gas boiler: @@ -44,9 +48,9 @@ class Boiler(LinearConverter): ```python gas_boiler = Boiler( label='natural_gas_boiler', - eta=0.85, # 85% thermal efficiency - Q_fu=natural_gas_flow, - Q_th=hot_water_flow, + thermal_efficiency=0.85, # 85% thermal efficiency + fuel_flow=natural_gas_flow, + thermal_flow=hot_water_flow, ) ``` @@ -55,9 +59,9 @@ class Boiler(LinearConverter): ```python biomass_boiler = Boiler( label='wood_chip_boiler', - eta=seasonal_efficiency_profile, # Time-varying efficiency - Q_fu=biomass_flow, - Q_th=district_heat_flow, + thermal_efficiency=seasonal_efficiency_profile, # Time-varying efficiency + fuel_flow=biomass_flow, + thermal_flow=district_heat_flow, on_off_parameters=OnOffParameters( consecutive_on_hours_min=4, # Minimum 4-hour operation effects_per_switch_on={'startup_fuel': 50}, # Startup fuel penalty @@ -66,7 +70,7 @@ class Boiler(LinearConverter): ``` Note: - The conversion relationship is: Q_th = Q_fu × eta + The conversion relationship is: thermal_flow = fuel_flow × thermal_efficiency Efficiency should be between 0 and 1, where 1 represents perfect conversion (100% of fuel energy converted to useful thermal output). @@ -75,31 +79,100 @@ class Boiler(LinearConverter): def __init__( self, label: str, - eta: Numeric_TPS, - Q_fu: Flow, - Q_th: Flow, + thermal_efficiency: Numeric_TPS | None = None, + fuel_flow: Flow | None = None, + thermal_flow: Flow | None = None, on_off_parameters: OnOffParameters | None = None, meta_data: dict | None = None, + **kwargs, ): + # Handle deprecated parameters + fuel_flow = self._handle_deprecated_kwarg(kwargs, 'Q_fu', 'fuel_flow', fuel_flow) + thermal_flow = self._handle_deprecated_kwarg(kwargs, 'Q_th', 'thermal_flow', thermal_flow) + thermal_efficiency = self._handle_deprecated_kwarg(kwargs, 'eta', 'thermal_efficiency', thermal_efficiency) + self._validate_kwargs(kwargs) + + # Validate required parameters + if fuel_flow is None: + raise ValueError(f"'{label}': fuel_flow is required and cannot be None") + if thermal_flow is None: + raise ValueError(f"'{label}': thermal_flow is required and cannot be None") + if thermal_efficiency is None: + raise ValueError(f"'{label}': thermal_efficiency is required and cannot be None") + super().__init__( label, - inputs=[Q_fu], - outputs=[Q_th], - conversion_factors=[{Q_fu.label: eta, Q_th.label: 1}], + inputs=[fuel_flow], + outputs=[thermal_flow], on_off_parameters=on_off_parameters, meta_data=meta_data, ) - self.Q_fu = Q_fu - self.Q_th = Q_th + self.fuel_flow = fuel_flow + self.thermal_flow = thermal_flow + self.thermal_efficiency = thermal_efficiency # Uses setter + + @property + def thermal_efficiency(self): + return self.conversion_factors[0][self.fuel_flow.label] + + @thermal_efficiency.setter + def thermal_efficiency(self, value): + check_bounds(value, 'thermal_efficiency', self.label_full, 0, 1) + self.conversion_factors = [{self.fuel_flow.label: value, self.thermal_flow.label: 1}] @property - def eta(self): - return self.conversion_factors[0][self.Q_fu.label] + def eta(self) -> Numeric_TPS: + warnings.warn( + 'The "eta" property is deprecated. Use "thermal_efficiency" instead.', + DeprecationWarning, + stacklevel=2, + ) + return self.thermal_efficiency @eta.setter - def eta(self, value): - check_bounds(value, 'eta', self.label_full, 0, 1) - self.conversion_factors[0][self.Q_fu.label] = value + def eta(self, value: Numeric_TPS) -> None: + warnings.warn( + 'The "eta" property is deprecated. Use "thermal_efficiency" instead.', + DeprecationWarning, + stacklevel=2, + ) + self.thermal_efficiency = value + + @property + def Q_fu(self) -> Flow: # noqa: N802 + warnings.warn( + 'The "Q_fu" property is deprecated. Use "fuel_flow" instead.', + DeprecationWarning, + stacklevel=2, + ) + return self.fuel_flow + + @Q_fu.setter + def Q_fu(self, value: Flow) -> None: # noqa: N802 + warnings.warn( + 'The "Q_fu" property is deprecated. Use "fuel_flow" instead.', + DeprecationWarning, + stacklevel=2, + ) + self.fuel_flow = value + + @property + def Q_th(self) -> Flow: # noqa: N802 + warnings.warn( + 'The "Q_th" property is deprecated. Use "thermal_flow" instead.', + DeprecationWarning, + stacklevel=2, + ) + return self.thermal_flow + + @Q_th.setter + def Q_th(self, value: Flow) -> None: # noqa: N802 + warnings.warn( + 'The "Q_th" property is deprecated. Use "thermal_flow" instead.', + DeprecationWarning, + stacklevel=2, + ) + self.thermal_flow = value @register_class_for_io @@ -114,14 +187,17 @@ class Power2Heat(LinearConverter): Args: label: The label of the Element. Used to identify it in the FlowSystem. - eta: Thermal efficiency factor (0-1 range). For resistance heating this is + thermal_efficiency: Thermal efficiency factor (0-1 range). For resistance heating this is typically close to 1.0 (nearly 100% efficiency), but may be lower for electrode boilers or systems with distribution losses. - P_el: Electrical input-flow representing electricity consumption. - Q_th: Thermal output-flow representing heat generation. + electrical_flow: Electrical input-flow representing electricity consumption. + thermal_flow: Thermal output-flow representing heat generation. on_off_parameters: Parameters defining binary operation constraints and costs. meta_data: Used to store additional information. Not used internally but saved in results. Only use Python native types. + eta: *Deprecated*. Use `thermal_efficiency` instead. + P_el: *Deprecated*. Use `electrical_flow` instead. + Q_th: *Deprecated*. Use `thermal_flow` instead. Examples: Electric resistance heater: @@ -129,9 +205,9 @@ class Power2Heat(LinearConverter): ```python electric_heater = Power2Heat( label='resistance_heater', - eta=0.98, # 98% efficiency (small losses) - P_el=electricity_flow, - Q_th=space_heating_flow, + thermal_efficiency=0.98, # 98% efficiency (small losses) + electrical_flow=electricity_flow, + thermal_flow=space_heating_flow, ) ``` @@ -140,9 +216,9 @@ class Power2Heat(LinearConverter): ```python electrode_boiler = Power2Heat( label='electrode_steam_boiler', - eta=0.95, # 95% efficiency including boiler losses - P_el=industrial_electricity, - Q_th=process_steam_flow, + thermal_efficiency=0.95, # 95% efficiency including boiler losses + electrical_flow=industrial_electricity, + thermal_flow=process_steam_flow, on_off_parameters=OnOffParameters( consecutive_on_hours_min=1, # Minimum 1-hour operation effects_per_switch_on={'startup_cost': 100}, @@ -151,9 +227,9 @@ class Power2Heat(LinearConverter): ``` Note: - The conversion relationship is: Q_th = P_el × eta + The conversion relationship is: thermal_flow = electrical_flow × thermal_efficiency - Unlike heat pumps, Power2Heat systems cannot exceed 100% efficiency (eta ≤ 1.0) + Unlike heat pumps, Power2Heat systems cannot exceed 100% efficiency (thermal_efficiency ≤ 1.0) as they only convert electrical energy without extracting additional energy from the environment. However, they provide fast response times and precise temperature control. @@ -162,32 +238,101 @@ class Power2Heat(LinearConverter): def __init__( self, label: str, - eta: Numeric_TPS, - P_el: Flow, - Q_th: Flow, + thermal_efficiency: Numeric_TPS | None = None, + electrical_flow: Flow | None = None, + thermal_flow: Flow | None = None, on_off_parameters: OnOffParameters | None = None, meta_data: dict | None = None, + **kwargs, ): + # Handle deprecated parameters + electrical_flow = self._handle_deprecated_kwarg(kwargs, 'P_el', 'electrical_flow', electrical_flow) + thermal_flow = self._handle_deprecated_kwarg(kwargs, 'Q_th', 'thermal_flow', thermal_flow) + thermal_efficiency = self._handle_deprecated_kwarg(kwargs, 'eta', 'thermal_efficiency', thermal_efficiency) + self._validate_kwargs(kwargs) + + # Validate required parameters + if electrical_flow is None: + raise ValueError(f"'{label}': electrical_flow is required and cannot be None") + if thermal_flow is None: + raise ValueError(f"'{label}': thermal_flow is required and cannot be None") + if thermal_efficiency is None: + raise ValueError(f"'{label}': thermal_efficiency is required and cannot be None") + super().__init__( label, - inputs=[P_el], - outputs=[Q_th], - conversion_factors=[{P_el.label: eta, Q_th.label: 1}], + inputs=[electrical_flow], + outputs=[thermal_flow], on_off_parameters=on_off_parameters, meta_data=meta_data, ) - self.P_el = P_el - self.Q_th = Q_th + self.electrical_flow = electrical_flow + self.thermal_flow = thermal_flow + self.thermal_efficiency = thermal_efficiency # Uses setter + + @property + def thermal_efficiency(self): + return self.conversion_factors[0][self.electrical_flow.label] + + @thermal_efficiency.setter + def thermal_efficiency(self, value): + check_bounds(value, 'thermal_efficiency', self.label_full, 0, 1) + self.conversion_factors = [{self.electrical_flow.label: value, self.thermal_flow.label: 1}] @property - def eta(self): - return self.conversion_factors[0][self.P_el.label] + def eta(self) -> Numeric_TPS: + warnings.warn( + 'The "eta" property is deprecated. Use "thermal_efficiency" instead.', + DeprecationWarning, + stacklevel=2, + ) + return self.thermal_efficiency @eta.setter - def eta(self, value): - check_bounds(value, 'eta', self.label_full, 0, 1) - self.conversion_factors[0][self.P_el.label] = value + def eta(self, value: Numeric_TPS) -> None: + warnings.warn( + 'The "eta" property is deprecated. Use "thermal_efficiency" instead.', + DeprecationWarning, + stacklevel=2, + ) + self.thermal_efficiency = value + + @property + def P_el(self) -> Flow: # noqa: N802 + warnings.warn( + 'The "P_el" property is deprecated. Use "electrical_flow" instead.', + DeprecationWarning, + stacklevel=2, + ) + return self.electrical_flow + + @P_el.setter + def P_el(self, value: Flow) -> None: # noqa: N802 + warnings.warn( + 'The "P_el" property is deprecated. Use "electrical_flow" instead.', + DeprecationWarning, + stacklevel=2, + ) + self.electrical_flow = value + + @property + def Q_th(self) -> Flow: # noqa: N802 + warnings.warn( + 'The "Q_th" property is deprecated. Use "thermal_flow" instead.', + DeprecationWarning, + stacklevel=2, + ) + return self.thermal_flow + + @Q_th.setter + def Q_th(self, value: Flow) -> None: # noqa: N802 + warnings.warn( + 'The "Q_th" property is deprecated. Use "thermal_flow" instead.', + DeprecationWarning, + stacklevel=2, + ) + self.thermal_flow = value @register_class_for_io @@ -202,14 +347,17 @@ class HeatPump(LinearConverter): Args: label: The label of the Element. Used to identify it in the FlowSystem. - COP: Coefficient of Performance (typically 1-20 range). Defines the ratio of + cop: Coefficient of Performance (typically 1-20 range). Defines the ratio of thermal output to electrical input. COP > 1 indicates the heat pump extracts additional energy from the environment. - P_el: Electrical input-flow representing electricity consumption. - Q_th: Thermal output-flow representing heat generation. + electrical_flow: Electrical input-flow representing electricity consumption. + thermal_flow: Thermal output-flow representing heat generation. on_off_parameters: Parameters defining binary operation constraints and costs. meta_data: Used to store additional information. Not used internally but saved in results. Only use Python native types. + COP: *Deprecated*. Use `cop` instead. + P_el: *Deprecated*. Use `electrical_flow` instead. + Q_th: *Deprecated*. Use `thermal_flow` instead. Examples: Air-source heat pump with constant COP: @@ -217,9 +365,9 @@ class HeatPump(LinearConverter): ```python air_hp = HeatPump( label='air_source_heat_pump', - COP=3.5, # COP of 3.5 (350% efficiency) - P_el=electricity_flow, - Q_th=heating_flow, + cop=3.5, # COP of 3.5 (350% efficiency) + electrical_flow=electricity_flow, + thermal_flow=heating_flow, ) ``` @@ -228,9 +376,9 @@ class HeatPump(LinearConverter): ```python ground_hp = HeatPump( label='geothermal_heat_pump', - COP=temperature_dependent_cop, # Time-varying COP based on ground temp - P_el=electricity_flow, - Q_th=radiant_heating_flow, + cop=temperature_dependent_cop, # Time-varying COP based on ground temp + electrical_flow=electricity_flow, + thermal_flow=radiant_heating_flow, on_off_parameters=OnOffParameters( consecutive_on_hours_min=2, # Avoid frequent cycling effects_per_running_hour={'maintenance': 0.5}, @@ -239,7 +387,7 @@ class HeatPump(LinearConverter): ``` Note: - The conversion relationship is: Q_th = P_el × COP + The conversion relationship is: thermal_flow = electrical_flow × COP COP should be greater than 1 for realistic heat pump operation, with typical values ranging from 2-6 depending on technology and operating conditions. @@ -249,32 +397,101 @@ class HeatPump(LinearConverter): def __init__( self, label: str, - COP: Numeric_TPS, - P_el: Flow, - Q_th: Flow, + cop: Numeric_TPS | None = None, + electrical_flow: Flow | None = None, + thermal_flow: Flow | None = None, on_off_parameters: OnOffParameters | None = None, meta_data: dict | None = None, + **kwargs, ): + # Handle deprecated parameters + electrical_flow = self._handle_deprecated_kwarg(kwargs, 'P_el', 'electrical_flow', electrical_flow) + thermal_flow = self._handle_deprecated_kwarg(kwargs, 'Q_th', 'thermal_flow', thermal_flow) + cop = self._handle_deprecated_kwarg(kwargs, 'COP', 'cop', cop) + self._validate_kwargs(kwargs) + + # Validate required parameters + if electrical_flow is None: + raise ValueError(f"'{label}': electrical_flow is required and cannot be None") + if thermal_flow is None: + raise ValueError(f"'{label}': thermal_flow is required and cannot be None") + if cop is None: + raise ValueError(f"'{label}': cop is required and cannot be None") + super().__init__( label, - inputs=[P_el], - outputs=[Q_th], - conversion_factors=[{P_el.label: COP, Q_th.label: 1}], + inputs=[electrical_flow], + outputs=[thermal_flow], + conversion_factors=[], on_off_parameters=on_off_parameters, meta_data=meta_data, ) - self.P_el = P_el - self.Q_th = Q_th - self.COP = COP + self.electrical_flow = electrical_flow + self.thermal_flow = thermal_flow + self.cop = cop # Uses setter + + @property + def cop(self): + return self.conversion_factors[0][self.electrical_flow.label] + + @cop.setter + def cop(self, value): + check_bounds(value, 'cop', self.label_full, 1, 20) + self.conversion_factors = [{self.electrical_flow.label: value, self.thermal_flow.label: 1}] @property - def COP(self): # noqa: N802 - return self.conversion_factors[0][self.P_el.label] + def COP(self) -> Numeric_TPS: # noqa: N802 + warnings.warn( + 'The "COP" property is deprecated. Use "cop" instead.', + DeprecationWarning, + stacklevel=2, + ) + return self.cop @COP.setter - def COP(self, value): # noqa: N802 - check_bounds(value, 'COP', self.label_full, 1, 20) - self.conversion_factors[0][self.P_el.label] = value + def COP(self, value: Numeric_TPS) -> None: # noqa: N802 + warnings.warn( + 'The "COP" property is deprecated. Use "cop" instead.', + DeprecationWarning, + stacklevel=2, + ) + self.cop = value + + @property + def P_el(self) -> Flow: # noqa: N802 + warnings.warn( + 'The "P_el" property is deprecated. Use "electrical_flow" instead.', + DeprecationWarning, + stacklevel=2, + ) + return self.electrical_flow + + @P_el.setter + def P_el(self, value: Flow) -> None: # noqa: N802 + warnings.warn( + 'The "P_el" property is deprecated. Use "electrical_flow" instead.', + DeprecationWarning, + stacklevel=2, + ) + self.electrical_flow = value + + @property + def Q_th(self) -> Flow: # noqa: N802 + warnings.warn( + 'The "Q_th" property is deprecated. Use "thermal_flow" instead.', + DeprecationWarning, + stacklevel=2, + ) + return self.thermal_flow + + @Q_th.setter + def Q_th(self, value: Flow) -> None: # noqa: N802 + warnings.warn( + 'The "Q_th" property is deprecated. Use "thermal_flow" instead.', + DeprecationWarning, + stacklevel=2, + ) + self.thermal_flow = value @register_class_for_io @@ -292,11 +509,13 @@ class CoolingTower(LinearConverter): specific_electricity_demand: Auxiliary electricity demand per unit of cooling power (dimensionless, typically 0.01-0.05 range). Represents the fraction of thermal power that must be supplied as electricity for fans and pumps. - P_el: Electrical input-flow representing electricity consumption for fans/pumps. - Q_th: Thermal input-flow representing waste heat to be rejected to environment. + electrical_flow: Electrical input-flow representing electricity consumption for fans/pumps. + thermal_flow: Thermal input-flow representing waste heat to be rejected to environment. on_off_parameters: Parameters defining binary operation constraints and costs. meta_data: Used to store additional information. Not used internally but saved in results. Only use Python native types. + P_el: *Deprecated*. Use `electrical_flow` instead. + Q_th: *Deprecated*. Use `thermal_flow` instead. Examples: Industrial cooling tower: @@ -305,8 +524,8 @@ class CoolingTower(LinearConverter): cooling_tower = CoolingTower( label='process_cooling_tower', specific_electricity_demand=0.025, # 2.5% auxiliary power - P_el=cooling_electricity, - Q_th=waste_heat_flow, + electrical_flow=cooling_electricity, + thermal_flow=waste_heat_flow, ) ``` @@ -316,8 +535,8 @@ class CoolingTower(LinearConverter): condenser_cooling = CoolingTower( label='power_plant_cooling', specific_electricity_demand=0.015, # 1.5% auxiliary power - P_el=auxiliary_electricity, - Q_th=condenser_waste_heat, + electrical_flow=auxiliary_electricity, + thermal_flow=condenser_waste_heat, on_off_parameters=OnOffParameters( consecutive_on_hours_min=4, # Minimum operation time effects_per_running_hour={'water_consumption': 2.5}, # m³/h @@ -326,7 +545,7 @@ class CoolingTower(LinearConverter): ``` Note: - The conversion relationship is: P_el = Q_th × specific_electricity_demand + The conversion relationship is: electrical_flow = thermal_flow × specific_electricity_demand The cooling tower consumes electrical power proportional to the thermal load. No thermal energy is produced - all thermal input is rejected to the environment. @@ -339,33 +558,79 @@ def __init__( self, label: str, specific_electricity_demand: Numeric_TPS, - P_el: Flow, - Q_th: Flow, + electrical_flow: Flow | None = None, + thermal_flow: Flow | None = None, on_off_parameters: OnOffParameters | None = None, meta_data: dict | None = None, + **kwargs, ): + # Handle deprecated parameters + electrical_flow = self._handle_deprecated_kwarg(kwargs, 'P_el', 'electrical_flow', electrical_flow) + thermal_flow = self._handle_deprecated_kwarg(kwargs, 'Q_th', 'thermal_flow', thermal_flow) + self._validate_kwargs(kwargs) + + # Validate required parameters + if electrical_flow is None: + raise ValueError(f"'{label}': electrical_flow is required and cannot be None") + if thermal_flow is None: + raise ValueError(f"'{label}': thermal_flow is required and cannot be None") + super().__init__( label, - inputs=[P_el, Q_th], + inputs=[electrical_flow, thermal_flow], outputs=[], - conversion_factors=[{P_el.label: -1, Q_th.label: specific_electricity_demand}], on_off_parameters=on_off_parameters, meta_data=meta_data, ) - self.P_el = P_el - self.Q_th = Q_th - - check_bounds(specific_electricity_demand, 'specific_electricity_demand', self.label_full, 0, 1) + self.electrical_flow = electrical_flow + self.thermal_flow = thermal_flow + self.specific_electricity_demand = specific_electricity_demand # Uses setter @property def specific_electricity_demand(self): - return self.conversion_factors[0][self.Q_th.label] + return self.conversion_factors[0][self.thermal_flow.label] @specific_electricity_demand.setter def specific_electricity_demand(self, value): check_bounds(value, 'specific_electricity_demand', self.label_full, 0, 1) - self.conversion_factors[0][self.Q_th.label] = value + self.conversion_factors = [{self.electrical_flow.label: -1, self.thermal_flow.label: value}] + + @property + def P_el(self) -> Flow: # noqa: N802 + warnings.warn( + 'The "P_el" property is deprecated. Use "electrical_flow" instead.', + DeprecationWarning, + stacklevel=2, + ) + return self.electrical_flow + + @P_el.setter + def P_el(self, value: Flow) -> None: # noqa: N802 + warnings.warn( + 'The "P_el" property is deprecated. Use "electrical_flow" instead.', + DeprecationWarning, + stacklevel=2, + ) + self.electrical_flow = value + + @property + def Q_th(self) -> Flow: # noqa: N802 + warnings.warn( + 'The "Q_th" property is deprecated. Use "thermal_flow" instead.', + DeprecationWarning, + stacklevel=2, + ) + return self.thermal_flow + + @Q_th.setter + def Q_th(self, value: Flow) -> None: # noqa: N802 + warnings.warn( + 'The "Q_th" property is deprecated. Use "thermal_flow" instead.', + DeprecationWarning, + stacklevel=2, + ) + self.thermal_flow = value @register_class_for_io @@ -380,16 +645,21 @@ class CHP(LinearConverter): Args: label: The label of the Element. Used to identify it in the FlowSystem. - eta_th: Thermal efficiency factor (0-1 range). Defines the fraction of fuel + thermal_efficiency: Thermal efficiency factor (0-1 range). Defines the fraction of fuel energy converted to useful thermal output. - eta_el: Electrical efficiency factor (0-1 range). Defines the fraction of fuel + electrical_efficiency: Electrical efficiency factor (0-1 range). Defines the fraction of fuel energy converted to electrical output. - Q_fu: Fuel input-flow representing fuel consumption. - P_el: Electrical output-flow representing electricity generation. - Q_th: Thermal output-flow representing heat generation. + fuel_flow: Fuel input-flow representing fuel consumption. + electrical_flow: Electrical output-flow representing electricity generation. + thermal_flow: Thermal output-flow representing heat generation. on_off_parameters: Parameters defining binary operation constraints and costs. meta_data: Used to store additional information. Not used internally but saved in results. Only use Python native types. + eta_th: *Deprecated*. Use `thermal_efficiency` instead. + eta_el: *Deprecated*. Use `electrical_efficiency` instead. + Q_fu: *Deprecated*. Use `fuel_flow` instead. + P_el: *Deprecated*. Use `electrical_flow` instead. + Q_th: *Deprecated*. Use `thermal_flow` instead. Examples: Natural gas CHP unit: @@ -397,11 +667,11 @@ class CHP(LinearConverter): ```python gas_chp = CHP( label='natural_gas_chp', - eta_th=0.45, # 45% thermal efficiency - eta_el=0.35, # 35% electrical efficiency (80% total) - Q_fu=natural_gas_flow, - P_el=electricity_flow, - Q_th=district_heat_flow, + thermal_efficiency=0.45, # 45% thermal efficiency + electrical_efficiency=0.35, # 35% electrical efficiency (80% total) + fuel_flow=natural_gas_flow, + electrical_flow=electricity_flow, + thermal_flow=district_heat_flow, ) ``` @@ -410,11 +680,11 @@ class CHP(LinearConverter): ```python industrial_chp = CHP( label='industrial_chp', - eta_th=0.40, - eta_el=0.38, - Q_fu=fuel_gas_flow, - P_el=plant_electricity, - Q_th=process_steam, + thermal_efficiency=0.40, + electrical_efficiency=0.38, + fuel_flow=fuel_gas_flow, + electrical_flow=plant_electricity, + thermal_flow=process_steam, on_off_parameters=OnOffParameters( consecutive_on_hours_min=8, # Minimum 8-hour operation effects_per_switch_on={'startup_cost': 5000}, @@ -425,10 +695,10 @@ class CHP(LinearConverter): Note: The conversion relationships are: - - Q_th = Q_fu × eta_th (thermal output) - - P_el = Q_fu × eta_el (electrical output) + - thermal_flow = fuel_flow × thermal_efficiency (thermal output) + - electrical_flow = fuel_flow × electrical_efficiency (electrical output) - Total efficiency (eta_th + eta_el) should be ≤ 1.0, with typical combined + Total efficiency (thermal_efficiency + electrical_efficiency) should be ≤ 1.0, with typical combined efficiencies of 80-90% for modern CHP units. This provides significant efficiency gains compared to separate heat and power generation. """ @@ -436,49 +706,167 @@ class CHP(LinearConverter): def __init__( self, label: str, - eta_th: Numeric_TPS, - eta_el: Numeric_TPS, - Q_fu: Flow, - P_el: Flow, - Q_th: Flow, + thermal_efficiency: Numeric_TPS | None = None, + electrical_efficiency: Numeric_TPS | None = None, + fuel_flow: Flow | None = None, + electrical_flow: Flow | None = None, + thermal_flow: Flow | None = None, on_off_parameters: OnOffParameters | None = None, meta_data: dict | None = None, + **kwargs, ): - heat = {Q_fu.label: eta_th, Q_th.label: 1} - electricity = {Q_fu.label: eta_el, P_el.label: 1} + # Handle deprecated parameters + fuel_flow = self._handle_deprecated_kwarg(kwargs, 'Q_fu', 'fuel_flow', fuel_flow) + electrical_flow = self._handle_deprecated_kwarg(kwargs, 'P_el', 'electrical_flow', electrical_flow) + thermal_flow = self._handle_deprecated_kwarg(kwargs, 'Q_th', 'thermal_flow', thermal_flow) + thermal_efficiency = self._handle_deprecated_kwarg(kwargs, 'eta_th', 'thermal_efficiency', thermal_efficiency) + electrical_efficiency = self._handle_deprecated_kwarg( + kwargs, 'eta_el', 'electrical_efficiency', electrical_efficiency + ) + self._validate_kwargs(kwargs) + + # Validate required parameters + if fuel_flow is None: + raise ValueError(f"'{label}': fuel_flow is required and cannot be None") + if electrical_flow is None: + raise ValueError(f"'{label}': electrical_flow is required and cannot be None") + if thermal_flow is None: + raise ValueError(f"'{label}': thermal_flow is required and cannot be None") + if thermal_efficiency is None: + raise ValueError(f"'{label}': thermal_efficiency is required and cannot be None") + if electrical_efficiency is None: + raise ValueError(f"'{label}': electrical_efficiency is required and cannot be None") super().__init__( label, - inputs=[Q_fu], - outputs=[Q_th, P_el], - conversion_factors=[heat, electricity], + inputs=[fuel_flow], + outputs=[thermal_flow, electrical_flow], + conversion_factors=[{}, {}], on_off_parameters=on_off_parameters, meta_data=meta_data, ) - self.Q_fu = Q_fu - self.P_el = P_el - self.Q_th = Q_th + self.fuel_flow = fuel_flow + self.electrical_flow = electrical_flow + self.thermal_flow = thermal_flow + self.thermal_efficiency = thermal_efficiency # Uses setter + self.electrical_efficiency = electrical_efficiency # Uses setter + + check_bounds( + electrical_efficiency + thermal_efficiency, + 'thermal_efficiency+electrical_efficiency', + self.label_full, + 0, + 1, + ) + + @property + def thermal_efficiency(self): + return self.conversion_factors[0][self.fuel_flow.label] + + @thermal_efficiency.setter + def thermal_efficiency(self, value): + check_bounds(value, 'thermal_efficiency', self.label_full, 0, 1) + self.conversion_factors[0] = {self.fuel_flow.label: value, self.thermal_flow.label: 1} + + @property + def electrical_efficiency(self): + return self.conversion_factors[1][self.fuel_flow.label] - check_bounds(eta_el + eta_th, 'eta_th+eta_el', self.label_full, 0, 1) + @electrical_efficiency.setter + def electrical_efficiency(self, value): + check_bounds(value, 'electrical_efficiency', self.label_full, 0, 1) + self.conversion_factors[1] = {self.fuel_flow.label: value, self.electrical_flow.label: 1} @property - def eta_th(self): - return self.conversion_factors[0][self.Q_fu.label] + def eta_th(self) -> Numeric_TPS: + warnings.warn( + 'The "eta_th" property is deprecated. Use "thermal_efficiency" instead.', + DeprecationWarning, + stacklevel=2, + ) + return self.thermal_efficiency @eta_th.setter - def eta_th(self, value): - check_bounds(value, 'eta_th', self.label_full, 0, 1) - self.conversion_factors[0][self.Q_fu.label] = value + def eta_th(self, value: Numeric_TPS) -> None: + warnings.warn( + 'The "eta_th" property is deprecated. Use "thermal_efficiency" instead.', + DeprecationWarning, + stacklevel=2, + ) + self.thermal_efficiency = value @property - def eta_el(self): - return self.conversion_factors[1][self.Q_fu.label] + def eta_el(self) -> Numeric_TPS: + warnings.warn( + 'The "eta_el" property is deprecated. Use "electrical_efficiency" instead.', + DeprecationWarning, + stacklevel=2, + ) + return self.electrical_efficiency @eta_el.setter - def eta_el(self, value): - check_bounds(value, 'eta_el', self.label_full, 0, 1) - self.conversion_factors[1][self.Q_fu.label] = value + def eta_el(self, value: Numeric_TPS) -> None: + warnings.warn( + 'The "eta_el" property is deprecated. Use "electrical_efficiency" instead.', + DeprecationWarning, + stacklevel=2, + ) + self.electrical_efficiency = value + + @property + def Q_fu(self) -> Flow: # noqa: N802 + warnings.warn( + 'The "Q_fu" property is deprecated. Use "fuel_flow" instead.', + DeprecationWarning, + stacklevel=2, + ) + return self.fuel_flow + + @Q_fu.setter + def Q_fu(self, value: Flow) -> None: # noqa: N802 + warnings.warn( + 'The "Q_fu" property is deprecated. Use "fuel_flow" instead.', + DeprecationWarning, + stacklevel=2, + ) + self.fuel_flow = value + + @property + def P_el(self) -> Flow: # noqa: N802 + warnings.warn( + 'The "P_el" property is deprecated. Use "electrical_flow" instead.', + DeprecationWarning, + stacklevel=2, + ) + return self.electrical_flow + + @P_el.setter + def P_el(self, value: Flow) -> None: # noqa: N802 + warnings.warn( + 'The "P_el" property is deprecated. Use "electrical_flow" instead.', + DeprecationWarning, + stacklevel=2, + ) + self.electrical_flow = value + + @property + def Q_th(self) -> Flow: # noqa: N802 + warnings.warn( + 'The "Q_th" property is deprecated. Use "thermal_flow" instead.', + DeprecationWarning, + stacklevel=2, + ) + return self.thermal_flow + + @Q_th.setter + def Q_th(self, value: Flow) -> None: # noqa: N802 + warnings.warn( + 'The "Q_th" property is deprecated. Use "thermal_flow" instead.', + DeprecationWarning, + stacklevel=2, + ) + self.thermal_flow = value @register_class_for_io @@ -493,16 +881,20 @@ class HeatPumpWithSource(LinearConverter): Args: label: The label of the Element. Used to identify it in the FlowSystem. - COP: Coefficient of Performance (typically 1-20 range). Defines the ratio of + cop: Coefficient of Performance (typically 1-20 range). Defines the ratio of thermal output to electrical input. The heat source extraction is automatically - calculated as Q_ab = Q_th × (COP-1)/COP. - P_el: Electrical input-flow representing electricity consumption for compressor. - Q_ab: Heat source input-flow representing thermal energy extracted from environment + calculated as heat_source_flow = thermal_flow × (COP-1)/COP. + electrical_flow: Electrical input-flow representing electricity consumption for compressor. + heat_source_flow: Heat source input-flow representing thermal energy extracted from environment (ground, air, water source). - Q_th: Thermal output-flow representing useful heat delivered to the application. + thermal_flow: Thermal output-flow representing useful heat delivered to the application. on_off_parameters: Parameters defining binary operation constraints and costs. meta_data: Used to store additional information. Not used internally but saved in results. Only use Python native types. + COP: *Deprecated*. Use `cop` instead. + P_el: *Deprecated*. Use `electrical_flow` instead. + Q_ab: *Deprecated*. Use `heat_source_flow` instead. + Q_th: *Deprecated*. Use `thermal_flow` instead. Examples: Ground-source heat pump with explicit ground coupling: @@ -510,10 +902,10 @@ class HeatPumpWithSource(LinearConverter): ```python ground_source_hp = HeatPumpWithSource( label='geothermal_heat_pump', - COP=4.5, # High COP due to stable ground temperature - P_el=electricity_flow, - Q_ab=ground_heat_extraction, # Heat extracted from ground loop - Q_th=building_heating_flow, + cop=4.5, # High COP due to stable ground temperature + electrical_flow=electricity_flow, + heat_source_flow=ground_heat_extraction, # Heat extracted from ground loop + thermal_flow=building_heating_flow, ) ``` @@ -522,10 +914,10 @@ class HeatPumpWithSource(LinearConverter): ```python waste_heat_pump = HeatPumpWithSource( label='waste_heat_pump', - COP=temperature_dependent_cop, # Varies with temperature of heat source - P_el=electricity_consumption, - Q_ab=industrial_heat_extraction, # Heat extracted from a industrial process or waste water - Q_th=heat_supply, + cop=temperature_dependent_cop, # Varies with temperature of heat source + electrical_flow=electricity_consumption, + heat_source_flow=industrial_heat_extraction, # Heat extracted from a industrial process or waste water + thermal_flow=heat_supply, on_off_parameters=OnOffParameters( consecutive_on_hours_min=0.5, # 30-minute minimum runtime effects_per_switch_on={'costs': 1000}, @@ -535,9 +927,9 @@ class HeatPumpWithSource(LinearConverter): Note: The conversion relationships are: - - Q_th = P_el × COP (thermal output from electrical input) - - Q_ab = Q_th × (COP-1)/COP (heat source extraction) - - Energy balance: Q_th = P_el + Q_ab + - thermal_flow = electrical_flow × COP (thermal output from electrical input) + - heat_source_flow = thermal_flow × (COP-1)/COP (heat source extraction) + - Energy balance: thermal_flow = electrical_flow + heat_source_flow This formulation explicitly tracks the heat source, which is important for systems where the source capacity or temperature is limited, @@ -550,41 +942,128 @@ class HeatPumpWithSource(LinearConverter): def __init__( self, label: str, - COP: Numeric_TPS, - P_el: Flow, - Q_ab: Flow, - Q_th: Flow, + cop: Numeric_TPS | None = None, + electrical_flow: Flow | None = None, + heat_source_flow: Flow | None = None, + thermal_flow: Flow | None = None, on_off_parameters: OnOffParameters | None = None, meta_data: dict | None = None, + **kwargs, ): + # Handle deprecated parameters + electrical_flow = self._handle_deprecated_kwarg(kwargs, 'P_el', 'electrical_flow', electrical_flow) + heat_source_flow = self._handle_deprecated_kwarg(kwargs, 'Q_ab', 'heat_source_flow', heat_source_flow) + thermal_flow = self._handle_deprecated_kwarg(kwargs, 'Q_th', 'thermal_flow', thermal_flow) + cop = self._handle_deprecated_kwarg(kwargs, 'COP', 'cop', cop) + self._validate_kwargs(kwargs) + + # Validate required parameters + if electrical_flow is None: + raise ValueError(f"'{label}': electrical_flow is required and cannot be None") + if heat_source_flow is None: + raise ValueError(f"'{label}': heat_source_flow is required and cannot be None") + if thermal_flow is None: + raise ValueError(f"'{label}': thermal_flow is required and cannot be None") + if cop is None: + raise ValueError(f"'{label}': cop is required and cannot be None") + super().__init__( label, - inputs=[P_el, Q_ab], - outputs=[Q_th], - conversion_factors=[{P_el.label: COP, Q_th.label: 1}, {Q_ab.label: COP / (COP - 1), Q_th.label: 1}], + inputs=[electrical_flow, heat_source_flow], + outputs=[thermal_flow], on_off_parameters=on_off_parameters, meta_data=meta_data, ) - self.P_el = P_el - self.Q_ab = Q_ab - self.Q_th = Q_th + self.electrical_flow = electrical_flow + self.heat_source_flow = heat_source_flow + self.thermal_flow = thermal_flow + self.cop = cop # Uses setter - if np.any(np.asarray(self.COP) <= 1): - raise ValueError(f'{self.label_full}.COP must be strictly > 1 for HeatPumpWithSource.') + @property + def cop(self): + return self.conversion_factors[0][self.electrical_flow.label] + + @cop.setter + def cop(self, value): + check_bounds(value, 'cop', self.label_full, 1, 20) + if np.any(np.asarray(value) == 1): + raise ValueError(f'{self.label_full}.cop must be strictly !=1 for HeatPumpWithSource.') + self.conversion_factors = [ + {self.electrical_flow.label: value, self.thermal_flow.label: 1}, + {self.heat_source_flow.label: value / (value - 1), self.thermal_flow.label: 1}, + ] @property - def COP(self): # noqa: N802 - return self.conversion_factors[0][self.P_el.label] + def COP(self) -> Numeric_TPS: # noqa: N802 + warnings.warn( + 'The "COP" property is deprecated. Use "cop" instead.', + DeprecationWarning, + stacklevel=2, + ) + return self.cop @COP.setter - def COP(self, value): # noqa: N802 - check_bounds(value, 'COP', self.label_full, 1, 20) - if np.any(np.asarray(value) <= 1): - raise ValueError(f'{self.label_full}.COP must be strictly > 1 for HeatPumpWithSource.') - self.conversion_factors = [ - {self.P_el.label: value, self.Q_th.label: 1}, - {self.Q_ab.label: value / (value - 1), self.Q_th.label: 1}, - ] + def COP(self, value: Numeric_TPS) -> None: # noqa: N802 + warnings.warn( + 'The "COP" property is deprecated. Use "cop" instead.', + DeprecationWarning, + stacklevel=2, + ) + self.cop = value + + @property + def P_el(self) -> Flow: # noqa: N802 + warnings.warn( + 'The "P_el" property is deprecated. Use "electrical_flow" instead.', + DeprecationWarning, + stacklevel=2, + ) + return self.electrical_flow + + @P_el.setter + def P_el(self, value: Flow) -> None: # noqa: N802 + warnings.warn( + 'The "P_el" property is deprecated. Use "electrical_flow" instead.', + DeprecationWarning, + stacklevel=2, + ) + self.electrical_flow = value + + @property + def Q_ab(self) -> Flow: # noqa: N802 + warnings.warn( + 'The "Q_ab" property is deprecated. Use "heat_source_flow" instead.', + DeprecationWarning, + stacklevel=2, + ) + return self.heat_source_flow + + @Q_ab.setter + def Q_ab(self, value: Flow) -> None: # noqa: N802 + warnings.warn( + 'The "Q_ab" property is deprecated. Use "heat_source_flow" instead.', + DeprecationWarning, + stacklevel=2, + ) + self.heat_source_flow = value + + @property + def Q_th(self) -> Flow: # noqa: N802 + warnings.warn( + 'The "Q_th" property is deprecated. Use "thermal_flow" instead.', + DeprecationWarning, + stacklevel=2, + ) + return self.thermal_flow + + @Q_th.setter + def Q_th(self, value: Flow) -> None: # noqa: N802 + warnings.warn( + 'The "Q_th" property is deprecated. Use "thermal_flow" instead.', + DeprecationWarning, + stacklevel=2, + ) + self.thermal_flow = value def check_bounds( @@ -604,21 +1083,12 @@ def check_bounds( lower_bound: The lower bound. upper_bound: The upper bound. """ - if isinstance(value, TimeSeriesData): - value = value.data - if isinstance(lower_bound, TimeSeriesData): - lower_bound = lower_bound.data - if isinstance(upper_bound, TimeSeriesData): - upper_bound = upper_bound.data - - # Convert to NumPy arrays to handle xr.DataArray, pd.Series, pd.DataFrame + # Convert to array for shape and statistics value_arr = np.asarray(value) - lower_arr = np.asarray(lower_bound) - upper_arr = np.asarray(upper_bound) - if not np.all(value_arr > lower_arr): + if not np.all(value_arr > lower_bound): logger.warning( - "'{}.{}' <= lower bound {}. {}.min={} shape={}", + "'{}.{}' <= lower bound {}. {}.min={}, shape={}", element_label, parameter_label, lower_bound, @@ -626,9 +1096,9 @@ def check_bounds( float(np.min(value_arr)), np.shape(value_arr), ) - if not np.all(value_arr < upper_arr): + if not np.all(value_arr < upper_bound): logger.warning( - "'{}.{}' >= upper bound {}. {}.max={} shape={}", + "'{}.{}' >= upper bound {}. {}.max={}, shape={}", element_label, parameter_label, upper_bound, diff --git a/flixopt/structure.py b/flixopt/structure.py index ca8d3580d..eef9f3685 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -24,7 +24,7 @@ from loguru import logger from . import io as fx_io -from .core import TimeSeriesData, get_dataarray_stats +from .core import FlowSystemDimensions, TimeSeriesData, get_dataarray_stats if TYPE_CHECKING: # for type checking and preventing circular imports import pathlib @@ -32,6 +32,7 @@ from .effects import EffectCollectionModel from .flow_system import FlowSystem + from .types import Effect_TPS, Numeric_TPS, NumericOrBool CLASS_REGISTRY = {} @@ -95,6 +96,7 @@ def __init__(self, flow_system: FlowSystem, normalize_weights: bool): self.submodels: Submodels = Submodels({}) def do_modeling(self): + # Create all element models self.effects = self.flow_system.effects.create_model(self) for component in self.flow_system.components.values(): component.create_model(self) @@ -267,21 +269,96 @@ class Interface: - Recursive handling of complex nested structures Subclasses must implement: - transform_data(flow_system): Transform data to match FlowSystem dimensions + transform_data(name_prefix=''): Transform data to match FlowSystem dimensions """ - def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None: + def transform_data(self, name_prefix: str = '') -> None: """Transform the data of the interface to match the FlowSystem's dimensions. Args: - flow_system: The FlowSystem containing timing and dimensional information name_prefix: The prefix to use for the names of the variables. Defaults to '', which results in no prefix. Raises: NotImplementedError: Must be implemented by subclasses + + Note: + The FlowSystem reference is available via self._flow_system (for Interface objects) + or self.flow_system property (for Element objects). Elements must be registered + to a FlowSystem before calling this method. """ raise NotImplementedError('Every Interface subclass needs a transform_data() method') + def _set_flow_system(self, flow_system: FlowSystem) -> None: + """Store flow_system reference and propagate to nested Interface objects. + + This method is called automatically during element registration to enable + elements to access FlowSystem properties without passing the reference + through every method call. + + Subclasses with nested Interface objects should override this method + to explicitly propagate the reference to their nested interfaces. + + Args: + flow_system: The FlowSystem that this interface belongs to + """ + self._flow_system = flow_system + + @property + def flow_system(self) -> FlowSystem: + """Access the FlowSystem this interface is linked to. + + Returns: + The FlowSystem instance this interface belongs to. + + Raises: + RuntimeError: If interface has not been linked to a FlowSystem yet. + + Note: + For Elements, this is set during add_elements(). + For parameter classes, this is set recursively when the parent Element is registered. + """ + if not hasattr(self, '_flow_system') or self._flow_system is None: + raise RuntimeError( + f'{self.__class__.__name__} is not linked to a FlowSystem. ' + f'Ensure the parent element is registered via flow_system.add_elements() first.' + ) + return self._flow_system + + def _fit_coords( + self, name: str, data: NumericOrBool | None, dims: Collection[FlowSystemDimensions] | None = None + ) -> xr.DataArray | None: + """Convenience wrapper for FlowSystem.fit_to_model_coords(). + + Args: + name: The name for the data variable + data: The data to transform + dims: Optional dimension names + + Returns: + Transformed data aligned to FlowSystem coordinates + """ + return self.flow_system.fit_to_model_coords(name, data, dims=dims) + + def _fit_effect_coords( + self, + prefix: str | None, + effect_values: Effect_TPS | Numeric_TPS | None, + suffix: str | None = None, + dims: Collection[FlowSystemDimensions] | None = None, + ) -> Effect_TPS | None: + """Convenience wrapper for FlowSystem.fit_effects_to_model_coords(). + + Args: + prefix: Label prefix for effect names + effect_values: The effect values to transform + suffix: Optional label suffix + dims: Optional dimension names + + Returns: + Transformed effect values aligned to FlowSystem coordinates + """ + return self.flow_system.fit_effects_to_model_coords(prefix, effect_values, suffix, dims=dims) + def _create_reference_structure(self) -> tuple[dict, dict[str, xr.DataArray]]: """ Convert all DataArrays to references and extract them. @@ -859,6 +936,7 @@ def __init__(self, label: str, meta_data: dict | None = None): self.label = Element._valid_label(label) self.meta_data = meta_data if meta_data is not None else {} self.submodel = None + self._flow_system: FlowSystem | None = None def _plausibility_checks(self) -> None: """This function is used to do some basic plausibility checks for each Element during initialization. @@ -1420,7 +1498,12 @@ def hours_per_step(self): return self._model.hours_per_step def _do_modeling(self): - """Called at the end of initialization. Override in subclasses to create variables and constraints.""" + """ + Override in subclasses to create variables, constraints, and submodels. + + This method is called during __init__. Create all nested submodels first + (so their variables exist), then create constraints that reference those variables. + """ pass diff --git a/tests/conftest.py b/tests/conftest.py index 047f3882b..4711e8e41 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -131,8 +131,8 @@ def simple(): """Simple boiler from simple_flow_system""" return fx.linear_converters.Boiler( 'Boiler', - eta=0.5, - Q_th=fx.Flow( + thermal_efficiency=0.5, + thermal_flow=fx.Flow( 'Q_th', bus='Fernwärme', size=50, @@ -140,7 +140,7 @@ def simple(): relative_maximum=1, on_off_parameters=fx.OnOffParameters(), ), - Q_fu=fx.Flow('Q_fu', bus='Gas'), + fuel_flow=fx.Flow('Q_fu', bus='Gas'), ) @staticmethod @@ -148,9 +148,9 @@ def complex(): """Complex boiler with investment parameters from flow_system_complex""" return fx.linear_converters.Boiler( 'Kessel', - eta=0.5, + thermal_efficiency=0.5, on_off_parameters=fx.OnOffParameters(effects_per_running_hour={'costs': 0, 'CO2': 1000}), - Q_th=fx.Flow( + thermal_flow=fx.Flow( 'Q_th', bus='Fernwärme', load_factor_max=1.0, @@ -175,7 +175,7 @@ def complex(): ), flow_hours_max=1e6, ), - Q_fu=fx.Flow('Q_fu', bus='Gas', size=200, relative_minimum=0, relative_maximum=1), + fuel_flow=fx.Flow('Q_fu', bus='Gas', size=200, relative_minimum=0, relative_maximum=1), ) class CHPs: @@ -184,13 +184,13 @@ def simple(): """Simple CHP from simple_flow_system""" return fx.linear_converters.CHP( 'CHP_unit', - eta_th=0.5, - eta_el=0.4, - P_el=fx.Flow( + thermal_efficiency=0.5, + electrical_efficiency=0.4, + electrical_flow=fx.Flow( 'P_el', bus='Strom', size=60, relative_minimum=5 / 60, on_off_parameters=fx.OnOffParameters() ), - Q_th=fx.Flow('Q_th', bus='Fernwärme'), - Q_fu=fx.Flow('Q_fu', bus='Gas'), + thermal_flow=fx.Flow('Q_th', bus='Fernwärme'), + fuel_flow=fx.Flow('Q_fu', bus='Gas'), ) @staticmethod @@ -198,12 +198,12 @@ def base(): """CHP from flow_system_base""" return fx.linear_converters.CHP( 'KWK', - eta_th=0.5, - eta_el=0.4, + thermal_efficiency=0.5, + electrical_efficiency=0.4, on_off_parameters=fx.OnOffParameters(effects_per_switch_on=0.01), - P_el=fx.Flow('P_el', bus='Strom', size=60, relative_minimum=5 / 60, previous_flow_rate=10), - Q_th=fx.Flow('Q_th', bus='Fernwärme', size=1e3), - Q_fu=fx.Flow('Q_fu', bus='Gas', size=1e3), + electrical_flow=fx.Flow('P_el', bus='Strom', size=60, relative_minimum=5 / 60, previous_flow_rate=10), + thermal_flow=fx.Flow('Q_th', bus='Fernwärme', size=1e3), + fuel_flow=fx.Flow('Q_fu', bus='Gas', size=1e3), ) class LinearConverters: @@ -596,9 +596,9 @@ def flow_system_long(): flow_system.add_elements( fx.linear_converters.Boiler( 'Kessel', - eta=0.85, - Q_th=fx.Flow(label='Q_th', bus='Fernwärme'), - Q_fu=fx.Flow( + thermal_efficiency=0.85, + thermal_flow=fx.Flow(label='Q_th', bus='Fernwärme'), + fuel_flow=fx.Flow( label='Q_fu', bus='Gas', size=95, @@ -609,12 +609,12 @@ def flow_system_long(): ), fx.linear_converters.CHP( 'BHKW2', - eta_th=0.58, - eta_el=0.22, + thermal_efficiency=0.58, + electrical_efficiency=0.22, on_off_parameters=fx.OnOffParameters(effects_per_switch_on=24000), - P_el=fx.Flow('P_el', bus='Strom'), - Q_th=fx.Flow('Q_th', bus='Fernwärme'), - Q_fu=fx.Flow('Q_fu', bus='Kohle', size=288, relative_minimum=87 / 288), + electrical_flow=fx.Flow('P_el', bus='Strom'), + thermal_flow=fx.Flow('Q_th', bus='Fernwärme'), + fuel_flow=fx.Flow('Q_fu', bus='Kohle', size=288, relative_minimum=87 / 288), ), fx.Storage( 'Speicher', diff --git a/tests/test_component.py b/tests/test_component.py index be1eecf3b..dbbd85c8f 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -416,7 +416,10 @@ def test_transmission_basic(self, basic_flow_system, highs_solver): flow_system.add_elements(fx.Bus('Wärme lokal')) boiler = fx.linear_converters.Boiler( - 'Boiler', eta=0.5, Q_th=fx.Flow('Q_th', bus='Wärme lokal'), Q_fu=fx.Flow('Q_fu', bus='Gas') + 'Boiler', + thermal_efficiency=0.5, + thermal_flow=fx.Flow('Q_th', bus='Wärme lokal'), + fuel_flow=fx.Flow('Q_fu', bus='Gas'), ) transmission = fx.Transmission( @@ -453,13 +456,16 @@ def test_transmission_balanced(self, basic_flow_system, highs_solver): boiler = fx.linear_converters.Boiler( 'Boiler_Standard', - eta=0.9, - Q_th=fx.Flow('Q_th', bus='Fernwärme', relative_maximum=np.array([0, 0, 0, 1, 1, 1, 1, 1, 1, 1])), - Q_fu=fx.Flow('Q_fu', bus='Gas'), + thermal_efficiency=0.9, + thermal_flow=fx.Flow('Q_th', bus='Fernwärme', relative_maximum=np.array([0, 0, 0, 1, 1, 1, 1, 1, 1, 1])), + fuel_flow=fx.Flow('Q_fu', bus='Gas'), ) boiler2 = fx.linear_converters.Boiler( - 'Boiler_backup', eta=0.4, Q_th=fx.Flow('Q_th', bus='Wärme lokal'), Q_fu=fx.Flow('Q_fu', bus='Gas') + 'Boiler_backup', + thermal_efficiency=0.4, + thermal_flow=fx.Flow('Q_th', bus='Wärme lokal'), + fuel_flow=fx.Flow('Q_fu', bus='Gas'), ) last2 = fx.Sink( @@ -527,13 +533,16 @@ def test_transmission_unbalanced(self, basic_flow_system, highs_solver): boiler = fx.linear_converters.Boiler( 'Boiler_Standard', - eta=0.9, - Q_th=fx.Flow('Q_th', bus='Fernwärme', relative_maximum=np.array([0, 0, 0, 1, 1, 1, 1, 1, 1, 1])), - Q_fu=fx.Flow('Q_fu', bus='Gas'), + thermal_efficiency=0.9, + thermal_flow=fx.Flow('Q_th', bus='Fernwärme', relative_maximum=np.array([0, 0, 0, 1, 1, 1, 1, 1, 1, 1])), + fuel_flow=fx.Flow('Q_fu', bus='Gas'), ) boiler2 = fx.linear_converters.Boiler( - 'Boiler_backup', eta=0.4, Q_th=fx.Flow('Q_th', bus='Wärme lokal'), Q_fu=fx.Flow('Q_fu', bus='Gas') + 'Boiler_backup', + thermal_efficiency=0.4, + thermal_flow=fx.Flow('Q_th', bus='Wärme lokal'), + fuel_flow=fx.Flow('Q_fu', bus='Gas'), ) last2 = fx.Sink( diff --git a/tests/test_effect.py b/tests/test_effect.py index cd3edc537..8293ec62f 100644 --- a/tests/test_effect.py +++ b/tests/test_effect.py @@ -247,13 +247,13 @@ def test_shares(self, basic_flow_system_linopy_coords, coords_config): effect3, fx.linear_converters.Boiler( 'Boiler', - eta=0.5, - Q_th=fx.Flow( + thermal_efficiency=0.5, + thermal_flow=fx.Flow( 'Q_th', bus='Fernwärme', size=fx.InvestParameters(effects_of_investment_per_size=10, minimum_size=20, mandatory=True), ), - Q_fu=fx.Flow('Q_fu', bus='Gas'), + fuel_flow=fx.Flow('Q_fu', bus='Gas'), ), ) diff --git a/tests/test_flow_system_resample.py b/tests/test_flow_system_resample.py index d28872a0f..8946dd02f 100644 --- a/tests/test_flow_system_resample.py +++ b/tests/test_flow_system_resample.py @@ -52,9 +52,9 @@ def complex_fs(): # Piecewise converter converter = fx.linear_converters.Boiler( - 'boiler', eta=0.9, Q_fu=fx.Flow('gas', bus='elec'), Q_th=fx.Flow('heat', bus='heat') + 'boiler', thermal_efficiency=0.9, fuel_flow=fx.Flow('gas', bus='elec'), thermal_flow=fx.Flow('heat', bus='heat') ) - converter.Q_th.size = 100 + converter.thermal_flow.size = 100 fs.add_elements(converter) # Component with investment diff --git a/tests/test_functional.py b/tests/test_functional.py index cb2a247c1..98f118526 100644 --- a/tests/test_functional.py +++ b/tests/test_functional.py @@ -85,9 +85,9 @@ def flow_system_minimal(timesteps) -> fx.FlowSystem: flow_system.add_elements( fx.linear_converters.Boiler( 'Boiler', - 0.5, - Q_fu=fx.Flow('Q_fu', bus='Gas'), - Q_th=fx.Flow('Q_th', bus='Fernwärme'), + thermal_efficiency=0.5, + fuel_flow=fx.Flow('Q_fu', bus='Gas'), + thermal_flow=fx.Flow('Q_th', bus='Fernwärme'), ) ) return flow_system @@ -141,9 +141,9 @@ def test_fixed_size(solver_fixture, time_steps_fixture): flow_system.add_elements( fx.linear_converters.Boiler( 'Boiler', - 0.5, - Q_fu=fx.Flow('Q_fu', bus='Gas'), - Q_th=fx.Flow( + thermal_efficiency=0.5, + fuel_flow=fx.Flow('Q_fu', bus='Gas'), + thermal_flow=fx.Flow( 'Q_th', bus='Fernwärme', size=fx.InvestParameters(fixed_size=1000, effects_of_investment=10, effects_of_investment_per_size=1), @@ -162,14 +162,14 @@ def test_fixed_size(solver_fixture, time_steps_fixture): err_msg='The total costs does not have the right value', ) assert_allclose( - boiler.Q_th.submodel._investment.size.solution.item(), + boiler.thermal_flow.submodel.investment.size.solution.item(), 1000, rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__Investment_size" does not have the right value', ) assert_allclose( - boiler.Q_th.submodel._investment.invested.solution.item(), + boiler.thermal_flow.submodel.investment.invested.solution.item(), 1, rtol=1e-5, atol=1e-10, @@ -182,9 +182,9 @@ def test_optimize_size(solver_fixture, time_steps_fixture): flow_system.add_elements( fx.linear_converters.Boiler( 'Boiler', - 0.5, - Q_fu=fx.Flow('Q_fu', bus='Gas'), - Q_th=fx.Flow( + thermal_efficiency=0.5, + fuel_flow=fx.Flow('Q_fu', bus='Gas'), + thermal_flow=fx.Flow( 'Q_th', bus='Fernwärme', size=fx.InvestParameters(effects_of_investment=10, effects_of_investment_per_size=1), @@ -203,14 +203,14 @@ def test_optimize_size(solver_fixture, time_steps_fixture): err_msg='The total costs does not have the right value', ) assert_allclose( - boiler.Q_th.submodel._investment.size.solution.item(), + boiler.thermal_flow.submodel.investment.size.solution.item(), 20, rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__Investment_size" does not have the right value', ) assert_allclose( - boiler.Q_th.submodel._investment.invested.solution.item(), + boiler.thermal_flow.submodel.investment.invested.solution.item(), 1, rtol=1e-5, atol=1e-10, @@ -223,9 +223,9 @@ def test_size_bounds(solver_fixture, time_steps_fixture): flow_system.add_elements( fx.linear_converters.Boiler( 'Boiler', - 0.5, - Q_fu=fx.Flow('Q_fu', bus='Gas'), - Q_th=fx.Flow( + thermal_efficiency=0.5, + fuel_flow=fx.Flow('Q_fu', bus='Gas'), + thermal_flow=fx.Flow( 'Q_th', bus='Fernwärme', size=fx.InvestParameters(minimum_size=40, effects_of_investment=10, effects_of_investment_per_size=1), @@ -244,14 +244,14 @@ def test_size_bounds(solver_fixture, time_steps_fixture): err_msg='The total costs does not have the right value', ) assert_allclose( - boiler.Q_th.submodel._investment.size.solution.item(), + boiler.thermal_flow.submodel.investment.size.solution.item(), 40, rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__Investment_size" does not have the right value', ) assert_allclose( - boiler.Q_th.submodel._investment.invested.solution.item(), + boiler.thermal_flow.submodel.investment.invested.solution.item(), 1, rtol=1e-5, atol=1e-10, @@ -264,9 +264,9 @@ def test_optional_invest(solver_fixture, time_steps_fixture): flow_system.add_elements( fx.linear_converters.Boiler( 'Boiler', - 0.5, - Q_fu=fx.Flow('Q_fu', bus='Gas'), - Q_th=fx.Flow( + thermal_efficiency=0.5, + fuel_flow=fx.Flow('Q_fu', bus='Gas'), + thermal_flow=fx.Flow( 'Q_th', bus='Fernwärme', size=fx.InvestParameters( @@ -276,9 +276,9 @@ def test_optional_invest(solver_fixture, time_steps_fixture): ), fx.linear_converters.Boiler( 'Boiler_optional', - 0.5, - Q_fu=fx.Flow('Q_fu', bus='Gas'), - Q_th=fx.Flow( + thermal_efficiency=0.5, + fuel_flow=fx.Flow('Q_fu', bus='Gas'), + thermal_flow=fx.Flow( 'Q_th', bus='Fernwärme', size=fx.InvestParameters( @@ -300,14 +300,14 @@ def test_optional_invest(solver_fixture, time_steps_fixture): err_msg='The total costs does not have the right value', ) assert_allclose( - boiler.Q_th.submodel._investment.size.solution.item(), + boiler.thermal_flow.submodel.investment.size.solution.item(), 40, rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__Investment_size" does not have the right value', ) assert_allclose( - boiler.Q_th.submodel._investment.invested.solution.item(), + boiler.thermal_flow.submodel.investment.invested.solution.item(), 1, rtol=1e-5, atol=1e-10, @@ -315,14 +315,14 @@ def test_optional_invest(solver_fixture, time_steps_fixture): ) assert_allclose( - boiler_optional.Q_th.submodel._investment.size.solution.item(), + boiler_optional.thermal_flow.submodel.investment.size.solution.item(), 0, rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__Investment_size" does not have the right value', ) assert_allclose( - boiler_optional.Q_th.submodel._investment.invested.solution.item(), + boiler_optional.thermal_flow.submodel.investment.invested.solution.item(), 0, rtol=1e-5, atol=1e-10, @@ -336,9 +336,9 @@ def test_on(solver_fixture, time_steps_fixture): flow_system.add_elements( fx.linear_converters.Boiler( 'Boiler', - 0.5, - Q_fu=fx.Flow('Q_fu', bus='Gas'), - Q_th=fx.Flow('Q_th', bus='Fernwärme', size=100, on_off_parameters=fx.OnOffParameters()), + thermal_efficiency=0.5, + fuel_flow=fx.Flow('Q_fu', bus='Gas'), + thermal_flow=fx.Flow('Q_th', bus='Fernwärme', size=100, on_off_parameters=fx.OnOffParameters()), ) ) @@ -354,14 +354,14 @@ def test_on(solver_fixture, time_steps_fixture): ) assert_allclose( - boiler.Q_th.submodel.on_off.on.solution.values, + boiler.thermal_flow.submodel.on_off.on.solution.values, [0, 1, 1, 0, 1], rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__on" does not have the right value', ) assert_allclose( - boiler.Q_th.submodel.flow_rate.solution.values, + boiler.thermal_flow.submodel.flow_rate.solution.values, [0, 10, 20, 0, 10], rtol=1e-5, atol=1e-10, @@ -375,9 +375,9 @@ def test_off(solver_fixture, time_steps_fixture): flow_system.add_elements( fx.linear_converters.Boiler( 'Boiler', - 0.5, - Q_fu=fx.Flow('Q_fu', bus='Gas'), - Q_th=fx.Flow( + thermal_efficiency=0.5, + fuel_flow=fx.Flow('Q_fu', bus='Gas'), + thermal_flow=fx.Flow( 'Q_th', bus='Fernwärme', size=100, @@ -398,21 +398,21 @@ def test_off(solver_fixture, time_steps_fixture): ) assert_allclose( - boiler.Q_th.submodel.on_off.on.solution.values, + boiler.thermal_flow.submodel.on_off.on.solution.values, [0, 1, 1, 0, 1], rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__on" does not have the right value', ) assert_allclose( - boiler.Q_th.submodel.on_off.off.solution.values, - 1 - boiler.Q_th.submodel.on_off.on.solution.values, + boiler.thermal_flow.submodel.on_off.off.solution.values, + 1 - boiler.thermal_flow.submodel.on_off.on.solution.values, rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__off" does not have the right value', ) assert_allclose( - boiler.Q_th.submodel.flow_rate.solution.values, + boiler.thermal_flow.submodel.flow_rate.solution.values, [0, 10, 20, 0, 10], rtol=1e-5, atol=1e-10, @@ -426,9 +426,9 @@ def test_switch_on_off(solver_fixture, time_steps_fixture): flow_system.add_elements( fx.linear_converters.Boiler( 'Boiler', - 0.5, - Q_fu=fx.Flow('Q_fu', bus='Gas'), - Q_th=fx.Flow( + thermal_efficiency=0.5, + fuel_flow=fx.Flow('Q_fu', bus='Gas'), + thermal_flow=fx.Flow( 'Q_th', bus='Fernwärme', size=100, @@ -449,28 +449,28 @@ def test_switch_on_off(solver_fixture, time_steps_fixture): ) assert_allclose( - boiler.Q_th.submodel.on_off.on.solution.values, + boiler.thermal_flow.submodel.on_off.on.solution.values, [0, 1, 1, 0, 1], rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__on" does not have the right value', ) assert_allclose( - boiler.Q_th.submodel.on_off.switch_on.solution.values, + boiler.thermal_flow.submodel.on_off.switch_on.solution.values, [0, 1, 0, 0, 1], rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__switch_on" does not have the right value', ) assert_allclose( - boiler.Q_th.submodel.on_off.switch_off.solution.values, + boiler.thermal_flow.submodel.on_off.switch_off.solution.values, [0, 0, 0, 1, 0], rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__switch_on" does not have the right value', ) assert_allclose( - boiler.Q_th.submodel.flow_rate.solution.values, + boiler.thermal_flow.submodel.flow_rate.solution.values, [0, 10, 20, 0, 10], rtol=1e-5, atol=1e-10, @@ -484,9 +484,9 @@ def test_on_total_max(solver_fixture, time_steps_fixture): flow_system.add_elements( fx.linear_converters.Boiler( 'Boiler', - 0.5, - Q_fu=fx.Flow('Q_fu', bus='Gas'), - Q_th=fx.Flow( + thermal_efficiency=0.5, + fuel_flow=fx.Flow('Q_fu', bus='Gas'), + thermal_flow=fx.Flow( 'Q_th', bus='Fernwärme', size=100, @@ -495,9 +495,9 @@ def test_on_total_max(solver_fixture, time_steps_fixture): ), fx.linear_converters.Boiler( 'Boiler_backup', - 0.2, - Q_fu=fx.Flow('Q_fu', bus='Gas'), - Q_th=fx.Flow('Q_th', bus='Fernwärme', size=100), + thermal_efficiency=0.2, + fuel_flow=fx.Flow('Q_fu', bus='Gas'), + thermal_flow=fx.Flow('Q_th', bus='Fernwärme', size=100), ), ) @@ -513,14 +513,14 @@ def test_on_total_max(solver_fixture, time_steps_fixture): ) assert_allclose( - boiler.Q_th.submodel.on_off.on.solution.values, + boiler.thermal_flow.submodel.on_off.on.solution.values, [0, 0, 1, 0, 0], rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__on" does not have the right value', ) assert_allclose( - boiler.Q_th.submodel.flow_rate.solution.values, + boiler.thermal_flow.submodel.flow_rate.solution.values, [0, 0, 20, 0, 0], rtol=1e-5, atol=1e-10, @@ -534,9 +534,9 @@ def test_on_total_bounds(solver_fixture, time_steps_fixture): flow_system.add_elements( fx.linear_converters.Boiler( 'Boiler', - 0.5, - Q_fu=fx.Flow('Q_fu', bus='Gas'), - Q_th=fx.Flow( + thermal_efficiency=0.5, + fuel_flow=fx.Flow('Q_fu', bus='Gas'), + thermal_flow=fx.Flow( 'Q_th', bus='Fernwärme', size=100, @@ -545,9 +545,9 @@ def test_on_total_bounds(solver_fixture, time_steps_fixture): ), fx.linear_converters.Boiler( 'Boiler_backup', - 0.2, - Q_fu=fx.Flow('Q_fu', bus='Gas'), - Q_th=fx.Flow( + thermal_efficiency=0.2, + fuel_flow=fx.Flow('Q_fu', bus='Gas'), + thermal_flow=fx.Flow( 'Q_th', bus='Fernwärme', size=100, @@ -572,14 +572,14 @@ def test_on_total_bounds(solver_fixture, time_steps_fixture): ) assert_allclose( - boiler.Q_th.submodel.on_off.on.solution.values, + boiler.thermal_flow.submodel.on_off.on.solution.values, [0, 0, 1, 0, 1], rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__on" does not have the right value', ) assert_allclose( - boiler.Q_th.submodel.flow_rate.solution.values, + boiler.thermal_flow.submodel.flow_rate.solution.values, [0, 0, 20, 0, 12 - 1e-5], rtol=1e-5, atol=1e-10, @@ -587,14 +587,14 @@ def test_on_total_bounds(solver_fixture, time_steps_fixture): ) assert_allclose( - sum(boiler_backup.Q_th.submodel.on_off.on.solution.values), + sum(boiler_backup.thermal_flow.submodel.on_off.on.solution.values), 3, rtol=1e-5, atol=1e-10, err_msg='"Boiler_backup__Q_th__on" does not have the right value', ) assert_allclose( - boiler_backup.Q_th.submodel.flow_rate.solution.values, + boiler_backup.thermal_flow.submodel.flow_rate.solution.values, [0, 10, 1.0e-05, 0, 1.0e-05], rtol=1e-5, atol=1e-10, @@ -608,9 +608,9 @@ def test_consecutive_on_off(solver_fixture, time_steps_fixture): flow_system.add_elements( fx.linear_converters.Boiler( 'Boiler', - 0.5, - Q_fu=fx.Flow('Q_fu', bus='Gas'), - Q_th=fx.Flow( + thermal_efficiency=0.5, + fuel_flow=fx.Flow('Q_fu', bus='Gas'), + thermal_flow=fx.Flow( 'Q_th', bus='Fernwärme', size=100, @@ -619,9 +619,9 @@ def test_consecutive_on_off(solver_fixture, time_steps_fixture): ), fx.linear_converters.Boiler( 'Boiler_backup', - 0.2, - Q_fu=fx.Flow('Q_fu', bus='Gas'), - Q_th=fx.Flow('Q_th', bus='Fernwärme', size=100), + thermal_efficiency=0.2, + fuel_flow=fx.Flow('Q_fu', bus='Gas'), + thermal_flow=fx.Flow('Q_th', bus='Fernwärme', size=100), ), ) flow_system['Wärmelast'].inputs[0].fixed_relative_profile = np.array([5, 10, 20, 18, 12]) @@ -640,14 +640,14 @@ def test_consecutive_on_off(solver_fixture, time_steps_fixture): ) assert_allclose( - boiler.Q_th.submodel.on_off.on.solution.values, + boiler.thermal_flow.submodel.on_off.on.solution.values, [1, 1, 0, 1, 1], rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__on" does not have the right value', ) assert_allclose( - boiler.Q_th.submodel.flow_rate.solution.values, + boiler.thermal_flow.submodel.flow_rate.solution.values, [5, 10, 0, 18, 12], rtol=1e-5, atol=1e-10, @@ -655,7 +655,7 @@ def test_consecutive_on_off(solver_fixture, time_steps_fixture): ) assert_allclose( - boiler_backup.Q_th.submodel.flow_rate.solution.values, + boiler_backup.thermal_flow.submodel.flow_rate.solution.values, [0, 0, 20, 0, 0], rtol=1e-5, atol=1e-10, @@ -669,15 +669,15 @@ def test_consecutive_off(solver_fixture, time_steps_fixture): flow_system.add_elements( fx.linear_converters.Boiler( 'Boiler', - 0.5, - Q_fu=fx.Flow('Q_fu', bus='Gas'), - Q_th=fx.Flow('Q_th', bus='Fernwärme'), + thermal_efficiency=0.5, + fuel_flow=fx.Flow('Q_fu', bus='Gas'), + thermal_flow=fx.Flow('Q_th', bus='Fernwärme'), ), fx.linear_converters.Boiler( 'Boiler_backup', - 0.2, - Q_fu=fx.Flow('Q_fu', bus='Gas'), - Q_th=fx.Flow( + thermal_efficiency=0.2, + fuel_flow=fx.Flow('Q_fu', bus='Gas'), + thermal_flow=fx.Flow( 'Q_th', bus='Fernwärme', size=100, @@ -703,21 +703,21 @@ def test_consecutive_off(solver_fixture, time_steps_fixture): ) assert_allclose( - boiler_backup.Q_th.submodel.on_off.on.solution.values, + boiler_backup.thermal_flow.submodel.on_off.on.solution.values, [0, 0, 1, 0, 0], rtol=1e-5, atol=1e-10, err_msg='"Boiler_backup__Q_th__on" does not have the right value', ) assert_allclose( - boiler_backup.Q_th.submodel.on_off.off.solution.values, + boiler_backup.thermal_flow.submodel.on_off.off.solution.values, [1, 1, 0, 1, 1], rtol=1e-5, atol=1e-10, err_msg='"Boiler_backup__Q_th__off" does not have the right value', ) assert_allclose( - boiler_backup.Q_th.submodel.flow_rate.solution.values, + boiler_backup.thermal_flow.submodel.flow_rate.solution.values, [0, 0, 1e-5, 0, 0], rtol=1e-5, atol=1e-10, @@ -725,7 +725,7 @@ def test_consecutive_off(solver_fixture, time_steps_fixture): ) assert_allclose( - boiler.Q_th.submodel.flow_rate.solution.values, + boiler.thermal_flow.submodel.flow_rate.solution.values, [5, 0, 20 - 1e-5, 18, 12], rtol=1e-5, atol=1e-10, diff --git a/tests/test_integration.py b/tests/test_integration.py index 6e5da63d6..88e4a21af 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -37,14 +37,14 @@ def test_model_components(self, simple_flow_system, highs_solver): # Boiler assertions assert_almost_equal_numeric( - comps['Boiler'].Q_th.submodel.flow_rate.solution.values, + comps['Boiler'].thermal_flow.submodel.flow_rate.solution.values, [0, 0, 0, 28.4864, 35, 0, 0, 0, 0], 'Q_th doesnt match expected value', ) # CHP unit assertions assert_almost_equal_numeric( - comps['CHP_unit'].Q_th.submodel.flow_rate.solution.values, + comps['CHP_unit'].thermal_flow.submodel.flow_rate.solution.values, [30.0, 26.66666667, 75.0, 75.0, 75.0, 20.0, 20.0, 20.0, 20.0], 'Q_th doesnt match expected value', ) @@ -220,7 +220,7 @@ def test_piecewise_conversion(self, flow_system_piecewise_conversion, highs_solv effects['CO2'].submodel.total.solution.item(), 1278.7939026086956, 'CO2 doesnt match expected value' ) assert_almost_equal_numeric( - comps['Kessel'].Q_th.submodel.flow_rate.solution.values, + comps['Kessel'].thermal_flow.submodel.flow_rate.solution.values, [0, 0, 0, 45, 0, 0, 0, 0, 0], 'Kessel doesnt match expected value', ) diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index 88cad32ed..025c4e735 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -85,7 +85,7 @@ def test_system(): ), eta_charge=0.95, eta_discharge=0.95, - initial_charge_state='lastValueOfSim', + initial_charge_state='equals_final', ) # Create effects and objective @@ -139,9 +139,9 @@ def flow_system_complex_scenarios() -> fx.FlowSystem: boiler = fx.linear_converters.Boiler( 'Kessel', - eta=0.5, + thermal_efficiency=0.5, on_off_parameters=fx.OnOffParameters(effects_per_running_hour={'costs': 0, 'CO2': 1000}), - Q_th=fx.Flow( + thermal_flow=fx.Flow( 'Q_th', bus='Fernwärme', load_factor_max=1.0, @@ -166,7 +166,7 @@ def flow_system_complex_scenarios() -> fx.FlowSystem: ), flow_hours_max=1e6, ), - Q_fu=fx.Flow('Q_fu', bus='Gas', size=200, relative_minimum=0, relative_maximum=1), + fuel_flow=fx.Flow('Q_fu', bus='Gas', size=200, relative_minimum=0, relative_maximum=1), ) invest_speicher = fx.InvestParameters( diff --git a/tests/test_storage.py b/tests/test_storage.py index 8d0c495c2..6220ee08a 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -362,7 +362,7 @@ def test_storage_cyclic_initialization(self, basic_flow_system_linopy_coords, co charging=fx.Flow('Q_th_in', bus='Fernwärme', size=20), discharging=fx.Flow('Q_th_out', bus='Fernwärme', size=20), capacity_in_flow_hours=30, - initial_charge_state='lastValueOfSim', # Cyclic initialization + initial_charge_state='equals_final', # Cyclic initialization eta_charge=0.9, eta_discharge=0.9, relative_loss_per_hour=0.05, From 8d048d9c7f2620968c727e631d8ef7c176e39e81 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 18 Nov 2025 19:38:43 +0100 Subject: [PATCH 10/35] Added single-period validation in _create_periods_with_extra(): --- flixopt/flow_system.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index a1c213f63..9777f5a0f 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -308,6 +308,10 @@ def _create_periods_with_extra(periods: pd.Index, weight_of_last_period: int | f Period index with an extra period appended at the end """ if weight_of_last_period is None: + if len(periods) < 2: + raise ValueError( + 'FlowSystem: weight_of_last_period must be provided explicitly when only one period is defined.' + ) # Calculate weight from difference between last two periods weight_of_last_period = int(periods[-1]) - int(periods[-2]) From f6c160ae8a1a94156ea2e2d5a84ed4eea65dc915 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 18 Nov 2025 19:42:07 +0100 Subject: [PATCH 11/35] Added dimension validation for flow_hours constraints --- flixopt/elements.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/flixopt/elements.py b/flixopt/elements.py index 3118c79da..8dda020a0 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -691,6 +691,12 @@ def _do_modeling(self): # Weighted sum over all periods constraint if self.element.flow_hours_min_over_periods is not None or self.element.flow_hours_max_over_periods is not None: + # Validate that period dimension exists + if self._model.flow_system.periods is None: + raise ValueError( + f"{self.label_full}: flow_hours_*_over_periods requires FlowSystem to define 'periods', " + f'but FlowSystem has no period dimension. Please define periods in FlowSystem constructor.' + ) # Get period weights from FlowSystem weight_per_period = self._model.flow_system.weight_per_period if weight_per_period is not None: From 306623414661a4657d46ef5a710f12a1f2640a9c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 18 Nov 2025 19:42:25 +0100 Subject: [PATCH 12/35] Fixed the DataArray boolean context --- flixopt/structure.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flixopt/structure.py b/flixopt/structure.py index eef9f3685..2d82c457b 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -228,8 +228,8 @@ def weights(self) -> xr.DataArray: if not self.normalize_weights: return weights - total = weights.sum() - if np.isclose(total, 0): + total = float(weights.sum().item()) + if np.isclose(total, 0.0): raise ValueError('FlowSystemModel.weights: weights sum to 0; cannot normalize.') return weights / total From debef084f65e7197a950060dff2f82350cd2bdf0 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 18 Nov 2025 19:46:56 +0100 Subject: [PATCH 13/35] Remove intermediate parameters that need no deprecation --- flixopt/effects.py | 40 ---------------------------------------- 1 file changed, 40 deletions(-) diff --git a/flixopt/effects.py b/flixopt/effects.py index cece1973e..640771748 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -403,46 +403,6 @@ def maximum_total_per_period(self, value): ) self.maximum_total = value - @property - def minimum(self): - """DEPRECATED: Use 'minimum_over_periods' property instead.""" - warnings.warn( - "Property 'minimum' is deprecated. Use 'minimum_over_periods' instead.", - DeprecationWarning, - stacklevel=2, - ) - return self.minimum_over_periods - - @minimum.setter - def minimum(self, value): - """DEPRECATED: Use 'minimum_over_periods' property instead.""" - warnings.warn( - "Property 'minimum' is deprecated. Use 'minimum_over_periods' instead.", - DeprecationWarning, - stacklevel=2, - ) - self.minimum_over_periods = value - - @property - def maximum(self): - """DEPRECATED: Use 'maximum_over_periods' property instead.""" - warnings.warn( - "Property 'maximum' is deprecated. Use 'maximum_over_periods' instead.", - DeprecationWarning, - stacklevel=2, - ) - return self.maximum_over_periods - - @maximum.setter - def maximum(self, value): - """DEPRECATED: Use 'maximum_over_periods' property instead.""" - warnings.warn( - "Property 'maximum' is deprecated. Use 'maximum_over_periods' instead.", - DeprecationWarning, - stacklevel=2, - ) - self.maximum_over_periods = value - 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) From 97821b87941c9b591b8dbb8ebb179b2b200aecde Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 18 Nov 2025 19:59:49 +0100 Subject: [PATCH 14/35] Remove intermediate parameters that need no deprecation --- flixopt/effects.py | 7 +++---- flixopt/flow_system.py | 14 +++----------- flixopt/structure.py | 16 ---------------- 3 files changed, 6 insertions(+), 31 deletions(-) diff --git a/flixopt/effects.py b/flixopt/effects.py index 640771748..f8a8852c4 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -445,6 +445,7 @@ def transform_data(self, name_prefix: str = '') -> None: self.maximum_over_periods = self._fit_coords( f'{prefix}|maximum_over_periods', self.maximum_over_periods, dims=['scenario'] ) + self.weights = self._fit_coords(f'{prefix}|weights', self.weights, dims=['scenario', 'period']) def create_model(self, model: FlowSystemModel) -> EffectModel: self._plausibility_checks() @@ -476,12 +477,10 @@ def weights(self) -> int | xr.DataArray: """ if self.element.weights is not None: # Use effect-specific weights - return self._model.flow_system.fit_to_model_coords( - f'weights_{self.element.label}', self.element.weights, dims=['period', 'scenario'] - ) + return self.element.weights else: # Fall back to FlowSystem weights - return self._model.weights + return self.element._flow_system.weights def _do_modeling(self): """Create variables, constraints, and nested submodels""" diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 9777f5a0f..7f7b347bc 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -182,18 +182,10 @@ def __init__( self.periods, weight_of_last_period ) - # Auto-derive weights from period index if not provided - if weights is None and weight_per_period is not None: - # Use period weights, broadcast to period×scenario dimensions - self.weights = self.fit_to_model_coords('weights', weight_per_period, dims=['period', 'scenario']) - else: - self.weights = weights - self.hours_per_timestep = self.fit_to_model_coords('hours_per_timestep', hours_per_timestep) - self.weight_per_period = ( - self.fit_to_model_coords('weight_per_period', weight_per_period, dims=['period']) - if weight_per_period is not None - else None + + self.weights = self.fit_to_model_coords( + 'weights', weights if weights is not None else weight_per_period, dims=['period', 'scenario'] ) # Element collections diff --git a/flixopt/structure.py b/flixopt/structure.py index 2d82c457b..9e952832b 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -217,22 +217,6 @@ def get_coords( return xr.Coordinates(coords) if coords else None - @property - def weights(self) -> xr.DataArray: - """Returns the weights of the FlowSystem. Normalizes to 1 if normalize_weights is True""" - weights = self.flow_system.fit_to_model_coords( - name='weights', - data=self.flow_system.weights if self.flow_system.weights is not None else 1, - dims=['period', 'scenario'], - ) - if not self.normalize_weights: - return weights - - total = float(weights.sum().item()) - if np.isclose(total, 0.0): - raise ValueError('FlowSystemModel.weights: weights sum to 0; cannot normalize.') - return weights / total - def __repr__(self) -> str: """ Return a string representation of the FlowSystemModel, borrowed from linopy.Model. From fec2f756ec577ab95b17f356c71a3e211cadf4f0 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 18 Nov 2025 20:02:43 +0100 Subject: [PATCH 15/35] Update CHANGELOG.md --- CHANGELOG.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50c856cb0..6342d75f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,7 +60,10 @@ If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOp - `Effect`: Added `minimum_over_periods` and `maximum_over_periods` for weighted sum constraints across all periods (complements existing per-period `minimum_total`/`maximum_total`) - `Flow`: Added `flow_hours_max_over_periods` and `flow_hours_min_over_periods` for weighted sum constraints across all periods - **Important**: Constraints with the `_over_periods` suffix compute weighted sums across all periods using the weights specified in `FlowSystem.weights` or `Effect.weights`. Per-period constraints (without the suffix) apply separately to each individual period. + **Important**: + - Constraints with the `_over_periods` suffix compute weighted sums across all periods using the **raw** weights from period durations (or user-specified weights). These constraints always use unnormalized weights. + - The `normalize_weights` parameter (in `Calculation` or `FlowSystem.create_model()`) only affects the objective function, not the `_over_periods` constraints. + - Per-period constraints (without the suffix) apply separately to each individual period. **Example**: ```python @@ -69,8 +72,11 @@ If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOp effect = fx.Effect('costs', maximum_total=1000) # ≤1000 in 2020 AND ≤1000 in 2030 AND ≤1000 in 2040 # Over-periods constraint: limits apply to WEIGHTED SUM across ALL periods - # With periods=[2020, 2030, 2040] and weights=[0.5, 0.3, 0.2], this creates 1 constraint - effect = fx.Effect('costs', maximum_over_periods=1000) # 0.5×costs₂₀₂₀ + 0.3×costs₂₀₃₀ + 0.2×costs₂₀₄₀ ≤ 1000 + # With periods=[2020, 2030, 2040] (auto-derived weights: [10, 10, 10] from 10-year intervals) + effect = fx.Effect('costs', maximum_over_periods=1000) # 10×costs₂₀₂₀ + 10×costs₂₀₃₀ + 10×costs₂₀₄₀ ≤ 1000 + + # Note: If normalize_weights=True, the objective uses normalized weights [0.33, 0.33, 0.33], + # but the constraint above still uses raw weights [10, 10, 10] ``` - **Auto-modeling**: `Calculation.solve()` now automatically calls `do_modeling()` if not already done, making the explicit `do_modeling()` call optional for simpler workflows From e0773308ccd0298e91ae5a8003cb3c2bac2e4064 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 18 Nov 2025 22:26:11 +0100 Subject: [PATCH 16/35] Fix weighting per period in Flows --- CHANGELOG.md | 6 +++--- flixopt/elements.py | 8 +------- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6342d75f8..8922944ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,7 +51,7 @@ If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOp ## [Unreleased] - ????-??-?? -**Summary**: Renaming parameters in Linear Transformers for readability (old parameters still work but emmit warnings) & new bounds for weighted sums over all periods for effects and flow hours. +**Summary**: Renaming parameters in Linear Transformers for readability (old parameters still work but emit warnings) & new bounds for weighted sums over all periods for effects and flow hours. If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOpt/flixOpt/releases/tag/v3.0.0) and [Migration Guide](https://flixopt.github.io/flixopt/latest/user-guide/migration-guide-v3/). @@ -102,8 +102,8 @@ If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOp - `Boiler`: `eta` → `thermal_efficiency` - `Power2Heat`: `eta` → `thermal_efficiency` - `CHP`: `eta_th` → `thermal_efficiency`, `eta_el` → `electrical_efficiency` - - `HetaPump`: `COP` → `cop` - - `HetaPumpWithSource`: `COP` → `cop` + - `HeatPump`: `COP` → `cop` + - `HeatPumpWithSource`: `COP` → `cop` - **Storage Parameters**: - `Storage`: `initial_charge_state="lastValueOfSim"` → `initial_charge_state="equals_last"` diff --git a/flixopt/elements.py b/flixopt/elements.py index 8dda020a0..f2651f55f 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -698,13 +698,7 @@ def _do_modeling(self): f'but FlowSystem has no period dimension. Please define periods in FlowSystem constructor.' ) # Get period weights from FlowSystem - weight_per_period = self._model.flow_system.weight_per_period - if weight_per_period is not None: - # Calculate weighted sum over all periods - weighted_flow_hours_over_periods = (self.total_flow_hours * weight_per_period).sum('period') - else: - # No period weights defined, use unweighted sum - weighted_flow_hours_over_periods = self.total_flow_hours.sum('period') + weighted_flow_hours_over_periods = (self.total_flow_hours * self._model.flow_system.weights).sum('period') # Create tracking variable for the weighted sum ModelingPrimitives.expression_tracking_variable( From 8e7ac7731ffbb75a6a07f65eb05879393fbda402 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 18 Nov 2025 22:47:06 +0100 Subject: [PATCH 17/35] Add type hints and fallback for weights = None --- flixopt/effects.py | 2 ++ flixopt/flow_system.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/flixopt/effects.py b/flixopt/effects.py index f8a8852c4..1f2f3dcb0 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -784,6 +784,8 @@ def _do_modeling(self): # Use effect-specific weights if defined, otherwise use FlowSystem weights objective_weights = self.effects.objective_effect.submodel.weights + if objective_weights is None: + objective_weights = xr.DataArray(1) if self._model.normalize_weights: objective_weights = objective_weights / objective_weights.sum() self._model.add_objective( diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 7f7b347bc..50fa37efa 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -184,7 +184,7 @@ def __init__( self.hours_per_timestep = self.fit_to_model_coords('hours_per_timestep', hours_per_timestep) - self.weights = self.fit_to_model_coords( + self.weights: xr.DataArray | None = self.fit_to_model_coords( 'weights', weights if weights is not None else weight_per_period, dims=['period', 'scenario'] ) From b16c7e3c196a9fd1495eee6ef7faf69a3aac7482 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 18 Nov 2025 23:37:41 +0100 Subject: [PATCH 18/35] Imrove weight computation in Effects --- flixopt/effects.py | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/flixopt/effects.py b/flixopt/effects.py index 1f2f3dcb0..3abd18357 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -464,7 +464,7 @@ def __init__(self, model: FlowSystemModel, element: Effect): super().__init__(model, element) @property - def weights(self) -> int | xr.DataArray: + def weights(self) -> xr.DataArray: """ Get weights for this effect. @@ -475,12 +475,13 @@ def weights(self) -> int | xr.DataArray: Returns: Weights for period and scenario dimensions """ - if self.element.weights is not None: - # Use effect-specific weights + effect_weights = self.element.weights + default_weights = self.element._flow_system.weights + if effect_weights is not None: # Use effect-specific weights return self.element.weights - else: - # Fall back to FlowSystem weights - return self.element._flow_system.weights + elif default_weights is not None: # Fall back to FlowSystem weights + return default_weights + return xr.DataArray(1) def _do_modeling(self): """Create variables, constraints, and nested submodels""" @@ -782,12 +783,14 @@ def _do_modeling(self): # Add cross-effect shares self._add_share_between_effects() - # Use effect-specific weights if defined, otherwise use FlowSystem weights - objective_weights = self.effects.objective_effect.submodel.weights - if objective_weights is None: - objective_weights = xr.DataArray(1) + # Use effect-specific weights if defined, otherwise use model weights + # (which handles normalization based on normalize_weights flag) + objective_effect = self.effects.objective_effect.submodel if self._model.normalize_weights: - objective_weights = objective_weights / objective_weights.sum() + objective_weights = objective_effect.weights / objective_effect.weights.sum() + else: + objective_weights = objective_effect.weights + self._model.add_objective( (self.effects.objective_effect.submodel.total * objective_weights).sum() + self.penalty.total.sum() ) From 915b6f1e7f0b70870a19e851ae9e0a80e27a9e85 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 18 Nov 2025 23:59:56 +0100 Subject: [PATCH 19/35] Improve objectove weight handling --- flixopt/effects.py | 12 +++--------- flixopt/structure.py | 7 +++++++ 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/flixopt/effects.py b/flixopt/effects.py index 3abd18357..767545d1c 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -783,16 +783,10 @@ def _do_modeling(self): # Add cross-effect shares self._add_share_between_effects() - # Use effect-specific weights if defined, otherwise use model weights - # (which handles normalization based on normalize_weights flag) - objective_effect = self.effects.objective_effect.submodel - if self._model.normalize_weights: - objective_weights = objective_effect.weights / objective_effect.weights.sum() - else: - objective_weights = objective_effect.weights - + # Use objective weights with objective effect self._model.add_objective( - (self.effects.objective_effect.submodel.total * objective_weights).sum() + self.penalty.total.sum() + (self.effects.objective_effect.submodel.total * self._model.objective_weights).sum() + + self.penalty.total.sum() ) def _add_share_between_effects(self): diff --git a/flixopt/structure.py b/flixopt/structure.py index 9e952832b..200820bc7 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -186,6 +186,13 @@ def hours_per_step(self): def hours_of_previous_timesteps(self): return self.flow_system.hours_of_previous_timesteps + @property + def objective_weights(self) -> xr.DataArray: + weights = self.flow_system.effects.objective_effect.submodel.weights + if self.normalize_weights: + return weights / weights.sum() + return weights + def get_coords( self, dims: Collection[str] | None = None, From 8e475514c731291c75cac5a9d2320dd16fc9de2c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 19 Nov 2025 00:04:28 +0100 Subject: [PATCH 20/35] Add tests --- tests/test_scenarios.py | 87 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 83 insertions(+), 4 deletions(-) diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index 025c4e735..a6e031f58 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -244,11 +244,11 @@ def test_weights(flow_system_piecewise_conversion_scenarios): normalized_weights = ( flow_system_piecewise_conversion_scenarios.weights / flow_system_piecewise_conversion_scenarios.weights.sum() ) - np.testing.assert_allclose(model.weights.values, normalized_weights) + np.testing.assert_allclose(model.objective_weights.values, normalized_weights) assert_linequal( model.objective.expression, (model.variables['costs'] * normalized_weights).sum() + model.variables['Penalty'] ) - assert np.isclose(model.weights.sum().item(), 1) + assert np.isclose(model.objective_weights.sum().item(), 1) def test_weights_io(flow_system_piecewise_conversion_scenarios): @@ -257,9 +257,9 @@ def test_weights_io(flow_system_piecewise_conversion_scenarios): weights = np.linspace(0.5, 1, len(scenarios)) / np.sum(np.linspace(0.5, 1, len(scenarios))) flow_system_piecewise_conversion_scenarios.weights = weights model = create_linopy_model(flow_system_piecewise_conversion_scenarios) - np.testing.assert_allclose(model.weights.values, weights) + np.testing.assert_allclose(model.objective_weights.values, weights) assert_linequal(model.objective.expression, (model.variables['costs'] * weights).sum() + model.variables['Penalty']) - assert np.isclose(model.weights.sum().item(), 1.0) + assert np.isclose(model.objective_weights.sum().item(), 1.0) def test_scenario_dimensions_in_variables(flow_system_piecewise_conversion_scenarios): @@ -690,3 +690,82 @@ def test_scenario_parameters_io_with_calculation(): finally: # Clean up shutil.rmtree(temp_dir) + + +def test_weights_io_persistence(): + """Test that weights persist through IO operations (to_dataset/from_dataset).""" + timesteps = pd.date_range('2023-01-01', periods=24, freq='h') + scenarios = pd.Index(['base', 'mid', 'high'], name='scenario') + custom_weights = np.array([0.3, 0.5, 0.2]) + + # Create FlowSystem with custom weights + fs_original = fx.FlowSystem( + timesteps=timesteps, + scenarios=scenarios, + weights=custom_weights, + ) + + bus = fx.Bus('grid') + source = fx.Source( + label='solar', + outputs=[ + fx.Flow( + label='out', + bus='grid', + size=fx.InvestParameters( + minimum_size=10, maximum_size=100, effects_of_investment_per_size={'cost': 100} + ), + ) + ], + ) + + fs_original.add_elements(bus, source, fx.Effect('cost', 'Total cost', '€', is_objective=True)) + + # Save to dataset + fs_original.connect_and_transform() + ds = fs_original.to_dataset() + + # Load from dataset + fs_loaded = fx.FlowSystem.from_dataset(ds) + + # Verify weights persisted correctly + np.testing.assert_allclose(fs_loaded.weights.values, fs_original.weights.values) + assert fs_loaded.weights.dims == fs_original.weights.dims + + +def test_weights_selection(): + """Test that weights are correctly sliced when using FlowSystem.sel().""" + timesteps = pd.date_range('2023-01-01', periods=24, freq='h') + scenarios = pd.Index(['base', 'mid', 'high'], name='scenario') + custom_weights = np.array([0.3, 0.5, 0.2]) + + # Create FlowSystem with custom weights + fs_full = fx.FlowSystem( + timesteps=timesteps, + scenarios=scenarios, + weights=custom_weights, + ) + + bus = fx.Bus('grid') + source = fx.Source( + label='solar', + outputs=[ + fx.Flow( + label='out', + bus='grid', + size=10, + ) + ], + ) + + fs_full.add_elements(bus, source, fx.Effect('cost', 'Total cost', '€', is_objective=True)) + + # Select a subset of scenarios + fs_subset = fs_full.sel(scenario=['base', 'high']) + + # Verify weights are correctly sliced + assert fs_subset.scenarios.equals(pd.Index(['base', 'high'], name='scenario')) + np.testing.assert_allclose(fs_subset.weights.values, custom_weights[[0, 2]]) + + # Verify weights are 1D with just scenario dimension (no period dimension) + assert fs_subset.weights.dims == ('scenario',) From 3177ca21dc61f001c182b7bbb87db76d41e8e633 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 19 Nov 2025 00:17:17 +0100 Subject: [PATCH 21/35] Typos --- tests/test_scenarios.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index a6e031f58..a64388982 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -27,7 +27,7 @@ def test_system(): flow_system = FlowSystem( timesteps=timesteps, scenarios=scenarios, - weights=weights, # Use TimeSeriesData for weights + weights=weights, ) # Create demand profiles that differ between scenarios @@ -291,7 +291,7 @@ def test_full_scenario_optimization(flow_system_piecewise_conversion_scenarios): @pytest.mark.skip(reason='This test is taking too long with highs and is too big for gurobipy free') -def test_io_persistance(flow_system_piecewise_conversion_scenarios): +def test_io_persistence(flow_system_piecewise_conversion_scenarios): """Test a full optimization with scenarios and verify results.""" scenarios = flow_system_piecewise_conversion_scenarios.scenarios weights = np.linspace(0.5, 1, len(scenarios)) / np.sum(np.linspace(0.5, 1, len(scenarios))) From f306ff2351faa35f68a7997d1ab30b8971c1b715 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 19 Nov 2025 00:21:40 +0100 Subject: [PATCH 22/35] Add zero-sum guard to objective_weights property to prevent silent NaN/inf corruption --- flixopt/structure.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/flixopt/structure.py b/flixopt/structure.py index 200820bc7..f81560fc8 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -189,9 +189,13 @@ def hours_of_previous_timesteps(self): @property def objective_weights(self) -> xr.DataArray: weights = self.flow_system.effects.objective_effect.submodel.weights - if self.normalize_weights: - return weights / weights.sum() - return weights + if not self.normalize_weights: + return weights + + total = float(weights.sum().values) + if np.isclose(total, 0.0): + raise ValueError('FlowSystemModel.objective_weights: weights sum to 0; cannot normalize.') + return weights / total def get_coords( self, From e0c29e232066d37960144058c5e16190f31c35ba Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 19 Nov 2025 00:23:00 +0100 Subject: [PATCH 23/35] =?UTF-8?q?Guard=20over=E2=80=91periods=20constraint?= =?UTF-8?q?=20when=20no=20period=20dimension=20is=20present.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- flixopt/effects.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/flixopt/effects.py b/flixopt/effects.py index 767545d1c..bd87ac320 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -16,6 +16,7 @@ import xarray as xr from loguru import logger +from .core import PlausibilityError from .features import ShareAllocationModel from .structure import Element, ElementContainer, ElementModel, FlowSystemModel, Submodel, register_class_for_io @@ -66,8 +67,10 @@ class Effect(Element): maximum_total: Maximum allowed total effect (temporal + periodic combined) per period. minimum_over_periods: Minimum allowed weighted sum of total effect 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. 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). meta_data: Used to store additional information. Not used internally but saved in results. Only use Python native types. @@ -453,8 +456,15 @@ def create_model(self, model: FlowSystemModel) -> EffectModel: return self.submodel def _plausibility_checks(self) -> None: - # TODO: Check for plausibility - pass + # Check that minimum_over_periods and maximum_over_periods require a period dimension + if ( + self.minimum_over_periods is not None or self.maximum_over_periods is not None + ) and self.flow_system.periods is None: + raise PlausibilityError( + f"Effect '{self.label}': minimum_over_periods and maximum_over_periods require " + f"the FlowSystem to have a 'period' dimension. Please define periods when creating " + f'the FlowSystem, or remove these constraints.' + ) class EffectModel(ElementModel): From 705503d0f1406d3dff084a2a4c88b7e3cfde55bf Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 19 Nov 2025 00:26:18 +0100 Subject: [PATCH 24/35] Fit fallback weight to dims --- flixopt/effects.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixopt/effects.py b/flixopt/effects.py index bd87ac320..d3a291ba4 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -491,7 +491,7 @@ def weights(self) -> xr.DataArray: return self.element.weights elif default_weights is not None: # Fall back to FlowSystem weights return default_weights - return xr.DataArray(1) + return self.element._fit_coords(name='weights', data=1, dims=['period', 'scenario']) def _do_modeling(self): """Create variables, constraints, and nested submodels""" From f818b23f8d8fb3cc1cb21097369c4470d1b8c816 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 19 Nov 2025 09:50:01 +0100 Subject: [PATCH 25/35] Improve weight handling. --- flixopt/effects.py | 26 ++++++------ flixopt/elements.py | 4 +- flixopt/flow_system.py | 90 ++++++++++++++++++++++++++++++++++++------ flixopt/structure.py | 34 ++++++++++++---- 4 files changed, 121 insertions(+), 33 deletions(-) diff --git a/flixopt/effects.py b/flixopt/effects.py index d3a291ba4..58d8e2dc4 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -189,7 +189,7 @@ def __init__( meta_data: dict | None = None, is_standard: bool = False, is_objective: bool = False, - weights: Numeric_PS | None = None, + period_weights: Numeric_PS | None = None, share_from_temporal: Effect_TPS | Numeric_TPS | None = None, share_from_periodic: Effect_PS | Numeric_PS | None = None, minimum_temporal: Numeric_PS | None = None, @@ -209,7 +209,7 @@ def __init__( self.description = description self.is_standard = is_standard self.is_objective = is_objective - self.weights = weights + self.period_weights = period_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). @@ -448,7 +448,9 @@ def transform_data(self, name_prefix: str = '') -> None: self.maximum_over_periods = self._fit_coords( f'{prefix}|maximum_over_periods', self.maximum_over_periods, dims=['scenario'] ) - self.weights = self._fit_coords(f'{prefix}|weights', self.weights, dims=['scenario', 'period']) + self.period_weights = self._fit_coords( + f'{prefix}|period_weights', self.period_weights, dims=['period', 'scenario'] + ) def create_model(self, model: FlowSystemModel) -> EffectModel: self._plausibility_checks() @@ -474,24 +476,24 @@ def __init__(self, model: FlowSystemModel, element: Effect): super().__init__(model, element) @property - def weights(self) -> xr.DataArray: + def period_weights(self) -> xr.DataArray: """ - Get weights for this effect. + Get period weights for this effect. - Returns effect-specific weights if defined, otherwise falls back to FlowSystem weights. - This allows different effects to have different weighting schemes (e.g., discounting for costs, + Returns effect-specific weights if defined, otherwise falls back to FlowSystem period weights. + This allows different effects to have different weighting schemes over periods (e.g., discounting for costs, equal weights for CO2 emissions). Returns: - Weights for period and scenario dimensions + Weights with period dimensions (if applicable) """ - effect_weights = self.element.weights - default_weights = self.element._flow_system.weights + effect_weights = self.element.period_weights + default_weights = self.element._flow_system.period_weights if effect_weights is not None: # Use effect-specific weights - return self.element.weights + return effect_weights elif default_weights is not None: # Fall back to FlowSystem weights return default_weights - return self.element._fit_coords(name='weights', data=1, dims=['period', 'scenario']) + return self.element._fit_coords(name='period_weights', data=1, dims=['period']) def _do_modeling(self): """Create variables, constraints, and nested submodels""" diff --git a/flixopt/elements.py b/flixopt/elements.py index f2651f55f..29387919e 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -698,7 +698,9 @@ def _do_modeling(self): f'but FlowSystem has no period dimension. Please define periods in FlowSystem constructor.' ) # Get period weights from FlowSystem - weighted_flow_hours_over_periods = (self.total_flow_hours * self._model.flow_system.weights).sum('period') + weighted_flow_hours_over_periods = (self.total_flow_hours * self._model.flow_system.period_weights).sum( + 'period' + ) # Create tracking variable for the weighted sum ModelingPrimitives.expression_tracking_variable( diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 50fa37efa..519611361 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -32,7 +32,7 @@ import pyvis - from .types import Bool_TPS, Effect_TPS, Numeric_PS, Numeric_TPS, NumericOrBool + from .types import Bool_TPS, Effect_TPS, Numeric_PS, Numeric_S, Numeric_TPS, NumericOrBool class FlowSystem(Interface, CompositeContainerMixin[Element]): @@ -53,8 +53,8 @@ class FlowSystem(Interface, CompositeContainerMixin[Element]): Used to calculate previous values (e.g., consecutive_on_hours). weight_of_last_period: Weight/duration of the last period. If None, computed from the last period interval. Used for calculating sums over periods in multi-period models. - weights: The weights of each period and scenario. If None, all scenarios have the same weight (normalized to 1). - Its recommended to normalize the weights to sum up to 1. + scenario_weights: The weights of each scenario. If None, all scenarios have the same weight (normalized to 1). + Period weights are always computed internally from the period index (like hours_per_timestep for time). scenario_independent_sizes: Controls whether investment sizes are equalized across scenarios. - True: All sizes are shared/equalized across scenarios - False: All sizes are optimized separately per scenario @@ -160,7 +160,7 @@ def __init__( hours_of_last_timestep: int | float | None = None, hours_of_previous_timesteps: int | float | np.ndarray | None = None, weight_of_last_period: int | float | None = None, - weights: Numeric_PS | None = None, + scenario_weights: Numeric_S | None = None, scenario_independent_sizes: bool | list[str] = True, scenario_independent_flow_rates: bool | list[str] = False, ): @@ -177,16 +177,18 @@ 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) + self.hours_per_timestep = self.fit_to_model_coords('hours_per_timestep', hours_per_timestep) + + self.scenario_weights: xr.DataArray = self.fit_to_model_coords( + 'scenario_weights', scenario_weights, dims=['scenario'] + ) + # Compute all period-related metadata using shared helper (self.periods_extra, self.weight_of_last_period, weight_per_period) = self._compute_period_metadata( self.periods, weight_of_last_period ) - self.hours_per_timestep = self.fit_to_model_coords('hours_per_timestep', hours_per_timestep) - - self.weights: xr.DataArray | None = self.fit_to_model_coords( - 'weights', weights if weights is not None else weight_per_period, dims=['period', 'scenario'] - ) + self.period_weights: xr.DataArray | None = weight_per_period # Element collections self.components: ElementContainer[Component] = ElementContainer( @@ -439,6 +441,60 @@ def _update_time_metadata( return dataset + @classmethod + def _update_period_metadata( + cls, + dataset: xr.Dataset, + weight_of_last_period: int | float | None = None, + ) -> xr.Dataset: + """ + Update period-related attributes and data variables in dataset based on its period index. + + Recomputes weight_of_last_period, period_weights, and weights from the dataset's + period index when weight_of_last_period is None. This ensures period metadata stays + synchronized with the actual periods after operations like selection. + + This is analogous to _update_time_metadata() for time-related metadata. + + Args: + dataset: Dataset to update (will be modified in place) + weight_of_last_period: Weight of the last period. If None, computed from the period index. + + Returns: + The same dataset with updated period-related attributes and data variables + """ + new_period_index = dataset.indexes.get('period') + if new_period_index is not None and len(new_period_index) >= 1: + # Use shared helper to compute all period metadata + _, weight_of_last_period, period_weights = cls._compute_period_metadata( + new_period_index, weight_of_last_period + ) + + # Update period_weights DataArray if it exists in the dataset + if 'period_weights' in dataset.data_vars: + dataset['period_weights'] = period_weights + + # Recompute weights from period_weights and scenario_weights + if 'weights' in dataset.data_vars: + # Get scenario_weights from dataset (if it exists) + scenario_weights = dataset.data_vars.get('scenario_weights') + + # Compute new weights + if scenario_weights is None: + # No scenario dimension or all scenarios have equal weight + new_weights = period_weights + else: + # Multiply period_weights × scenario_weights + new_weights = period_weights * scenario_weights + + dataset['weights'] = new_weights + + # Update period-related attributes only when new values are provided/computed + if weight_of_last_period is not None: + dataset.attrs['weight_of_last_period'] = weight_of_last_period + + return dataset + def _create_reference_structure(self) -> tuple[dict, dict[str, xr.DataArray]]: """ Override Interface method to handle FlowSystem-specific serialization. @@ -519,8 +575,8 @@ def from_dataset(cls, ds: xr.Dataset) -> FlowSystem: hours_of_last_timestep=reference_structure.get('hours_of_last_timestep'), hours_of_previous_timesteps=reference_structure.get('hours_of_previous_timesteps'), weight_of_last_period=reference_structure.get('weight_of_last_period'), - weights=cls._resolve_dataarray_reference(reference_structure['weights'], arrays_dict) - if 'weights' in reference_structure + scenario_weights=cls._resolve_dataarray_reference(reference_structure['scenario_weights'], arrays_dict) + if 'scenario_weights' in reference_structure else None, scenario_independent_sizes=reference_structure.get('scenario_independent_sizes', True), scenario_independent_flow_rates=reference_structure.get('scenario_independent_flow_rates', False), @@ -670,8 +726,6 @@ def connect_and_transform(self): logger.debug('FlowSystem already connected and transformed') return - self.weights = self.fit_to_model_coords('weights', self.weights, dims=['period', 'scenario']) - self._connect_network() for element in chain(self.components.values(), self.effects.values(), self.buses.values()): element.transform_data() @@ -1197,6 +1251,11 @@ def _dataset_sel( if 'time' in indexers: result = cls._update_time_metadata(result, hours_of_last_timestep, hours_of_previous_timesteps) + # Update period-related attributes if period was selected + # This recalculates period_weights and weights from the new period index + if 'period' in indexers: + result = cls._update_period_metadata(result) + return result def sel( @@ -1273,6 +1332,11 @@ def _dataset_isel( if 'time' in indexers: result = cls._update_time_metadata(result, hours_of_last_timestep, hours_of_previous_timesteps) + # Update period-related attributes if period was selected + # This recalculates period_weights and weights from the new period index + if 'period' in indexers: + result = cls._update_period_metadata(result) + return result def isel( diff --git a/flixopt/structure.py b/flixopt/structure.py index f81560fc8..9ee76c9f8 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -187,15 +187,35 @@ def hours_of_previous_timesteps(self): return self.flow_system.hours_of_previous_timesteps @property - def objective_weights(self) -> xr.DataArray: - weights = self.flow_system.effects.objective_effect.submodel.weights + def scenario_weights(self) -> xr.DataArray: + """ + Scenario weights of model. With optional normalization. + """ + if self.flow_system.scenarios is None: + return xr.DataArray(1) + + if self.flow_system.scenario_weights is None: + scenario_weights = xr.DataArray(1, coords=(self.flow_system.scenarios,), name='scenario_weights') + else: + scenario_weights = self.flow_system.scenario_weights + if not self.normalize_weights: - return weights + return scenario_weights + + norm = scenario_weights.sum('scenario') + if np.isclose(norm, 0.0).any(): + raise ValueError('FlowSystemModel.scenario_weights: weights sum to 0; cannot normalize.') + return scenario_weights / norm + + @property + def objective_weights(self) -> xr.DataArray: + """ + Objective weights of model. With optional normalization of scenario weights. + """ + period_weights = self.flow_system.effects.objective_effect.submodel.period_weights + scenario_weights = self.scenario_weights - total = float(weights.sum().values) - if np.isclose(total, 0.0): - raise ValueError('FlowSystemModel.objective_weights: weights sum to 0; cannot normalize.') - return weights / total + return period_weights * scenario_weights def get_coords( self, From 1b0c1ed11ea72a4ec6e4d41926787aa8996acda1 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 19 Nov 2025 09:50:22 +0100 Subject: [PATCH 26/35] Typos and updates --- examples/04_Scenarios/scenario_example.py | 4 ++- flixopt/plotting.py | 2 +- flixopt/results.py | 2 +- tests/conftest.py | 2 +- tests/test_scenarios.py | 35 +++++++++++++---------- 5 files changed, 26 insertions(+), 19 deletions(-) diff --git a/examples/04_Scenarios/scenario_example.py b/examples/04_Scenarios/scenario_example.py index ca50876c7..6bb920188 100644 --- a/examples/04_Scenarios/scenario_example.py +++ b/examples/04_Scenarios/scenario_example.py @@ -83,7 +83,9 @@ # Base Case: 60% probability, High Demand: 40% probability scenario_weights = np.array([0.6, 0.4]) - flow_system = fx.FlowSystem(timesteps=timesteps, periods=periods, scenarios=scenarios, weights=scenario_weights) + flow_system = fx.FlowSystem( + timesteps=timesteps, periods=periods, scenarios=scenarios, scenario_weights=scenario_weights + ) # --- Define Energy Buses --- # These represent nodes, where the used medias are balanced (electricity, heat, and gas) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 27dbaf78c..85d2bab2f 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -1267,7 +1267,7 @@ def heatmap_with_plotly( Automatic time reshaping (when only time dimension remains): ```python - # Data with dims ['time', 'scenario', 'period'] + # Data with dims ['time', period','scenario'] # After faceting and animation, only 'time' remains -> auto-reshapes to (timestep, timeframe) fig = heatmap_with_plotly(data_array, facet_by='scenario', animate_by='period') ``` diff --git a/flixopt/results.py b/flixopt/results.py index eaff79fe4..bc80ccf98 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -1231,7 +1231,7 @@ def plot_node_balance( facet_by: Dimension(s) to create facets (subplots) for. Can be a single dimension name (str) or list of dimensions. Each unique value combination creates a subplot. Ignored if not found. Example: 'scenario' creates one subplot per scenario. - Example: ['scenario', 'period'] creates a grid of subplots for each scenario-period combination. + Example: ['period', 'scenario'] creates a grid of subplots for each scenario-period combination. animate_by: Dimension to animate over (Plotly only). Creates animation frames that cycle through dimension values. Only one dimension can be animated. Ignored if not found. facet_cols: Number of columns in the facet grid layout (default: 3). diff --git a/tests/conftest.py b/tests/conftest.py index 4711e8e41..93d3c9f0e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -451,7 +451,7 @@ def simple_flow_system_scenarios() -> fx.FlowSystem: # Create flow system flow_system = fx.FlowSystem( - base_timesteps, scenarios=pd.Index(['A', 'B', 'C']), weights=np.array([0.5, 0.25, 0.25]) + base_timesteps, scenarios=pd.Index(['A', 'B', 'C']), scenario_weights=np.array([0.5, 0.25, 0.25]) ) flow_system.add_elements(*Buses.defaults()) flow_system.add_elements(storage, costs, co2, boiler, heat_load, gas_tariff, electricity_feed_in, chp) diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index a64388982..824a23ad8 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -238,8 +238,9 @@ def flow_system_piecewise_conversion_scenarios(flow_system_complex_scenarios) -> def test_weights(flow_system_piecewise_conversion_scenarios): """Test that scenario weights are correctly used in the model.""" scenarios = flow_system_piecewise_conversion_scenarios.scenarios - weights = np.linspace(0.5, 1, len(scenarios)) - flow_system_piecewise_conversion_scenarios.weights = weights + scenario_weights = np.linspace(0.5, 1, len(scenarios)) + flow_system_piecewise_conversion_scenarios.scenario_weights = scenario_weights + flow_system_piecewise_conversion_scenarios.weights = flow_system_piecewise_conversion_scenarios._compute_weights() model = create_linopy_model(flow_system_piecewise_conversion_scenarios) normalized_weights = ( flow_system_piecewise_conversion_scenarios.weights / flow_system_piecewise_conversion_scenarios.weights.sum() @@ -254,11 +255,14 @@ def test_weights(flow_system_piecewise_conversion_scenarios): def test_weights_io(flow_system_piecewise_conversion_scenarios): """Test that scenario weights are correctly used in the model.""" scenarios = flow_system_piecewise_conversion_scenarios.scenarios - weights = np.linspace(0.5, 1, len(scenarios)) / np.sum(np.linspace(0.5, 1, len(scenarios))) - flow_system_piecewise_conversion_scenarios.weights = weights + scenario_weights = np.linspace(0.5, 1, len(scenarios)) / np.sum(np.linspace(0.5, 1, len(scenarios))) + flow_system_piecewise_conversion_scenarios.scenario_weights = scenario_weights + flow_system_piecewise_conversion_scenarios.weights = flow_system_piecewise_conversion_scenarios._compute_weights() model = create_linopy_model(flow_system_piecewise_conversion_scenarios) - np.testing.assert_allclose(model.objective_weights.values, weights) - assert_linequal(model.objective.expression, (model.variables['costs'] * weights).sum() + model.variables['Penalty']) + np.testing.assert_allclose(model.objective_weights.values, scenario_weights) + assert_linequal( + model.objective.expression, (model.variables['costs'] * scenario_weights).sum() + model.variables['Penalty'] + ) assert np.isclose(model.objective_weights.sum().item(), 1.0) @@ -317,8 +321,9 @@ def test_io_persistence(flow_system_piecewise_conversion_scenarios): def test_scenarios_selection(flow_system_piecewise_conversion_scenarios): flow_system_full = flow_system_piecewise_conversion_scenarios scenarios = flow_system_full.scenarios - weights = np.linspace(0.5, 1, len(scenarios)) / np.sum(np.linspace(0.5, 1, len(scenarios))) - flow_system_full.weights = weights + scenario_weights = np.linspace(0.5, 1, len(scenarios)) / np.sum(np.linspace(0.5, 1, len(scenarios))) + flow_system_full.scenario_weights = scenario_weights + flow_system_full.weights = flow_system_full._compute_weights() flow_system = flow_system_full.sel(scenario=scenarios[0:2]) assert flow_system.scenarios.equals(flow_system_full.scenarios[0:2]) @@ -696,13 +701,13 @@ def test_weights_io_persistence(): """Test that weights persist through IO operations (to_dataset/from_dataset).""" timesteps = pd.date_range('2023-01-01', periods=24, freq='h') scenarios = pd.Index(['base', 'mid', 'high'], name='scenario') - custom_weights = np.array([0.3, 0.5, 0.2]) + custom_scenario_weights = np.array([0.3, 0.5, 0.2]) - # Create FlowSystem with custom weights + # Create FlowSystem with custom scenario weights fs_original = fx.FlowSystem( timesteps=timesteps, scenarios=scenarios, - weights=custom_weights, + scenario_weights=custom_scenario_weights, ) bus = fx.Bus('grid') @@ -737,13 +742,13 @@ def test_weights_selection(): """Test that weights are correctly sliced when using FlowSystem.sel().""" timesteps = pd.date_range('2023-01-01', periods=24, freq='h') scenarios = pd.Index(['base', 'mid', 'high'], name='scenario') - custom_weights = np.array([0.3, 0.5, 0.2]) + custom_scenario_weights = np.array([0.3, 0.5, 0.2]) - # Create FlowSystem with custom weights + # Create FlowSystem with custom scenario weights fs_full = fx.FlowSystem( timesteps=timesteps, scenarios=scenarios, - weights=custom_weights, + scenario_weights=custom_scenario_weights, ) bus = fx.Bus('grid') @@ -765,7 +770,7 @@ def test_weights_selection(): # Verify weights are correctly sliced assert fs_subset.scenarios.equals(pd.Index(['base', 'high'], name='scenario')) - np.testing.assert_allclose(fs_subset.weights.values, custom_weights[[0, 2]]) + np.testing.assert_allclose(fs_subset.weights.values, custom_scenario_weights[[0, 2]]) # Verify weights are 1D with just scenario dimension (no period dimension) assert fs_subset.weights.dims == ('scenario',) From 43ccdd49bdff9508d35800d8edeff774a5f02a80 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 19 Nov 2025 11:17:41 +0100 Subject: [PATCH 27/35] Update CHANGELOG.md --- CHANGELOG.md | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8922944ec..112ce0afd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,11 +51,13 @@ If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOp ## [Unreleased] - ????-??-?? -**Summary**: Renaming parameters in Linear Transformers for readability (old parameters still work but emit warnings) & new bounds for weighted sums over all periods for effects and flow hours. +**Summary**: Renaming parameters in Linear Transformers for readability (old parameters still work but emit warnings), new bounds for weighted sums over all periods for effects and flow hours, and refactored weights handling to fix `.sel()` issues with periods. If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOpt/flixOpt/releases/tag/v3.0.0) and [Migration Guide](https://flixopt.github.io/flixopt/latest/user-guide/migration-guide-v3/). ### ✨ Added +- **Internal period weight computation**: `FlowSystem` now automatically computes `period_weights` from the period index (similar to `hours_per_timestep` for time dimension), ensuring weights are always consistent with the actual periods + - **New constraint parameters for sum across all periods**: - `Effect`: Added `minimum_over_periods` and `maximum_over_periods` for weighted sum constraints across all periods (complements existing per-period `minimum_total`/`maximum_total`) - `Flow`: Added `flow_hours_max_over_periods` and `flow_hours_min_over_periods` for weighted sum constraints across all periods @@ -81,7 +83,34 @@ If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOp - **Auto-modeling**: `Calculation.solve()` now automatically calls `do_modeling()` if not already done, making the explicit `do_modeling()` call optional for simpler workflows +### 💥 Breaking Changes +- **FlowSystem weights parameter renamed**: The `weights` parameter in `FlowSystem.__init__()` has been renamed to `scenario_weights` to clarify that it only accepts scenario dimension weights (not period × scenario) + - Period weights are now **always computed internally** from the period index (similar to `hours_per_timestep` for time) + - The combined `weights` (period × scenario) are computed automatically by multiplying `period_weights × scenario_weights` + + **Migration**: Update your code from: + ```python + # Old (v3.6 and earlier) + fs = FlowSystem(..., weights=np.array([0.3, 0.5, 0.2])) # scenario weights + ``` + + To: + ```python + # New (v3.7+) + fs = FlowSystem(..., scenario_weights=np.array([0.3, 0.5, 0.2])) + ``` + + **Note**: If you were previously passing period × scenario weights to `weights`, you now need to: + 1. Pass only scenario weights to `scenario_weights` + 2. Period weights will be computed automatically from your `periods` index + ### ♻️ Changed +- **Period weights now computed from period index**: FlowSystem now computes `period_weights` automatically from the period index (using period intervals/differences), making weight handling consistent with `hours_per_timestep` for time dimension + - Added `_update_period_metadata()` method (analogous to `_update_time_metadata()`) to recalculate weights when periods are selected + - Period weights are stored separately in `FlowSystem.period_weights` (1D array with 'period' dimension) + - Scenario weights are stored in `FlowSystem.scenario_weights` (1D array with 'scenario' dimension) + - Combined weights `FlowSystem.weights` (2D array with 'period' and 'scenario' dimensions) are computed via `_compute_weights()` method + - **Refactored FlowSystem-Element coupling**: - Introduced `_set_flow_system()` method in Interface base class to propagate FlowSystem reference to nested Interface objects - Each Interface subclass now explicitly propagates the reference to its nested interfaces (e.g., Component → OnOffParameters, Flow → InvestParameters) @@ -140,6 +169,10 @@ All deprecated parameter names continue to work with deprecation warnings for ba ### 🐛 Fixed +- **Fixed weights not recalculating when using `.sel()` on periods**: `FlowSystem.sel()` and `FlowSystem.isel()` now correctly recalculate `period_weights` and `weights` when selecting a subset of periods (previously weights would be incorrectly sliced instead of recomputed from the new period index) + - Added `_update_period_metadata()` call in `_dataset_sel()` and `_dataset_isel()` to ensure weights stay consistent with selected periods + - This matches the existing behavior for time dimension where `hours_per_timestep` is recalculated on selection + - Fixed inconsistent argument passing in `_fit_effect_coords()` - standardized all calls to use named arguments (`prefix=`, `effect_values=`, `suffix=`) instead of mix of positional and named arguments - Fixed `check_bounds` function in `linear_converters.py` to normalize array inputs before comparisons, ensuring correct boundary checks with DataFrames, Series, and other array-like types From bd3f60df3d799619e00d586df08921e9e3771b34 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 19 Nov 2025 11:42:58 +0100 Subject: [PATCH 28/35] Update tests --- tests/test_scenarios.py | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index 824a23ad8..23a26f5cf 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -1,6 +1,7 @@ import numpy as np import pandas as pd import pytest +import xarray as xr from linopy.testing import assert_linequal import flixopt as fx @@ -239,12 +240,14 @@ def test_weights(flow_system_piecewise_conversion_scenarios): """Test that scenario weights are correctly used in the model.""" scenarios = flow_system_piecewise_conversion_scenarios.scenarios scenario_weights = np.linspace(0.5, 1, len(scenarios)) - flow_system_piecewise_conversion_scenarios.scenario_weights = scenario_weights - flow_system_piecewise_conversion_scenarios.weights = flow_system_piecewise_conversion_scenarios._compute_weights() - model = create_linopy_model(flow_system_piecewise_conversion_scenarios) - normalized_weights = ( - flow_system_piecewise_conversion_scenarios.weights / flow_system_piecewise_conversion_scenarios.weights.sum() + scenario_weights_da = xr.DataArray( + scenario_weights, + dims=['scenario'], + coords={'scenario': scenarios}, ) + flow_system_piecewise_conversion_scenarios.scenario_weights = scenario_weights_da + 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) assert_linequal( model.objective.expression, (model.variables['costs'] * normalized_weights).sum() + model.variables['Penalty'] @@ -255,13 +258,20 @@ def test_weights(flow_system_piecewise_conversion_scenarios): def test_weights_io(flow_system_piecewise_conversion_scenarios): """Test that scenario weights are correctly used in the model.""" scenarios = flow_system_piecewise_conversion_scenarios.scenarios - scenario_weights = np.linspace(0.5, 1, len(scenarios)) / np.sum(np.linspace(0.5, 1, len(scenarios))) - flow_system_piecewise_conversion_scenarios.scenario_weights = scenario_weights - flow_system_piecewise_conversion_scenarios.weights = flow_system_piecewise_conversion_scenarios._compute_weights() + scenario_weights = np.linspace(0.5, 1, len(scenarios)) + scenario_weights_da = xr.DataArray( + scenario_weights, + dims=['scenario'], + coords={'scenario': scenarios}, + ) + normalized_scenario_weights_da = scenario_weights_da / scenario_weights_da.sum() + flow_system_piecewise_conversion_scenarios.scenario_weights = scenario_weights_da + model = create_linopy_model(flow_system_piecewise_conversion_scenarios) - np.testing.assert_allclose(model.objective_weights.values, scenario_weights) + np.testing.assert_allclose(model.objective_weights.values, normalized_scenario_weights_da) assert_linequal( - model.objective.expression, (model.variables['costs'] * scenario_weights).sum() + model.variables['Penalty'] + model.objective.expression, + (model.variables['costs'] * normalized_scenario_weights_da).sum() + model.variables['Penalty'], ) assert np.isclose(model.objective_weights.sum().item(), 1.0) From 6d3e58d88fb9e0ba1cf9947646c500fe0b7ee433 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 19 Nov 2025 11:46:35 +0100 Subject: [PATCH 29/35] Improve handling of scenario weights --- CHANGELOG.md | 5 +++-- flixopt/flow_system.py | 34 +++++++++++++++++++--------------- flixopt/structure.py | 14 ++++++++++++-- 3 files changed, 34 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 112ce0afd..fe0b382ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -87,6 +87,7 @@ If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOp - **FlowSystem weights parameter renamed**: The `weights` parameter in `FlowSystem.__init__()` has been renamed to `scenario_weights` to clarify that it only accepts scenario dimension weights (not period × scenario) - Period weights are now **always computed internally** from the period index (similar to `hours_per_timestep` for time) - The combined `weights` (period × scenario) are computed automatically by multiplying `period_weights × scenario_weights` + - only scenario_weights are normalized in the objective function **Migration**: Update your code from: ```python @@ -160,13 +161,13 @@ If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOp - **Flow parameters**: `flow_hours_total_max`, `flow_hours_total_min` (use `flow_hours_max`, `flow_hours_min`) - **OnOffParameters**: `on_hours_total_max`, `on_hours_total_min`, `switch_on_total_max` (use `on_hours_max`, `on_hours_min`, `switch_on_max`) -All deprecated parameter names continue to work with deprecation warnings for backward compatibility. **Deprecated names will be removed in version 4.0.0.** Please update your code to use the new parameter names. Additional property aliases have been added internally to handle various naming variations that may have been used. - **Migration**: Simply rename parameters by removing `_total` from the middle: - `flow_hours_total_max` → `flow_hours_max` - `on_hours_total_min` → `on_hours_min` - `switch_on_total_max` → `switch_on_max` +All deprecated parameter names continue to work with deprecation warnings for backward compatibility. **Deprecated names will be removed in version 4.0.0.** Please update your code to use the new parameter names. Additional property aliases have been added internally to handle various naming variations that may have been used. + ### 🐛 Fixed - **Fixed weights not recalculating when using `.sel()` on periods**: `FlowSystem.sel()` and `FlowSystem.isel()` now correctly recalculate `period_weights` and `weights` when selecting a subset of periods (previously weights would be incorrectly sliced instead of recomputed from the new period index) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 519611361..c2edbf2d1 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -163,7 +163,17 @@ def __init__( scenario_weights: Numeric_S | None = None, scenario_independent_sizes: bool | list[str] = True, scenario_independent_flow_rates: bool | list[str] = False, + **kwargs, ): + self._handle_deprecated_kwarg( + kwargs, + 'weights', + 'scenario_weights', + scenario_weights, + additional_warning_message='This might lead to later errors if your custom weights used the period dimension.', + ) + self._validate_kwargs(kwargs) + self.timesteps = self._validate_timesteps(timesteps) # Compute all time-related metadata using shared helper @@ -474,21 +484,6 @@ def _update_period_metadata( if 'period_weights' in dataset.data_vars: dataset['period_weights'] = period_weights - # Recompute weights from period_weights and scenario_weights - if 'weights' in dataset.data_vars: - # Get scenario_weights from dataset (if it exists) - scenario_weights = dataset.data_vars.get('scenario_weights') - - # Compute new weights - if scenario_weights is None: - # No scenario dimension or all scenarios have equal weight - new_weights = period_weights - else: - # Multiply period_weights × scenario_weights - new_weights = period_weights * scenario_weights - - dataset['weights'] = new_weights - # Update period-related attributes only when new values are provided/computed if weight_of_last_period is not None: dataset.attrs['weight_of_last_period'] = weight_of_last_period @@ -1125,6 +1120,15 @@ def coords(self) -> dict[FlowSystemDimensions, pd.Index]: def used_in_calculation(self) -> bool: return self._used_in_calculation + @property + def weights(self) -> Numeric_S | None: + warnings.warn( + 'FlowSystem.weights is deprecated. Use FlowSystem.scenario_weights instead.', + DeprecationWarning, + stacklevel=2, + ) + return self.scenario_weights + def _validate_scenario_parameter(self, value: bool | list[str], param_name: str, element_type: str) -> None: """ Validate scenario parameter value. diff --git a/flixopt/structure.py b/flixopt/structure.py index 9ee76c9f8..1953a2ae4 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -195,7 +195,12 @@ def scenario_weights(self) -> xr.DataArray: return xr.DataArray(1) if self.flow_system.scenario_weights is None: - scenario_weights = xr.DataArray(1, coords=(self.flow_system.scenarios,), name='scenario_weights') + scenario_weights = xr.DataArray( + np.ones(self.flow_system.scenarios.size, dtype=float), + coords={'scenario': self.flow_system.scenarios}, + dims=['scenario'], + name='scenario_weights', + ) else: scenario_weights = self.flow_system.scenario_weights @@ -515,6 +520,7 @@ def _handle_deprecated_kwarg( current_value: Any = None, transform: callable = None, check_conflict: bool = True, + additional_warning_message: str = '', ) -> Any: """ Handle a deprecated keyword argument by issuing a warning and returning the appropriate value. @@ -530,6 +536,7 @@ def _handle_deprecated_kwarg( check_conflict: Whether to check if both old and new parameters are specified (default: True). Note: For parameters with non-None default values (e.g., bool parameters with default=False), set check_conflict=False since we cannot distinguish between an explicit value and the default. + additional_warning_message: Add a custom message which gets appended with a line break to the default warning. Returns: The value to use (either from old parameter or current_value) @@ -550,10 +557,13 @@ def _handle_deprecated_kwarg( """ import warnings + if additional_warning_message: + additional_warning_message = r'\n ' + additional_warning_message + old_value = kwargs.pop(old_name, None) if old_value is not None: warnings.warn( - f'The use of the "{old_name}" argument is deprecated. Use the "{new_name}" argument instead.', + f'The use of the "{old_name}" argument is deprecated. Use the "{new_name}" argument instead.{additional_warning_message}', DeprecationWarning, stacklevel=3, # Stack: this method -> __init__ -> caller ) From 523365f37fadbeb42f259c463f0f205b85841683 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 19 Nov 2025 11:57:07 +0100 Subject: [PATCH 30/35] Improve docs --- .../mathematical-notation/dimensions.md | 71 +++++++++++++++---- flixopt/calculation.py | 4 +- flixopt/flow_system.py | 1 + 3 files changed, 62 insertions(+), 14 deletions(-) diff --git a/docs/user-guide/mathematical-notation/dimensions.md b/docs/user-guide/mathematical-notation/dimensions.md index d1bc99c8e..fc16ad0d5 100644 --- a/docs/user-guide/mathematical-notation/dimensions.md +++ b/docs/user-guide/mathematical-notation/dimensions.md @@ -52,7 +52,7 @@ flow_system = fx.FlowSystem( timesteps=timesteps, periods=periods, scenarios=scenarios, - weights=np.array([0.5, 0.5]) # Scenario weights + scenario_weights=np.array([0.5, 0.5]) # Scenario weights ) ``` @@ -220,28 +220,75 @@ Where: Weights determine the relative importance of scenarios and periods in the objective function. -**Specification:** +### Scenario Weights + +You provide scenario weights explicitly via the `scenario_weights` parameter: ```python flow_system = fx.FlowSystem( timesteps=timesteps, - periods=periods, scenarios=scenarios, - weights=weights # Shape depends on dimensions + scenario_weights=np.array([0.3, 0.7]) # Scenario probabilities ) ``` -**Weight Dimensions:** +**Default:** If not specified, all scenarios have equal weight (normalized to sum to 1). + +### Period Weights + +Period weights are **automatically computed** from the period index (similar to how `hours_per_timestep` is computed from the time index): -| Dimensions Present | Weight Shape | Example | Meaning | -|-------------------|--------------|---------|---------| -| Time + Scenario | 1D array of length `n_scenarios` | `[0.3, 0.7]` | Scenario probabilities | -| Time + Period | 1D array of length `n_periods` | `[0.5, 0.3, 0.2]` | Period importance | -| Time + Period + Scenario | 2D array `(n_periods, n_scenarios)` | `[[0.25, 0.25], [0.25, 0.25]]` | Combined weights | +```python +# Period weights are computed from the differences between period values +periods = pd.Index([2020, 2025, 2030, 2035]) +# → period_weights = [5, 5, 5, 5] (representing 5-year intervals) -**Default:** If not specified, all scenarios/periods have equal weight (normalized to sum to 1). +flow_system = fx.FlowSystem( + timesteps=timesteps, + periods=periods, + # No need to specify period weights - they're computed automatically +) +``` + +**How period weights are computed:** +- For periods `[2020, 2025, 2030, 2035]`, the weights are `[5, 5, 5, 5]` (the interval sizes) +- This ensures that when you use `.sel()` to select a subset of periods, the weights are correctly recalculated +- You can specify `weight_of_last_period` if the last period weight cannot be inferred from the index + +### Combined Weights + +When both periods and scenarios are present, the combined `weights` array (accessible via `flow_system.model.objective_weights`) is computed as: + +$$ +w_{y,s} = w_y \times \frac{w_s}{\sum_{s \in \mathcal{S}} w_s} +$$ + +Where: +- $w_y$ are the period weights (computed from period index) +- $w_s$ are the scenario weights (user-specified) +- $\mathcal{S}$ is the set of all scenarios +- The scenario weights are normalized to sum to 1 before multiplication + +**Example:** +```python +periods = pd.Index([2020, 2030, 2040]) # → period_weights = [10, 10, 10] +scenarios = pd.Index(['Base', 'High']) +scenario_weights = np.array([0.6, 0.4]) + +flow_system = fx.FlowSystem( + timesteps=timesteps, + periods=periods, + scenarios=scenarios, + scenario_weights=scenario_weights +) + +# Combined weights shape: (3 periods, 2 scenarios) +# [[6.0, 4.0], # 2020: 10 × [0.6, 0.4] +# [6.0, 4.0], # 2030: 10 × [0.6, 0.4] +# [6.0, 4.0]] # 2040: 10 × [0.6, 0.4] +``` -**Normalization:** Set `normalize_weights=True` in `Calculation` to automatically normalize weights to sum to 1. +**Normalization:** Set `normalize_weights=False` in `Calculation` to turn of the normalization. --- diff --git a/flixopt/calculation.py b/flixopt/calculation.py index fcde018b7..2977f5a02 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -48,7 +48,7 @@ class for defined way of solving a flow_system optimization name: name of calculation flow_system: flow_system which should be calculated folder: folder where results should be saved. If None, then the current working directory is used. - normalize_weights: Whether to automatically normalize the weights (periods and scenarios) to sum up to 1 when solving. + normalize_weights: Whether to automatically normalize the weights of scenarios to sum up to 1 when solving. active_timesteps: Deprecated. Use FlowSystem.sel(time=...) or FlowSystem.isel(time=...) instead. """ @@ -182,7 +182,7 @@ class FullCalculation(Calculation): name: name of calculation flow_system: flow_system which should be calculated folder: folder where results should be saved. If None, then the current working directory is used. - normalize_weights: Whether to automatically normalize the weights (periods and scenarios) to sum up to 1 when solving. + normalize_weights: Whether to automatically normalize the weights of scenarios to sum up to 1 when solving. active_timesteps: Deprecated. Use FlowSystem.sel(time=...) or FlowSystem.isel(time=...) instead. """ diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index c2edbf2d1..9b674b53a 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -55,6 +55,7 @@ class FlowSystem(Interface, CompositeContainerMixin[Element]): Used for calculating sums over periods in multi-period models. scenario_weights: The weights of each scenario. If None, all scenarios have the same weight (normalized to 1). Period weights are always computed internally from the period index (like hours_per_timestep for time). + The final `weights` array (accessible via `flow_system.model.objective_weights`) is computed as period_weights × normalized_scenario_weights, with normalization applied to the scenario weights by default. scenario_independent_sizes: Controls whether investment sizes are equalized across scenarios. - True: All sizes are shared/equalized across scenarios - False: All sizes are optimized separately per scenario From d767fd41909e153a8d14900b7fcb3297e89d837b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 19 Nov 2025 11:58:27 +0100 Subject: [PATCH 31/35] Typos --- flixopt/effects.py | 2 +- flixopt/plotting.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/flixopt/effects.py b/flixopt/effects.py index 58d8e2dc4..02181920a 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -540,7 +540,7 @@ def _do_modeling(self): # Add weighted sum over all periods constraint if minimum_over_periods or maximum_over_periods is defined if self.element.minimum_over_periods is not None or self.element.maximum_over_periods is not None: # Calculate weighted sum over all periods - weighted_total = (self.total * self.weights).sum('period') + weighted_total = (self.total * self.period_weights).sum('period') # Create tracking variable for the weighted sum self.total_over_periods = self.add_variables( diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 85d2bab2f..93f4dfc85 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -1267,7 +1267,7 @@ def heatmap_with_plotly( Automatic time reshaping (when only time dimension remains): ```python - # Data with dims ['time', period','scenario'] + # Data with dims ['time', 'period','scenario'] # After faceting and animation, only 'time' remains -> auto-reshapes to (timestep, timeframe) fig = heatmap_with_plotly(data_array, facet_by='scenario', animate_by='period') ``` From a8be0ab877aa7055da5355421629f87052387ff8 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 19 Nov 2025 11:59:02 +0100 Subject: [PATCH 32/35] Rename in tests --- tests/test_scenarios.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index 23a26f5cf..8f409e916 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -22,13 +22,13 @@ def test_system(): scenarios = pd.Index(['Scenario A', 'Scenario B'], name='scenario') # Create scenario weights - weights = np.array([0.7, 0.3]) + scenario_weights = np.array([0.7, 0.3]) # Create a flow system with scenarios flow_system = FlowSystem( timesteps=timesteps, scenarios=scenarios, - weights=weights, + scenario_weights=scenario_weights, ) # Create demand profiles that differ between scenarios From 613a32372c8f06654a00133619a72e7981b7668f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 19 Nov 2025 12:11:05 +0100 Subject: [PATCH 33/35] Fix tests --- flixopt/flow_system.py | 15 +++++++++++++++ tests/test_scenarios.py | 8 ++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 9b674b53a..feb304c3c 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -1130,6 +1130,21 @@ def weights(self) -> Numeric_S | None: ) return self.scenario_weights + @weights.setter + def weights(self, value: Numeric_S) -> None: + """ + Set weights (deprecated - sets scenario_weights). + + Args: + value: Scenario weights to set + """ + warnings.warn( + 'Setting FlowSystem.weights is deprecated. Set FlowSystem.scenario_weights instead.', + DeprecationWarning, + stacklevel=2, + ) + self.scenario_weights = self.fit_to_model_coords('scenario_weights', value, dims=['scenario']) + def _validate_scenario_parameter(self, value: bool | list[str], param_name: str, element_type: str) -> None: """ Validate scenario parameter value. diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index 8f409e916..6a0b61b79 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -331,9 +331,13 @@ def test_io_persistence(flow_system_piecewise_conversion_scenarios): def test_scenarios_selection(flow_system_piecewise_conversion_scenarios): flow_system_full = flow_system_piecewise_conversion_scenarios scenarios = flow_system_full.scenarios - scenario_weights = np.linspace(0.5, 1, len(scenarios)) / np.sum(np.linspace(0.5, 1, len(scenarios))) + scenario_weights = xr.DataArray( + data=np.linspace(0.5, 1, len(scenarios)) / np.sum(np.linspace(0.5, 1, len(scenarios))), + dims=['scenario'], + coords={'scenario': scenarios}, + name='scenario_weights', + ) flow_system_full.scenario_weights = scenario_weights - flow_system_full.weights = flow_system_full._compute_weights() flow_system = flow_system_full.sel(scenario=scenarios[0:2]) assert flow_system.scenarios.equals(flow_system_full.scenarios[0:2]) From 7df63342ae590a03a4ad34c99eba7b228b503364 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 19 Nov 2025 12:15:41 +0100 Subject: [PATCH 34/35] Use setter for scenario_weights --- flixopt/flow_system.py | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index feb304c3c..70a6b5aab 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -166,11 +166,12 @@ def __init__( scenario_independent_flow_rates: bool | list[str] = False, **kwargs, ): - self._handle_deprecated_kwarg( + scenario_weights = self._handle_deprecated_kwarg( kwargs, 'weights', 'scenario_weights', scenario_weights, + check_conflict=False, additional_warning_message='This might lead to later errors if your custom weights used the period dimension.', ) self._validate_kwargs(kwargs) @@ -190,9 +191,7 @@ def __init__( self.hours_per_timestep = self.fit_to_model_coords('hours_per_timestep', hours_per_timestep) - self.scenario_weights: xr.DataArray = self.fit_to_model_coords( - 'scenario_weights', scenario_weights, dims=['scenario'] - ) + self.scenario_weights = scenario_weights # Use setter # Compute all period-related metadata using shared helper (self.periods_extra, self.weight_of_last_period, weight_per_period) = self._compute_period_metadata( @@ -1121,6 +1120,26 @@ def coords(self) -> dict[FlowSystemDimensions, pd.Index]: def used_in_calculation(self) -> bool: return self._used_in_calculation + @property + def scenario_weights(self) -> xr.DataArray | None: + """ + Weights for each scenario. + + Returns: + xr.DataArray: Scenario weights with 'scenario' dimension + """ + return self._scenario_weights + + @scenario_weights.setter + def scenario_weights(self, value: Numeric_S) -> None: + """ + Set scenario weights. + + Args: + value: Scenario weights to set (will be converted to DataArray with 'scenario' dimension) + """ + self._scenario_weights = self.fit_to_model_coords('scenario_weights', value, dims=['scenario']) + @property def weights(self) -> Numeric_S | None: warnings.warn( @@ -1143,7 +1162,7 @@ def weights(self, value: Numeric_S) -> None: DeprecationWarning, stacklevel=2, ) - self.scenario_weights = self.fit_to_model_coords('scenario_weights', value, dims=['scenario']) + self.scenario_weights = value # Use the scenario_weights setter def _validate_scenario_parameter(self, value: bool | list[str], param_name: str, element_type: str) -> None: """ From b6abb9d1fc57f203137cf2d0d295b32297f3c098 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 19 Nov 2025 12:17:20 +0100 Subject: [PATCH 35/35] Revert test changes --- tests/test_scenarios.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index 6a0b61b79..bdc0bdc33 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -331,12 +331,7 @@ def test_io_persistence(flow_system_piecewise_conversion_scenarios): def test_scenarios_selection(flow_system_piecewise_conversion_scenarios): flow_system_full = flow_system_piecewise_conversion_scenarios scenarios = flow_system_full.scenarios - scenario_weights = xr.DataArray( - data=np.linspace(0.5, 1, len(scenarios)) / np.sum(np.linspace(0.5, 1, len(scenarios))), - dims=['scenario'], - coords={'scenario': scenarios}, - name='scenario_weights', - ) + scenario_weights = np.linspace(0.5, 1, len(scenarios)) / np.sum(np.linspace(0.5, 1, len(scenarios))) flow_system_full.scenario_weights = scenario_weights flow_system = flow_system_full.sel(scenario=scenarios[0:2])