diff --git a/CHANGELOG.md b/CHANGELOG.md index d16443eeb..f6097e7a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,18 +30,18 @@ Please remove all irrelevant sections before releasing. Until here --> ## [Unreleased] - ????-??-?? -This Release brings Multi-year-investments and stochastic modeling to flixopt. -Further, IO methods were improved and resampling and selection of parts of the FlowSystem is now possible. +This release brings multi-year 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. -#### Multi-year-investments +### Multi-year investments A flixopt model might be modeled with a "year" dimension. -This enables to model transformation pathways over multiple years with several investment decisions +This enables modeling transformation pathways over multiple years with several investment decisions -#### Stochastic modeling +### Stochastic modeling A flixopt model can be modeled with a scenario dimension. -Scenarios can be weighted and variables can be equated across scenarios. This enables to model uncertainties in the flow system, such as: +Scenarios can be weighted and variables can be equated across scenarios. This enables modeling uncertainties in the flow system, such as: * Different demand profiles * Different price forecasts * Different weather conditions @@ -52,7 +52,7 @@ Common use cases are: The weighted sum of the total objective effect of each scenario is used as the objective of the optimization. -#### Improved Data handling: IO, resampling and more through xarray +#### Improved Data handling: I/O, resampling and more through xarray * IO for all Interfaces and the FlowSystem with round-trip serialization support * NetCDF export/import capabilities for all Interface objects and FlowSystem * JSON export for documentation purposes @@ -69,7 +69,7 @@ The weighted sum of the total objective effect of each scenario is used as the o ### Added -* FlowSystem Restoring: The used FlowSystem is now accessible directly form the results without manual restoring (lazily). All Parameters can be safely accessed anytime after the solve. +* FlowSystem restoring: The used FlowSystem is now accessible directly from the results without manual restoring (lazily). All parameters can be safely accessed anytime after the solve. * FlowResults added as a new class to store the results of Flows. They can now be accessed directly. * Added precomputed DataArrays for `size`s, `flow_rate`s and `flow_hour`s. * Added `effects_per_component()`-Dataset to Results that stores the direct (and indirect) effects of each component. This greatly improves the evaluation of the impact of individual Components, even with many and complex effects. @@ -83,7 +83,7 @@ The weighted sum of the total objective effect of each scenario is used as the o * **BREAKING**: Renamed class `SystemModel` to `FlowSystemModel` * **BREAKING**: Renamed class `Model` to `Submodel` * **BREAKING**: Renamed `mode` parameter in plotting methods to `style` -* FlowSystems can not be shared across multiple Calculations anymore. A copy of the FlowSystem is created instead, making every Calculation independent +* FlowSystems cannot be shared across multiple Calculations anymore. A copy of the FlowSystem is created instead, making every Calculation independent * Each Subcalculation in `SegmentedCalculation` now has its own distinct `FlowSystem` object * Type system overhaul - added clear separation between temporal and non-temporal data throughout codebase for better clarity * Enhanced FlowSystem interface with improved `__repr__()` and `__str__()` methods @@ -109,14 +109,12 @@ The weighted sum of the total objective effect of each scenario is used as the o ### *Development* * **BREAKING**: Calculation.do_modeling() now returns the Calculation object instead of its linopy.Model -* **BREAKING**: Renamed class `SystemModel` to `FlowSystemModel` -* **BREAKING**: Renamed class `Model` to `Submodel` * FlowSystem data management simplified - removed `time_series_collection` pattern in favor of direct timestep properties * Change modeling hierarchy to allow for more flexibility in future development. This leads to minimal changes in the access and creation of Submodels and their variables. * Added new module `.modeling`that contains Modelling primitives and utilities * Clearer separation between the main Model and "Submodels" * Improved access to the Submodels and their variables, constraints and submodels -* Added __repr__() for Submodels to easily inspect its content +* Added `__repr__()` for Submodels to easily inspect its content * Enhanced data handling methods * `fit_to_model_coords()` method for data alignment * `fit_effects_to_model_coords()` method for effect data processing @@ -179,7 +177,7 @@ There are no changes or new features. ## [2.1.6] - 2025-09-02 ### Changed -- `Sink`, `Source` and `SourceAndSink` now accept multiple `flows` as `inputs` and `outputs` instead of just one. This enables to model more use cases using these classes. [[#291](https://github.com/flixOpt/flixopt/pull/291) by [@FBumann](https://github.com/FBumann)] +- `Sink`, `Source` and `SourceAndSink` now accept multiple `flows` as `inputs` and `outputs` instead of just one. This enables modeling more use cases using these classes. [[#291](https://github.com/flixOpt/flixopt/pull/291) by [@FBumann](https://github.com/FBumann)] - Further, both `Sink` and `Source` now have a `prevent_simultaneous_flow_rates` argument to prevent simultaneous flow rates of more than one of their Flows. [[#291](https://github.com/flixOpt/flixopt/pull/291) by [@FBumann](https://github.com/FBumann)] ### Added diff --git a/examples/03_Calculation_types/example_calculation_types.py b/examples/03_Calculation_types/example_calculation_types.py index 7248c87ad..f449485de 100644 --- a/examples/03_Calculation_types/example_calculation_types.py +++ b/examples/03_Calculation_types/example_calculation_types.py @@ -136,7 +136,10 @@ a_strom_tarif = fx.Source( 'Stromtarif', source=fx.Flow( - 'P_el', bus='Strom', size=1000, effects_per_flow_hour={costs.label: TS_electricity_price_buy, CO2: 0.3} + 'P_el', + bus='Strom', + size=1000, + effects_per_flow_hour={costs.label: TS_electricity_price_buy, CO2.label: 0.3}, ), ) diff --git a/examples/05_Two-stage-optimization/two_stage_optimization.py b/examples/05_Two-stage-optimization/two_stage_optimization.py index eee8dd92d..7701f6056 100644 --- a/examples/05_Two-stage-optimization/two_stage_optimization.py +++ b/examples/05_Two-stage-optimization/two_stage_optimization.py @@ -1,9 +1,9 @@ """ This script demonstrates how to use downsampling of a FlowSystem to effectively reduce the size of a model. -This can be very useful when working with large models or during developement state, +This can be very useful when working with large models or during development, as it can drastically reduce the computational time. This leads to faster results and easier debugging. -A common use case is to do optimize the investments of a model with a downsampled version of the original model, and than fix the computed sizes when calculating th actual dispatch. +A common use case is to optimize the investments of a model with a downsampled version of the original model, and then fix the computed sizes when calculating the actual dispatch. While the final optimum might differ from the global optimum, the solving will be much faster. """ @@ -124,10 +124,10 @@ calculation_dispatch.solve(fx.solvers.HighsSolver(0.1 / 100, 600)) timer_dispatch = timeit.default_timer() - start - if (calculation_dispatch.results.sizes().round(5) == calculation_sizing.results.sizes().round(5)).all(): - logger.info('Sizes where correctly equalized') + if (calculation_dispatch.results.sizes().round(5) == calculation_sizing.results.sizes().round(5)).all().item(): + logger.info('Sizes were correctly equalized') else: - raise RuntimeError('Sizes where not correctly equalized') + raise RuntimeError('Sizes were not correctly equalized') # Optimization of both flow sizes and dispatch together start = timeit.default_timer() diff --git a/flixopt/calculation.py b/flixopt/calculation.py index dc525e147..966d31e1e 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -160,7 +160,7 @@ def summary(self): @property def active_timesteps(self) -> pd.DatetimeIndex: warnings.warn( - 'active_timesteps is deprecated. Use active_timesteps instead.', + 'active_timesteps is deprecated. Use flow_system.sel(time=...) or flow_system.isel(time=...) instead.', DeprecationWarning, stacklevel=2, ) @@ -322,23 +322,18 @@ def _perform_aggregation(self): t_start_agg = timeit.default_timer() # Validation - dt_min, dt_max = ( - np.min(self.flow_system.hours_per_timestep), - np.max(self.flow_system.hours_per_timestep), - ) + dt_min = float(self.flow_system.hours_per_timestep.min().item()) + dt_max = float(self.flow_system.hours_per_timestep.max().item()) if not dt_min == dt_max: raise ValueError( f'Aggregation failed due to inconsistent time step sizes:' f'delta_t varies from {dt_min} to {dt_max} hours.' ) - steps_per_period = self.aggregation_parameters.hours_per_period / self.flow_system.hours_per_timestep.max() - is_integer = ( - self.aggregation_parameters.hours_per_period % self.flow_system.hours_per_timestep.max() - ).item() == 0 - if not (steps_per_period.size == 1 and is_integer): + ratio = self.aggregation_parameters.hours_per_period / dt_max + if not np.isclose(ratio, round(ratio), atol=1e-9): raise ValueError( f'The selected {self.aggregation_parameters.hours_per_period=} does not match the time ' - f'step size of {dt_min} hours). It must be a multiple of {dt_min} hours.' + f'step size of {dt_max} hours. It must be an integer multiple of {dt_max} hours.' ) logger.info(f'{"":#^80}') diff --git a/flixopt/components.py b/flixopt/components.py index 4b6f3699e..dcd3fd205 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -142,11 +142,11 @@ class LinearConverter(Component): Note: Conversion factors define linear relationships where the sum of (coefficient × flow_rate) equals zero for each equation: factor1×flow1 + factor2×flow2 + ... = 0 - Conversion factors define linear relationships. - `{flow1: a1, flow2: a2, ...}` leads to `a1×flow_rate1 + a2×flow_rate2 + ... = 0` - Unfortunately the current input format doest read intuitively: - {"electricity": 1, "H2": 50} means that the electricity_in flow rate is multiplied by 1 - and the hydrogen_out flow rate is multiplied by 50. THis leads to 50 electricity --> 1 H2. + Conversion factors define linear relationships: + `{flow1: a1, flow2: a2, ...}` yields `a1×flow_rate1 + a2×flow_rate2 + ... = 0`. + Note: The input format may be unintuitive. For example, + `{"electricity": 1, "H2": 50}` implies `1×electricity = 50×H2`, + i.e., 50 units of electricity produce 1 unit of H2. The system must have fewer conversion factors than total flows (degrees of freedom > 0) to avoid over-constraining the problem. For n total flows, use at most n-1 conversion factors. @@ -200,8 +200,9 @@ def _plausibility_checks(self) -> None: for flow in self.flows.values(): if isinstance(flow.size, InvestParameters) and flow.size.fixed_size is None: logger.warning( - f'Using a FLow with a fixed size ({flow.label_full}) AND a piecewise_conversion ' - f'(in {self.label_full}) and variable size is uncommon. Please check if this is intended!' + f'Using a Flow with variable size (InvestParameters without fixed_size) ' + f'and a piecewise_conversion in {self.label_full} is uncommon. Please verify intent ' + f'({flow.label_full}).' ) def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None: diff --git a/flixopt/effects.py b/flixopt/effects.py index f465dcc3b..c6c6cc374 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -9,7 +9,7 @@ import logging import warnings -from collections.abc import Iterator +from collections import deque from typing import TYPE_CHECKING, Literal import linopy @@ -325,14 +325,16 @@ def create_effect_values_dict( Examples -------- - effect_values_user = 20 -> {None: 20} - effect_values_user = None -> None - effect_values_user = {effect1: 20, effect2: 0.3} -> {effect1: 20, effect2: 0.3} + effect_values_user = 20 -> {'': 20} + effect_values_user = {None: 20} -> {'': 20} + effect_values_user = None -> None + effect_values_user = {'effect1': 20, 'effect2': 0.3} -> {'effect1': 20, 'effect2': 0.3} Returns ------- dict or None - A dictionary with None or Effect as the key, or None if input is None. + A dictionary keyed by effect label, or None if input is None. + Note: a standard effect must be defined when passing scalars or None labels. """ def get_effect_label(eff: Effect | str) -> str: @@ -354,6 +356,11 @@ def get_effect_label(eff: Effect | str) -> str: return None if isinstance(effect_values_user, dict): return {get_effect_label(effect): value for effect, value in effect_values_user.items()} + if self.standard_effect is None: + raise KeyError( + 'Scalar effect value provided but no standard effect is configured. ' + 'Either set an effect as is_standard=True or provide a mapping {effect_label: value}.' + ) return {self.standard_effect.label: effect_values_user} def _plausibility_checks(self) -> None: @@ -532,7 +539,7 @@ def _add_share_between_effects(self): def calculate_all_conversion_paths( - conversion_dict: dict[str, dict[str, xr.DataArray]], + conversion_dict: dict[str, dict[str, Scalar | xr.DataArray]], ) -> dict[tuple[str, str], xr.DataArray]: """ Calculates all possible direct and indirect conversion factors between units/domains. @@ -564,10 +571,10 @@ def calculate_all_conversion_paths( # Keep track of visited paths to avoid repeating calculations processed_paths = set() # Use a queue with (current_domain, factor, path_history) - queue = [(origin, 1, [origin])] + queue = deque([(origin, 1, [origin])]) while queue: - current_domain, factor, path = queue.pop(0) + current_domain, factor, path = queue.popleft() # Skip if we've processed this exact path before path_key = tuple(path) diff --git a/flixopt/elements.py b/flixopt/elements.py index e67f6bd8f..dde1c3379 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -484,8 +484,8 @@ 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 yetsupported.' + 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.' ) @property diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 5a91dc36c..09781ae3f 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -77,9 +77,9 @@ def __init__( weights: NonTemporalDataUser | None = None, ): self.timesteps = self._validate_timesteps(timesteps) - self.timesteps_extra = self._create_timesteps_with_extra(timesteps, hours_of_last_timestep) + self.timesteps_extra = self._create_timesteps_with_extra(self.timesteps, hours_of_last_timestep) self.hours_of_previous_timesteps = self._calculate_hours_of_previous_timesteps( - timesteps, hours_of_previous_timesteps + self.timesteps, hours_of_previous_timesteps ) self.years_of_last_year = years_of_last_year @@ -432,11 +432,13 @@ def connect_and_transform(self): return self.weights = self.fit_to_model_coords('weights', self.weights, dims=['year', 'scenario']) - if self.weights is not None and self.weights.sum() != 1: - logger.warning( - f'Scenario weights are not normalized to 1. This is recomended for a better scaled model. ' - f'Sum of weights={self.weights.sum().item()}' - ) + 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()): @@ -474,8 +476,8 @@ def create_model(self) -> FlowSystemModel: raise RuntimeError( 'FlowSystem is not connected_and_transformed. Call FlowSystem.connect_and_transform() first.' ) - self.submodel = FlowSystemModel(self) - return self.submodel + self.model = FlowSystemModel(self) + return self.model def plot_network( self, @@ -558,7 +560,7 @@ def stop_network_app(self): ) if self._network_app is None: - logger.warning('No network app is currently running. Cant stop it') + logger.warning("No network app is currently running. Can't stop it") return try: diff --git a/flixopt/interface.py b/flixopt/interface.py index fea55b17e..da456efee 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -420,7 +420,7 @@ class PiecewiseConversion(Interface): def __init__(self, piecewises: dict[str, Piecewise]): self.piecewises = piecewises self._has_time_dim = True - self.has_time_dim = True # Inital propagation + self.has_time_dim = True # Initial propagation @property def has_time_dim(self): @@ -640,7 +640,7 @@ def __init__(self, piecewise_origin: Piecewise, piecewise_shares: dict[str, Piec self.piecewise_origin = piecewise_origin self.piecewise_shares = piecewise_shares self._has_time_dim = False - self.has_time_dim = False # Inital propagation + self.has_time_dim = False # Initial propagation @property def has_time_dim(self): @@ -1166,7 +1166,7 @@ def use_consecutive_off_hours(self) -> bool: @property def use_switch_on(self) -> bool: - """Determines wether a Variable for SWITCH-ON is needed or not""" + """Determines whether a variable for switch_on is needed or not""" if self.force_switch_on: return True diff --git a/flixopt/modeling.py b/flixopt/modeling.py index 4e368d7ae..ac3231d60 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -525,7 +525,7 @@ def scaled_bounds_with_state( List[linopy.Constraint]: List of constraint objects """ if not isinstance(model, Submodel): - raise ValueError('BoundingPatterns.active_bounds_with_state() can only be used with a Submodel') + raise ValueError('BoundingPatterns.scaled_bounds_with_state() can only be used with a Submodel') rel_lower, rel_upper = relative_bounds scaling_min, scaling_max = scaling_bounds diff --git a/flixopt/results.py b/flixopt/results.py index d8834213a..e153178be 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -292,8 +292,6 @@ def flow_system(self) -> FlowSystem: Contains all input parameters.""" if self._flow_system is None: try: - from . import FlowSystem - current_logger_level = logger.getEffectiveLevel() logger.setLevel(logging.CRITICAL) self._flow_system = FlowSystem.from_dataset(self.flow_system_data)