From ed9cd75c858c3c1b22f5a7d51dbbfcd1376c8db0 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 29 Sep 2025 12:20:35 +0200 Subject: [PATCH 01/14] The framework now uses "period" instead of "year" as the dimension name and "periodic" instead of "nontemporal" for the effect domain --- examples/04_Scenarios/scenario_example.py | 4 +- .../two_stage_optimization.py | 2 +- flixopt/calculation.py | 2 +- flixopt/components.py | 26 +-- flixopt/core.py | 14 +- flixopt/effects.py | 189 +++++++++--------- flixopt/elements.py | 16 +- flixopt/features.py | 26 +-- flixopt/flow_system.py | 100 ++++----- flixopt/interface.py | 48 ++--- flixopt/modeling.py | 2 +- flixopt/results.py | 60 +++--- flixopt/structure.py | 4 +- tests/conftest.py | 12 +- tests/test_effect.py | 89 ++++----- tests/test_flow.py | 51 ++--- tests/test_integration.py | 6 +- tests/test_storage.py | 4 +- 18 files changed, 334 insertions(+), 321 deletions(-) diff --git a/examples/04_Scenarios/scenario_example.py b/examples/04_Scenarios/scenario_example.py index 73d37fb90..a2f32d666 100644 --- a/examples/04_Scenarios/scenario_example.py +++ b/examples/04_Scenarios/scenario_example.py @@ -11,7 +11,7 @@ # Create datetime array starting from '2020-01-01' for the given time period timesteps = pd.date_range('2020-01-01', periods=9, freq='h') scenarios = pd.Index(['Base Case', 'High Demand']) - years = pd.Index([2020, 2021, 2022]) + periods = pd.Index([2020, 2021, 2022]) # --- Create Time Series Data --- # Heat demand profile (e.g., kW) over time and corresponding power prices @@ -21,7 +21,7 @@ ) power_prices = np.array([0.08, 0.09, 0.10]) - flow_system = fx.FlowSystem(timesteps=timesteps, years=years, scenarios=scenarios, weights=np.array([0.5, 0.6])) + flow_system = fx.FlowSystem(timesteps=timesteps, periods=periods, scenarios=scenarios, weights=np.array([0.5, 0.6])) # --- Define Energy Buses --- # These represent nodes, where the used medias are balanced (electricity, heat, and gas) diff --git a/examples/05_Two-stage-optimization/two_stage_optimization.py b/examples/05_Two-stage-optimization/two_stage_optimization.py index b6a0bfa67..77bd74a3b 100644 --- a/examples/05_Two-stage-optimization/two_stage_optimization.py +++ b/examples/05_Two-stage-optimization/two_stage_optimization.py @@ -146,7 +146,7 @@ [ 'Duration [s]', 'costs', - 'costs(nontemporal)', + 'costs(periodic)', 'costs(temporal)', 'BHKW2(Q_fu)|size', 'Kessel(Q_fu)|size', diff --git a/flixopt/calculation.py b/flixopt/calculation.py index 2b01d2925..9b2fd7b2b 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -108,7 +108,7 @@ def main_results(self) -> dict[str, Scalar | dict]: 'Effects': { f'{effect.label} [{effect.unit}]': { 'temporal': effect.submodel.temporal.total.solution.values, - 'nontemporal': effect.submodel.nontemporal.total.solution.values, + 'periodic': effect.submodel.periodic.total.solution.values, 'total': effect.submodel.total.solution.values, } for effect in self.flow_system.effects diff --git a/flixopt/components.py b/flixopt/components.py index 5bf76afaf..883aa20bf 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -11,7 +11,7 @@ import numpy as np import xarray as xr -from .core import NonTemporalDataUser, PlausibilityError, TemporalData, TemporalDataUser +from .core import PeriodicDataUser, PlausibilityError, TemporalData, TemporalDataUser from .elements import Component, ComponentModel, Flow from .features import InvestmentModel, PiecewiseModel from .interface import InvestParameters, OnOffParameters, PiecewiseConversion @@ -376,14 +376,14 @@ def __init__( label: str, charging: Flow, discharging: Flow, - capacity_in_flow_hours: NonTemporalDataUser | InvestParameters, + capacity_in_flow_hours: PeriodicDataUser | InvestParameters, relative_minimum_charge_state: TemporalDataUser = 0, relative_maximum_charge_state: TemporalDataUser = 1, - initial_charge_state: NonTemporalDataUser | Literal['lastValueOfSim'] = 0, - minimal_final_charge_state: NonTemporalDataUser | None = None, - maximal_final_charge_state: NonTemporalDataUser | None = None, - relative_minimum_final_charge_state: NonTemporalDataUser | None = None, - relative_maximum_final_charge_state: NonTemporalDataUser | None = None, + initial_charge_state: PeriodicDataUser | Literal['lastValueOfSim'] = 0, + minimal_final_charge_state: PeriodicDataUser | None = None, + maximal_final_charge_state: PeriodicDataUser | None = None, + relative_minimum_final_charge_state: PeriodicDataUser | None = None, + relative_maximum_final_charge_state: PeriodicDataUser | None = None, eta_charge: TemporalDataUser = 1, eta_discharge: TemporalDataUser = 1, relative_loss_per_hour: TemporalDataUser = 0, @@ -442,29 +442,29 @@ def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None ) if not isinstance(self.initial_charge_state, str): self.initial_charge_state = flow_system.fit_to_model_coords( - f'{prefix}|initial_charge_state', self.initial_charge_state, dims=['year', 'scenario'] + f'{prefix}|initial_charge_state', self.initial_charge_state, dims=['period', 'scenario'] ) self.minimal_final_charge_state = flow_system.fit_to_model_coords( - f'{prefix}|minimal_final_charge_state', self.minimal_final_charge_state, dims=['year', 'scenario'] + 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( - f'{prefix}|maximal_final_charge_state', self.maximal_final_charge_state, dims=['year', 'scenario'] + 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( f'{prefix}|relative_minimum_final_charge_state', self.relative_minimum_final_charge_state, - dims=['year', 'scenario'], + dims=['period', 'scenario'], ) self.relative_maximum_final_charge_state = flow_system.fit_to_model_coords( f'{prefix}|relative_maximum_final_charge_state', self.relative_maximum_final_charge_state, - dims=['year', 'scenario'], + dims=['period', 'scenario'], ) if isinstance(self.capacity_in_flow_hours, InvestParameters): self.capacity_in_flow_hours.transform_data(flow_system, f'{prefix}|InvestParameters') else: self.capacity_in_flow_hours = flow_system.fit_to_model_coords( - f'{prefix}|capacity_in_flow_hours', self.capacity_in_flow_hours, dims=['year', 'scenario'] + f'{prefix}|capacity_in_flow_hours', self.capacity_in_flow_hours, dims=['period', 'scenario'] ) def _plausibility_checks(self) -> None: diff --git a/flixopt/core.py b/flixopt/core.py index 3376b2c5f..a3dc3cbb0 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -17,13 +17,13 @@ Scalar = int | float """A single number, either integer or float.""" -NonTemporalDataUser = int | float | np.integer | np.floating | np.ndarray | pd.Series | pd.DataFrame | xr.DataArray +PeriodicDataUser = int | float | np.integer | np.floating | np.ndarray | pd.Series | pd.DataFrame | xr.DataArray """User data which has no time dimension. Internally converted to a Scalar or an xr.DataArray without a time dimension.""" -NonTemporalData = xr.DataArray -"""Internally used datatypes for non-temporal data. Can be a Scalar or an xr.DataArray.""" +PeriodicData = xr.DataArray +"""Internally used datatypes for periodic data. Can be a Scalar or an xr.DataArray.""" -FlowSystemDimensions = Literal['time', 'year', 'scenario'] +FlowSystemDimensions = Literal['time', 'period', 'scenario'] """Possible dimensions of a FlowSystem.""" @@ -620,3 +620,9 @@ def drop_constant_arrays(ds: xr.Dataset, dim='time', drop_arrays_without_dim: bo logger.debug(f'Dropping {len(drop_vars)} arrays with constant values') return ds.drop_vars(drop_vars) + + +# Backward compatibility aliases +# TODO: Needed? +NonTemporalDataUser = PeriodicDataUser +NonTemporalData = PeriodicData diff --git a/flixopt/effects.py b/flixopt/effects.py index e2277401a..cc7564191 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -16,7 +16,7 @@ import numpy as np import xarray as xr -from .core import NonTemporalDataUser, Scalar, TemporalData, TemporalDataUser +from .core import PeriodicDataUser, Scalar, TemporalData, TemporalDataUser from .features import ShareAllocationModel from .structure import Element, ElementModel, FlowSystemModel, Submodel, register_class_for_io @@ -52,24 +52,24 @@ class Effect(Element): Only one effect can be marked as objective per optimization. share_from_temporal: Temporal cross-effect contributions. Maps temporal contributions from other effects to this effect - share_from_nontemporal: Nontemporal cross-effect contributions. - Maps nontemporal 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_per_hour: Minimum allowed contribution per hour. maximum_per_hour: Maximum allowed contribution per hour. - minimum_nontemporal: Minimum allowed total nontemporal contribution. - maximum_nontemporal: Maximum allowed total nontemporal contribution. - minimum_total: Minimum allowed total effect (temporal + nontemporal combined). - maximum_total: Maximum allowed total effect (temporal + nontemporal combined). + minimum_periodic: Minimum allowed total periodic contribution. + maximum_periodic: Maximum allowed total periodic contribution. + minimum_total: Minimum allowed total effect (temporal + periodic combined). + maximum_total: Maximum allowed total effect (temporal + periodic combined). meta_data: Used to store additional information. Not used internally but saved in results. Only use Python native types. **Deprecated Parameters** (for backwards compatibility): minimum_operation: Use `minimum_temporal` instead. maximum_operation: Use `maximum_temporal` instead. - minimum_invest: Use `minimum_nontemporal` instead. - maximum_invest: Use `maximum_nontemporal` instead. + minimum_invest: Use `minimum_periodic` instead. + maximum_invest: Use `maximum_periodic` instead. minimum_operation_per_hour: Use `minimum_per_hour` instead. maximum_operation_per_hour: Use `maximum_per_hour` instead. @@ -155,7 +155,7 @@ class Effect(Element): across all contributions to each effect manually. Effects are accumulated as: - - Total = Σ(temporal contributions) + Σ(nontemporal contributions) + - Total = Σ(temporal contributions) + Σ(periodic contributions) """ @@ -168,11 +168,11 @@ def __init__( is_standard: bool = False, is_objective: bool = False, share_from_temporal: TemporalEffectsUser | None = None, - share_from_nontemporal: NonTemporalEffectsUser | None = None, - minimum_temporal: NonTemporalEffectsUser | None = None, - maximum_temporal: NonTemporalEffectsUser | None = None, - minimum_nontemporal: NonTemporalEffectsUser | None = None, - maximum_nontemporal: NonTemporalEffectsUser | None = None, + share_from_periodic: PeriodicEffectsUser | None = None, + minimum_temporal: PeriodicEffectsUser | None = None, + maximum_temporal: PeriodicEffectsUser | None = None, + minimum_periodic: PeriodicEffectsUser | None = None, + maximum_periodic: PeriodicEffectsUser | None = None, minimum_per_hour: TemporalDataUser | None = None, maximum_per_hour: TemporalDataUser | None = None, minimum_total: Scalar | None = None, @@ -185,9 +185,7 @@ def __init__( self.is_standard = is_standard self.is_objective = is_objective self.share_from_temporal: TemporalEffectsUser = share_from_temporal if share_from_temporal is not None else {} - self.share_from_nontemporal: NonTemporalEffectsUser = ( - share_from_nontemporal if share_from_nontemporal is not None else {} - ) + self.share_from_periodic: PeriodicEffectsUser = share_from_periodic if share_from_periodic is not None else {} # Handle backwards compatibility for deprecated parameters # Extract deprecated parameters from kwargs @@ -220,27 +218,27 @@ def __init__( raise ValueError('Either maximum_operation or maximum_temporal can be specified, but not both.') maximum_temporal = maximum_operation - # Handle minimum_nontemporal + # Handle minimum_periodic if minimum_invest is not None: warnings.warn( - "Parameter 'minimum_invest' is deprecated. Use 'minimum_nontemporal' instead.", + "Parameter 'minimum_invest' is deprecated. Use 'minimum_periodic' instead.", DeprecationWarning, stacklevel=2, ) - if minimum_nontemporal is not None: - raise ValueError('Either minimum_invest or minimum_nontemporal can be specified, but not both.') - minimum_nontemporal = minimum_invest + if minimum_periodic is not None: + raise ValueError('Either minimum_invest or minimum_periodic can be specified, but not both.') + minimum_periodic = minimum_invest - # Handle maximum_nontemporal + # Handle maximum_periodic if maximum_invest is not None: warnings.warn( - "Parameter 'maximum_invest' is deprecated. Use 'maximum_nontemporal' instead.", + "Parameter 'maximum_invest' is deprecated. Use 'maximum_periodic' instead.", DeprecationWarning, stacklevel=2, ) - if maximum_nontemporal is not None: - raise ValueError('Either maximum_invest or maximum_nontemporal can be specified, but not both.') - maximum_nontemporal = maximum_invest + if maximum_periodic is not None: + raise ValueError('Either maximum_invest or maximum_periodic can be specified, but not both.') + maximum_periodic = maximum_invest # Handle minimum_per_hour if minimum_operation_per_hour is not None: @@ -274,8 +272,8 @@ def __init__( # Set attributes directly self.minimum_temporal = minimum_temporal self.maximum_temporal = maximum_temporal - self.minimum_nontemporal = minimum_nontemporal - self.maximum_nontemporal = maximum_nontemporal + self.minimum_periodic = minimum_periodic + self.maximum_periodic = maximum_periodic self.minimum_per_hour = minimum_per_hour self.maximum_per_hour = maximum_per_hour self.minimum_total = minimum_total @@ -324,43 +322,43 @@ def maximum_operation(self, value): @property def minimum_invest(self): - """DEPRECATED: Use 'minimum_nontemporal' property instead.""" + """DEPRECATED: Use 'minimum_periodic' property instead.""" warnings.warn( - "Property 'minimum_invest' is deprecated. Use 'minimum_nontemporal' instead.", + "Property 'minimum_invest' is deprecated. Use 'minimum_periodic' instead.", DeprecationWarning, stacklevel=2, ) - return self.minimum_nontemporal + return self.minimum_periodic @minimum_invest.setter def minimum_invest(self, value): - """DEPRECATED: Use 'minimum_nontemporal' property instead.""" + """DEPRECATED: Use 'minimum_periodic' property instead.""" warnings.warn( - "Property 'minimum_invest' is deprecated. Use 'minimum_nontemporal' instead.", + "Property 'minimum_invest' is deprecated. Use 'minimum_periodic' instead.", DeprecationWarning, stacklevel=2, ) - self.minimum_nontemporal = value + self.minimum_periodic = value @property def maximum_invest(self): - """DEPRECATED: Use 'maximum_nontemporal' property instead.""" + """DEPRECATED: Use 'maximum_periodic' property instead.""" warnings.warn( - "Property 'maximum_invest' is deprecated. Use 'maximum_nontemporal' instead.", + "Property 'maximum_invest' is deprecated. Use 'maximum_periodic' instead.", DeprecationWarning, stacklevel=2, ) - return self.maximum_nontemporal + return self.maximum_periodic @maximum_invest.setter def maximum_invest(self, value): - """DEPRECATED: Use 'maximum_nontemporal' property instead.""" + """DEPRECATED: Use 'maximum_periodic' property instead.""" warnings.warn( - "Property 'maximum_invest' is deprecated. Use 'maximum_nontemporal' instead.", + "Property 'maximum_invest' is deprecated. Use 'maximum_periodic' instead.", DeprecationWarning, stacklevel=2, ) - self.maximum_nontemporal = value + self.maximum_periodic = value @property def minimum_operation_per_hour(self): @@ -412,34 +410,34 @@ def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None label_prefix=None, effect_values=self.share_from_temporal, label_suffix=f'(temporal)->{prefix}(temporal)', - dims=['time', 'year', 'scenario'], + dims=['time', 'period', 'scenario'], ) - self.share_from_nontemporal = flow_system.fit_effects_to_model_coords( + self.share_from_periodic = flow_system.fit_effects_to_model_coords( label_prefix=None, - effect_values=self.share_from_nontemporal, - label_suffix=f'(nontemporal)->{prefix}(nontemporal)', - dims=['year', 'scenario'], + effect_values=self.share_from_periodic, + label_suffix=f'(periodic)->{prefix}(periodic)', + dims=['period', 'scenario'], ) self.minimum_temporal = flow_system.fit_to_model_coords( - f'{prefix}|minimum_temporal', self.minimum_temporal, dims=['year', 'scenario'] + f'{prefix}|minimum_temporal', self.minimum_temporal, dims=['period', 'scenario'] ) self.maximum_temporal = flow_system.fit_to_model_coords( - f'{prefix}|maximum_temporal', self.maximum_temporal, dims=['year', 'scenario'] + f'{prefix}|maximum_temporal', self.maximum_temporal, dims=['period', 'scenario'] ) - self.minimum_nontemporal = flow_system.fit_to_model_coords( - f'{prefix}|minimum_nontemporal', self.minimum_nontemporal, dims=['year', 'scenario'] + self.minimum_periodic = flow_system.fit_to_model_coords( + f'{prefix}|minimum_periodic', self.minimum_periodic, dims=['period', 'scenario'] ) - self.maximum_nontemporal = flow_system.fit_to_model_coords( - f'{prefix}|maximum_nontemporal', self.maximum_nontemporal, dims=['year', 'scenario'] + 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, - dims=['year', 'scenario'], + dims=['period', 'scenario'], ) self.maximum_total = flow_system.fit_to_model_coords( - f'{prefix}|maximum_total', self.maximum_total, dims=['year', 'scenario'] + f'{prefix}|maximum_total', self.maximum_total, dims=['period', 'scenario'] ) def create_model(self, model: FlowSystemModel) -> EffectModel: @@ -460,22 +458,22 @@ def __init__(self, model: FlowSystemModel, element: Effect): def _do_modeling(self): self.total: linopy.Variable | None = None - self.nontemporal: ShareAllocationModel = self.add_submodels( + self.periodic: ShareAllocationModel = self.add_submodels( ShareAllocationModel( model=self._model, - dims=('year', 'scenario'), + dims=('period', 'scenario'), label_of_element=self.label_of_element, - label_of_model=f'{self.label_of_model}(nontemporal)', - total_max=self.element.maximum_nontemporal, - total_min=self.element.minimum_nontemporal, + label_of_model=f'{self.label_of_model}(periodic)', + total_max=self.element.maximum_periodic, + total_min=self.element.minimum_periodic, ), - short_name='nontemporal', + short_name='periodic', ) self.temporal: ShareAllocationModel = self.add_submodels( ShareAllocationModel( model=self._model, - dims=('time', 'year', 'scenario'), + dims=('time', 'period', 'scenario'), label_of_element=self.label_of_element, label_of_model=f'{self.label_of_model}(temporal)', total_max=self.element.maximum_temporal, @@ -489,25 +487,25 @@ 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, - coords=self._model.get_coords(['year', 'scenario']), + coords=self._model.get_coords(['period', 'scenario']), name=self.label_full, ) self.add_constraints( - self.total == self.temporal.total + self.nontemporal.total, name=self.label_full, short_name='total' + self.total == self.temporal.total + self.periodic.total, name=self.label_full, short_name='total' ) TemporalEffectsUser = TemporalDataUser | dict[str, TemporalDataUser] # User-specified Shares to Effects """ This datatype is used to define a temporal share to an effect by a certain attribute. """ -NonTemporalEffectsUser = NonTemporalDataUser | dict[str, NonTemporalDataUser] # User-specified Shares to Effects +PeriodicEffectsUser = PeriodicDataUser | dict[str, PeriodicDataUser] # User-specified Shares to Effects """ This datatype is used to define a scalar share to an effect by a certain attribute. """ TemporalEffects = dict[str, TemporalData] # User-specified Shares to Effects """ This datatype is used internally to handle temporal shares to an effect. """ -NonTemporalEffects = dict[str, Scalar] +PeriodicEffects = dict[str, Scalar] """ This datatype is used internally to handle scalar shares to an effect. """ EffectExpr = dict[str, linopy.LinearExpression] # Used to create Shares @@ -543,7 +541,7 @@ def add_effects(self, *effects: Effect) -> None: logger.info(f'Registered new Effect: {effect.label}') def create_effect_values_dict( - self, effect_values_user: NonTemporalEffectsUser | TemporalEffectsUser + self, effect_values_user: PeriodicEffectsUser | TemporalEffectsUser ) -> dict[str, Scalar | TemporalDataUser] | None: """ Converts effect values into a dictionary. If a scalar is provided, it is associated with a default effect type. @@ -585,23 +583,23 @@ def get_effect_label(eff: Effect | str) -> str: def _plausibility_checks(self) -> None: # Check circular loops in effects: - temporal, nontemporal = self.calculate_effect_share_factors() + temporal, periodic = self.calculate_effect_share_factors() # Validate all referenced sources exist - unknown = {src for src, _ in list(temporal.keys()) + list(nontemporal.keys()) if src not in self.effects} + unknown = {src for src, _ in list(temporal.keys()) + list(periodic.keys()) if src not in self.effects} if unknown: raise KeyError(f'Unknown effects used in in effect share mappings: {sorted(unknown)}') temporal_cycles = detect_cycles(tuples_to_adjacency_list([key for key in temporal])) - nontemporal_cycles = detect_cycles(tuples_to_adjacency_list([key for key in nontemporal])) + periodic_cycles = detect_cycles(tuples_to_adjacency_list([key for key in periodic])) if temporal_cycles: cycle_str = '\n'.join([' -> '.join(cycle) for cycle in temporal_cycles]) raise ValueError(f'Error: circular temporal-shares detected:\n{cycle_str}') - if nontemporal_cycles: - cycle_str = '\n'.join([' -> '.join(cycle) for cycle in nontemporal_cycles]) - raise ValueError(f'Error: circular nontemporal-shares detected:\n{cycle_str}') + if periodic_cycles: + cycle_str = '\n'.join([' -> '.join(cycle) for cycle in periodic_cycles]) + raise ValueError(f'Error: circular periodic-shares detected:\n{cycle_str}') def __getitem__(self, effect: str | Effect | None) -> Effect: """ @@ -677,14 +675,14 @@ def calculate_effect_share_factors( dict[tuple[str, str], xr.DataArray], dict[tuple[str, str], xr.DataArray], ]: - shares_nontemporal = {} + shares_periodic = {} for name, effect in self.effects.items(): - if effect.share_from_nontemporal: - for source, data in effect.share_from_nontemporal.items(): - if source not in shares_nontemporal: - shares_nontemporal[source] = {} - shares_nontemporal[source][name] = data - shares_nontemporal = calculate_all_conversion_paths(shares_nontemporal) + if effect.share_from_periodic: + for source, data in effect.share_from_periodic.items(): + if source not in shares_periodic: + shares_periodic[source] = {} + shares_periodic[source][name] = data + shares_periodic = calculate_all_conversion_paths(shares_periodic) shares_temporal = {} for name, effect in self.effects.items(): @@ -695,7 +693,7 @@ def calculate_effect_share_factors( shares_temporal[source][name] = data shares_temporal = calculate_all_conversion_paths(shares_temporal) - return shares_temporal, shares_nontemporal + return shares_temporal, shares_periodic class EffectCollectionModel(Submodel): @@ -712,20 +710,20 @@ def add_share_to_effects( self, name: str, expressions: EffectExpr, - target: Literal['temporal', 'nontemporal'], + target: Literal['temporal', 'periodic'], ) -> None: for effect, expression in expressions.items(): if target == 'temporal': self.effects[effect].submodel.temporal.add_share( name, expression, - dims=('time', 'year', 'scenario'), + dims=('time', 'period', 'scenario'), ) - elif target == 'nontemporal': - self.effects[effect].submodel.nontemporal.add_share( + elif target == 'periodic': + self.effects[effect].submodel.periodic.add_share( name, expression, - dims=('year', 'scenario'), + dims=('period', 'scenario'), ) else: raise ValueError(f'Target {target} not supported!') @@ -757,14 +755,14 @@ def _add_share_between_effects(self): target_effect.submodel.temporal.add_share( self.effects[source_effect].submodel.temporal.label_full, self.effects[source_effect].submodel.temporal.total_per_timestep * time_series, - dims=('time', 'year', 'scenario'), + dims=('time', 'period', 'scenario'), ) - # 2. nontemporal: <- receiving nontemporal shares from other effects - for source_effect, factor in target_effect.share_from_nontemporal.items(): - target_effect.submodel.nontemporal.add_share( - self.effects[source_effect].submodel.nontemporal.label_full, - self.effects[source_effect].submodel.nontemporal.total * factor, - dims=('year', 'scenario'), + # 2. periodic: <- receiving periodic shares from other effects + for source_effect, factor in target_effect.share_from_periodic.items(): + target_effect.submodel.periodic.add_share( + self.effects[source_effect].submodel.periodic.label_full, + self.effects[source_effect].submodel.periodic.total * factor, + dims=('period', 'scenario'), ) @@ -911,3 +909,8 @@ def tuples_to_adjacency_list(edges: list[tuple[str, str]]) -> dict[str, list[str graph[target] = [] return graph + + +# Backward compatibility aliases +NonTemporalEffectsUser = PeriodicEffectsUser +NonTemporalEffects = PeriodicEffects diff --git a/flixopt/elements.py b/flixopt/elements.py index 84f619489..6ae1c2cf1 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -316,7 +316,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 year + switch_on_total_max=200, # Maximum 200 starts per period ), ) ``` @@ -430,16 +430,16 @@ def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None 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=['year', 'scenario'] + f'{prefix}|flow_hours_total_max', self.flow_hours_total_max, dims=['period', 'scenario'] ) self.flow_hours_total_min = flow_system.fit_to_model_coords( - f'{prefix}|flow_hours_total_min', self.flow_hours_total_min, dims=['year', 'scenario'] + f'{prefix}|flow_hours_total_min', self.flow_hours_total_min, dims=['period', 'scenario'] ) self.load_factor_max = flow_system.fit_to_model_coords( - f'{prefix}|load_factor_max', self.load_factor_max, dims=['year', 'scenario'] + f'{prefix}|load_factor_max', self.load_factor_max, dims=['period', 'scenario'] ) self.load_factor_min = flow_system.fit_to_model_coords( - f'{prefix}|load_factor_min', self.load_factor_min, dims=['year', 'scenario'] + f'{prefix}|load_factor_min', self.load_factor_min, dims=['period', 'scenario'] ) if self.on_off_parameters is not None: @@ -447,7 +447,7 @@ def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None if isinstance(self.size, InvestParameters): self.size.transform_data(flow_system, prefix) else: - self.size = flow_system.fit_to_model_coords(f'{prefix}|size', self.size, dims=['year', 'scenario']) + self.size = flow_system.fit_to_model_coords(f'{prefix}|size', self.size, dims=['period', 'scenario']) def _plausibility_checks(self) -> None: # TODO: Incorporate into Variable? (Lower_bound can not be greater than upper bound @@ -485,7 +485,7 @@ def _plausibility_checks(self) -> None: ): raise TypeError( f'previous_flow_rate must be None, a scalar, a list of scalars or a 1D-numpy-array. Got {type(self.previous_flow_rate)}. ' - f'Different values in different years or scenarios are not yet supported.' + f'Different values in different periods or scenarios are not yet supported.' ) @property @@ -530,7 +530,7 @@ def _do_modeling(self): 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, ), - coords=['year', 'scenario'], + coords=['period', 'scenario'], short_name='total_flow_hours', ) diff --git a/flixopt/features.py b/flixopt/features.py index 160a32b33..835654f94 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -58,13 +58,13 @@ def _create_variables_and_constraints(self): short_name='size', lower=0 if self.parameters.optional else size_min, upper=size_max, - coords=self._model.get_coords(['year', 'scenario']), + coords=self._model.get_coords(['period', 'scenario']), ) if self.parameters.optional: self.add_variables( binary=True, - coords=self._model.get_coords(['year', 'scenario']), + coords=self._model.get_coords(['period', 'scenario']), short_name='is_invested', ) @@ -97,7 +97,7 @@ def _add_effects(self): effect: self.is_invested * factor if self.is_invested is not None else factor for effect, factor in self.parameters.fix_effects.items() }, - target='nontemporal', + target='periodic', ) if self.parameters.divest_effects and self.parameters.optional: @@ -107,14 +107,14 @@ def _add_effects(self): effect: -self.is_invested * factor + factor for effect, factor in self.parameters.divest_effects.items() }, - target='nontemporal', + target='periodic', ) if self.parameters.specific_effects: self._model.effects.add_share_to_effects( name=self.label_of_element, expressions={effect: self.size * factor for effect, factor in self.parameters.specific_effects.items()}, - target='nontemporal', + target='periodic', ) @property @@ -175,7 +175,7 @@ def _do_modeling(self): self.parameters.on_hours_total_max if self.parameters.on_hours_total_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=['year', 'scenario'], + coords=['period', 'scenario'], ) # 4. Switch tracking using existing pattern @@ -197,7 +197,7 @@ def _do_modeling(self): count = self.add_variables( lower=0, upper=self.parameters.switch_on_total_max, - coords=self._model.get_coords(('year', 'scenario')), + coords=self._model.get_coords(('period', 'scenario')), short_name='switch|count', ) self.add_constraints(count == self.switch_on.sum('time'), short_name='switch|count') @@ -325,7 +325,7 @@ def __init__( def _do_modeling(self): super()._do_modeling() - dims = ('time', 'year', 'scenario') if self._as_time_series else ('year', 'scenario') + dims = ('time', 'period', 'scenario') if self._as_time_series else ('period', 'scenario') self.inside_piece = self.add_variables( binary=True, short_name='inside_piece', @@ -417,7 +417,7 @@ def _do_modeling(self): rhs = self.zero_point elif self._zero_point is True: self.zero_point = self.add_variables( - coords=self._model.get_coords(('year', 'scenario') if self._as_time_series else None), + coords=self._model.get_coords(('period', 'scenario') if self._as_time_series else None), binary=True, short_name='zero_point', ) @@ -456,7 +456,7 @@ def __init__( def _do_modeling(self): self.shares = { - effect: self.add_variables(coords=self._model.get_coords(['year', 'scenario']), short_name=effect) + effect: self.add_variables(coords=self._model.get_coords(['period', 'scenario']), short_name=effect) for effect in self._piecewise_shares } @@ -484,7 +484,7 @@ def _do_modeling(self): self._model.effects.add_share_to_effects( name=self.label_of_element, expressions={effect: variable * 1 for effect, variable in self.shares.items()}, - target='nontemporal', + target='periodic', ) @@ -567,8 +567,8 @@ def add_share( else: if 'time' in dims and 'time' not in self._dims: raise ValueError('Cannot add share with time-dim to a model without time-dim') - if 'year' in dims and 'year' not in self._dims: - raise ValueError('Cannot add share with year-dim to a model without year-dim') + if 'period' in dims and 'period' not in self._dims: + raise ValueError('Cannot add share with period-dim to a model without period-dim') if 'scenario' in dims and 'scenario' not in self._dims: raise ValueError('Cannot add share with scenario-dim to a model without scenario-dim') diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index ffd9761d7..83faac60e 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -17,8 +17,8 @@ ConversionError, DataConverter, FlowSystemDimensions, - NonTemporalData, - NonTemporalDataUser, + PeriodicData, + PeriodicDataUser, TemporalData, TemporalDataUser, TimeSeriesData, @@ -26,8 +26,8 @@ from .effects import ( Effect, EffectCollection, - NonTemporalEffects, - NonTemporalEffectsUser, + PeriodicEffects, + PeriodicEffectsUser, TemporalEffects, TemporalEffectsUser, ) @@ -51,15 +51,15 @@ class FlowSystem(Interface): Args: timesteps: The timesteps of the model. - years: The years of the model. + periods: The periods of the model. scenarios: The scenarios of the model. hours_of_last_timestep: The duration of the last time step. Uses the last time interval if not specified hours_of_previous_timesteps: The duration of previous timesteps. If None, the first time increment of time_series is used. This is needed to calculate previous durations (for example consecutive_on_hours). If you use an array, take care that its long enough to cover all previous values! - years_of_last_year: The duration of the last year. Uses the last year interval if not specified - weights: The weights of each year and scenario. If None, all scenarios have the same weight, while the years have the weight of their represented year (all normalized to 1). Its recommended to scale the weights to sum up to 1. + periods_of_last_period: The duration of the last period. Uses the last period interval if not specified + weights: The weights of each period and scenario. If None, all scenarios have the same weight, while the periods have the weight of their represented period (all normalized to 1). Its recommended to scale the weights to sum up to 1. Notes: - Creates an empty registry for components and buses, an empty EffectCollection, and a placeholder for a SystemModel. @@ -69,12 +69,12 @@ class FlowSystem(Interface): def __init__( self, timesteps: pd.DatetimeIndex, - years: pd.Index | None = None, + periods: pd.Index | None = None, scenarios: pd.Index | None = None, hours_of_last_timestep: float | None = None, hours_of_previous_timesteps: int | float | np.ndarray | None = None, - years_of_last_year: int | None = None, - weights: NonTemporalDataUser | None = None, + periods_of_last_period: int | None = None, + weights: PeriodicDataUser | None = None, ): self.timesteps = self._validate_timesteps(timesteps) self.timesteps_extra = self._create_timesteps_with_extra(self.timesteps, hours_of_last_timestep) @@ -82,12 +82,12 @@ def __init__( self.timesteps, hours_of_previous_timesteps ) - self.years_of_last_year = years_of_last_year - if years is None: - self.years, self.years_per_year = None, None + self.periods_of_last_period = periods_of_last_period + if periods is None: + self.periods, self.periods_per_period = None, None else: - self.years = self._validate_years(years) - self.years_per_year = self.calculate_years_per_year(self.years, years_of_last_year) + self.periods = self._validate_periods(periods) + self.periods_per_period = self.calculate_periods_per_period(self.periods, periods_of_last_period) self.scenarios = None if scenarios is None else self._validate_scenarios(scenarios) @@ -140,27 +140,27 @@ def _validate_scenarios(scenarios: pd.Index) -> pd.Index: return scenarios @staticmethod - def _validate_years(years: pd.Index) -> pd.Index: + def _validate_periods(periods: pd.Index) -> pd.Index: """ - Validate and prepare year index. + Validate and prepare period index. Args: - years: The year index to validate + periods: The period index to validate """ - if not isinstance(years, pd.Index) or len(years) == 0: - raise ConversionError(f'Years must be a non-empty Index. Got {years}') + if not isinstance(periods, pd.Index) or len(periods) == 0: + raise ConversionError(f'Periods must be a non-empty Index. Got {periods}') if not ( - years.dtype.kind == 'i' # integer dtype - and years.is_monotonic_increasing # rising - and years.is_unique + periods.dtype.kind == 'i' # integer dtype + and periods.is_monotonic_increasing # rising + and periods.is_unique ): - raise ConversionError(f'Years must be a monotonically increasing and unique Index. Got {years}') + raise ConversionError(f'Periods must be a monotonically increasing and unique Index. Got {periods}') - if years.name != 'year': - years = years.rename('year') + if periods.name != 'period': + periods = periods.rename('period') - return years + return periods @staticmethod def _create_timesteps_with_extra( @@ -182,14 +182,14 @@ def calculate_hours_per_timestep(timesteps_extra: pd.DatetimeIndex) -> xr.DataAr ) @staticmethod - def calculate_years_per_year(years: pd.Index, years_of_last_year: int | None = None) -> xr.DataArray: + def calculate_periods_per_period(periods: pd.Index, periods_of_last_period: int | None = None) -> xr.DataArray: """Calculate duration of each timestep as a 1D DataArray.""" - years_per_year = np.diff(years) + periods_per_period = np.diff(periods) return xr.DataArray( - np.append(years_per_year, years_of_last_year or years_per_year[-1]), - coords={'year': years}, - dims='year', - name='years_per_year', + np.append(periods_per_period, periods_of_last_period or periods_per_period[-1]), + coords={'period': periods}, + dims='period', + name='periods_per_period', ) @staticmethod @@ -278,9 +278,9 @@ def from_dataset(cls, ds: xr.Dataset) -> FlowSystem: # Create FlowSystem instance with constructor parameters flow_system = cls( timesteps=ds.indexes['time'], - years=ds.indexes.get('year'), + periods=ds.indexes.get('period'), scenarios=ds.indexes.get('scenario'), - years_of_last_year=reference_structure.get('years_of_last_year'), + periods_of_last_period=reference_structure.get('periods_of_last_period'), weights=cls._resolve_dataarray_reference(reference_structure['weights'], arrays_dict) if 'weights' in reference_structure else None, @@ -364,9 +364,9 @@ def to_json(self, path: str | pathlib.Path): def fit_to_model_coords( self, name: str, - data: TemporalDataUser | NonTemporalDataUser | None, + data: TemporalDataUser | PeriodicDataUser | None, dims: Collection[FlowSystemDimensions] | None = None, - ) -> TemporalData | NonTemporalData | None: + ) -> TemporalData | PeriodicData | None: """ Fit data to model coordinate system (currently time, but extensible). @@ -404,11 +404,11 @@ def fit_to_model_coords( def fit_effects_to_model_coords( self, label_prefix: str | None, - effect_values: TemporalEffectsUser | NonTemporalEffectsUser | None, + effect_values: TemporalEffectsUser | PeriodicEffectsUser | None, label_suffix: str | None = None, dims: Collection[FlowSystemDimensions] | None = None, delimiter: str = '|', - ) -> TemporalEffects | NonTemporalEffects | None: + ) -> TemporalEffects | PeriodicEffects | None: """ Transform EffectValues from the user to Internal Datatypes aligned with model coordinates. """ @@ -432,7 +432,7 @@ def connect_and_transform(self): logger.debug('FlowSystem already connected and transformed') return - self.weights = self.fit_to_model_coords('weights', self.weights, dims=['year', 'scenario']) + self.weights = self.fit_to_model_coords('weights', self.weights, dims=['period', 'scenario']) if self.weights is not None: total = float(self.weights.sum().item()) if not np.isclose(total, 1.0, atol=1e-12): @@ -750,8 +750,8 @@ def all_elements(self) -> dict[str, Element]: @property def coords(self) -> dict[FlowSystemDimensions, pd.Index]: active_coords = {'time': self.timesteps} - if self.years is not None: - active_coords['year'] = self.years + if self.periods is not None: + active_coords['period'] = self.periods if self.scenarios is not None: active_coords['scenario'] = self.scenarios return active_coords @@ -763,7 +763,7 @@ def used_in_calculation(self) -> bool: def sel( self, time: str | slice | list[str] | pd.Timestamp | pd.DatetimeIndex | None = None, - year: int | slice | list[int] | pd.Index | None = None, + period: int | slice | list[int] | pd.Index | None = None, scenario: str | slice | list[str] | pd.Index | None = None, ) -> FlowSystem: """ @@ -771,7 +771,7 @@ def sel( Args: time: Time selection (e.g., slice('2023-01-01', '2023-12-31'), '2023-06-15', or list of times) - year: Year selection (e.g., slice(2023, 2024), or list of years) + period: Period selection (e.g., slice(2023, 2024), or list of periods) scenario: Scenario selection (e.g., slice('scenario1', 'scenario2'), or list of scenarios) Returns: @@ -786,8 +786,8 @@ def sel( indexers = {} if time is not None: indexers['time'] = time - if year is not None: - indexers['year'] = year + if period is not None: + indexers['period'] = period if scenario is not None: indexers['scenario'] = scenario @@ -800,7 +800,7 @@ def sel( def isel( self, time: int | slice | list[int] | None = None, - year: int | slice | list[int] | None = None, + period: int | slice | list[int] | None = None, scenario: int | slice | list[int] | None = None, ) -> FlowSystem: """ @@ -808,7 +808,7 @@ def isel( Args: time: Time selection by integer index (e.g., slice(0, 100), 50, or [0, 5, 10]) - year: Year selection by integer index (e.g., slice(0, 100), 50, or [0, 5, 10]) + period: Period selection by integer index (e.g., slice(0, 100), 50, or [0, 5, 10]) scenario: Scenario selection by integer index (e.g., slice(0, 3), 50, or [0, 5, 10]) Returns: @@ -823,8 +823,8 @@ def isel( indexers = {} if time is not None: indexers['time'] = time - if year is not None: - indexers['year'] = year + if period is not None: + indexers['period'] = period if scenario is not None: indexers['scenario'] = scenario diff --git a/flixopt/interface.py b/flixopt/interface.py index da456efee..b3edffffc 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -14,8 +14,8 @@ if TYPE_CHECKING: # for type checking and preventing circular imports from collections.abc import Iterator - from .core import NonTemporalData, NonTemporalDataUser, Scalar, TemporalDataUser - from .effects import NonTemporalEffectsUser, TemporalEffectsUser + from .core import PeriodicData, PeriodicDataUser, Scalar, TemporalDataUser + from .effects import PeriodicEffectsUser, TemporalEffectsUser from .flow_system import FlowSystem @@ -74,7 +74,7 @@ def __init__(self, start: TemporalDataUser, end: TemporalDataUser): self.has_time_dim = False def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None: - dims = None if self.has_time_dim else ['year', 'scenario'] + dims = None if self.has_time_dim else ['period', 'scenario'] self.start = flow_system.fit_to_model_coords(f'{name_prefix}|start', self.start, dims=dims) self.end = flow_system.fit_to_model_coords(f'{name_prefix}|end', self.end, dims=dims) @@ -853,20 +853,20 @@ class InvestParameters(Interface): def __init__( self, - fixed_size: NonTemporalDataUser | None = None, - minimum_size: NonTemporalDataUser | None = None, - maximum_size: NonTemporalDataUser | None = None, + fixed_size: PeriodicDataUser | None = None, + minimum_size: PeriodicDataUser | None = None, + maximum_size: PeriodicDataUser | None = None, optional: bool = True, # Investition ist weglassbar - fix_effects: NonTemporalEffectsUser | None = None, - specific_effects: NonTemporalEffectsUser | None = None, # costs per Flow-Unit/Storage-Size/... + fix_effects: PeriodicEffectsUser | None = None, + specific_effects: PeriodicEffectsUser | None = None, # costs per Flow-Unit/Storage-Size/... piecewise_effects: PiecewiseEffects | None = None, - divest_effects: NonTemporalEffectsUser | None = None, + divest_effects: PeriodicEffectsUser | None = None, ): - self.fix_effects: NonTemporalEffectsUser = fix_effects or {} - self.divest_effects: NonTemporalEffectsUser = divest_effects or {} + self.fix_effects: PeriodicEffectsUser = fix_effects or {} + self.divest_effects: PeriodicEffectsUser = divest_effects or {} self.fixed_size = fixed_size self.optional = optional - self.specific_effects: NonTemporalEffectsUser = specific_effects if specific_effects is not None else {} + self.specific_effects: PeriodicEffectsUser = specific_effects if specific_effects is not None else {} self.piecewise_effects = piecewise_effects self.minimum_size = minimum_size if minimum_size is not None else CONFIG.modeling.EPSILON self.maximum_size = maximum_size if maximum_size is not None else CONFIG.modeling.BIG # default maximum @@ -876,41 +876,41 @@ def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None label_prefix=name_prefix, effect_values=self.fix_effects, label_suffix='fix_effects', - dims=['year', 'scenario'], + dims=['period', 'scenario'], ) self.divest_effects = flow_system.fit_effects_to_model_coords( label_prefix=name_prefix, effect_values=self.divest_effects, label_suffix='divest_effects', - dims=['year', 'scenario'], + dims=['period', 'scenario'], ) self.specific_effects = flow_system.fit_effects_to_model_coords( label_prefix=name_prefix, effect_values=self.specific_effects, label_suffix='specific_effects', - dims=['year', 'scenario'], + dims=['period', 'scenario'], ) if self.piecewise_effects is not None: self.piecewise_effects.has_time_dim = False self.piecewise_effects.transform_data(flow_system, f'{name_prefix}|PiecewiseEffects') self.minimum_size = flow_system.fit_to_model_coords( - f'{name_prefix}|minimum_size', self.minimum_size, dims=['year', 'scenario'] + f'{name_prefix}|minimum_size', self.minimum_size, dims=['period', 'scenario'] ) self.maximum_size = flow_system.fit_to_model_coords( - f'{name_prefix}|maximum_size', self.maximum_size, dims=['year', 'scenario'] + f'{name_prefix}|maximum_size', self.maximum_size, dims=['period', 'scenario'] ) if self.fixed_size is not None: self.fixed_size = flow_system.fit_to_model_coords( - f'{name_prefix}|fixed_size', self.fixed_size, dims=['year', 'scenario'] + f'{name_prefix}|fixed_size', self.fixed_size, dims=['period', 'scenario'] ) @property - def minimum_or_fixed_size(self) -> NonTemporalData: + def minimum_or_fixed_size(self) -> PeriodicData: return self.fixed_size if self.fixed_size is not None else self.minimum_size @property - def maximum_or_fixed_size(self) -> NonTemporalData: + def maximum_or_fixed_size(self) -> PeriodicData: return self.fixed_size if self.fixed_size is not None else self.maximum_size @@ -1014,7 +1014,7 @@ 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 year + switch_on_total_max=200, # Maximum 200 batches per period on_hours_total_max=4000, # Maximum production time ) ``` @@ -1140,13 +1140,13 @@ def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None 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=['year', 'scenario'] + f'{name_prefix}|on_hours_total_max', self.on_hours_total_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=['year', 'scenario'] + f'{name_prefix}|on_hours_total_min', self.on_hours_total_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=['year', 'scenario'] + f'{name_prefix}|switch_on_total_max', self.switch_on_total_max, dims=['period', 'scenario'] ) @property diff --git a/flixopt/modeling.py b/flixopt/modeling.py index ac3231d60..1f1dbe08f 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -668,7 +668,7 @@ def link_changes_to_level_with_binaries( name: str, max_change: float | xr.DataArray, initial_level: float | xr.DataArray = 0.0, - coord: str = 'year', + coord: str = 'period', ) -> tuple[linopy.Constraint, linopy.Constraint, linopy.Constraint, linopy.Constraint, linopy.Constraint]: """ Link changes to level evolution with binary control and mutual exclusivity. diff --git a/flixopt/results.py b/flixopt/results.py index 596f6870e..562247b14 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -283,7 +283,7 @@ def constraints(self) -> linopy.Constraints: def effect_share_factors(self): if self._effect_share_factors is None: effect_share_factors = self.flow_system.effects.calculate_effect_share_factors() - self._effect_share_factors = {'temporal': effect_share_factors[0], 'nontemporal': effect_share_factors[1]} + self._effect_share_factors = {'temporal': effect_share_factors[0], 'periodic': effect_share_factors[1]} return self._effect_share_factors @property @@ -356,10 +356,10 @@ def effects_per_component(self) -> xr.Dataset: self._effects_per_component = xr.Dataset( { mode: self._create_effects_dataset(mode).to_dataarray('effect', name=mode) - for mode in ['temporal', 'nontemporal', 'total'] + for mode in ['temporal', 'periodic', 'total'] } ) - dim_order = ['time', 'year', 'scenario', 'component', 'effect'] + dim_order = ['time', 'period', 'scenario', 'component', 'effect'] self._effects_per_component = self._effects_per_component.transpose(*dim_order, missing_dims='ignore') return self._effects_per_component @@ -477,7 +477,7 @@ def get_effect_shares( self, element: str, effect: str, - mode: Literal['temporal', 'nontemporal'] | None = None, + mode: Literal['temporal', 'periodic'] | None = None, include_flows: bool = False, ) -> xr.Dataset: """Retrieves individual effect shares for a specific element and effect. @@ -487,7 +487,7 @@ def get_effect_shares( Args: element: The element identifier for which to retrieve effect shares. effect: The effect identifier for which to retrieve shares. - mode: Optional. The mode to retrieve shares for. Can be 'temporal', 'nontemporal', + mode: Optional. The mode to retrieve shares for. Can be 'temporal', 'periodic', or None to retrieve both. Defaults to None. Returns: @@ -507,13 +507,13 @@ def get_effect_shares( element=element, effect=effect, mode='temporal', include_flows=include_flows ), self.get_effect_shares( - element=element, effect=effect, mode='nontemporal', include_flows=include_flows + element=element, effect=effect, mode='periodic', include_flows=include_flows ), ] ) - if mode not in ['temporal', 'nontemporal']: - raise ValueError(f'Mode {mode} is not available. Choose between "temporal" and "nontemporal".') + if mode not in ['temporal', 'periodic']: + raise ValueError(f'Mode {mode} is not available. Choose between "temporal" and "periodic".') ds = xr.Dataset() @@ -541,7 +541,7 @@ def _compute_effect_total( self, element: str, effect: str, - mode: Literal['temporal', 'nontemporal', 'total'] = 'total', + mode: Literal['temporal', 'periodic', 'total'] = 'total', include_flows: bool = False, ) -> xr.DataArray: """Calculates the total effect for a specific element and effect. @@ -554,8 +554,8 @@ def _compute_effect_total( effect: The effect identifier to calculate. mode: The calculation mode. Options are: 'temporal': Returns temporal effects. - 'nontemporal': Returns investment-specific effects. - 'total': Returns the sum of temporal effects and non-temporal effects. Defaults to 'total'. + 'periodic': Returns investment-specific effects. + 'total': Returns the sum of temporal effects and periodic effects. Defaults to 'total'. include_flows: Whether to include effects from flows connected to this element. Returns: @@ -573,19 +573,19 @@ def _compute_effect_total( temporal = self._compute_effect_total( element=element, effect=effect, mode='temporal', include_flows=include_flows ) - nontemporal = self._compute_effect_total( - element=element, effect=effect, mode='nontemporal', include_flows=include_flows + periodic = self._compute_effect_total( + element=element, effect=effect, mode='periodic', include_flows=include_flows ) - if nontemporal.isnull().all() and temporal.isnull().all(): + if periodic.isnull().all() and temporal.isnull().all(): return xr.DataArray(np.nan) if temporal.isnull().all(): - return nontemporal.rename(f'{element}->{effect}') + return periodic.rename(f'{element}->{effect}') temporal = temporal.sum('time') - if nontemporal.isnull().all(): + if periodic.isnull().all(): return temporal.rename(f'{element}->{effect}') if 'time' in temporal.indexes: temporal = temporal.sum('time') - return nontemporal + temporal + return periodic + temporal total = xr.DataArray(0) share_exists = False @@ -618,12 +618,12 @@ def _compute_effect_total( total = xr.DataArray(np.nan) return total.rename(f'{element}->{effect}({mode})') - def _create_effects_dataset(self, mode: Literal['temporal', 'nontemporal', 'total']) -> xr.Dataset: + def _create_effects_dataset(self, mode: Literal['temporal', 'periodic', 'total']) -> xr.Dataset: """Creates a dataset containing effect totals for all components (including their flows). The dataset does contain the direct as well as the indirect effects of each component. Args: - mode: The calculation mode ('temporal', 'nontemporal', or 'total'). + mode: The calculation mode ('temporal', 'periodic', or 'total'). Returns: An xarray Dataset with components as dimension and effects as variables. @@ -670,7 +670,7 @@ def _create_effects_dataset(self, mode: Literal['temporal', 'nontemporal', 'tota # For now include a test to ensure correctness suffix = { 'temporal': '(temporal)|per_timestep', - 'nontemporal': '(nontemporal)', + 'periodic': '(periodic)', 'total': '', } for effect in self.effects: @@ -706,18 +706,18 @@ def plot_heatmap( save: Whether to save the plot or not. If a path is provided, the plot will be saved at that location. show: Whether to show the plot or not. engine: The engine to use for plotting. Can be either 'plotly' or 'matplotlib'. - indexer: Optional selection dict, e.g., {'scenario': 'base', 'year': 2024}. + indexer: Optional selection dict, e.g., {'scenario': 'base', 'period': 2024}. If None, uses first value for each dimension. If empty dict {}, uses all values. Examples: - Basic usage (uses first scenario, first year, all time): + Basic usage (uses first scenario, first period, all time): >>> results.plot_heatmap('Battery|charge_state') - Select specific scenario and year: + Select specific scenario and period: - >>> results.plot_heatmap('Boiler(Qth)|flow_rate', indexer={'scenario': 'base', 'year': 2024}) + >>> results.plot_heatmap('Boiler(Qth)|flow_rate', indexer={'scenario': 'base', 'period': 2024}) Time filtering (summer months only): @@ -931,7 +931,7 @@ def plot_node_balance( show: Whether to show the plot or not. colors: The colors to use for the plot. See `flixopt.plotting.ColorType` for options. engine: The engine to use for plotting. Can be either 'plotly' or 'matplotlib'. - indexer: Optional selection dict, e.g., {'scenario': 'base', 'year': 2024}. + indexer: Optional selection dict, e.g., {'scenario': 'base', 'period': 2024}. If None, uses first value for each dimension (except time). If empty dict {}, uses all values. style: The style to use for the dataset. Can be 'flow_rate' or 'flow_hours'. @@ -992,7 +992,7 @@ def plot_node_balance_pie( save: Whether to save plot. show: Whether to display plot. engine: Plotting engine ('plotly' or 'matplotlib'). - indexer: Optional selection dict, e.g., {'scenario': 'base', 'year': 2024}. + indexer: Optional selection dict, e.g., {'scenario': 'base', 'period': 2024}. If None, uses first value for each dimension. If empty dict {}, uses all values. """ @@ -1077,7 +1077,7 @@ def node_balance( - 'flow_rate': Returns the flow_rates of the Node. - 'flow_hours': Returns the flow_hours of the Node. [flow_hours(t) = flow_rate(t) * dt(t)]. Renames suffixes to |flow_hours. drop_suffix: Whether to drop the suffix from the variable names. - indexer: Optional selection dict, e.g., {'scenario': 'base', 'year': 2024}. + indexer: Optional selection dict, e.g., {'scenario': 'base', 'period': 2024}. If None, uses first value for each dimension. If empty dict {}, uses all values. """ @@ -1147,7 +1147,7 @@ def plot_charge_state( colors: Color scheme. Also see plotly. engine: Plotting engine to use. Only 'plotly' is implemented atm. style: The colors to use for the plot. See `flixopt.plotting.ColorType` for options. - indexer: Optional selection dict, e.g., {'scenario': 'base', 'year': 2024}. + indexer: Optional selection dict, e.g., {'scenario': 'base', 'period': 2024}. If None, uses first value for each dimension. If empty dict {}, uses all values. @@ -1378,7 +1378,7 @@ class SegmentedCalculationResults: identify potential issues from segmentation approach. Common Use Cases: - - **Large-Scale Analysis**: Annual or multi-year optimization results + - **Large-Scale Analysis**: Annual or multi-period optimization results - **Memory-Constrained Systems**: Results from systems exceeding hardware limits - **Segment Validation**: Verifying segmentation approach effectiveness - **Performance Monitoring**: Comparing segmented vs. full-horizon solutions @@ -1555,7 +1555,7 @@ def plot_heatmap( save: Whether to save plot. show: Whether to display plot. engine: Plotting engine. - indexer: Optional selection dict, e.g., {'scenario': 'base', 'year': 2024}. + indexer: Optional selection dict, e.g., {'scenario': 'base', 'period': 2024}. If None, uses first value for each dimension. If empty dict {}, uses all values. """ diff --git a/flixopt/structure.py b/flixopt/structure.py index 76ed57c1d..06f5b4c91 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -173,8 +173,8 @@ def weights(self) -> int | xr.DataArray: if self.flow_system.weights is None: weights = self.flow_system.fit_to_model_coords( 'weights', - 1 if self.flow_system.years is None else self.flow_system.years_per_year, - dims=['year', 'scenario'], + 1 if self.flow_system.periods is None else self.flow_system.periods_per_period, + dims=['period', 'scenario'], ) return weights / weights.sum() diff --git a/tests/conftest.py b/tests/conftest.py index 1b0f23a29..f22036283 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -43,19 +43,23 @@ def solver_fixture(request): @pytest.fixture( params=[ - {'timesteps': pd.date_range('2020-01-01', periods=10, freq='h', name='time'), 'years': None, 'scenarios': None}, { 'timesteps': pd.date_range('2020-01-01', periods=10, freq='h', name='time'), - 'years': pd.Index([2020, 2030, 2040], name='year'), + 'periods': None, 'scenarios': None, }, { 'timesteps': pd.date_range('2020-01-01', periods=10, freq='h', name='time'), - 'years': pd.Index([2020, 2030, 2040], name='year'), + 'periods': pd.Index([2020, 2030, 2040], name='period'), + 'scenarios': None, + }, + { + 'timesteps': pd.date_range('2020-01-01', periods=10, freq='h', name='time'), + 'periods': pd.Index([2020, 2030, 2040], name='period'), 'scenarios': pd.Index(['A', 'B'], name='scenario'), }, ], - ids=['time_only', 'time+years', 'time+years+scenarios'], + ids=['time_only', 'time+periods', 'time+periods+scenarios'], ) def coords_config(request): """Coordinate configurations for parametrized testing.""" diff --git a/tests/test_effect.py b/tests/test_effect.py index 48e057419..c6a29de1a 100644 --- a/tests/test_effect.py +++ b/tests/test_effect.py @@ -25,7 +25,7 @@ def test_minimal(self, basic_flow_system_linopy_coords, coords_config): assert_sets_equal( set(effect.submodel.variables), { - 'Effect1(nontemporal)', + 'Effect1(periodic)', 'Effect1(temporal)', 'Effect1(temporal)|per_timestep', 'Effect1', @@ -36,7 +36,7 @@ def test_minimal(self, basic_flow_system_linopy_coords, coords_config): assert_sets_equal( set(effect.submodel.constraints), { - 'Effect1(nontemporal)', + 'Effect1(periodic)', 'Effect1(temporal)', 'Effect1(temporal)|per_timestep', 'Effect1', @@ -44,13 +44,15 @@ def test_minimal(self, basic_flow_system_linopy_coords, coords_config): msg='Incorrect constraints', ) - assert_var_equal(model.variables['Effect1'], model.add_variables(coords=model.get_coords(['year', 'scenario']))) assert_var_equal( - model.variables['Effect1(nontemporal)'], model.add_variables(coords=model.get_coords(['year', 'scenario'])) + model.variables['Effect1'], model.add_variables(coords=model.get_coords(['period', 'scenario'])) + ) + assert_var_equal( + model.variables['Effect1(periodic)'], model.add_variables(coords=model.get_coords(['period', 'scenario'])) ) assert_var_equal( model.variables['Effect1(temporal)'], - model.add_variables(coords=model.get_coords(['year', 'scenario'])), + model.add_variables(coords=model.get_coords(['period', 'scenario'])), ) assert_var_equal( model.variables['Effect1(temporal)|per_timestep'], model.add_variables(coords=model.get_coords()) @@ -58,10 +60,9 @@ def test_minimal(self, basic_flow_system_linopy_coords, coords_config): assert_conequal( model.constraints['Effect1'], - model.variables['Effect1'] - == model.variables['Effect1(temporal)'] + model.variables['Effect1(nontemporal)'], + model.variables['Effect1'] == model.variables['Effect1(temporal)'] + model.variables['Effect1(periodic)'], ) - assert_conequal(model.constraints['Effect1(nontemporal)'], model.variables['Effect1(nontemporal)'] == 0) + assert_conequal(model.constraints['Effect1(periodic)'], model.variables['Effect1(periodic)'] == 0) assert_conequal( model.constraints['Effect1(temporal)'], model.variables['Effect1(temporal)'] == model.variables['Effect1(temporal)|per_timestep'].sum('time'), @@ -79,8 +80,8 @@ def test_bounds(self, basic_flow_system_linopy_coords, coords_config): 'Testing Effect', minimum_temporal=1.0, maximum_temporal=1.1, - minimum_nontemporal=2.0, - maximum_nontemporal=2.1, + minimum_periodic=2.0, + maximum_periodic=2.1, minimum_total=3.0, maximum_total=3.1, minimum_per_hour=4.0, @@ -93,7 +94,7 @@ def test_bounds(self, basic_flow_system_linopy_coords, coords_config): assert_sets_equal( set(effect.submodel.variables), { - 'Effect1(nontemporal)', + 'Effect1(periodic)', 'Effect1(temporal)', 'Effect1(temporal)|per_timestep', 'Effect1', @@ -104,7 +105,7 @@ def test_bounds(self, basic_flow_system_linopy_coords, coords_config): assert_sets_equal( set(effect.submodel.constraints), { - 'Effect1(nontemporal)', + 'Effect1(periodic)', 'Effect1(temporal)', 'Effect1(temporal)|per_timestep', 'Effect1', @@ -114,31 +115,30 @@ def test_bounds(self, basic_flow_system_linopy_coords, coords_config): assert_var_equal( model.variables['Effect1'], - model.add_variables(lower=3.0, upper=3.1, coords=model.get_coords(['year', 'scenario'])), + model.add_variables(lower=3.0, upper=3.1, coords=model.get_coords(['period', 'scenario'])), ) assert_var_equal( - model.variables['Effect1(nontemporal)'], - model.add_variables(lower=2.0, upper=2.1, coords=model.get_coords(['year', 'scenario'])), + model.variables['Effect1(periodic)'], + model.add_variables(lower=2.0, upper=2.1, coords=model.get_coords(['period', 'scenario'])), ) assert_var_equal( model.variables['Effect1(temporal)'], - model.add_variables(lower=1.0, upper=1.1, coords=model.get_coords(['year', 'scenario'])), + model.add_variables(lower=1.0, upper=1.1, coords=model.get_coords(['period', 'scenario'])), ) assert_var_equal( model.variables['Effect1(temporal)|per_timestep'], model.add_variables( lower=4.0 * model.hours_per_step, upper=4.1 * model.hours_per_step, - coords=model.get_coords(['time', 'year', 'scenario']), + coords=model.get_coords(['time', 'period', 'scenario']), ), ) assert_conequal( model.constraints['Effect1'], - model.variables['Effect1'] - == model.variables['Effect1(temporal)'] + model.variables['Effect1(nontemporal)'], + model.variables['Effect1'] == model.variables['Effect1(temporal)'] + model.variables['Effect1(periodic)'], ) - assert_conequal(model.constraints['Effect1(nontemporal)'], model.variables['Effect1(nontemporal)'] == 0) + assert_conequal(model.constraints['Effect1(periodic)'], model.variables['Effect1(periodic)'] == 0) assert_conequal( model.constraints['Effect1(temporal)'], model.variables['Effect1(temporal)'] == model.variables['Effect1(temporal)|per_timestep'].sum('time'), @@ -160,14 +160,14 @@ def test_shares(self, basic_flow_system_linopy_coords, coords_config): '€', 'Testing Effect', share_from_temporal={'Effect1': 1.1}, - share_from_nontemporal={'Effect1': 2.1}, + share_from_periodic={'Effect1': 2.1}, ) effect3 = fx.Effect( 'Effect3', '€', 'Testing Effect', share_from_temporal={'Effect1': 1.2}, - share_from_nontemporal={'Effect1': 2.2}, + share_from_periodic={'Effect1': 2.2}, ) flow_system.add_elements(effect1, effect2, effect3) model = create_linopy_model(flow_system) @@ -175,11 +175,11 @@ def test_shares(self, basic_flow_system_linopy_coords, coords_config): assert_sets_equal( set(effect2.submodel.variables), { - 'Effect2(nontemporal)', + 'Effect2(periodic)', 'Effect2(temporal)', 'Effect2(temporal)|per_timestep', 'Effect2', - 'Effect1(nontemporal)->Effect2(nontemporal)', + 'Effect1(periodic)->Effect2(periodic)', 'Effect1(temporal)->Effect2(temporal)', }, msg='Incorrect variables for effect2', @@ -188,19 +188,19 @@ def test_shares(self, basic_flow_system_linopy_coords, coords_config): assert_sets_equal( set(effect2.submodel.constraints), { - 'Effect2(nontemporal)', + 'Effect2(periodic)', 'Effect2(temporal)', 'Effect2(temporal)|per_timestep', 'Effect2', - 'Effect1(nontemporal)->Effect2(nontemporal)', + 'Effect1(periodic)->Effect2(periodic)', 'Effect1(temporal)->Effect2(temporal)', }, msg='Incorrect constraints for effect2', ) assert_conequal( - model.constraints['Effect2(nontemporal)'], - model.variables['Effect2(nontemporal)'] == model.variables['Effect1(nontemporal)->Effect2(nontemporal)'], + model.constraints['Effect2(periodic)'], + model.variables['Effect2(periodic)'] == model.variables['Effect1(periodic)->Effect2(periodic)'], ) assert_conequal( @@ -216,9 +216,8 @@ def test_shares(self, basic_flow_system_linopy_coords, coords_config): ) assert_conequal( - model.constraints['Effect1(nontemporal)->Effect2(nontemporal)'], - model.variables['Effect1(nontemporal)->Effect2(nontemporal)'] - == model.variables['Effect1(nontemporal)'] * 2.1, + model.constraints['Effect1(periodic)->Effect2(periodic)'], + model.variables['Effect1(periodic)->Effect2(periodic)'] == model.variables['Effect1(periodic)'] * 2.1, ) @@ -231,14 +230,14 @@ def test_shares(self, basic_flow_system_linopy_coords, coords_config): '€', 'Testing Effect', share_from_temporal={'Effect1': 1.1}, - share_from_nontemporal={'Effect1': 2.1}, + share_from_periodic={'Effect1': 2.1}, ) effect3 = fx.Effect( 'Effect3', '€', 'Testing Effect', share_from_temporal={'Effect1': 1.2, 'Effect2': 5}, - share_from_nontemporal={'Effect1': 2.2}, + share_from_periodic={'Effect1': 2.2}, ) flow_system.add_elements( effect1, @@ -267,7 +266,7 @@ def test_shares(self, basic_flow_system_linopy_coords, coords_config): ('Effect1', 'Effect3'): 1.2 + 1.1 * 5, ('Effect2', 'Effect3'): 5, }, - 'nontemporal': { + 'periodic': { ('Effect1', 'Effect2'): 2.1, ('Effect1', 'Effect3'): 2.2, }, @@ -275,8 +274,8 @@ def test_shares(self, basic_flow_system_linopy_coords, coords_config): for key, value in effect_share_factors['temporal'].items(): np.testing.assert_allclose(results.effect_share_factors['temporal'][key].values, value) - for key, value in effect_share_factors['nontemporal'].items(): - np.testing.assert_allclose(results.effect_share_factors['nontemporal'][key].values, value) + for key, value in effect_share_factors['periodic'].items(): + np.testing.assert_allclose(results.effect_share_factors['periodic'][key].values, value) xr.testing.assert_allclose( results.effects_per_component['temporal'].sum('component').sel(effect='costs', drop=True), @@ -298,25 +297,25 @@ def test_shares(self, basic_flow_system_linopy_coords, coords_config): results.solution['Effect3(temporal)|per_timestep'].fillna(0), ) - # nontemporal mode checks + # periodic mode checks xr.testing.assert_allclose( - results.effects_per_component['nontemporal'].sum('component').sel(effect='costs', drop=True), - results.solution['costs(nontemporal)'], + results.effects_per_component['periodic'].sum('component').sel(effect='costs', drop=True), + results.solution['costs(periodic)'], ) xr.testing.assert_allclose( - results.effects_per_component['nontemporal'].sum('component').sel(effect='Effect1', drop=True), - results.solution['Effect1(nontemporal)'], + results.effects_per_component['periodic'].sum('component').sel(effect='Effect1', drop=True), + results.solution['Effect1(periodic)'], ) xr.testing.assert_allclose( - results.effects_per_component['nontemporal'].sum('component').sel(effect='Effect2', drop=True), - results.solution['Effect2(nontemporal)'], + results.effects_per_component['periodic'].sum('component').sel(effect='Effect2', drop=True), + results.solution['Effect2(periodic)'], ) xr.testing.assert_allclose( - results.effects_per_component['nontemporal'].sum('component').sel(effect='Effect3', drop=True), - results.solution['Effect3(nontemporal)'], + results.effects_per_component['periodic'].sum('component').sel(effect='Effect3', drop=True), + results.solution['Effect3(periodic)'], ) # Total mode checks diff --git a/tests/test_flow.py b/tests/test_flow.py index 3c393e965..14100feb6 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -27,7 +27,8 @@ def test_flow_minimal(self, basic_flow_system_linopy_coords, coords_config): ) assert_var_equal(flow.submodel.flow_rate, model.add_variables(lower=0, upper=100, coords=model.get_coords())) assert_var_equal( - flow.submodel.total_flow_hours, model.add_variables(lower=0, coords=model.get_coords(['year', 'scenario'])) + flow.submodel.total_flow_hours, + model.add_variables(lower=0, coords=model.get_coords(['period', 'scenario'])), ) assert_sets_equal( @@ -65,7 +66,7 @@ def test_flow(self, basic_flow_system_linopy_coords, coords_config): assert_var_equal( flow.submodel.total_flow_hours, - model.add_variables(lower=10, upper=1000, coords=model.get_coords(['year', 'scenario'])), + model.add_variables(lower=10, upper=1000, coords=model.get_coords(['period', 'scenario'])), ) assert flow.relative_minimum.dims == tuple(model.get_coords()) @@ -178,7 +179,7 @@ def test_flow_invest(self, basic_flow_system_linopy_coords, coords_config): # size assert_var_equal( model['Sink(Wärme)|size'], - model.add_variables(lower=20, upper=100, coords=model.get_coords(['year', 'scenario'])), + model.add_variables(lower=20, upper=100, coords=model.get_coords(['period', 'scenario'])), ) assert flow.relative_minimum.dims == tuple(model.get_coords()) @@ -238,12 +239,12 @@ def test_flow_invest_optional(self, basic_flow_system_linopy_coords, coords_conf assert_var_equal( model['Sink(Wärme)|size'], - model.add_variables(lower=0, upper=100, coords=model.get_coords(['year', 'scenario'])), + model.add_variables(lower=0, upper=100, coords=model.get_coords(['period', 'scenario'])), ) assert_var_equal( model['Sink(Wärme)|is_invested'], - model.add_variables(binary=True, coords=model.get_coords(['year', 'scenario'])), + model.add_variables(binary=True, coords=model.get_coords(['period', 'scenario'])), ) assert flow.relative_minimum.dims == tuple(model.get_coords()) @@ -313,12 +314,12 @@ def test_flow_invest_optional_wo_min_size(self, basic_flow_system_linopy_coords, assert_var_equal( model['Sink(Wärme)|size'], - model.add_variables(lower=0, upper=100, coords=model.get_coords(['year', 'scenario'])), + model.add_variables(lower=0, upper=100, coords=model.get_coords(['period', 'scenario'])), ) assert_var_equal( model['Sink(Wärme)|is_invested'], - model.add_variables(binary=True, coords=model.get_coords(['year', 'scenario'])), + model.add_variables(binary=True, coords=model.get_coords(['period', 'scenario'])), ) assert flow.relative_minimum.dims == tuple(model.get_coords()) @@ -386,7 +387,7 @@ def test_flow_invest_wo_min_size_non_optional(self, basic_flow_system_linopy_coo assert_var_equal( model['Sink(Wärme)|size'], - model.add_variables(lower=1e-5, upper=100, coords=model.get_coords(['year', 'scenario'])), + model.add_variables(lower=1e-5, upper=100, coords=model.get_coords(['period', 'scenario'])), ) assert flow.relative_minimum.dims == tuple(model.get_coords()) @@ -436,7 +437,7 @@ def test_flow_invest_fixed_size(self, basic_flow_system_linopy_coords, coords_co # Check that size is fixed to 75 assert_var_equal( flow.submodel.variables['Sink(Wärme)|size'], - model.add_variables(lower=75, upper=75, coords=model.get_coords(['year', 'scenario'])), + model.add_variables(lower=75, upper=75, coords=model.get_coords(['period', 'scenario'])), ) # Check flow rate bounds @@ -467,20 +468,20 @@ def test_flow_invest_with_effects(self, basic_flow_system_linopy_coords, coords_ model = create_linopy_model(flow_system) # Check investment effects - assert 'Sink(Wärme)->costs(nontemporal)' in model.variables - assert 'Sink(Wärme)->CO2(nontemporal)' in model.variables + assert 'Sink(Wärme)->costs(periodic)' in model.variables + assert 'Sink(Wärme)->CO2(periodic)' in model.variables # Check fix effects (applied only when is_invested=1) assert_conequal( - model.constraints['Sink(Wärme)->costs(nontemporal)'], - model.variables['Sink(Wärme)->costs(nontemporal)'] + model.constraints['Sink(Wärme)->costs(periodic)'], + model.variables['Sink(Wärme)->costs(periodic)'] == flow.submodel.variables['Sink(Wärme)|is_invested'] * 1000 + flow.submodel.variables['Sink(Wärme)|size'] * 500, ) assert_conequal( - model.constraints['Sink(Wärme)->CO2(nontemporal)'], - model.variables['Sink(Wärme)->CO2(nontemporal)'] + model.constraints['Sink(Wärme)->CO2(periodic)'], + model.variables['Sink(Wärme)->CO2(periodic)'] == flow.submodel.variables['Sink(Wärme)|is_invested'] * 5 + flow.submodel.variables['Sink(Wärme)|size'] * 0.1, ) @@ -504,11 +505,11 @@ def test_flow_invest_divest_effects(self, basic_flow_system_linopy_coords, coord model = create_linopy_model(flow_system) # Check divestment effects - assert 'Sink(Wärme)->costs(nontemporal)' in model.constraints + assert 'Sink(Wärme)->costs(periodic)' in model.constraints assert_conequal( - model.constraints['Sink(Wärme)->costs(nontemporal)'], - model.variables['Sink(Wärme)->costs(nontemporal)'] + (model.variables['Sink(Wärme)|is_invested'] - 1) * 500 + model.constraints['Sink(Wärme)->costs(periodic)'], + model.variables['Sink(Wärme)->costs(periodic)'] + (model.variables['Sink(Wärme)|is_invested'] - 1) * 500 == 0, ) @@ -563,7 +564,7 @@ def test_flow_on(self, basic_flow_system_linopy_coords, coords_config): ) assert_var_equal( model.variables['Sink(Wärme)|on_hours_total'], - model.add_variables(lower=0, coords=model.get_coords(['year', 'scenario'])), + model.add_variables(lower=0, coords=model.get_coords(['period', 'scenario'])), ) assert_conequal( model.constraints['Sink(Wärme)|flow_rate|lb'], @@ -1011,7 +1012,7 @@ def test_switch_on_constraints(self, basic_flow_system_linopy_coords, coords_con # Check switch_on_nr variable bounds assert_var_equal( flow.submodel.variables['Sink(Wärme)|switch|count'], - model.add_variables(lower=0, upper=5, coords=model.get_coords(['year', 'scenario'])), + model.add_variables(lower=0, upper=5, coords=model.get_coords(['period', 'scenario'])), ) # Verify switch_on_nr constraint (limits number of startups) @@ -1056,7 +1057,7 @@ def test_on_hours_limits(self, basic_flow_system_linopy_coords, coords_config): # Check on_hours_total variable bounds assert_var_equal( flow.submodel.variables['Sink(Wärme)|on_hours_total'], - model.add_variables(lower=20, upper=100, coords=model.get_coords(['year', 'scenario'])), + model.add_variables(lower=20, upper=100, coords=model.get_coords(['period', 'scenario'])), ) # Check on_hours_total constraint @@ -1128,7 +1129,7 @@ def test_flow_on_invest_optional(self, basic_flow_system_linopy_coords, coords_c ) assert_var_equal( model.variables['Sink(Wärme)|on_hours_total'], - model.add_variables(lower=0, coords=model.get_coords(['year', 'scenario'])), + model.add_variables(lower=0, coords=model.get_coords(['period', 'scenario'])), ) assert_conequal( model.constraints['Sink(Wärme)|size|lb'], @@ -1155,7 +1156,7 @@ def test_flow_on_invest_optional(self, basic_flow_system_linopy_coords, coords_c # Investment assert_var_equal( model['Sink(Wärme)|size'], - model.add_variables(lower=0, upper=200, coords=model.get_coords(['year', 'scenario'])), + model.add_variables(lower=0, upper=200, coords=model.get_coords(['period', 'scenario'])), ) mega = 0.2 * 200 # Relative minimum * maximum size @@ -1226,7 +1227,7 @@ def test_flow_on_invest_non_optional(self, basic_flow_system_linopy_coords, coor ) assert_var_equal( model.variables['Sink(Wärme)|on_hours_total'], - model.add_variables(lower=0, coords=model.get_coords(['year', 'scenario'])), + model.add_variables(lower=0, coords=model.get_coords(['period', 'scenario'])), ) assert_conequal( model.constraints['Sink(Wärme)|flow_rate|lb1'], @@ -1245,7 +1246,7 @@ def test_flow_on_invest_non_optional(self, basic_flow_system_linopy_coords, coor # Investment assert_var_equal( model['Sink(Wärme)|size'], - model.add_variables(lower=20, upper=200, coords=model.get_coords(['year', 'scenario'])), + model.add_variables(lower=20, upper=200, coords=model.get_coords(['period', 'scenario'])), ) mega = 0.2 * 200 # Relative minimum * maximum size diff --git a/tests/test_integration.py b/tests/test_integration.py index 76143a1f7..6e5da63d6 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -129,13 +129,13 @@ def test_basic_flow_system(self, flow_system_base, highs_solver): ) assert_almost_equal_numeric( - calculation.results.model['Kessel(Q_th)->costs(nontemporal)'].solution.values, + calculation.results.model['Kessel(Q_th)->costs(periodic)'].solution.values, 1000 + 500, 'costs doesnt match expected value', ) assert_almost_equal_numeric( - calculation.results.model['Speicher->costs(nontemporal)'].solution.values, + calculation.results.model['Speicher->costs(periodic)'].solution.values, 800 + 1, 'costs doesnt match expected value', ) @@ -146,7 +146,7 @@ def test_basic_flow_system(self, flow_system_base, highs_solver): 'CO2 doesnt match expected value', ) assert_almost_equal_numeric( - calculation.results.model['CO2(nontemporal)'].solution.values, + calculation.results.model['CO2(periodic)'].solution.values, 0.9999999999999994, 'CO2 doesnt match expected value', ) diff --git a/tests/test_storage.py b/tests/test_storage.py index 5b84214b8..2b2bc07e4 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -288,11 +288,11 @@ def test_storage_with_investment(self, basic_flow_system_linopy_coords, coords_c # Check variable properties assert_var_equal( model['InvestStorage|size'], - model.add_variables(lower=0, upper=100, coords=model.get_coords(['year', 'scenario'])), + model.add_variables(lower=0, upper=100, coords=model.get_coords(['period', 'scenario'])), ) assert_var_equal( model['InvestStorage|is_invested'], - model.add_variables(binary=True, coords=model.get_coords(['year', 'scenario'])), + model.add_variables(binary=True, coords=model.get_coords(['period', 'scenario'])), ) assert_conequal( model.constraints['InvestStorage|size|ub'], From 870b325c1447050c2ab7a2dc6f045a4012958a5b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 29 Sep 2025 12:25:39 +0200 Subject: [PATCH 02/14] Update CHANGELOG.md --- CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 43080438d..f80bbabd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,7 +55,7 @@ costs = fx.Effect( 'costs', '€', 'Total costs', is_standard=True, is_objective=True, share_from_temporal={'CO2': 0.2, 'energy': 0.05}, # Costs receive contributions from other effects - share_from_nontemporal={'land': 100} # €100 per m² land use + share_from_periodic={'land': 100} # €100 per m² land use ) ``` This replaces the less intuitive `specific_share_to_other_effects_*` parameters and makes it clearer where effect contributions are coming from, rather than where they are going to. @@ -122,8 +122,8 @@ The weighted sum of the total objective effect of each scenario is used as the o * The assignment of Bus Objects to Flow.bus is deprecated and will be removed in a future version. Use the label of the Bus instead. * The usage of Effects objects in Dicts to assign shares to Effects is deprecated and will be removed in a future version. Use the label of the Effect instead. - Renamed `Effect` parameters: - - `minimum_investment` → `minimum_nontemporal` - - `maximum_investment` → `maximum_nontemporal` + - `minimum_investment` → `minimum_periodic` + - `maximum_investment` → `maximum_periodic` - `minimum_operation` → `minimum_temporal` - `maximum_operation` → `maximum_temporal` - `minimum_operation_per_hour` → `minimum_per_hour` @@ -132,7 +132,7 @@ The weighted sum of the total objective effect of each scenario is used as the o ### 🔥 Removed * **Effect share parameters**: The old `specific_share_to_other_effects_*` parameters were replaced WITHOUT DEPRECATION - `specific_share_to_other_effects_operation` → `share_from_temporal` (with inverted direction) - - `specific_share_to_other_effects_invest` → `share_from_nontemporal` (with inverted direction) + - `specific_share_to_other_effects_invest` → `share_from_periodic` (with inverted direction) ### 🐛 Fixed * Enhanced NetCDF I/O with proper attribute preservation for DataArrays From a555cb5aa30a61000a02b2e88c24bfd41dd3cc24 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 29 Sep 2025 12:36:00 +0200 Subject: [PATCH 03/14] Remove periods_of_last_period parameter and adjust weights calculation --- flixopt/flow_system.py | 16 +++++----------- flixopt/structure.py | 13 ++++--------- 2 files changed, 9 insertions(+), 20 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 83faac60e..48cfbaaee 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -58,12 +58,13 @@ class FlowSystem(Interface): If None, the first time increment of time_series is used. This is needed to calculate previous durations (for example consecutive_on_hours). If you use an array, take care that its long enough to cover all previous values! - periods_of_last_period: The duration of the last period. Uses the last period interval if not specified - weights: The weights of each period and scenario. If None, all scenarios have the same weight, while the periods have the weight of their represented period (all normalized to 1). Its recommended to scale the weights to sum up to 1. + 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. Notes: - Creates an empty registry for components and buses, an empty EffectCollection, and a placeholder for a SystemModel. - - The instance starts disconnected (self._connected == False) and will be connected automatically when trying to solve a calculation. + - The instance starts disconnected (self._connected_and_transformed == False) and will be + connected_and_transformed automatically when trying to solve a calculation. """ def __init__( @@ -73,7 +74,6 @@ def __init__( scenarios: pd.Index | None = None, hours_of_last_timestep: float | None = None, hours_of_previous_timesteps: int | float | np.ndarray | None = None, - periods_of_last_period: int | None = None, weights: PeriodicDataUser | None = None, ): self.timesteps = self._validate_timesteps(timesteps) @@ -82,13 +82,7 @@ def __init__( self.timesteps, hours_of_previous_timesteps ) - self.periods_of_last_period = periods_of_last_period - if periods is None: - self.periods, self.periods_per_period = None, None - else: - self.periods = self._validate_periods(periods) - self.periods_per_period = self.calculate_periods_per_period(self.periods, periods_of_last_period) - + 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 diff --git a/flixopt/structure.py b/flixopt/structure.py index 06f5b4c91..bc24ed31d 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -170,16 +170,11 @@ def get_coords( @property def weights(self) -> int | xr.DataArray: """Returns the scenario weights of the FlowSystem. If None, return weights that are normalized to 1 (one)""" - if self.flow_system.weights is None: - weights = self.flow_system.fit_to_model_coords( - 'weights', - 1 if self.flow_system.periods is None else self.flow_system.periods_per_period, - dims=['period', 'scenario'], - ) - - return weights / weights.sum() + if self.flow_system.weights is not None: + return self.flow_system.weights - return self.flow_system.weights + weights = self.flow_system.fit_to_model_coords('weights', 1, dims=['period', 'scenario']) + return weights / weights.sum() def __repr__(self) -> str: """ From d947209d6708a4b87cca1911f0db12aaab413405 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 29 Sep 2025 12:40:54 +0200 Subject: [PATCH 04/14] Bugfix --- flixopt/flow_system.py | 1 - 1 file changed, 1 deletion(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 48cfbaaee..133012ffb 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -274,7 +274,6 @@ def from_dataset(cls, ds: xr.Dataset) -> FlowSystem: timesteps=ds.indexes['time'], periods=ds.indexes.get('period'), scenarios=ds.indexes.get('scenario'), - periods_of_last_period=reference_structure.get('periods_of_last_period'), weights=cls._resolve_dataarray_reference(reference_structure['weights'], arrays_dict) if 'weights' in reference_structure else None, From 75710bf2f614df57cf453f6e8b5431591a90ff0a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 29 Sep 2025 12:42:13 +0200 Subject: [PATCH 05/14] Bugfix --- flixopt/flow_system.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 133012ffb..a63dbb50e 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -175,17 +175,6 @@ def calculate_hours_per_timestep(timesteps_extra: pd.DatetimeIndex) -> xr.DataAr hours_per_step, coords={'time': timesteps_extra[:-1]}, dims='time', name='hours_per_timestep' ) - @staticmethod - def calculate_periods_per_period(periods: pd.Index, periods_of_last_period: int | None = None) -> xr.DataArray: - """Calculate duration of each timestep as a 1D DataArray.""" - periods_per_period = np.diff(periods) - return xr.DataArray( - np.append(periods_per_period, periods_of_last_period or periods_per_period[-1]), - coords={'period': periods}, - dims='period', - name='periods_per_period', - ) - @staticmethod def _calculate_hours_of_previous_timesteps( timesteps: pd.DatetimeIndex, hours_of_previous_timesteps: float | np.ndarray | None From a3a95080361473c7375346f7608950fc709014ff Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 29 Sep 2025 13:34:39 +0200 Subject: [PATCH 06/14] Switch from "as_time_series": bool to "dims": [time, period, scenario] arguments --- flixopt/components.py | 2 +- flixopt/core.py | 2 +- flixopt/features.py | 25 ++++++++++++------------- 3 files changed, 14 insertions(+), 15 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index 883aa20bf..f35e193bb 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -787,7 +787,7 @@ def _do_modeling(self): label_of_model=f'{self.label_of_element}', piecewise_variables=piecewise_conversion, zero_point=self.on_off.on if self.on_off is not None else False, - as_time_series=True, + dims=('time', 'period', 'scenario'), ), short_name='PiecewiseConversion', ) diff --git a/flixopt/core.py b/flixopt/core.py index a3dc3cbb0..dea56ffb2 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -21,7 +21,7 @@ """User data which has no time dimension. Internally converted to a Scalar or an xr.DataArray without a time dimension.""" PeriodicData = xr.DataArray -"""Internally used datatypes for periodic data. Can be a Scalar or an xr.DataArray.""" +"""Internally used datatypes for periodic data.""" FlowSystemDimensions = Literal['time', 'period', 'scenario'] """Possible dimensions of a FlowSystem.""" diff --git a/flixopt/features.py b/flixopt/features.py index 835654f94..9843a7f57 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -314,35 +314,34 @@ def __init__( model: FlowSystemModel, label_of_element: str, label_of_model: str, - as_time_series: bool = True, + dims: FlowSystemDimensions | None, ): self.inside_piece: linopy.Variable | None = None self.lambda0: linopy.Variable | None = None self.lambda1: linopy.Variable | None = None - self._as_time_series = as_time_series + self.dims = dims super().__init__(model, label_of_element, label_of_model) def _do_modeling(self): super()._do_modeling() - dims = ('time', 'period', 'scenario') if self._as_time_series else ('period', 'scenario') self.inside_piece = self.add_variables( binary=True, short_name='inside_piece', - coords=self._model.get_coords(dims=dims), + coords=self._model.get_coords(dims=self.dims), ) self.lambda0 = self.add_variables( lower=0, upper=1, short_name='lambda0', - coords=self._model.get_coords(dims=dims), + coords=self._model.get_coords(dims=self.dims), ) self.lambda1 = self.add_variables( lower=0, upper=1, short_name='lambda1', - coords=self._model.get_coords(dims=dims), + coords=self._model.get_coords(dims=self.dims), ) # eq: lambda0(t) + lambda1(t) = inside_piece(t) @@ -357,7 +356,7 @@ def __init__( label_of_model: str, piecewise_variables: dict[str, Piecewise], zero_point: bool | linopy.Variable | None, - as_time_series: bool, + dims: FlowSystemDimensions | None, ): """ Modeling a Piecewise relation between miultiple variables. @@ -367,14 +366,14 @@ def __init__( Args: model: The FlowSystemModel that is used to create the model. label_of_element: The label of the parent (Element). Used to construct the full label of the model. - label: The label of the model. Used to construct the full label of the model. + label_of_model: The label of the model. Used to construct the full label of the model. piecewise_variables: The variables to which the Pieces are assigned. zero_point: A variable that can be used to define a zero point for the Piecewise relation. If None or False, no zero point is defined. - as_time_series: Whether the Piecewise relation is defined for a TimeSeries or a single variable. + dims: The dimensions used for variable creation. If None, all dimensions are used. """ self._piecewise_variables = piecewise_variables self._zero_point = zero_point - self._as_time_series = as_time_series + self.dims = dims self.pieces: list[PieceModel] = [] self.zero_point: linopy.Variable | None = None @@ -388,7 +387,7 @@ def _do_modeling(self): model=self._model, label_of_element=self.label_of_element, label_of_model=f'{self.label_of_element}|Piece_{i}', - as_time_series=self._as_time_series, + dims=self.dims, ), short_name=f'Piece_{i}', ) @@ -417,7 +416,7 @@ def _do_modeling(self): rhs = self.zero_point elif self._zero_point is True: self.zero_point = self.add_variables( - coords=self._model.get_coords(('period', 'scenario') if self._as_time_series else None), + coords=self._model.get_coords(self.dims), binary=True, short_name='zero_point', ) @@ -474,7 +473,7 @@ def _do_modeling(self): label_of_element=self.label_of_element, piecewise_variables=piecewise_variables, zero_point=self._zero_point, - as_time_series=False, + dims=('period', 'scenario'), label_of_model=f'{self.label_of_element}|PiecewiseEffects', ), short_name='PiecewiseEffects', From 4f7fd55a42192893947f730517a9f5783d9bac92 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 29 Sep 2025 13:59:21 +0200 Subject: [PATCH 07/14] Improve normalization of weights --- flixopt/calculation.py | 7 +++++-- flixopt/flow_system.py | 11 ++--------- flixopt/structure.py | 21 +++++++++++++-------- 3 files changed, 20 insertions(+), 19 deletions(-) diff --git a/flixopt/calculation.py b/flixopt/calculation.py index 9b2fd7b2b..6b0f509b4 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -56,12 +56,14 @@ def __init__( 'DEPRECATED: Use flow_system.sel(time=...) or flow_system.isel(time=...) instead', ] = None, folder: pathlib.Path | None = None, + normalize_weights: bool = True, ): """ Args: 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. active_timesteps: Deprecated. Use FLowSystem.sel(time=...) or FlowSystem.isel(time=...) instead. """ self.name = name @@ -82,6 +84,7 @@ def __init__( ) flow_system = flow_system.sel(time=active_timesteps) self._active_timesteps = active_timesteps # deprecated + self.normalize_weights = normalize_weights flow_system._used_in_calculation = True @@ -183,7 +186,7 @@ def do_modeling(self) -> FullCalculation: t_start = timeit.default_timer() self.flow_system.connect_and_transform() - self.model = self.flow_system.create_model() + self.model = self.flow_system.create_model(self.normalize_weights) self.model.do_modeling() self.durations['modeling'] = round(timeit.default_timer() - t_start, 2) @@ -306,7 +309,7 @@ def do_modeling(self) -> AggregatedCalculation: self._perform_aggregation() # Model the System - self.model = self.flow_system.create_model() + self.model = self.flow_system.create_model(self.normalize_weights) self.model.do_modeling() # Add Aggregation Submodel after modeling the rest self.aggregation = AggregationModel( diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index a63dbb50e..e0a3fa77f 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -415,13 +415,6 @@ def connect_and_transform(self): return self.weights = self.fit_to_model_coords('weights', self.weights, dims=['period', 'scenario']) - if self.weights is not None: - total = float(self.weights.sum().item()) - if not np.isclose(total, 1.0, atol=1e-12): - logger.warning( - 'Scenario weights are not normalized to 1. Normalizing to 1 is recommended for a better scaled model. ' - f'Sum of weights={total}' - ) self._connect_network() for element in list(self.components.values()) + list(self.effects.effects.values()) + list(self.buses.values()): @@ -454,12 +447,12 @@ def add_elements(self, *elements: Element) -> None: f'Tried to add incompatible object to FlowSystem: {type(new_element)=}: {new_element=} ' ) - def create_model(self) -> FlowSystemModel: + def create_model(self, normalize_weights: bool = True) -> FlowSystemModel: if not self.connected_and_transformed: raise RuntimeError( 'FlowSystem is not connected_and_transformed. Call FlowSystem.connect_and_transform() first.' ) - self.model = FlowSystemModel(self) + self.model = FlowSystemModel(self, normalize_weights) return self.model def plot_network( diff --git a/flixopt/structure.py b/flixopt/structure.py index bc24ed31d..82b460757 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -83,15 +83,16 @@ class FlowSystemModel(linopy.Model, SubmodelsMixin): """ The FlowSystemModel is the linopy Model that is used to create the mathematical model of the flow_system. It is used to create and store the variables and constraints for the flow_system. + + Args: + flow_system: The flow_system that is used to create the model. + normalize_weights: Whether to automatically normalize the weights to sum up to 1 when solving. """ - def __init__(self, flow_system: FlowSystem): - """ - Args: - flow_system: The flow_system that is used to create the model. - """ + def __init__(self, flow_system: FlowSystem, normalize_weights: bool): super().__init__(force_dim_names=True) self.flow_system = flow_system + self.normalize_weights = normalize_weights self.effects: EffectCollectionModel | None = None self.submodels: Submodels = Submodels({}) @@ -169,11 +170,15 @@ def get_coords( @property def weights(self) -> int | xr.DataArray: - """Returns the scenario weights of the FlowSystem. If None, return weights that are normalized to 1 (one)""" + """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 + else: + weights = self.flow_system.fit_to_model_coords('weights', 1, dims=['period', 'scenario']) + + if not self.normalize_weights: + return weights - weights = self.flow_system.fit_to_model_coords('weights', 1, dims=['period', 'scenario']) return weights / weights.sum() def __repr__(self) -> str: From 9f1dcfff448df53119aa87d6702a2280d131e585 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 29 Sep 2025 14:14:57 +0200 Subject: [PATCH 08/14] Update tests --- tests/test_scenarios.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index bfc537b8e..c00e5d6a2 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -238,9 +238,14 @@ def test_weights(flow_system_piecewise_conversion_scenarios): weights = 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) - assert_linequal(model.objective.expression, (model.variables['costs'] * weights).sum() + model.variables['Penalty']) - assert np.isclose(model.weights.sum().item(), 2.25) + normalized_weights = ( + flow_system_piecewise_conversion_scenarios.weights / flow_system_piecewise_conversion_scenarios.weights.sum() + ) + np.testing.assert_allclose(model.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) def test_weights_io(flow_system_piecewise_conversion_scenarios): @@ -317,7 +322,7 @@ def test_scenarios_selection(flow_system_piecewise_conversion_scenarios): np.testing.assert_allclose(flow_system.weights.values, flow_system_full.weights[0:2]) - calc = fx.FullCalculation(flow_system=flow_system, name='test_full_scenario') + calc = fx.FullCalculation(flow_system=flow_system, name='test_full_scenario', normalize_weights=False) calc.do_modeling() calc.solve(fx.solvers.GurobiSolver(mip_gap=0.01, time_limit_seconds=60)) @@ -326,6 +331,6 @@ def test_scenarios_selection(flow_system_piecewise_conversion_scenarios): np.testing.assert_allclose( calc.results.objective, ((calc.results.solution['costs'] * flow_system.weights).sum() + calc.results.solution['Penalty']).item(), - ) ## Acount for rounding errors + ) ## Account for rounding errors assert calc.results.solution.indexes['scenario'].equals(flow_system_full.scenarios[0:2]) From 4136a9cd476daf1f15bdd0cb871560ea08f1db7b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 29 Sep 2025 14:15:16 +0200 Subject: [PATCH 09/14] Typos in docs --- flixopt/aggregation.py | 2 +- flixopt/structure.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/flixopt/aggregation.py b/flixopt/aggregation.py index 5b9ee8a30..e29c11ce7 100644 --- a/flixopt/aggregation.py +++ b/flixopt/aggregation.py @@ -286,7 +286,7 @@ def use_low_peaks(self) -> bool: class AggregationModel(Submodel): - """The AggregationModel holds equations and variables related to the Aggregation of a FLowSystem. + """The AggregationModel holds equations and variables related to the Aggregation of a FlowSystem. It creates Equations that equates indices of variables, and introduces penalties related to binary variables, that escape the equation to their related binaries in other periods""" diff --git a/flixopt/structure.py b/flixopt/structure.py index 82b460757..3dffeb5c3 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -791,7 +791,7 @@ def _valid_label(label: str) -> str: class Submodel(SubmodelsMixin): """Stores Variables and Constraints. Its a subset of a FlowSystemModel. - Variables and constraints are stored in the main FLowSystemModel, and are referenced here. + Variables and constraints are stored in the main FlowSystemModel, and are referenced here. Can have other Submodels assigned, and can be a Submodel of another Submodel. """ @@ -1038,7 +1038,7 @@ def get(self, name: str, default=None): class ElementModel(Submodel): """ Stores the mathematical Variables and Constraints for Elements. - ElementModels are directly registered in the main FLowSystemModel + ElementModels are directly registered in the main FlowSystemModel """ def __init__(self, model: FlowSystemModel, element: Element): From 918baeb23942701efd185c57f638d90d0b15d8c1 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 29 Sep 2025 14:15:23 +0200 Subject: [PATCH 10/14] Improve docstrings --- flixopt/calculation.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/flixopt/calculation.py b/flixopt/calculation.py index 6b0f509b4..aa35e72f0 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -45,6 +45,13 @@ class Calculation: """ class for defined way of solving a flow_system optimization + + Args: + 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. + active_timesteps: Deprecated. Use FlowSystem.sel(time=...) or FlowSystem.isel(time=...) instead. """ def __init__( @@ -58,14 +65,6 @@ def __init__( folder: pathlib.Path | None = None, normalize_weights: bool = True, ): - """ - Args: - 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. - active_timesteps: Deprecated. Use FLowSystem.sel(time=...) or FlowSystem.isel(time=...) instead. - """ self.name = name if flow_system.used_in_calculation: logger.warning( @@ -180,6 +179,13 @@ class FullCalculation(Calculation): This is the most comprehensive calculation type that considers every time step in the optimization, providing the most accurate but computationally intensive solution. + + Args: + 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. + active_timesteps: Deprecated. Use FlowSystem.sel(time=...) or FlowSystem.isel(time=...) instead. """ def do_modeling(self) -> FullCalculation: From d99eac7dc3df7ff62547cfaed734d42b79f82a4a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 29 Sep 2025 14:25:08 +0200 Subject: [PATCH 11/14] Improve docstrings --- flixopt/flow_system.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index e0a3fa77f..cbfbccf4a 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -448,6 +448,12 @@ def add_elements(self, *elements: Element) -> None: ) def create_model(self, normalize_weights: bool = True) -> FlowSystemModel: + """ + Create a linopy model from the FlowSystem. + + Args: + normalize_weights: Whether to automatically normalize the weights (periods and scenarios) to sum up to 1 when solving. + """ if not self.connected_and_transformed: raise RuntimeError( 'FlowSystem is not connected_and_transformed. Call FlowSystem.connect_and_transform() first.' From 1464fccbd26a1d2f9a7112dc9f7a42ad1ad1a44a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 29 Sep 2025 14:26:14 +0200 Subject: [PATCH 12/14] Update CHANGELOG.md --- CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f80bbabd9..dddaf7c3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,7 +42,7 @@ Please keep the format of the changelog consistent with the other releases, so t ## [Unreleased] - ????-??-?? -This release brings multi-year investments and stochastic modeling to flixopt. +This release brings multi-period investments and stochastic modeling to flixopt. Furthermore, I/O methods were improved, and resampling and selection of parts of the FlowSystem are now possible. Several internal improvements were made to the codebase. @@ -60,9 +60,9 @@ costs = fx.Effect( ``` This replaces the less intuitive `specific_share_to_other_effects_*` parameters and makes it clearer where effect contributions are coming from, rather than where they are going to. -**Multi-year investments:** -A flixopt model might be modeled with a "year" dimension. -This enables modeling transformation pathways over multiple years with several investment decisions +**Multi-period investments:** +A flixopt model might be modeled with a "period" dimension. +This enables modeling transformation pathways over multiple periods with several distinct investment decisions in each period. **Stochastic modeling:** A flixopt model can be modeled with a scenario dimension. From 00e5eca087e699a7f20c5fa3201eded4526b627c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 29 Sep 2025 15:14:49 +0200 Subject: [PATCH 13/14] Improved tests: added extra time+scenarios combination --- tests/conftest.py | 7 ++++++- tests/test_effect.py | 2 ++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index f22036283..6b593c35b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -48,6 +48,11 @@ def solver_fixture(request): 'periods': None, 'scenarios': None, }, + { + 'timesteps': pd.date_range('2020-01-01', periods=10, freq='h', name='time'), + 'periods': None, + 'scenarios': pd.Index(['A', 'B'], name='scenario'), + }, { 'timesteps': pd.date_range('2020-01-01', periods=10, freq='h', name='time'), 'periods': pd.Index([2020, 2030, 2040], name='period'), @@ -59,7 +64,7 @@ def solver_fixture(request): 'scenarios': pd.Index(['A', 'B'], name='scenario'), }, ], - ids=['time_only', 'time+periods', 'time+periods+scenarios'], + ids=['time_only', 'time+scenarios', 'time+periods', 'time+periods+scenarios'], ) def coords_config(request): """Coordinate configurations for parametrized testing.""" diff --git a/tests/test_effect.py b/tests/test_effect.py index c6a29de1a..09ac6e418 100644 --- a/tests/test_effect.py +++ b/tests/test_effect.py @@ -62,6 +62,7 @@ def test_minimal(self, basic_flow_system_linopy_coords, coords_config): model.constraints['Effect1'], model.variables['Effect1'] == model.variables['Effect1(temporal)'] + model.variables['Effect1(periodic)'], ) + # In minimal/bounds tests with no contributing components, periodic totals should be zero assert_conequal(model.constraints['Effect1(periodic)'], model.variables['Effect1(periodic)'] == 0) assert_conequal( model.constraints['Effect1(temporal)'], @@ -138,6 +139,7 @@ def test_bounds(self, basic_flow_system_linopy_coords, coords_config): model.constraints['Effect1'], model.variables['Effect1'] == model.variables['Effect1(temporal)'] + model.variables['Effect1(periodic)'], ) + # In minimal/bounds tests with no contributing components, periodic totals should be zero assert_conequal(model.constraints['Effect1(periodic)'], model.variables['Effect1(periodic)'] == 0) assert_conequal( model.constraints['Effect1(temporal)'], From d2345f5b3ebc46c3b417df8d57c203c0da1c1715 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 29 Sep 2025 15:15:12 +0200 Subject: [PATCH 14/14] Add rename and improve CHANGELOG.md --- CHANGELOG.md | 2 +- flixopt/results.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dddaf7c3c..4f13a770f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -121,7 +121,7 @@ The weighted sum of the total objective effect of each scenario is used as the o * The `active_timesteps` parameter of `Calculation` is deprecated and will be removed in a future version. Use the new `sel(time=...)` method on the FlowSystem instead. * The assignment of Bus Objects to Flow.bus is deprecated and will be removed in a future version. Use the label of the Bus instead. * The usage of Effects objects in Dicts to assign shares to Effects is deprecated and will be removed in a future version. Use the label of the Effect instead. -- Renamed `Effect` parameters: +* Effect parameters renamed: - `minimum_investment` → `minimum_periodic` - `maximum_investment` → `maximum_periodic` - `minimum_operation` → `minimum_temporal` diff --git a/flixopt/results.py b/flixopt/results.py index 562247b14..f4ddc0071 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -665,7 +665,7 @@ def _create_effects_dataset(self, mode: Literal['temporal', 'periodic', 'total'] component_arrays.append(arr.expand_dims(component=[component])) - ds[effect] = xr.concat(component_arrays, dim='component', coords='minimal') + ds[effect] = xr.concat(component_arrays, dim='component', coords='minimal').rename(effect) # For now include a test to ensure correctness suffix = {