From 0a4a5302174a1240ff9fa55b8b563d785051ca3f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 29 Jan 2025 15:37:52 +0100 Subject: [PATCH 001/507] Improving linopy --- flixOpt/calculation.py | 2 +- flixOpt/math_modeling.py | 155 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 155 insertions(+), 2 deletions(-) diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py index 36de3bf23..391ddd516 100644 --- a/flixOpt/calculation.py +++ b/flixOpt/calculation.py @@ -39,7 +39,7 @@ def __init__( self, name, flow_system: FlowSystem, - modeling_language: Literal['pyomo', 'cvxpy'] = 'pyomo', + modeling_language: Literal['pyomo', 'cvxpy', 'linopy'] = 'pyomo', time_indices: Optional[Union[range, List[int]]] = None, ): """ diff --git a/flixOpt/math_modeling.py b/flixOpt/math_modeling.py index 4fe7d70c0..203cf2754 100644 --- a/flixOpt/math_modeling.py +++ b/flixOpt/math_modeling.py @@ -13,6 +13,7 @@ from typing import Any, Dict, List, Literal, Optional, Union import numpy as np +from numpy import inf import pyomo.environ as pyo from . import utils @@ -472,7 +473,7 @@ class MathModel: Returns a dictionary of variable results after solving. """ - def __init__(self, label: str, modeling_language: Literal['pyomo', 'cvxpy'] = 'pyomo'): + def __init__(self, label: str, modeling_language: Literal['pyomo', 'cvxpy', 'linopy'] = 'linopy'): self._infos = {} self.label = label self.modeling_language: str = modeling_language @@ -513,6 +514,9 @@ def translate_to_modeling_language(self) -> None: if self.modeling_language == 'pyomo': self.model = PyomoModel() self.model.translate_model(self) + elif self.modeling_language == 'linopy': + self.model = LinopyModel() + self.model.translate_model(self) else: raise NotImplementedError('Modeling Language cvxpy is not yet implemented') self.duration['Translation'] = round(timeit.default_timer() - t_start, 2) @@ -799,6 +803,14 @@ def solve(self, modeling_language: 'ModelingLanguage'): '"gurobi_logtools". For further details of the solving process, ' 'install the dependency via "pip install gurobi_logtools".' ) + elif isinstance(modeling_language, LinopyModel): + status = modeling_language.model.solve( + 'gurobi', + **{'mipgap': self.mip_gap, 'TimeLimit': self.time_limit_seconds}) + + self.objective = modeling_language.model.objective.value + self.termination_message = status[1] + self.best_bound = modeling_language.model.solver_model.ObjBound else: raise NotImplementedError('Only Pyomo is implemented for GUROBI solver.') @@ -1143,3 +1155,144 @@ def _register_pyomo_comp(self, pyomo_comp, part: Union[Variable, Equation, Inequ self._counter += 1 # Counter to guarantee unique names self.model.add_component(f'{part.label}__{self._counter}', pyomo_comp) self.mapping[part] = pyomo_comp + + +class LinopyModel(ModelingLanguage): + """ + Pyomo-based modeling language for constructing and solving optimization models. + Translates a MathModel into a PyomoModel. + + Attributes: + model: Pyomo model instance. + mapping (dict): Maps variables and equations to Pyomo components. + _counter (int): Counter for naming Pyomo components. + """ + + def __init__(self): + global linopy + global pd + import linopy + import pandas as pd + + logger.debug('Imported linopy and pandas') + self.model = linopy.Model() + self.mapping: Dict[Variable, linopy.Variable] = {} + + def solve(self, math_model: MathModel, solver: Solver): + solver.solve(self) + + # write results + math_model.result_of_objective = self.model.objective.value + for variable in math_model.variables: + raw_results = self.mapping[variable].solution + if variable.is_binary: + dtype = np.int8 # geht das vielleicht noch kleiner ??? + else: + dtype = float + # transform to np-array (fromiter() is 5-7x faster than np.array(list(...)) ) + result = np.fromiter(raw_results, dtype=dtype) + # Falls skalar: + if len(result) == 1: + variable.result = result[0] + else: + variable.result = result + + def translate_model(self, math_model: MathModel): + for variable in math_model.variables: # Variablen erstellen + logger.debug(f'VAR {variable.label} gets translated to linopy') + self.translate_variable(variable) + for eq in math_model.equations: # Gleichungen erstellen + logger.debug(f'EQ {eq.label} gets translated to linopy') + self.translate_equation(eq) + for ineq in math_model.inequations: # Ungleichungen erstellen: + logger.debug(f'INEQ {ineq.label} gets translated to linopy') + self.translate_equation(ineq) + + obj = math_model.objective + logger.debug(f'{obj.label} gets translated to Pyomo') + self.translate_objective(obj) + + def translate_variable(self, variable: Variable): + assert isinstance(variable, Variable), 'Wrong type of variable' + + if variable.is_binary: + var = self.model.add_variables(binary=True, + coords=(pd.RangeIndex(variable.indices),), + name=variable.label) + else: + lower = utils.as_vector(variable.lower_bound, + variable.length) if variable.lower_bound is not None else -inf + upper = utils.as_vector(variable.upper_bound, + variable.length) if variable.upper_bound is not None else inf + if isinstance(lower, np.ndarray) and variable.length == 1: + lower = lower[0] + if isinstance(upper, np.ndarray) and variable.length == 1: + upper = upper[0] + var = self.model.add_variables( + lower=lower, + upper=upper, + coords=(pd.RangeIndex(variable.indices),), + name=variable.label) + + if variable.fixed: # Wenn Vorgabe-Wert vorhanden: + fixed_value = utils.as_vector(variable.fixed_value, variable.length) + if isinstance(fixed_value, np.ndarray) and variable.length == 1: + fixed_value = fixed_value[0] + self.model.add_constraints( + var == fixed_value, + name=f'fix_{variable.label}' + ) + + self.mapping[variable] = var + + def translate_equation(self, constraint: _Constraint): + if not isinstance(constraint, _Constraint): + raise TypeError(f'Wrong Class: {constraint.__class__.__name__}') + + lhs = 0 + for summand in constraint.summands: + lhs += self._summand_math_expression(summand) # i-te Gleichung (wenn Skalar, dann wird i ignoriert) + rhs = constraint.constant_vector + if len(rhs) == 1: + rhs = rhs[0] + if isinstance(constraint, Equation): + self.model.add_constraints(lhs == rhs, name=constraint.label) + elif isinstance(constraint, Inequation): + self.model.add_constraints(lhs <= rhs, name=constraint.label) + else: + raise TypeError(f'Wrong Class: {constraint.__class__.__name__}') + + def translate_objective(self, objective: Equation): + if not isinstance(objective, Equation): + raise TypeError(f'Class {objective.__class__.__name__} Can not be the objective!') + if not objective.is_objective: + raise TypeError( + f'Objective Equation is not marked as objective, {objective.is_objective=}, ' + f'but was sent to translate to objective!' + ) + if objective.length != 1: + raise Exception('Length of Objective must be 0') + + lhs = 0 + for summand in objective.summands: + lhs += self._summand_math_expression(summand) # i-te Gleichung (wenn Skalar, dann wird i ignoriert) + self.model.add_objective(lhs) + + def _summand_math_expression(self, summand: Summand) -> 'linopy.LinearExpression': + linopy_variable = self.mapping[summand.variable] + if isinstance(summand, SumOfSummand): + factor = summand.factor_vec + if len(summand.factor_vec) == 1: + factor = factor[0] + return (linopy_variable * factor).sum() + + # Ausdruck für i-te Gleichung (falls Skalar, dann immer gleicher Ausdruck ausgegeben) + if summand.length == 1: # ignore argument at_index, because Skalar is used for every single equation + if linopy_variable.size == 1: + return linopy_variable * summand.factor_vec[0] + return linopy_variable.loc[0] * summand.factor_vec[0] + if len(summand.indices) == 1: + if linopy_variable.size == 1: + return linopy_variable * summand.factor_vec[0] + return linopy_variable.loc[0] * summand.factor_vec[0] + return linopy_variable.loc[summand.indices] * summand.factor_vec From e0fe36ef56ba9d11c1e76c638565290fb5cedef0 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 30 Jan 2025 08:18:59 +0100 Subject: [PATCH 002/507] Improve linopy --- flixOpt/math_modeling.py | 60 +++++++++++++++++++++++----------------- 1 file changed, 35 insertions(+), 25 deletions(-) diff --git a/flixOpt/math_modeling.py b/flixOpt/math_modeling.py index 203cf2754..c7cbd7c57 100644 --- a/flixOpt/math_modeling.py +++ b/flixOpt/math_modeling.py @@ -1189,13 +1189,13 @@ def solve(self, math_model: MathModel, solver: Solver): dtype = np.int8 # geht das vielleicht noch kleiner ??? else: dtype = float - # transform to np-array (fromiter() is 5-7x faster than np.array(list(...)) ) - result = np.fromiter(raw_results, dtype=dtype) - # Falls skalar: - if len(result) == 1: - variable.result = result[0] - else: - variable.result = result + + if raw_results.ndim == 0 and dtype == float: + variable.result = float(raw_results) + elif raw_results.ndim == 0 and dtype == np.int8: + variable.result = np.int8(raw_results) + else: # transform to np-array (fromiter() is 5-7x faster than np.array(list(...)) ) + variable.result = np.fromiter(raw_results, dtype=dtype) def translate_model(self, math_model: MathModel): for variable in math_model.variables: # Variablen erstellen @@ -1217,7 +1217,7 @@ def translate_variable(self, variable: Variable): if variable.is_binary: var = self.model.add_variables(binary=True, - coords=(pd.RangeIndex(variable.indices),), + coords=(pd.RangeIndex(variable.indices),) if len(variable.indices) > 1 else None, name=variable.label) else: lower = utils.as_vector(variable.lower_bound, @@ -1231,7 +1231,7 @@ def translate_variable(self, variable: Variable): var = self.model.add_variables( lower=lower, upper=upper, - coords=(pd.RangeIndex(variable.indices),), + coords=(pd.RangeIndex(variable.indices),) if len(variable.indices) > 1 else None, name=variable.label) if variable.fixed: # Wenn Vorgabe-Wert vorhanden: @@ -1250,7 +1250,8 @@ def translate_equation(self, constraint: _Constraint): raise TypeError(f'Wrong Class: {constraint.__class__.__name__}') lhs = 0 - for summand in constraint.summands: + summands_sorted = sorted(constraint.summands, key=lambda summand: len(summand.factor_vec), reverse=True) + for summand in summands_sorted: #Sorting is necessary to not cretae a ScalarExpression if SumOfSummand is present lhs += self._summand_math_expression(summand) # i-te Gleichung (wenn Skalar, dann wird i ignoriert) rhs = constraint.constant_vector if len(rhs) == 1: @@ -1279,20 +1280,29 @@ def translate_objective(self, objective: Equation): self.model.add_objective(lhs) def _summand_math_expression(self, summand: Summand) -> 'linopy.LinearExpression': + linopy_variable = self.mapping[summand.variable] - if isinstance(summand, SumOfSummand): - factor = summand.factor_vec - if len(summand.factor_vec) == 1: - factor = factor[0] - return (linopy_variable * factor).sum() - # Ausdruck für i-te Gleichung (falls Skalar, dann immer gleicher Ausdruck ausgegeben) - if summand.length == 1: # ignore argument at_index, because Skalar is used for every single equation - if linopy_variable.size == 1: - return linopy_variable * summand.factor_vec[0] - return linopy_variable.loc[0] * summand.factor_vec[0] - if len(summand.indices) == 1: - if linopy_variable.size == 1: - return linopy_variable * summand.factor_vec[0] - return linopy_variable.loc[0] * summand.factor_vec[0] - return linopy_variable.loc[summand.indices] * summand.factor_vec + if summand.variable.length != 1: + linopy_variable = linopy_variable.loc[summand.indices] + + factor = summand.factor_vec + if len(summand.factor_vec) == 1: + factor = factor[0] + + if summand.variable.length == 1 and len(summand.factor_vec) != 1: + + def scalar_var_and_array_factor(m, i): + return linopy_variable.at[i] * factor[i] + + expr = self.model.linexpr(scalar_var_and_array_factor, (range(len(factor)),)) + if isinstance(summand, SumOfSummand): + return expr.sum() + else: + return expr + + if isinstance(summand, SumOfSummand): + return (factor * linopy_variable).sum() + else: + # Ausdruck für i-te Gleichung (falls Skalar, dann immer gleicher Ausdruck ausgegeben) + return linopy_variable * factor From 863b4b20f14c24e3a114ecf6084f7a622d7a50f4 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 30 Jan 2025 08:39:54 +0100 Subject: [PATCH 003/507] Add highs solver to linopy --- flixOpt/math_modeling.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/flixOpt/math_modeling.py b/flixOpt/math_modeling.py index c7cbd7c57..2d8d7c102 100644 --- a/flixOpt/math_modeling.py +++ b/flixOpt/math_modeling.py @@ -904,6 +904,14 @@ def solve(self, modeling_language: 'ModelingLanguage'): logger.warning(f'Solution is not optimal. Termination Message: "{self.termination_message}"') self.best_bound = self._results.best_objective_bound self.log = f'Not Implemented for {self.__class__.__name__} yet' + elif isinstance(modeling_language, LinopyModel): + status = modeling_language.model.solve( + 'highs', + **{'mip_rel_gap': self.mip_gap, 'time_limit': self.time_limit_seconds}) + + self.objective = modeling_language.model.objective.value + self.termination_message = status[1] + self.best_bound = None else: raise NotImplementedError('Only Pyomo is implemented for HIGHS solver.') From 093c6b043f9ef68125fd035822ebf5c0cc0250bf Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 31 Jan 2025 09:03:45 +0100 Subject: [PATCH 004/507] Temporary commit --- examples/00_Minmal/minimal_example.py | 2 +- examples/01_Simple/simple_example.py | 4 ++-- examples/02_Complex/complex_example.py | 2 +- tests/test_integration.py | 10 +++++----- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/examples/00_Minmal/minimal_example.py b/examples/00_Minmal/minimal_example.py index efaed0dbf..db004cfe8 100644 --- a/examples/00_Minmal/minimal_example.py +++ b/examples/00_Minmal/minimal_example.py @@ -50,7 +50,7 @@ flow_system.add_elements(cost_effect, boiler, heat_load, gas_source) # --- Define and Run Calculation --- - calculation = fx.FullCalculation('Simulation1', flow_system) + calculation = fx.FullCalculation('Simulation1', flow_system, modeling_language='linopy') calculation.do_modeling() # --- Solve the Calculation and Save Results --- diff --git a/examples/01_Simple/simple_example.py b/examples/01_Simple/simple_example.py index 317df2665..b07db3f17 100644 --- a/examples/01_Simple/simple_example.py +++ b/examples/01_Simple/simple_example.py @@ -63,7 +63,7 @@ label='Storage', charging=fx.Flow('Q_th_load', bus=Fernwaerme, size=1000), discharging=fx.Flow('Q_th_unload', bus=Fernwaerme, size=1000), - capacity_in_flow_hours=fx.InvestParameters(fix_effects=20, fixed_size=30, optional=False), + capacity_in_flow_hours=30,#fx.InvestParameters(fix_effects=20, fixed_size=30, optional=False), initial_charge_state=0, # Initial storage state: empty relative_maximum_charge_state=1 / 100 * np.array([80, 70, 80, 80, 80, 80, 80, 80, 80, 80]), eta_charge=0.9, @@ -99,7 +99,7 @@ # --- Define and Run Calculation --- # Create a calculation object to model the Flow System - calculation = fx.FullCalculation(name='Sim1', flow_system=flow_system) + calculation = fx.FullCalculation(name='Sim1', flow_system=flow_system, modeling_language='linopy') calculation.do_modeling() # Translate the model to a solvable form, creating equations and Variables # --- Solve the Calculation and Save Results --- diff --git a/examples/02_Complex/complex_example.py b/examples/02_Complex/complex_example.py index e5828583d..8b9f5b5a6 100644 --- a/examples/02_Complex/complex_example.py +++ b/examples/02_Complex/complex_example.py @@ -178,7 +178,7 @@ pprint(flow_system) # Get a string representation of the FlowSystem # --- Solve FlowSystem --- - calculation = fx.FullCalculation('Sim1', flow_system, 'pyomo', time_indices) + calculation = fx.FullCalculation('Sim1', flow_system, 'linopy', time_indices) calculation.do_modeling() # Show variables as str (else, you can find them in the results.yaml file diff --git a/tests/test_integration.py b/tests/test_integration.py index d6d2a9135..9c88e929e 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -169,7 +169,7 @@ def model(self, save_results=False) -> fx.FullCalculation: print(es) es.visualize_network() - aCalc = fx.FullCalculation('Test_Sim', es, 'pyomo', time_indices) + aCalc = fx.FullCalculation('Test_Sim', es, 'linopy', time_indices) aCalc.do_modeling() aCalc.solve(self.get_solver(), save_results=save_results) @@ -592,7 +592,7 @@ def basic_model(self) -> fx.FullCalculation: print(es) es.visualize_network() - aCalc = fx.FullCalculation('Sim1', es, 'pyomo', None) + aCalc = fx.FullCalculation('Sim1', es, 'linopy', None) aCalc.do_modeling() aCalc.solve(self.get_solver()) @@ -696,7 +696,7 @@ def segments_of_flows_model(self): print(es) es.visualize_network() - aCalc = fx.FullCalculation('Sim1', es, 'pyomo', None) + aCalc = fx.FullCalculation('Sim1', es, 'linopy', None) aCalc.do_modeling() aCalc.solve(self.get_solver()) @@ -840,12 +840,12 @@ def calculate(self, modeling_type: Literal['full', 'segmented', 'aggregated']): es.visualize_network() if doFullCalc: - calc = fx.FullCalculation('fullModel', es, 'pyomo') + calc = fx.FullCalculation('fullModel', es, 'linopy') calc.do_modeling() calc.solve(self.get_solver(), save_results=True) elif doSegmentedCalc: calc = fx.SegmentedCalculation( - 'segModel', es, segment_length=96, overlap_length=1, modeling_language='pyomo' + 'segModel', es, segment_length=96, overlap_length=1, modeling_language='linopy' ) calc.do_modeling_and_solve(self.get_solver(), save_results=True) elif doAggregatedCalc: From 51ebbede2f9aec08655df83d8b7add3ad5c39881 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 3 Feb 2025 17:33:59 +0100 Subject: [PATCH 005/507] Add scipy version requirement to prevent issues with highspy --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 128189d7e..6cebd7209 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ dependencies = [ "Pyomo >= 6.4.2", "rich >= 13.0.1", "tsam >= 2.3.1", # Used for time series aggregation + "scipy >= 1.15.1", # Used by tsam. Prior versions have conflict with highspy. See https://github.com/scipy/scipy/issues/22257 "highspy >= 1.5.3", # Default solver "pandas >= 2, < 3", # Used in post-processing "matplotlib >= 3.5.2", # Used in post-processing From b2e966616bc3e14c1bfad7a85060ec31c95dfb22 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 3 Feb 2025 17:41:43 +0100 Subject: [PATCH 006/507] Move pyomo import into class --- flixOpt/math_modeling.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/flixOpt/math_modeling.py b/flixOpt/math_modeling.py index 2d8d7c102..731be22da 100644 --- a/flixOpt/math_modeling.py +++ b/flixOpt/math_modeling.py @@ -14,7 +14,6 @@ import numpy as np from numpy import inf -import pyomo.environ as pyo from . import utils from .core import Numeric @@ -1016,6 +1015,8 @@ class PyomoModel(ModelingLanguage): """ def __init__(self): + global pyo + import pyomo.environ as pyo logger.debug('Loaded pyomo modules') self.model = pyo.ConcreteModel(name='(Minimalbeispiel)') @@ -1198,7 +1199,7 @@ def solve(self, math_model: MathModel, solver: Solver): else: dtype = float - if raw_results.ndim == 0 and dtype == float: + if raw_results.ndim == 0 and dtype is float: variable.result = float(raw_results) elif raw_results.ndim == 0 and dtype == np.int8: variable.result = np.int8(raw_results) From 7129e3411b82a146f3ecd6c6435afeb0c7c0dbe2 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 3 Feb 2025 17:47:54 +0100 Subject: [PATCH 007/507] Adding linopy in comments, removing cvxpy --- flixOpt/calculation.py | 12 ++++++------ flixOpt/math_modeling.py | 10 +++++----- flixOpt/structure.py | 2 +- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py index 391ddd516..ca072822c 100644 --- a/flixOpt/calculation.py +++ b/flixOpt/calculation.py @@ -39,7 +39,7 @@ def __init__( self, name, flow_system: FlowSystem, - modeling_language: Literal['pyomo', 'cvxpy', 'linopy'] = 'pyomo', + modeling_language: Literal['pyomo', 'linopy'] = 'pyomo', time_indices: Optional[Union[range, List[int]]] = None, ): """ @@ -49,7 +49,7 @@ def __init__( name of calculation flow_system : FlowSystem flow_system which should be calculated - modeling_language : 'pyomo','cvxpy' (not implemeted yet) + modeling_language : 'pyomo', 'linopy' choose optimization modeling language time_indices : List[int] or None list with indices, which should be used for calculation. If None, then all timesteps are used. @@ -183,7 +183,7 @@ def __init__( flow_system: FlowSystem, aggregation_parameters: AggregationParameters, components_to_clusterize: Optional[List[Component]] = None, - modeling_language: Literal['pyomo', 'cvxpy'] = 'pyomo', + modeling_language: Literal['pyomo', 'linopy'] = 'pyomo', time_indices: Optional[Union[range, List[int]]] = None, ): """ @@ -202,7 +202,7 @@ def __init__( computed in the DataAggregation flow_system : FlowSystem flow_system which should be calculated - modeling_language : 'pyomo','cvxpy' (not implemeted yet) + modeling_language : 'pyomo', 'linopy' choose optimization modeling language time_indices : List[int] or None list with indices, which should be used for calculation. If None, then all timesteps are used. @@ -309,7 +309,7 @@ def __init__( flow_system: FlowSystem, segment_length: int, overlap_length: int, - modeling_language: Literal['pyomo', 'cvxpy'] = 'pyomo', + modeling_language: Literal['pyomo', 'linopy'] = 'pyomo', time_indices: Optional[Union[range, list[int]]] = None, ): """ @@ -334,7 +334,7 @@ def __init__( overlap_length : int The number of time_steps that are added to each individual model. Used for better results of storages) - modeling_language : 'pyomo', 'cvxpy' (not implemeted yet) + modeling_language : 'pyomo', 'linopy' (not implemeted yet) choose optimization modeling language time_indices : List[int] or None list with indices, which should be used for calculation. If None, then all timesteps are used. diff --git a/flixOpt/math_modeling.py b/flixOpt/math_modeling.py index 731be22da..9f0499c4b 100644 --- a/flixOpt/math_modeling.py +++ b/flixOpt/math_modeling.py @@ -434,7 +434,7 @@ class MathModel: ---------- label : str A descriptive label for the model. - modeling_language : {'pyomo', 'cvxpy'}, optional + modeling_language : {'pyomo', 'linopy'}, optional Specifies the modeling language used for translation (default is 'pyomo'). Attributes @@ -472,7 +472,7 @@ class MathModel: Returns a dictionary of variable results after solving. """ - def __init__(self, label: str, modeling_language: Literal['pyomo', 'cvxpy', 'linopy'] = 'linopy'): + def __init__(self, label: str, modeling_language: Literal['pyomo', 'linopy'] = 'linopy'): self._infos = {} self.label = label self.modeling_language: str = modeling_language @@ -517,7 +517,7 @@ def translate_to_modeling_language(self) -> None: self.model = LinopyModel() self.model.translate_model(self) else: - raise NotImplementedError('Modeling Language cvxpy is not yet implemented') + raise NotImplementedError(f'Modeling Language {self.modeling_language} is not yet implemented') self.duration['Translation'] = round(timeit.default_timer() - t_start, 2) def solve(self, solver: 'Solver') -> None: @@ -811,7 +811,7 @@ def solve(self, modeling_language: 'ModelingLanguage'): self.termination_message = status[1] self.best_bound = modeling_language.model.solver_model.ObjBound else: - raise NotImplementedError('Only Pyomo is implemented for GUROBI solver.') + raise NotImplementedError('Only Pyomo and Linopy are implemented for GUROBI solver.') class CplexSolver(Solver): @@ -912,7 +912,7 @@ def solve(self, modeling_language: 'ModelingLanguage'): self.termination_message = status[1] self.best_bound = None else: - raise NotImplementedError('Only Pyomo is implemented for HIGHS solver.') + raise NotImplementedError('Only Pyomo and linopy are implemented for HIGHS solver.') class CbcSolver(Solver): diff --git a/flixOpt/structure.py b/flixOpt/structure.py index a708def53..fb37fb555 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -33,7 +33,7 @@ class SystemModel(MathModel): def __init__( self, label: str, - modeling_language: Literal['pyomo', 'cvxpy'], + modeling_language: Literal['pyomo', 'linopy'], flow_system: 'FlowSystem', time_indices: Optional[Union[List[int], range]], ): From 34b2a19f4df00fa865edcf9e56518a77d5178d22 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 3 Feb 2025 17:49:00 +0100 Subject: [PATCH 008/507] Keeping pyomo as the default --- flixOpt/math_modeling.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixOpt/math_modeling.py b/flixOpt/math_modeling.py index 9f0499c4b..9e9a97e64 100644 --- a/flixOpt/math_modeling.py +++ b/flixOpt/math_modeling.py @@ -472,7 +472,7 @@ class MathModel: Returns a dictionary of variable results after solving. """ - def __init__(self, label: str, modeling_language: Literal['pyomo', 'linopy'] = 'linopy'): + def __init__(self, label: str, modeling_language: Literal['pyomo', 'linopy'] = 'pyomo'): self._infos = {} self.label = label self.modeling_language: str = modeling_language From add9cae50069331e282f54228bd4f2f1bd27c4fe Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 3 Feb 2025 17:50:28 +0100 Subject: [PATCH 009/507] Revert "Temporary commit" This reverts commit 093c6b043f9ef68125fd035822ebf5c0cc0250bf. --- examples/00_Minmal/minimal_example.py | 2 +- examples/01_Simple/simple_example.py | 4 ++-- examples/02_Complex/complex_example.py | 2 +- tests/test_integration.py | 10 +++++----- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/examples/00_Minmal/minimal_example.py b/examples/00_Minmal/minimal_example.py index db004cfe8..efaed0dbf 100644 --- a/examples/00_Minmal/minimal_example.py +++ b/examples/00_Minmal/minimal_example.py @@ -50,7 +50,7 @@ flow_system.add_elements(cost_effect, boiler, heat_load, gas_source) # --- Define and Run Calculation --- - calculation = fx.FullCalculation('Simulation1', flow_system, modeling_language='linopy') + calculation = fx.FullCalculation('Simulation1', flow_system) calculation.do_modeling() # --- Solve the Calculation and Save Results --- diff --git a/examples/01_Simple/simple_example.py b/examples/01_Simple/simple_example.py index b07db3f17..317df2665 100644 --- a/examples/01_Simple/simple_example.py +++ b/examples/01_Simple/simple_example.py @@ -63,7 +63,7 @@ label='Storage', charging=fx.Flow('Q_th_load', bus=Fernwaerme, size=1000), discharging=fx.Flow('Q_th_unload', bus=Fernwaerme, size=1000), - capacity_in_flow_hours=30,#fx.InvestParameters(fix_effects=20, fixed_size=30, optional=False), + capacity_in_flow_hours=fx.InvestParameters(fix_effects=20, fixed_size=30, optional=False), initial_charge_state=0, # Initial storage state: empty relative_maximum_charge_state=1 / 100 * np.array([80, 70, 80, 80, 80, 80, 80, 80, 80, 80]), eta_charge=0.9, @@ -99,7 +99,7 @@ # --- Define and Run Calculation --- # Create a calculation object to model the Flow System - calculation = fx.FullCalculation(name='Sim1', flow_system=flow_system, modeling_language='linopy') + calculation = fx.FullCalculation(name='Sim1', flow_system=flow_system) calculation.do_modeling() # Translate the model to a solvable form, creating equations and Variables # --- Solve the Calculation and Save Results --- diff --git a/examples/02_Complex/complex_example.py b/examples/02_Complex/complex_example.py index 8b9f5b5a6..e5828583d 100644 --- a/examples/02_Complex/complex_example.py +++ b/examples/02_Complex/complex_example.py @@ -178,7 +178,7 @@ pprint(flow_system) # Get a string representation of the FlowSystem # --- Solve FlowSystem --- - calculation = fx.FullCalculation('Sim1', flow_system, 'linopy', time_indices) + calculation = fx.FullCalculation('Sim1', flow_system, 'pyomo', time_indices) calculation.do_modeling() # Show variables as str (else, you can find them in the results.yaml file diff --git a/tests/test_integration.py b/tests/test_integration.py index 9c88e929e..d6d2a9135 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -169,7 +169,7 @@ def model(self, save_results=False) -> fx.FullCalculation: print(es) es.visualize_network() - aCalc = fx.FullCalculation('Test_Sim', es, 'linopy', time_indices) + aCalc = fx.FullCalculation('Test_Sim', es, 'pyomo', time_indices) aCalc.do_modeling() aCalc.solve(self.get_solver(), save_results=save_results) @@ -592,7 +592,7 @@ def basic_model(self) -> fx.FullCalculation: print(es) es.visualize_network() - aCalc = fx.FullCalculation('Sim1', es, 'linopy', None) + aCalc = fx.FullCalculation('Sim1', es, 'pyomo', None) aCalc.do_modeling() aCalc.solve(self.get_solver()) @@ -696,7 +696,7 @@ def segments_of_flows_model(self): print(es) es.visualize_network() - aCalc = fx.FullCalculation('Sim1', es, 'linopy', None) + aCalc = fx.FullCalculation('Sim1', es, 'pyomo', None) aCalc.do_modeling() aCalc.solve(self.get_solver()) @@ -840,12 +840,12 @@ def calculate(self, modeling_type: Literal['full', 'segmented', 'aggregated']): es.visualize_network() if doFullCalc: - calc = fx.FullCalculation('fullModel', es, 'linopy') + calc = fx.FullCalculation('fullModel', es, 'pyomo') calc.do_modeling() calc.solve(self.get_solver(), save_results=True) elif doSegmentedCalc: calc = fx.SegmentedCalculation( - 'segModel', es, segment_length=96, overlap_length=1, modeling_language='linopy' + 'segModel', es, segment_length=96, overlap_length=1, modeling_language='pyomo' ) calc.do_modeling_and_solve(self.get_solver(), save_results=True) elif doAggregatedCalc: From 52dff89965683f6b1d4708ebc69c18263d3d98da Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 3 Feb 2025 17:51:15 +0100 Subject: [PATCH 010/507] ruff format --- flixOpt/math_modeling.py | 36 ++++++++++++++++++------------------ flixOpt/structure.py | 4 +--- tests/test_functional.py | 4 +++- 3 files changed, 22 insertions(+), 22 deletions(-) diff --git a/flixOpt/math_modeling.py b/flixOpt/math_modeling.py index 9e9a97e64..e568714f7 100644 --- a/flixOpt/math_modeling.py +++ b/flixOpt/math_modeling.py @@ -804,8 +804,8 @@ def solve(self, modeling_language: 'ModelingLanguage'): ) elif isinstance(modeling_language, LinopyModel): status = modeling_language.model.solve( - 'gurobi', - **{'mipgap': self.mip_gap, 'TimeLimit': self.time_limit_seconds}) + 'gurobi', **{'mipgap': self.mip_gap, 'TimeLimit': self.time_limit_seconds} + ) self.objective = modeling_language.model.objective.value self.termination_message = status[1] @@ -905,8 +905,8 @@ def solve(self, modeling_language: 'ModelingLanguage'): self.log = f'Not Implemented for {self.__class__.__name__} yet' elif isinstance(modeling_language, LinopyModel): status = modeling_language.model.solve( - 'highs', - **{'mip_rel_gap': self.mip_gap, 'time_limit': self.time_limit_seconds}) + 'highs', **{'mip_rel_gap': self.mip_gap, 'time_limit': self.time_limit_seconds} + ) self.objective = modeling_language.model.objective.value self.termination_message = status[1] @@ -1017,6 +1017,7 @@ class PyomoModel(ModelingLanguage): def __init__(self): global pyo import pyomo.environ as pyo + logger.debug('Loaded pyomo modules') self.model = pyo.ConcreteModel(name='(Minimalbeispiel)') @@ -1225,14 +1226,14 @@ def translate_variable(self, variable: Variable): assert isinstance(variable, Variable), 'Wrong type of variable' if variable.is_binary: - var = self.model.add_variables(binary=True, - coords=(pd.RangeIndex(variable.indices),) if len(variable.indices) > 1 else None, - name=variable.label) + var = self.model.add_variables( + binary=True, + coords=(pd.RangeIndex(variable.indices),) if len(variable.indices) > 1 else None, + name=variable.label, + ) else: - lower = utils.as_vector(variable.lower_bound, - variable.length) if variable.lower_bound is not None else -inf - upper = utils.as_vector(variable.upper_bound, - variable.length) if variable.upper_bound is not None else inf + lower = utils.as_vector(variable.lower_bound, variable.length) if variable.lower_bound is not None else -inf + upper = utils.as_vector(variable.upper_bound, variable.length) if variable.upper_bound is not None else inf if isinstance(lower, np.ndarray) and variable.length == 1: lower = lower[0] if isinstance(upper, np.ndarray) and variable.length == 1: @@ -1241,16 +1242,14 @@ def translate_variable(self, variable: Variable): lower=lower, upper=upper, coords=(pd.RangeIndex(variable.indices),) if len(variable.indices) > 1 else None, - name=variable.label) + name=variable.label, + ) if variable.fixed: # Wenn Vorgabe-Wert vorhanden: fixed_value = utils.as_vector(variable.fixed_value, variable.length) if isinstance(fixed_value, np.ndarray) and variable.length == 1: fixed_value = fixed_value[0] - self.model.add_constraints( - var == fixed_value, - name=f'fix_{variable.label}' - ) + self.model.add_constraints(var == fixed_value, name=f'fix_{variable.label}') self.mapping[variable] = var @@ -1260,7 +1259,9 @@ def translate_equation(self, constraint: _Constraint): lhs = 0 summands_sorted = sorted(constraint.summands, key=lambda summand: len(summand.factor_vec), reverse=True) - for summand in summands_sorted: #Sorting is necessary to not cretae a ScalarExpression if SumOfSummand is present + for ( + summand + ) in summands_sorted: # Sorting is necessary to not cretae a ScalarExpression if SumOfSummand is present lhs += self._summand_math_expression(summand) # i-te Gleichung (wenn Skalar, dann wird i ignoriert) rhs = constraint.constant_vector if len(rhs) == 1: @@ -1289,7 +1290,6 @@ def translate_objective(self, objective: Equation): self.model.add_objective(lhs) def _summand_math_expression(self, summand: Summand) -> 'linopy.LinearExpression': - linopy_variable = self.mapping[summand.variable] if summand.variable.length != 1: diff --git a/flixOpt/structure.py b/flixOpt/structure.py index fb37fb555..3c89bd466 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -670,6 +670,7 @@ def format_np_array_if_found(value: Any) -> Any: def shorten_np_array(arr: np.ndarray) -> str: """Shortens NumPy arrays if they exceed the specified length.""" + def normalized_center_of_mass(array: Any) -> float: # position in array (0 bis 1 normiert) positions = np.linspace(0, 1, len(array)) # weights w_i @@ -693,6 +694,3 @@ def normalized_center_of_mass(array: Any) -> float: console = Console(file=output_buffer, width=1000) # Adjust width as needed console.print(Pretty(formatted_data, expand_all=True, indent_guides=True)) return output_buffer.getvalue() - - - diff --git a/tests/test_functional.py b/tests/test_functional.py index 959b4be10..f0120de80 100644 --- a/tests/test_functional.py +++ b/tests/test_functional.py @@ -389,7 +389,9 @@ def test_on(self): 'Boiler', 0.5, Q_fu=fx.Flow('Q_fu', bus=self.get_element('Gas')), - Q_th=fx.Flow('Q_th', bus=self.get_element('Fernwärme'), size=100, on_off_parameters=fx.OnOffParameters()), + Q_th=fx.Flow( + 'Q_th', bus=self.get_element('Fernwärme'), size=100, on_off_parameters=fx.OnOffParameters() + ), ) ) From 5513b0edd6e51a9893ca685f156d104d46b792df Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 3 Feb 2025 17:53:24 +0100 Subject: [PATCH 011/507] Move linopy dependencies --- pyproject.toml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 6cebd7209..4c3ab934c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,6 @@ dependencies = [ "Pyomo >= 6.4.2", "rich >= 13.0.1", "tsam >= 2.3.1", # Used for time series aggregation - "scipy >= 1.15.1", # Used by tsam. Prior versions have conflict with highspy. See https://github.com/scipy/scipy/issues/22257 "highspy >= 1.5.3", # Default solver "pandas >= 2, < 3", # Used in post-processing "matplotlib >= 3.5.2", # Used in post-processing @@ -49,6 +48,11 @@ dev = [ "ruff" ] +linopy = [ + "linopy >= 0.4.4", + "scipy >= 1.15.1", # Used by tsam. Prior versions have conflict with highspy. See https://github.com/scipy/scipy/issues/22257 +] + visualization = [ "pyvis == 0.3.1", # Used for visualizing the FLowSystem ] From 9137bdcd5fd5ae56da92b98703674159ddc97f91 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 3 Feb 2025 18:50:35 +0100 Subject: [PATCH 012/507] Add tests for linopy --- tests/test_functional.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/tests/test_functional.py b/tests/test_functional.py index f0120de80..655092240 100644 --- a/tests/test_functional.py +++ b/tests/test_functional.py @@ -78,6 +78,7 @@ class BaseTest(unittest.TestCase): def setUp(self): fx.change_logging_level('DEBUG') + self.modeling_language = 'pyomo' self.mip_gap = 0.0001 self.datetime_array = fx.create_datetime_array('2020-01-01', 5, 'h') @@ -103,7 +104,7 @@ def create_model(self, datetime_array: np.ndarray[np.datetime64]) -> fx.FlowSyst return self.flow_system def solve_and_load(self, flow_system: fx.FlowSystem) -> fx.results.CalculationResults: - calculation = fx.FullCalculation('Calculation', flow_system) + calculation = fx.FullCalculation('Calculation', flow_system, self.modeling_language) calculation.do_modeling() calculation.solve(self.solver, True) results = fx.results.CalculationResults('Calculation', 'results') @@ -775,5 +776,22 @@ def test_consecutive_off(self): ) +class LinopyTest(BaseTest): + def setUp(self): + super().setUp() + self.modeling_language = 'linopy' + + +class TestMinimalLinopy(LinopyTest, TestMinimal): + pass + + +class TestInvestmentLinopy(LinopyTest, TestInvestment): + pass + + +class TestOnOffLinopy(LinopyTest, TestInvestment): + pass + if __name__ == '__main__': pytest.main(['-v', '--disable-warnings']) From 1bcf285a278f91137603be07bea189a9a2de31f8 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 4 Feb 2025 11:56:37 +0100 Subject: [PATCH 013/507] Moving from unittest to pytest with fixtures - Step 1 --- tests/test_functional.py | 201 ++++++++++++++++++--------------------- 1 file changed, 92 insertions(+), 109 deletions(-) diff --git a/tests/test_functional.py b/tests/test_functional.py index 655092240..34ea0bf30 100644 --- a/tests/test_functional.py +++ b/tests/test_functional.py @@ -17,8 +17,6 @@ - On-off operational constraints (`TestOnOff`). """ -import unittest - import numpy as np import pytest from numpy.testing import assert_allclose @@ -62,116 +60,101 @@ def _adjust_length(self, array, new_length: int): return extended_array[:new_length] # Truncate to exact length -class BaseTest(unittest.TestCase): - """ - Base test class for setting up flow systems in flixOpt. - - Provides shared setup, utility methods, and common functionality for the other test cases. - Methods: - - setUp: Initializes logging and default parameters. - - create_model: Creates a base flow system model with predefined buses and components. - - solve_and_load: Solves the flow system model and loads the results. - - get_element: Retrieves an element from the flow system by label. - - solver: Configures and returns a solver instance. - """ +def flow_system_base(datetime_array: np.ndarray[np.datetime64]) -> fx.FlowSystem: + data = Data(len(datetime_array)) - def setUp(self): - fx.change_logging_level('DEBUG') - self.modeling_language = 'pyomo' - self.mip_gap = 0.0001 - self.datetime_array = fx.create_datetime_array('2020-01-01', 5, 'h') - - def create_model(self, datetime_array: np.ndarray[np.datetime64]) -> fx.FlowSystem: - self.flow_system = fx.FlowSystem(datetime_array) - self.buses = { - 'Fernwärme': fx.Bus('Fernwärme', excess_penalty_per_flow_hour=None), - 'Gas': fx.Bus('Gas', excess_penalty_per_flow_hour=None), - } - self.flow_system.add_elements(fx.Effect('costs', '€', 'Kosten', is_standard=True, is_objective=True)) - data = Data(len(datetime_array)) - self.flow_system.add_elements( - fx.Sink( - label='Wärmelast', - sink=fx.Flow( - label='Wärme', bus=self.get_element('Fernwärme'), fixed_relative_profile=data.thermal_demand, size=1 - ), - ), - fx.Source( - label='Gastarif', source=fx.Flow(label='Gas', bus=self.get_element('Gas'), effects_per_flow_hour=1) + flow_system = fx.FlowSystem(datetime_array) + buses = { + 'Fernwärme': fx.Bus('Fernwärme', excess_penalty_per_flow_hour=None), + 'Gas': fx.Bus('Gas', excess_penalty_per_flow_hour=None), + } + flow_system.add_elements(fx.Effect('costs', '€', 'Kosten', is_standard=True, is_objective=True)) + flow_system.add_elements( + fx.Sink( + label='Wärmelast', + sink=fx.Flow( + label='Wärme', bus=buses['Fernwärme'], fixed_relative_profile=data.thermal_demand, size=1 ), - ) - return self.flow_system - - def solve_and_load(self, flow_system: fx.FlowSystem) -> fx.results.CalculationResults: - calculation = fx.FullCalculation('Calculation', flow_system, self.modeling_language) - calculation.do_modeling() - calculation.solve(self.solver, True) - results = fx.results.CalculationResults('Calculation', 'results') - return results - - def get_element(self, label: str): - return {**self.flow_system.all_elements, **self.buses}[label] - - @property - def solver(self): - """Returns a (new) solver instance with the specified parameters.""" - return fx.solvers.HighsSolver(mip_gap=self.mip_gap, time_limit_seconds=3600, solver_output_to_console=False) - - -class TestMinimal(BaseTest): - """ - Tests a minimal setup of a flow system. - - Focuses on: - - Adding basic components. - - Verifying the correct setup and results for a small system with minimal complexity. - """ - - def create_model(self, datetime_array: np.ndarray[np.datetime64]) -> fx.FlowSystem: - super().create_model(datetime_array) - self.flow_system.add_elements( - fx.linear_converters.Boiler( - 'Boiler', - 0.5, - Q_fu=fx.Flow('Q_fu', bus=self.get_element('Gas')), - Q_th=fx.Flow('Q_th', bus=self.get_element('Fernwärme')), - ) - ) - return self.flow_system - - def test_solve_and_load(self): - flow_system = self.create_model(self.datetime_array) - self.solve_and_load(flow_system) - - def test_results(self): - flow_system = self.create_model(self.datetime_array) - results = self.solve_and_load(flow_system) - - assert_allclose( - results.effect_results['costs'].all_results['all']['all_sum'], 80, rtol=self.mip_gap, atol=1e-10 - ) - - assert_allclose( - results.component_results['Boiler'].all_results['Q_th']['flow_rate'], - [-0.0, 10.0, 20.0, -0.0, 10.0], - rtol=self.mip_gap, - atol=1e-10, - ) - - assert_allclose( - results.effect_results['costs'].all_results['operation']['operation_sum_TS'], - [-0.0, 20.0, 40.0, -0.0, 20.0], - rtol=self.mip_gap, - atol=1e-10, - ) - - assert_allclose( - results.effect_results['costs'].all_results['operation']['Shares']['Gastarif__Gas__effects_per_flow_hour'], - [-0.0, 20.0, 40.0, -0.0, 20.0], - rtol=self.mip_gap, - atol=1e-10, - ) + ), + fx.Source( + label='Gastarif', source=fx.Flow(label='Gas', bus=buses['Gas'], effects_per_flow_hour=1) + ), + ) + return flow_system + + +def flow_system_minimal(datetime_array) -> fx.FlowSystem: + flow_system = flow_system_base(datetime_array) + buses = flow_system.buses + flow_system.add_elements( + fx.linear_converters.Boiler( + 'Boiler', + 0.5, + Q_fu=fx.Flow('Q_fu', bus=buses['Gas']), + Q_th=fx.Flow('Q_th', bus=buses['Fernwärme']), + ) + ) + return flow_system + + +def solve_and_load(flow_system: fx.FlowSystem, modeling_language: str, solver: fx.solvers.Solver) -> fx.results.CalculationResults: + calculation = fx.FullCalculation('Calculation', flow_system, modeling_language) + calculation.do_modeling() + calculation.solve(solver, True) + results = fx.results.CalculationResults('Calculation', 'results') + return results + + +@pytest.fixture(params=['pyomo', 'linopy']) +def modeling_language_fixture(request): + return request.param + +@pytest.fixture(params=['highs', 'gurobi']) +def solver_fixture(request): + solvers = {'highs': fx.solvers.HighsSolver, + 'gurobi': fx.solvers.GurobiSolver} + return solvers[request.param](mip_gap=0.0001) + +@pytest.fixture +def time_steps_fixture(request): + return fx.create_datetime_array('2020-01-01', 5, 'h') + + +def test_solve_and_load(modeling_language_fixture, solver_fixture, time_steps_fixture): + results = solve_and_load(flow_system_minimal(time_steps_fixture), + modeling_language_fixture, solver_fixture) + assert results is not None + + +def test_minimal_model(modeling_language_fixture, solver_fixture, time_steps_fixture): + results = solve_and_load(flow_system_minimal(time_steps_fixture), + modeling_language_fixture, solver_fixture) + + assert_allclose( + results.effect_results['costs'].all_results['all']['all_sum'], 80, rtol=solver_fixture.mip_gap, atol=1e-10 + ) + + assert_allclose( + results.component_results['Boiler'].all_results['Q_th']['flow_rate'], + [-0.0, 10.0, 20.0, -0.0, 10.0], + rtol=solver_fixture.mip_gap, + atol=1e-10, + ) + + assert_allclose( + results.effect_results['costs'].all_results['operation']['operation_sum_TS'], + [-0.0, 20.0, 40.0, -0.0, 20.0], + rtol=solver_fixture.mip_gap, + atol=1e-10, + ) + + assert_allclose( + results.effect_results['costs'].all_results['operation']['Shares']['Gastarif__Gas__effects_per_flow_hour'], + [-0.0, 20.0, 40.0, -0.0, 20.0], + rtol=solver_fixture.mip_gap, + atol=1e-10, + ) class TestInvestment(BaseTest): From 2685da4e877586140911790c7eded02ec9c1005d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 4 Feb 2025 12:11:14 +0100 Subject: [PATCH 014/507] Moving from unittest to pytest with fixtures - Step 2 --- tests/test_functional.py | 357 +++++++++++++++++++-------------------- 1 file changed, 174 insertions(+), 183 deletions(-) diff --git a/tests/test_functional.py b/tests/test_functional.py index 34ea0bf30..14343854d 100644 --- a/tests/test_functional.py +++ b/tests/test_functional.py @@ -121,6 +121,7 @@ def time_steps_fixture(request): return fx.create_datetime_array('2020-01-01', 5, 'h') + def test_solve_and_load(modeling_language_fixture, solver_fixture, time_steps_fixture): results = solve_and_load(flow_system_minimal(time_steps_fixture), modeling_language_fixture, solver_fixture) @@ -157,202 +158,192 @@ def test_minimal_model(modeling_language_fixture, solver_fixture, time_steps_fix ) -class TestInvestment(BaseTest): - """ - Tests investment modeling and optimization in flow systems. - - Focuses on: - - Fixed size investments. - - Optimized sizing of components. - - Investment constraints, including bounds and optional investments. - - Validating cost calculations and investment decisions. - """ - - def test_fixed_size(self): - self.flow_system = self.create_model(self.datetime_array) - self.flow_system.add_elements( - fx.linear_converters.Boiler( - 'Boiler', - 0.5, - Q_fu=fx.Flow('Q_fu', bus=self.get_element('Gas')), - Q_th=fx.Flow( - 'Q_th', - bus=self.get_element('Fernwärme'), - size=fx.InvestParameters(fixed_size=1000, fix_effects=10, specific_effects=1), - ), - ) +def test_fixed_size(modeling_language_fixture, solver_fixture, time_steps_fixture): + flow_system = flow_system_base(time_steps_fixture) + flow_system.add_elements( + fx.linear_converters.Boiler( + 'Boiler', + 0.5, + Q_fu=fx.Flow('Q_fu', bus=flow_system.buses['Gas']), + Q_th=fx.Flow( + 'Q_th', + bus=flow_system.buses['Fernwärme'], + size=fx.InvestParameters(fixed_size=1000, fix_effects=10, specific_effects=1), + ), ) + ) - self.solve_and_load(self.flow_system) - boiler = self.get_element('Boiler') - costs = self.get_element('costs') - assert_allclose( - costs.model.all.sum.result, - 80 + 1000 * 1 + 10, - rtol=self.mip_gap, - atol=1e-10, - err_msg='The total costs does not have the right value', - ) - assert_allclose( - boiler.model.all_variables['Boiler__Q_th__Investment_size'].result, - 1000, - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__Investment_size" does not have the right value', - ) - assert_allclose( - boiler.model.all_variables['Boiler__Q_th__Investment_isInvested'].result, - 1, - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__Investment_size" does not have the right value', - ) + solve_and_load(flow_system, modeling_language_fixture, solver_fixture) + boiler = flow_system.all_elements['Boiler'] + costs = flow_system.all_elements['costs'] + assert_allclose( + costs.model.all.sum.result, + 80 + 1000 * 1 + 10, + rtol=solver_fixture.mip_gap, + atol=1e-10, + err_msg='The total costs does not have the right value', + ) + assert_allclose( + boiler.model.all_variables['Boiler__Q_th__Investment_size'].result, + 1000, + rtol=solver_fixture.mip_gap, + atol=1e-10, + err_msg='"Boiler__Q_th__Investment_size" does not have the right value', + ) + assert_allclose( + boiler.model.all_variables['Boiler__Q_th__Investment_isInvested'].result, + 1, + rtol=solver_fixture.mip_gap, + atol=1e-10, + err_msg='"Boiler__Q_th__Investment_size" does not have the right value', + ) - def test_optimize_size(self): - self.flow_system = self.create_model(self.datetime_array) - self.flow_system.add_elements( - fx.linear_converters.Boiler( - 'Boiler', - 0.5, - Q_fu=fx.Flow('Q_fu', bus=self.get_element('Gas')), - Q_th=fx.Flow( - 'Q_th', - bus=self.get_element('Fernwärme'), - size=fx.InvestParameters(fix_effects=10, specific_effects=1), - ), - ) +def test_optimize_size(modeling_language_fixture, solver_fixture, time_steps_fixture): + flow_system = flow_system_base(time_steps_fixture) + flow_system.add_elements( + fx.linear_converters.Boiler( + 'Boiler', + 0.5, + Q_fu=fx.Flow('Q_fu', bus=flow_system.buses['Gas']), + Q_th=fx.Flow( + 'Q_th', + bus=flow_system.buses['Fernwärme'], + size=fx.InvestParameters(fix_effects=10, specific_effects=1), + ), ) + ) - self.solve_and_load(self.flow_system) - boiler = self.get_element('Boiler') - costs = self.get_element('costs') - assert_allclose( - costs.model.all.sum.result, - 80 + 20 * 1 + 10, - rtol=self.mip_gap, - atol=1e-10, - err_msg='The total costs does not have the right value', - ) - assert_allclose( - boiler.Q_th.model._investment.size.result, - 20, - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__Investment_size" does not have the right value', - ) - assert_allclose( - boiler.Q_th.model._investment.is_invested.result, - 1, - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__IsInvested" does not have the right value', - ) + solve_and_load(flow_system, modeling_language_fixture, solver_fixture) + boiler = flow_system.all_elements['Boiler'] + costs = flow_system.all_elements['costs'] + assert_allclose( + costs.model.all.sum.result, + 80 + 20 * 1 + 10, + rtol=solver_fixture.mip_gap, + atol=1e-10, + err_msg='The total costs does not have the right value', + ) + assert_allclose( + boiler.Q_th.model._investment.size.result, + 20, + rtol=solver_fixture.mip_gap, + atol=1e-10, + err_msg='"Boiler__Q_th__Investment_size" does not have the right value', + ) + assert_allclose( + boiler.Q_th.model._investment.is_invested.result, + 1, + rtol=solver_fixture.mip_gap, + atol=1e-10, + err_msg='"Boiler__Q_th__IsInvested" does not have the right value', + ) - def test_size_bounds(self): - self.flow_system = self.create_model(self.datetime_array) - self.flow_system.add_elements( - fx.linear_converters.Boiler( - 'Boiler', - 0.5, - Q_fu=fx.Flow('Q_fu', bus=self.get_element('Gas')), - Q_th=fx.Flow( - 'Q_th', - bus=self.get_element('Fernwärme'), - size=fx.InvestParameters(minimum_size=40, fix_effects=10, specific_effects=1), - ), - ) +def test_size_bounds(modeling_language_fixture, solver_fixture, time_steps_fixture): + flow_system = flow_system_base(time_steps_fixture) + flow_system.add_elements( + fx.linear_converters.Boiler( + 'Boiler', + 0.5, + Q_fu=fx.Flow('Q_fu', bus=flow_system.buses['Gas']), + Q_th=fx.Flow( + 'Q_th', + bus=flow_system.buses['Fernwärme'], + size=fx.InvestParameters(minimum_size=40, fix_effects=10, specific_effects=1), + ), ) + ) - self.solve_and_load(self.flow_system) - boiler = self.get_element('Boiler') - costs = self.get_element('costs') - assert_allclose( - costs.model.all.sum.result, - 80 + 40 * 1 + 10, - rtol=self.mip_gap, - atol=1e-10, - err_msg='The total costs does not have the right value', - ) - assert_allclose( - boiler.Q_th.model._investment.size.result, - 40, - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__Investment_size" does not have the right value', - ) - assert_allclose( - boiler.Q_th.model._investment.is_invested.result, - 1, - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__IsInvested" does not have the right value', - ) + solve_and_load(flow_system, modeling_language_fixture, solver_fixture) + boiler = flow_system.all_elements['Boiler'] + costs = flow_system.all_elements['costs'] + assert_allclose( + costs.model.all.sum.result, + 80 + 40 * 1 + 10, + rtol=solver_fixture.mip_gap, + atol=1e-10, + err_msg='The total costs does not have the right value', + ) + assert_allclose( + boiler.Q_th.model._investment.size.result, + 40, + rtol=solver_fixture.mip_gap, + atol=1e-10, + err_msg='"Boiler__Q_th__Investment_size" does not have the right value', + ) + assert_allclose( + boiler.Q_th.model._investment.is_invested.result, + 1, + rtol=solver_fixture.mip_gap, + atol=1e-10, + err_msg='"Boiler__Q_th__IsInvested" does not have the right value', + ) - def test_optional_invest(self): - self.flow_system = self.create_model(self.datetime_array) - self.flow_system.add_elements( - fx.linear_converters.Boiler( - 'Boiler', - 0.5, - Q_fu=fx.Flow('Q_fu', bus=self.get_element('Gas')), - Q_th=fx.Flow( - 'Q_th', - bus=self.get_element('Fernwärme'), - size=fx.InvestParameters(optional=True, minimum_size=40, fix_effects=10, specific_effects=1), - ), +def test_optional_invest(modeling_language_fixture, solver_fixture, time_steps_fixture): + flow_system = flow_system_base(time_steps_fixture) + flow_system.add_elements( + fx.linear_converters.Boiler( + 'Boiler', + 0.5, + Q_fu=fx.Flow('Q_fu', bus=flow_system.buses['Gas']), + Q_th=fx.Flow( + 'Q_th', + bus=flow_system.buses['Fernwärme'], + size=fx.InvestParameters(optional=True, minimum_size=40, fix_effects=10, specific_effects=1), ), - fx.linear_converters.Boiler( - 'Boiler_optional', - 0.5, - Q_fu=fx.Flow('Q_fu', bus=self.get_element('Gas')), - Q_th=fx.Flow( - 'Q_th', - bus=self.get_element('Fernwärme'), - size=fx.InvestParameters(optional=True, minimum_size=50, fix_effects=10, specific_effects=1), - ), + ), + fx.linear_converters.Boiler( + 'Boiler_optional', + 0.5, + Q_fu=fx.Flow('Q_fu', bus=flow_system.buses['Gas']), + Q_th=fx.Flow( + 'Q_th', + bus=flow_system.buses['Fernwärme'], + size=fx.InvestParameters(optional=True, minimum_size=50, fix_effects=10, specific_effects=1), ), - ) + ), + ) - self.solve_and_load(self.flow_system) - boiler = self.get_element('Boiler') - boiler_optional = self.get_element('Boiler_optional') - costs = self.get_element('costs') - assert_allclose( - costs.model.all.sum.result, - 80 + 40 * 1 + 10, - rtol=self.mip_gap, - atol=1e-10, - err_msg='The total costs does not have the right value', - ) - assert_allclose( - boiler.Q_th.model._investment.size.result, - 40, - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__Investment_size" does not have the right value', - ) - assert_allclose( - boiler.Q_th.model._investment.is_invested.result, - 1, - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__IsInvested" does not have the right value', - ) + solve_and_load(flow_system, modeling_language_fixture, solver_fixture) + boiler = flow_system.all_elements['Boiler'] + boiler_optional = flow_system.all_elements['Boiler_optional'] + costs = flow_system.all_elements['costs'] + assert_allclose( + costs.model.all.sum.result, + 80 + 40 * 1 + 10, + rtol=solver_fixture.mip_gap, + atol=1e-10, + err_msg='The total costs does not have the right value', + ) + assert_allclose( + boiler.Q_th.model._investment.size.result, + 40, + rtol=solver_fixture.mip_gap, + atol=1e-10, + err_msg='"Boiler__Q_th__Investment_size" does not have the right value', + ) + assert_allclose( + boiler.Q_th.model._investment.is_invested.result, + 1, + rtol=solver_fixture.mip_gap, + atol=1e-10, + err_msg='"Boiler__Q_th__IsInvested" does not have the right value', + ) + + assert_allclose( + boiler_optional.Q_th.model._investment.size.result, + 0, + rtol=solver_fixture.mip_gap, + atol=1e-10, + err_msg='"Boiler__Q_th__Investment_size" does not have the right value', + ) + assert_allclose( + boiler_optional.Q_th.model._investment.is_invested.result, + 0, + rtol=solver_fixture.mip_gap, + atol=1e-10, + err_msg='"Boiler__Q_th__IsInvested" does not have the right value', + ) - assert_allclose( - boiler_optional.Q_th.model._investment.size.result, - 0, - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__Investment_size" does not have the right value', - ) - assert_allclose( - boiler_optional.Q_th.model._investment.is_invested.result, - 0, - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__IsInvested" does not have the right value', - ) class TestOnOff(BaseTest): From 3487d8691d4df0e3888e2a5fad3edc973638ec15 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 4 Feb 2025 14:37:27 +0100 Subject: [PATCH 015/507] Transfering all test_functional.py to pytest with fixtures --- tests/test_functional.py | 752 +++++++++++++++++++-------------------- 1 file changed, 362 insertions(+), 390 deletions(-) diff --git a/tests/test_functional.py b/tests/test_functional.py index 14343854d..a1fcd5c6d 100644 --- a/tests/test_functional.py +++ b/tests/test_functional.py @@ -345,427 +345,399 @@ def test_optional_invest(modeling_language_fixture, solver_fixture, time_steps_f ) - -class TestOnOff(BaseTest): - """ - Tests on-off operational constraints in flow systems. - - Focuses on: - - Verifying the correct behavior of Flows that can toggle on or off. - - Testing constraints like maximum consecutive off hours. - - Validating flow rates and operational costs under on-off scenarios. - """ - - def test_on(self): - """Tests if the On Variable is correctly created and calculated in a Flow""" - self.flow_system = self.create_model(self.datetime_array) - self.flow_system.add_elements( - fx.linear_converters.Boiler( - 'Boiler', - 0.5, - Q_fu=fx.Flow('Q_fu', bus=self.get_element('Gas')), - Q_th=fx.Flow( - 'Q_th', bus=self.get_element('Fernwärme'), size=100, on_off_parameters=fx.OnOffParameters() - ), - ) - ) - - self.solve_and_load(self.flow_system) - boiler = self.get_element('Boiler') - costs = self.get_element('costs') - assert_allclose( - costs.model.all.sum.result, - 80, - rtol=self.mip_gap, - atol=1e-10, - err_msg='The total costs does not have the right value', - ) - - assert_allclose( - boiler.Q_th.model._on.on.result, - [0, 1, 1, 0, 1], - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__on" does not have the right value', - ) - assert_allclose( - boiler.Q_th.model.flow_rate.result, - [0, 10, 20, 0, 10], - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__flow_rate" does not have the right value', - ) - - def test_off(self): - """Tests if the Off Variable is correctly created and calculated in a Flow""" - self.flow_system = self.create_model(self.datetime_array) - self.flow_system.add_elements( - fx.linear_converters.Boiler( - 'Boiler', - 0.5, - Q_fu=fx.Flow('Q_fu', bus=self.get_element('Gas')), - Q_th=fx.Flow( - 'Q_th', - bus=self.get_element('Fernwärme'), - size=100, - on_off_parameters=fx.OnOffParameters(consecutive_off_hours_max=100), - ), - ) +def test_on(modeling_language_fixture, solver_fixture, time_steps_fixture): + """Tests if the On Variable is correctly created and calculated in a Flow""" + flow_system = flow_system_base(time_steps_fixture) + flow_system.add_elements( + fx.linear_converters.Boiler( + 'Boiler', + 0.5, + Q_fu=fx.Flow('Q_fu', bus=flow_system.buses['Gas']), + Q_th=fx.Flow( + 'Q_th', bus=flow_system.buses['Fernwärme'], size=100, on_off_parameters=fx.OnOffParameters() + ), ) + ) - self.solve_and_load(self.flow_system) - boiler = self.get_element('Boiler') - costs = self.get_element('costs') - assert_allclose( - costs.model.all.sum.result, - 80, - rtol=self.mip_gap, - atol=1e-10, - err_msg='The total costs does not have the right value', - ) + solve_and_load(flow_system, modeling_language_fixture, solver_fixture) + boiler = flow_system.all_elements['Boiler'] + costs = flow_system.all_elements['costs'] + assert_allclose( + costs.model.all.sum.result, + 80, + rtol=solver_fixture.mip_gap, + atol=1e-10, + err_msg='The total costs does not have the right value', + ) - assert_allclose( - boiler.Q_th.model._on.on.result, - [0, 1, 1, 0, 1], - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__on" does not have the right value', - ) - assert_allclose( - boiler.Q_th.model._on.off.result, - 1 - boiler.Q_th.model._on.on.result, - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__off" does not have the right value', - ) - assert_allclose( - boiler.Q_th.model.flow_rate.result, - [0, 10, 20, 0, 10], - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__flow_rate" does not have the right value', - ) + assert_allclose( + boiler.Q_th.model._on.on.result, + [0, 1, 1, 0, 1], + rtol=solver_fixture.mip_gap, + atol=1e-10, + err_msg='"Boiler__Q_th__on" does not have the right value', + ) + assert_allclose( + boiler.Q_th.model.flow_rate.result, + [0, 10, 20, 0, 10], + rtol=solver_fixture.mip_gap, + atol=1e-10, + err_msg='"Boiler__Q_th__flow_rate" does not have the right value', + ) - def test_switch_on_off(self): - """Tests if the Switch On/Off Variable is correctly created and calculated in a Flow""" - self.flow_system = self.create_model(self.datetime_array) - self.flow_system.add_elements( - fx.linear_converters.Boiler( - 'Boiler', - 0.5, - Q_fu=fx.Flow('Q_fu', bus=self.get_element('Gas')), - Q_th=fx.Flow( - 'Q_th', - bus=self.get_element('Fernwärme'), - size=100, - on_off_parameters=fx.OnOffParameters(force_switch_on=True), - ), - ) +def test_off(modeling_language_fixture, solver_fixture, time_steps_fixture): + """Tests if the Off Variable is correctly created and calculated in a Flow""" + flow_system = flow_system_base(time_steps_fixture) + flow_system.add_elements( + fx.linear_converters.Boiler( + 'Boiler', + 0.5, + Q_fu=fx.Flow('Q_fu', bus=flow_system.buses['Gas']), + Q_th=fx.Flow( + 'Q_th', + bus=flow_system.buses['Fernwärme'], + size=100, + on_off_parameters=fx.OnOffParameters(consecutive_off_hours_max=100), + ), ) + ) - self.solve_and_load(self.flow_system) - boiler = self.get_element('Boiler') - costs = self.get_element('costs') - assert_allclose( - costs.model.all.sum.result, - 80, - rtol=self.mip_gap, - atol=1e-10, - err_msg='The total costs does not have the right value', - ) + solve_and_load(flow_system, modeling_language_fixture, solver_fixture) + boiler = flow_system.all_elements['Boiler'] + costs = flow_system.all_elements['costs'] + assert_allclose( + costs.model.all.sum.result, + 80, + rtol=solver_fixture.mip_gap, + atol=1e-10, + err_msg='The total costs does not have the right value', + ) - assert_allclose( - boiler.Q_th.model._on.on.result, - [0, 1, 1, 0, 1], - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__on" does not have the right value', - ) - assert_allclose( - boiler.Q_th.model._on.switch_on.result, - [0, 1, 0, 0, 1], - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__switch_on" does not have the right value', - ) - assert_allclose( - boiler.Q_th.model._on.switch_off.result, - [0, 0, 0, 1, 0], - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__switch_on" does not have the right value', - ) - assert_allclose( - boiler.Q_th.model.flow_rate.result, - [0, 10, 20, 0, 10], - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__flow_rate" does not have the right value', - ) + assert_allclose( + boiler.Q_th.model._on.on.result, + [0, 1, 1, 0, 1], + rtol=solver_fixture.mip_gap, + atol=1e-10, + err_msg='"Boiler__Q_th__on" does not have the right value', + ) + assert_allclose( + boiler.Q_th.model._on.off.result, + 1 - boiler.Q_th.model._on.on.result, + rtol=solver_fixture.mip_gap, + atol=1e-10, + err_msg='"Boiler__Q_th__off" does not have the right value', + ) + assert_allclose( + boiler.Q_th.model.flow_rate.result, + [0, 10, 20, 0, 10], + rtol=solver_fixture.mip_gap, + atol=1e-10, + err_msg='"Boiler__Q_th__flow_rate" does not have the right value', + ) - def test_on_total_max(self): - """Tests if the On Total Max Variable is correctly created and calculated in a Flow""" - self.flow_system = self.create_model(self.datetime_array) - self.flow_system.add_elements( - fx.linear_converters.Boiler( - 'Boiler', - 0.5, - Q_fu=fx.Flow('Q_fu', bus=self.get_element('Gas')), - Q_th=fx.Flow( - 'Q_th', - bus=self.get_element('Fernwärme'), - size=100, - on_off_parameters=fx.OnOffParameters(on_hours_total_max=1), - ), - ), - fx.linear_converters.Boiler( - 'Boiler_backup', - 0.2, - Q_fu=fx.Flow('Q_fu', bus=self.get_element('Gas')), - Q_th=fx.Flow('Q_th', bus=self.get_element('Fernwärme'), size=100), +def test_switch_on_off(modeling_language_fixture, solver_fixture, time_steps_fixture): + """Tests if the Switch On/Off Variable is correctly created and calculated in a Flow""" + flow_system = flow_system_base(time_steps_fixture) + flow_system.add_elements( + fx.linear_converters.Boiler( + 'Boiler', + 0.5, + Q_fu=fx.Flow('Q_fu', bus=flow_system.buses['Gas']), + Q_th=fx.Flow( + 'Q_th', + bus=flow_system.buses['Fernwärme'], + size=100, + on_off_parameters=fx.OnOffParameters(force_switch_on=True), ), ) + ) - self.solve_and_load(self.flow_system) - boiler = self.get_element('Boiler') - costs = self.get_element('costs') - assert_allclose( - costs.model.all.sum.result, - 140, - rtol=self.mip_gap, - atol=1e-10, - err_msg='The total costs does not have the right value', - ) + solve_and_load(flow_system, modeling_language_fixture, solver_fixture) + boiler = flow_system.all_elements['Boiler'] + costs = flow_system.all_elements['costs'] + assert_allclose( + costs.model.all.sum.result, + 80, + rtol=solver_fixture.mip_gap, + atol=1e-10, + err_msg='The total costs does not have the right value', + ) - assert_allclose( - boiler.Q_th.model._on.on.result, - [0, 0, 1, 0, 0], - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__on" does not have the right value', - ) - assert_allclose( - boiler.Q_th.model.flow_rate.result, - [0, 0, 20, 0, 0], - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__flow_rate" does not have the right value', - ) + assert_allclose( + boiler.Q_th.model._on.on.result, + [0, 1, 1, 0, 1], + rtol=solver_fixture.mip_gap, + atol=1e-10, + err_msg='"Boiler__Q_th__on" does not have the right value', + ) + assert_allclose( + boiler.Q_th.model._on.switch_on.result, + [0, 1, 0, 0, 1], + rtol=solver_fixture.mip_gap, + atol=1e-10, + err_msg='"Boiler__Q_th__switch_on" does not have the right value', + ) + assert_allclose( + boiler.Q_th.model._on.switch_off.result, + [0, 0, 0, 1, 0], + rtol=solver_fixture.mip_gap, + atol=1e-10, + err_msg='"Boiler__Q_th__switch_on" does not have the right value', + ) + assert_allclose( + boiler.Q_th.model.flow_rate.result, + [0, 10, 20, 0, 10], + rtol=solver_fixture.mip_gap, + atol=1e-10, + err_msg='"Boiler__Q_th__flow_rate" does not have the right value', + ) - def test_on_total_bounds(self): - """Tests if the On Hours min and max are correctly created and calculated in a Flow""" - self.flow_system = self.create_model(self.datetime_array) - self.flow_system.add_elements( - fx.linear_converters.Boiler( - 'Boiler', - 0.5, - Q_fu=fx.Flow('Q_fu', bus=self.get_element('Gas')), - Q_th=fx.Flow( - 'Q_th', - bus=self.get_element('Fernwärme'), - size=100, - on_off_parameters=fx.OnOffParameters(on_hours_total_max=2), - ), - ), - fx.linear_converters.Boiler( - 'Boiler_backup', - 0.2, - Q_fu=fx.Flow('Q_fu', bus=self.get_element('Gas')), - Q_th=fx.Flow( - 'Q_th', - bus=self.get_element('Fernwärme'), - size=100, - on_off_parameters=fx.OnOffParameters(on_hours_total_min=3), - ), +def test_on_total_max(modeling_language_fixture, solver_fixture, time_steps_fixture): + """Tests if the On Total Max Variable is correctly created and calculated in a Flow""" + flow_system = flow_system_base(time_steps_fixture) + flow_system.add_elements( + fx.linear_converters.Boiler( + 'Boiler', + 0.5, + Q_fu=fx.Flow('Q_fu', bus=flow_system.buses['Gas']), + Q_th=fx.Flow( + 'Q_th', + bus=flow_system.buses['Fernwärme'], + size=100, + on_off_parameters=fx.OnOffParameters(on_hours_total_max=1), ), - ) - self.get_element('Wärmelast').sink.fixed_relative_profile = [0, 10, 20, 0, 12] # Else its non deterministic - - self.solve_and_load(self.flow_system) - boiler = self.get_element('Boiler') - boiler_backup = self.get_element('Boiler_backup') - costs = self.get_element('costs') - assert_allclose( - costs.model.all.sum.result, - 114, - rtol=self.mip_gap, - atol=1e-10, - err_msg='The total costs does not have the right value', - ) + ), + fx.linear_converters.Boiler( + 'Boiler_backup', + 0.2, + Q_fu=fx.Flow('Q_fu', bus=flow_system.buses['Gas']), + Q_th=fx.Flow('Q_th', bus=flow_system.buses['Fernwärme'], size=100), + ), + ) - assert_allclose( - boiler.Q_th.model._on.on.result, - [0, 0, 1, 0, 1], - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__on" does not have the right value', - ) - assert_allclose( - boiler.Q_th.model.flow_rate.result, - [0, 0, 20, 0, 12 - 1e-5], - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__flow_rate" does not have the right value', - ) + solve_and_load(flow_system, modeling_language_fixture, solver_fixture) + boiler = flow_system.all_elements['Boiler'] + costs = flow_system.all_elements['costs'] + assert_allclose( + costs.model.all.sum.result, + 140, + rtol=solver_fixture.mip_gap, + atol=1e-10, + err_msg='The total costs does not have the right value', + ) - assert_allclose( - sum(boiler_backup.Q_th.model._on.on.result), - 3, - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler_backup__Q_th__on" does not have the right value', - ) - assert_allclose( - boiler_backup.Q_th.model.flow_rate.result, - [0, 10, 1.0e-05, 0, 1.0e-05], - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__flow_rate" does not have the right value', - ) + assert_allclose( + boiler.Q_th.model._on.on.result, + [0, 0, 1, 0, 0], + rtol=solver_fixture.mip_gap, + atol=1e-10, + err_msg='"Boiler__Q_th__on" does not have the right value', + ) + assert_allclose( + boiler.Q_th.model.flow_rate.result, + [0, 0, 20, 0, 0], + rtol=solver_fixture.mip_gap, + atol=1e-10, + err_msg='"Boiler__Q_th__flow_rate" does not have the right value', + ) - def test_consecutive_on_off(self): - """Tests if the consecutive on/off hours are correctly created and calculated in a Flow""" - self.flow_system = self.create_model(self.datetime_array) - self.flow_system.add_elements( - fx.linear_converters.Boiler( - 'Boiler', - 0.5, - Q_fu=fx.Flow('Q_fu', bus=self.get_element('Gas')), - Q_th=fx.Flow( - 'Q_th', - bus=self.get_element('Fernwärme'), - size=100, - on_off_parameters=fx.OnOffParameters(consecutive_on_hours_max=2, consecutive_on_hours_min=2), - ), +def test_on_total_bounds(modeling_language_fixture, solver_fixture, time_steps_fixture): + """Tests if the On Hours min and max are correctly created and calculated in a Flow""" + flow_system = flow_system_base(time_steps_fixture) + flow_system.add_elements( + fx.linear_converters.Boiler( + 'Boiler', + 0.5, + Q_fu=fx.Flow('Q_fu', bus=flow_system.buses['Gas']), + Q_th=fx.Flow( + 'Q_th', + bus=flow_system.buses['Fernwärme'], + size=100, + on_off_parameters=fx.OnOffParameters(on_hours_total_max=2), ), - fx.linear_converters.Boiler( - 'Boiler_backup', - 0.2, - Q_fu=fx.Flow('Q_fu', bus=self.get_element('Gas')), - Q_th=fx.Flow('Q_th', bus=self.get_element('Fernwärme'), size=100), + ), + fx.linear_converters.Boiler( + 'Boiler_backup', + 0.2, + Q_fu=fx.Flow('Q_fu', bus=flow_system.buses['Gas']), + Q_th=fx.Flow( + 'Q_th', + bus=flow_system.buses['Fernwärme'], + size=100, + on_off_parameters=fx.OnOffParameters(on_hours_total_min=3), ), - ) - self.get_element('Wärmelast').sink.fixed_relative_profile = [5, 10, 20, 18, 12] # Else its non deterministic - - self.solve_and_load(self.flow_system) - boiler = self.get_element('Boiler') - boiler_backup = self.get_element('Boiler_backup') - costs = self.get_element('costs') - assert_allclose( - costs.model.all.sum.result, - 190, - rtol=self.mip_gap, - atol=1e-10, - err_msg='The total costs does not have the right value', - ) - - assert_allclose( - boiler.Q_th.model._on.on.result, - [1, 1, 0, 1, 1], - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__on" does not have the right value', - ) - assert_allclose( - boiler.Q_th.model.flow_rate.result, - [5, 10, 0, 18, 12], - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__flow_rate" does not have the right value', - ) + ), + ) + flow_system.all_elements['Wärmelast'].sink.fixed_relative_profile = [0, 10, 20, 0, 12] # Else its non deterministic - assert_allclose( - boiler_backup.Q_th.model.flow_rate.result, - [0, 0, 20, 0, 0], - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__flow_rate" does not have the right value', - ) + solve_and_load(flow_system, modeling_language_fixture, solver_fixture) + boiler = flow_system.all_elements['Boiler'] + boiler_backup = flow_system.all_elements['Boiler_backup'] + costs = flow_system.all_elements['costs'] + assert_allclose( + costs.model.all.sum.result, + 114, + rtol=solver_fixture.mip_gap, + atol=1e-10, + err_msg='The total costs does not have the right value', + ) - def test_consecutive_off(self): - """Tests if the consecutive on hours are correctly created and calculated in a Flow""" - self.flow_system = self.create_model(self.datetime_array) - self.flow_system.add_elements( - fx.linear_converters.Boiler( - 'Boiler', - 0.5, - Q_fu=fx.Flow('Q_fu', bus=self.get_element('Gas')), - Q_th=fx.Flow('Q_th', bus=self.get_element('Fernwärme')), - ), - fx.linear_converters.Boiler( - 'Boiler_backup', - 0.2, - Q_fu=fx.Flow('Q_fu', bus=self.get_element('Gas')), - Q_th=fx.Flow( - 'Q_th', - bus=self.get_element('Fernwärme'), - size=100, - previous_flow_rate=np.array([20]), # Otherwise its Off before the start - on_off_parameters=fx.OnOffParameters(consecutive_off_hours_max=2, consecutive_off_hours_min=2), - ), - ), - ) - self.get_element('Wärmelast').sink.fixed_relative_profile = [5, 0, 20, 18, 12] # Else its non deterministic - - self.solve_and_load(self.flow_system) - boiler = self.get_element('Boiler') - boiler_backup = self.get_element('Boiler_backup') - costs = self.get_element('costs') - assert_allclose( - costs.model.all.sum.result, - 110, - rtol=self.mip_gap, - atol=1e-10, - err_msg='The total costs does not have the right value', - ) + assert_allclose( + boiler.Q_th.model._on.on.result, + [0, 0, 1, 0, 1], + rtol=solver_fixture.mip_gap, + atol=1e-10, + err_msg='"Boiler__Q_th__on" does not have the right value', + ) + assert_allclose( + boiler.Q_th.model.flow_rate.result, + [0, 0, 20, 0, 12 - 1e-5], + rtol=solver_fixture.mip_gap, + atol=1e-10, + err_msg='"Boiler__Q_th__flow_rate" does not have the right value', + ) - assert_allclose( - boiler_backup.Q_th.model._on.on.result, - [0, 0, 1, 0, 0], - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler_backup__Q_th__on" does not have the right value', - ) - assert_allclose( - boiler_backup.Q_th.model._on.off.result, - [1, 1, 0, 1, 1], - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler_backup__Q_th__off" does not have the right value', - ) - assert_allclose( - boiler_backup.Q_th.model.flow_rate.result, - [0, 0, 1e-5, 0, 0], - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler_backup__Q_th__flow_rate" does not have the right value', - ) + assert_allclose( + sum(boiler_backup.Q_th.model._on.on.result), + 3, + rtol=solver_fixture.mip_gap, + atol=1e-10, + err_msg='"Boiler_backup__Q_th__on" does not have the right value', + ) + assert_allclose( + boiler_backup.Q_th.model.flow_rate.result, + [0, 10, 1.0e-05, 0, 1.0e-05], + rtol=solver_fixture.mip_gap, + atol=1e-10, + err_msg='"Boiler__Q_th__flow_rate" does not have the right value', + ) - assert_allclose( - boiler.Q_th.model.flow_rate.result, - [5, 0, 20 - 1e-5, 18, 12], - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__flow_rate" does not have the right value', - ) +def test_consecutive_on_off(modeling_language_fixture, solver_fixture, time_steps_fixture): + """Tests if the consecutive on/off hours are correctly created and calculated in a Flow""" + flow_system = flow_system_base(time_steps_fixture) + flow_system.add_elements( + fx.linear_converters.Boiler( + 'Boiler', + 0.5, + Q_fu=fx.Flow('Q_fu', bus=flow_system.buses['Gas']), + Q_th=fx.Flow( + 'Q_th', + bus=flow_system.buses['Fernwärme'], + size=100, + on_off_parameters=fx.OnOffParameters(consecutive_on_hours_max=2, consecutive_on_hours_min=2), + ), + ), + fx.linear_converters.Boiler( + 'Boiler_backup', + 0.2, + Q_fu=fx.Flow('Q_fu', bus=flow_system.buses['Gas']), + Q_th=fx.Flow('Q_th', bus=flow_system.buses['Fernwärme'], size=100), + ), + ) + flow_system.all_elements['Wärmelast'].sink.fixed_relative_profile = [5, 10, 20, 18, 12] # Else its non deterministic + solve_and_load(flow_system, modeling_language_fixture, solver_fixture) + boiler = flow_system.all_elements['Boiler'] + boiler_backup = flow_system.all_elements['Boiler_backup'] + costs = flow_system.all_elements['costs'] + assert_allclose( + costs.model.all.sum.result, + 190, + rtol=solver_fixture.mip_gap, + atol=1e-10, + err_msg='The total costs does not have the right value', + ) -class LinopyTest(BaseTest): - def setUp(self): - super().setUp() - self.modeling_language = 'linopy' + assert_allclose( + boiler.Q_th.model._on.on.result, + [1, 1, 0, 1, 1], + rtol=solver_fixture.mip_gap, + atol=1e-10, + err_msg='"Boiler__Q_th__on" does not have the right value', + ) + assert_allclose( + boiler.Q_th.model.flow_rate.result, + [5, 10, 0, 18, 12], + rtol=solver_fixture.mip_gap, + atol=1e-10, + err_msg='"Boiler__Q_th__flow_rate" does not have the right value', + ) + assert_allclose( + boiler_backup.Q_th.model.flow_rate.result, + [0, 0, 20, 0, 0], + rtol=solver_fixture.mip_gap, + atol=1e-10, + err_msg='"Boiler__Q_th__flow_rate" does not have the right value', + ) -class TestMinimalLinopy(LinopyTest, TestMinimal): - pass +def test_consecutive_off(modeling_language_fixture, solver_fixture, time_steps_fixture): + """Tests if the consecutive on hours are correctly created and calculated in a Flow""" + flow_system = flow_system_base(time_steps_fixture) + flow_system.add_elements( + fx.linear_converters.Boiler( + 'Boiler', + 0.5, + Q_fu=fx.Flow('Q_fu', bus=flow_system.buses['Gas']), + Q_th=fx.Flow('Q_th', bus=flow_system.buses['Fernwärme']), + ), + fx.linear_converters.Boiler( + 'Boiler_backup', + 0.2, + Q_fu=fx.Flow('Q_fu', bus=flow_system.buses['Gas']), + Q_th=fx.Flow( + 'Q_th', + bus=flow_system.buses['Fernwärme'], + size=100, + previous_flow_rate=np.array([20]), # Otherwise its Off before the start + on_off_parameters=fx.OnOffParameters(consecutive_off_hours_max=2, consecutive_off_hours_min=2), + ), + ), + ) + flow_system.all_elements['Wärmelast'].sink.fixed_relative_profile = [5, 0, 20, 18, 12] # Else its non deterministic + solve_and_load(flow_system, modeling_language_fixture, solver_fixture) + boiler = flow_system.all_elements['Boiler'] + boiler_backup = flow_system.all_elements['Boiler_backup'] + costs = flow_system.all_elements['costs'] + assert_allclose( + costs.model.all.sum.result, + 110, + rtol=solver_fixture.mip_gap, + atol=1e-10, + err_msg='The total costs does not have the right value', + ) -class TestInvestmentLinopy(LinopyTest, TestInvestment): - pass + assert_allclose( + boiler_backup.Q_th.model._on.on.result, + [0, 0, 1, 0, 0], + rtol=solver_fixture.mip_gap, + atol=1e-10, + err_msg='"Boiler_backup__Q_th__on" does not have the right value', + ) + assert_allclose( + boiler_backup.Q_th.model._on.off.result, + [1, 1, 0, 1, 1], + rtol=solver_fixture.mip_gap, + atol=1e-10, + err_msg='"Boiler_backup__Q_th__off" does not have the right value', + ) + assert_allclose( + boiler_backup.Q_th.model.flow_rate.result, + [0, 0, 1e-5, 0, 0], + rtol=solver_fixture.mip_gap, + atol=1e-10, + err_msg='"Boiler_backup__Q_th__flow_rate" does not have the right value', + ) + assert_allclose( + boiler.Q_th.model.flow_rate.result, + [5, 0, 20 - 1e-5, 18, 12], + rtol=solver_fixture.mip_gap, + atol=1e-10, + err_msg='"Boiler__Q_th__flow_rate" does not have the right value', + ) -class TestOnOffLinopy(LinopyTest, TestInvestment): - pass if __name__ == '__main__': pytest.main(['-v', '--disable-warnings']) From 3a1ff20fd6cdcb750c811b1a127ac5da0db12989 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 4 Feb 2025 15:43:48 +0100 Subject: [PATCH 016/507] ruff format --- tests/test_functional.py | 46 +++++++++++++++++++++++----------------- 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/tests/test_functional.py b/tests/test_functional.py index a1fcd5c6d..e33a4747e 100644 --- a/tests/test_functional.py +++ b/tests/test_functional.py @@ -60,7 +60,6 @@ def _adjust_length(self, array, new_length: int): return extended_array[:new_length] # Truncate to exact length - def flow_system_base(datetime_array: np.ndarray[np.datetime64]) -> fx.FlowSystem: data = Data(len(datetime_array)) @@ -73,13 +72,9 @@ def flow_system_base(datetime_array: np.ndarray[np.datetime64]) -> fx.FlowSystem flow_system.add_elements( fx.Sink( label='Wärmelast', - sink=fx.Flow( - label='Wärme', bus=buses['Fernwärme'], fixed_relative_profile=data.thermal_demand, size=1 - ), - ), - fx.Source( - label='Gastarif', source=fx.Flow(label='Gas', bus=buses['Gas'], effects_per_flow_hour=1) + sink=fx.Flow(label='Wärme', bus=buses['Fernwärme'], fixed_relative_profile=data.thermal_demand, size=1), ), + fx.Source(label='Gastarif', source=fx.Flow(label='Gas', bus=buses['Gas'], effects_per_flow_hour=1)), ) return flow_system @@ -98,7 +93,9 @@ def flow_system_minimal(datetime_array) -> fx.FlowSystem: return flow_system -def solve_and_load(flow_system: fx.FlowSystem, modeling_language: str, solver: fx.solvers.Solver) -> fx.results.CalculationResults: +def solve_and_load( + flow_system: fx.FlowSystem, modeling_language: str, solver: fx.solvers.Solver +) -> fx.results.CalculationResults: calculation = fx.FullCalculation('Calculation', flow_system, modeling_language) calculation.do_modeling() calculation.solve(solver, True) @@ -110,27 +107,25 @@ def solve_and_load(flow_system: fx.FlowSystem, modeling_language: str, solver: f def modeling_language_fixture(request): return request.param + @pytest.fixture(params=['highs', 'gurobi']) def solver_fixture(request): - solvers = {'highs': fx.solvers.HighsSolver, - 'gurobi': fx.solvers.GurobiSolver} + solvers = {'highs': fx.solvers.HighsSolver, 'gurobi': fx.solvers.GurobiSolver} return solvers[request.param](mip_gap=0.0001) + @pytest.fixture def time_steps_fixture(request): return fx.create_datetime_array('2020-01-01', 5, 'h') - def test_solve_and_load(modeling_language_fixture, solver_fixture, time_steps_fixture): - results = solve_and_load(flow_system_minimal(time_steps_fixture), - modeling_language_fixture, solver_fixture) + results = solve_and_load(flow_system_minimal(time_steps_fixture), modeling_language_fixture, solver_fixture) assert results is not None def test_minimal_model(modeling_language_fixture, solver_fixture, time_steps_fixture): - results = solve_and_load(flow_system_minimal(time_steps_fixture), - modeling_language_fixture, solver_fixture) + results = solve_and_load(flow_system_minimal(time_steps_fixture), modeling_language_fixture, solver_fixture) assert_allclose( results.effect_results['costs'].all_results['all']['all_sum'], 80, rtol=solver_fixture.mip_gap, atol=1e-10 @@ -198,6 +193,7 @@ def test_fixed_size(modeling_language_fixture, solver_fixture, time_steps_fixtur err_msg='"Boiler__Q_th__Investment_size" does not have the right value', ) + def test_optimize_size(modeling_language_fixture, solver_fixture, time_steps_fixture): flow_system = flow_system_base(time_steps_fixture) flow_system.add_elements( @@ -238,6 +234,7 @@ def test_optimize_size(modeling_language_fixture, solver_fixture, time_steps_fix err_msg='"Boiler__Q_th__IsInvested" does not have the right value', ) + def test_size_bounds(modeling_language_fixture, solver_fixture, time_steps_fixture): flow_system = flow_system_base(time_steps_fixture) flow_system.add_elements( @@ -278,6 +275,7 @@ def test_size_bounds(modeling_language_fixture, solver_fixture, time_steps_fixtu err_msg='"Boiler__Q_th__IsInvested" does not have the right value', ) + def test_optional_invest(modeling_language_fixture, solver_fixture, time_steps_fixture): flow_system = flow_system_base(time_steps_fixture) flow_system.add_elements( @@ -353,9 +351,7 @@ def test_on(modeling_language_fixture, solver_fixture, time_steps_fixture): 'Boiler', 0.5, Q_fu=fx.Flow('Q_fu', bus=flow_system.buses['Gas']), - Q_th=fx.Flow( - 'Q_th', bus=flow_system.buses['Fernwärme'], size=100, on_off_parameters=fx.OnOffParameters() - ), + Q_th=fx.Flow('Q_th', bus=flow_system.buses['Fernwärme'], size=100, on_off_parameters=fx.OnOffParameters()), ) ) @@ -385,6 +381,7 @@ def test_on(modeling_language_fixture, solver_fixture, time_steps_fixture): err_msg='"Boiler__Q_th__flow_rate" does not have the right value', ) + def test_off(modeling_language_fixture, solver_fixture, time_steps_fixture): """Tests if the Off Variable is correctly created and calculated in a Flow""" flow_system = flow_system_base(time_steps_fixture) @@ -435,6 +432,7 @@ def test_off(modeling_language_fixture, solver_fixture, time_steps_fixture): err_msg='"Boiler__Q_th__flow_rate" does not have the right value', ) + def test_switch_on_off(modeling_language_fixture, solver_fixture, time_steps_fixture): """Tests if the Switch On/Off Variable is correctly created and calculated in a Flow""" flow_system = flow_system_base(time_steps_fixture) @@ -492,6 +490,7 @@ def test_switch_on_off(modeling_language_fixture, solver_fixture, time_steps_fix err_msg='"Boiler__Q_th__flow_rate" does not have the right value', ) + def test_on_total_max(modeling_language_fixture, solver_fixture, time_steps_fixture): """Tests if the On Total Max Variable is correctly created and calculated in a Flow""" flow_system = flow_system_base(time_steps_fixture) @@ -541,6 +540,7 @@ def test_on_total_max(modeling_language_fixture, solver_fixture, time_steps_fixt err_msg='"Boiler__Q_th__flow_rate" does not have the right value', ) + def test_on_total_bounds(modeling_language_fixture, solver_fixture, time_steps_fixture): """Tests if the On Hours min and max are correctly created and calculated in a Flow""" flow_system = flow_system_base(time_steps_fixture) @@ -612,6 +612,7 @@ def test_on_total_bounds(modeling_language_fixture, solver_fixture, time_steps_f err_msg='"Boiler__Q_th__flow_rate" does not have the right value', ) + def test_consecutive_on_off(modeling_language_fixture, solver_fixture, time_steps_fixture): """Tests if the consecutive on/off hours are correctly created and calculated in a Flow""" flow_system = flow_system_base(time_steps_fixture) @@ -634,7 +635,13 @@ def test_consecutive_on_off(modeling_language_fixture, solver_fixture, time_step Q_th=fx.Flow('Q_th', bus=flow_system.buses['Fernwärme'], size=100), ), ) - flow_system.all_elements['Wärmelast'].sink.fixed_relative_profile = [5, 10, 20, 18, 12] # Else its non deterministic + flow_system.all_elements['Wärmelast'].sink.fixed_relative_profile = [ + 5, + 10, + 20, + 18, + 12, + ] # Else its non deterministic solve_and_load(flow_system, modeling_language_fixture, solver_fixture) boiler = flow_system.all_elements['Boiler'] @@ -671,6 +678,7 @@ def test_consecutive_on_off(modeling_language_fixture, solver_fixture, time_step err_msg='"Boiler__Q_th__flow_rate" does not have the right value', ) + def test_consecutive_off(modeling_language_fixture, solver_fixture, time_steps_fixture): """Tests if the consecutive on hours are correctly created and calculated in a Flow""" flow_system = flow_system_base(time_steps_fixture) From bdbd1e28f3e90a1af339df95dfe349eb5fa0715c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 4 Feb 2025 16:49:03 +0100 Subject: [PATCH 017/507] add linopy to dev requirements --- pyproject.toml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 4c3ab934c..bce914ff2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,9 @@ dependencies = [ [project.optional-dependencies] dev = [ "pytest", - "ruff" + "ruff", + "linopy >= 0.4.4", + "scipy >= 1.15.1", # Used by tsam. Prior versions have conflict with highspy. See https://github.com/scipy/scipy/issues/22257 ] linopy = [ From 18b52f40bd493ea8df3e12411f4d6771fa8b9361 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 4 Feb 2025 17:02:08 +0100 Subject: [PATCH 018/507] add gurobipy to dev requirements --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index bce914ff2..38399a64b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,7 @@ dev = [ "ruff", "linopy >= 0.4.4", "scipy >= 1.15.1", # Used by tsam. Prior versions have conflict with highspy. See https://github.com/scipy/scipy/issues/22257 + "gurobipy >= 10", ] linopy = [ From 44aa14e55fee3800c3159d28cec8800a46af0e82 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 7 Feb 2025 16:27:34 +0100 Subject: [PATCH 019/507] Write the solver lof to a file with linopy --- flixOpt/math_modeling.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/flixOpt/math_modeling.py b/flixOpt/math_modeling.py index e568714f7..2d4b141e7 100644 --- a/flixOpt/math_modeling.py +++ b/flixOpt/math_modeling.py @@ -804,7 +804,9 @@ def solve(self, modeling_language: 'ModelingLanguage'): ) elif isinstance(modeling_language, LinopyModel): status = modeling_language.model.solve( - 'gurobi', **{'mipgap': self.mip_gap, 'TimeLimit': self.time_limit_seconds} + log_fn=self.logfile_name, + solver_name='gurobi', + **{'mipgap': self.mip_gap, 'TimeLimit': self.time_limit_seconds} ) self.objective = modeling_language.model.objective.value @@ -905,7 +907,9 @@ def solve(self, modeling_language: 'ModelingLanguage'): self.log = f'Not Implemented for {self.__class__.__name__} yet' elif isinstance(modeling_language, LinopyModel): status = modeling_language.model.solve( - 'highs', **{'mip_rel_gap': self.mip_gap, 'time_limit': self.time_limit_seconds} + log_fn=self.logfile_name, + solver_name='highs', + **{'mip_rel_gap': self.mip_gap, 'time_limit': self.time_limit_seconds} ) self.objective = modeling_language.model.objective.value From 6940449d2505ee7d15c4043901c19c5b550f91d8 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 9 Feb 2025 13:03:51 +0100 Subject: [PATCH 020/507] First experiments with linopy --- examples/linopy_native_experiments.py | 160 ++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 examples/linopy_native_experiments.py diff --git a/examples/linopy_native_experiments.py b/examples/linopy_native_experiments.py new file mode 100644 index 000000000..5502f17dd --- /dev/null +++ b/examples/linopy_native_experiments.py @@ -0,0 +1,160 @@ +import pandas as pd +import xarray as xr +import numpy as np + +import linopy +import plotly.express as px +import matplotlib.pyplot as plt + + +from typing import List, Optional, Tuple, Literal, Dict + +class SystemModel(linopy.Model): + def __init__( + self, + timesteps: pd.DatetimeIndex, + hours_of_last_step: Optional[float] = None, + periods: Optional[List[int]] = None, + ): + """ + Parameters + ---------- + timesteps : pd.DatetimeIndex + The timesteps of the model. + hours_of_last_step : Optional[float], optional + The duration of the last time step. Uses the last time interval if not specified + periods : Optional[List[int]], optional + The periods of the model. Every period has the same timesteps. + Usually years are used as periods. + """ + super().__init__(force_dim_names=True) + self.timesteps = timesteps + self.timesteps.name = 'time' + self.periods = pd.Index(periods, name='period') if periods is not None else None + + if hours_of_last_step: + last_date = pd.DatetimeIndex([self.timesteps[-1] + pd.to_timedelta(hours_of_last_step, 'h')]) + else: + last_date = pd.DatetimeIndex([self.timesteps[-1] + (self.timesteps[-1] - self.timesteps[-2])]) + self.timesteps_extra = self.timesteps.append(last_date) + self.timesteps_extra.name = 'time' + hours_per_step = self.timesteps_extra.to_series().diff()[1:].values / pd.to_timedelta(1, 'h') + self.hours_per_step = xr.DataArray( + data=np.tile(hours_per_step, (len(self.periods), 1)) if self.periods is not None else hours_per_step, + coords=self.coords, + name='hours_per_step' + ) + + @property + def snapshots(self): + return xr.Dataset( + coords={'period': list(self.periods), 'time': list(self.timesteps)} if self.periods is not None else {'time': list(self.timesteps)}, + ) + + @property + def coords(self): + return self.snapshots.coords + + @property + def time_variables(self, filter_by: Optional[Literal['binary', 'continous', 'integer']] = None): + if filter_by is None: + all_variables = super().variables + elif filter_by == 'binary': + all_variables = super().binaries + elif filter_by == 'integer': + all_variables = super().integers + elif filter_by == 'continous': + all_variables = super().continuous + else: + raise ValueError(f'Invalid filter_by "{filter_by}", must be one of "binary", "continous", "integer"') + return all_variables[[name for name in all_variables if 'time' in all_variables[name].dims]] + + @property + def index_shape(self) -> Tuple[int, int]: + return len(self.periods) if self.periods is not None else 1, len(self.timesteps) + + +m = SystemModel(pd.date_range(start='2025-01-01', end='2025-01-08', freq='h'), periods=[2025, 2030]) + +rng = np.random.default_rng(seed=42) +random_array = rng.random(m.index_shape) + +total = pd.Index(range(1), name='total') + +x = m.add_variables(lower=0, coords=m.coords, name="x") # x is a variable for every timestep and period +y = m.add_variables(lower=0, coords=m.coords, name="y") # y is a variable for every timestep +z = m.add_variables(lower=0, name="z") # z is a scalar variable + +factor = xr.DataArray(random_array * 10, coords=m.coords) + +con1 = m.add_constraints(3 * x + 7 * y >= 10 * factor, name="con1") +con2 = m.add_constraints(5 * x + 2 * y >= 3 * factor, name="con2") +con3 = m.add_constraints(z >= 3, name="con3") + +# Complex constraint +con_weekly = m.add_constraints( + (3 * y).where(m.snapshots['period'] == 2025).sum() <= 20, name="con_per_period") + +# Size constraint, using a scalar variable +s = m.add_variables(lower=0, name="s") +con_size = m.add_constraints(x <= s, name="con_size") + +# Size constraint, using a period variable +s_per_period = m.add_variables(lower=0, coords=(m.periods,), name="s_per_period") +con_size_per_period = m.add_constraints(x <= s_per_period, name="con_size_per_period") + +# Constraint the total over the month of April to be 11000 +total_of_kw1 = m.add_variables(upper=11000, name="total_of_KW1") +con_per_month = m.add_constraints( + (m.hours_per_step * x).where(x.coords['time'].dt.week == 1).sum() <= total_of_kw1, + name="con_total_per_month" +) + + +##### Storage ##### +# Add a variable thats one step longer (charge state) +charge_state = m.add_variables(lower=100, coords=(m.periods, m.timesteps_extra), name="charge_state") +flow_storage = m.add_variables(lower=-100, upper=100, coords=m.coords, name="flow_netto_charging") + +con_storage = m.add_constraints( + charge_state.isel(time=slice(1, None)) == charge_state.isel(time=slice(None, -1)) * 0.99 + flow_storage, + name="con_storage" +) + +# Start every period with 1000 kWh +con_storage_start = m.add_constraints( + charge_state.isel(time=0) == 1000, + name="con_storage_start" +) +# Start = End for every period +start_is_end = True +if start_is_end: + con_storage_start_end = m.add_constraints( + charge_state.isel(time=0) == charge_state.isel(time=-1), + name="con_storage_start_end" + ) +m.add_constraints(charge_state.isel(period=0, time=40) == 6*charge_state.isel(period=1, time=40), name="couple_periods") +m.add_objective((x + 2 * y).sum() + z) +m.solve() + +m.solve() + +# --- Plotting --- +# plot all results directly +m.time_variables.solution.to_dataframe().plot(grid=True, ylabel="Optimal Value", title="All Time variables",) +plt.xticks(rotation=90) +plt.tight_layout() +plt.show() + +# Order the dataframe by period and time +df = m.time_variables.solution.to_dataframe() + +# Plotting per period is easy +fig = px.line(charge_state.solution.to_dataframe().reset_index(), x="time", y="solution", color="period", title="Charge State in MWh") +fig.show() + +# Plotting the whole is even easier +fig= charge_state.solution.plot() +fig.figure.show() + + From 1b441209d7d241fd24bb6388aaf36350cad6521c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 11 Feb 2025 10:14:43 +0100 Subject: [PATCH 021/507] Change structure of flow_system.py and structure.py --- flixOpt/flow_system.py | 47 ++++---- flixOpt/structure.py | 258 +++++++++-------------------------------- 2 files changed, 74 insertions(+), 231 deletions(-) diff --git a/flixOpt/flow_system.py b/flixOpt/flow_system.py index 42c2fbe7f..673d80483 100644 --- a/flixOpt/flow_system.py +++ b/flixOpt/flow_system.py @@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Tuple, Union import numpy as np +import pandas as pd from . import utils from .core import TimeSeries @@ -27,42 +28,36 @@ class FlowSystem: """ def __init__( - self, - time_series: np.ndarray[np.datetime64], - last_time_step_hours: Optional[Union[int, float]] = None, - previous_dt_in_hours: Optional[Union[int, float, np.ndarray]] = None, + self, + timesteps: pd.DatetimeIndex, + hours_of_last_timestep: Optional[float] = None, + hours_of_previous_timesteps: Optional[Union[int, float, np.ndarray]] = None, + periods: Optional[List[int]] = None, ): """ Parameters ---------- - time_series : np.ndarray of datetime64 - timeseries of the data. Must be in datetime64 format. Don't use precisions below 'us'. !np.datetime64[ns]! - last_time_step_hours : - The duration of last time step. - Storages needs this time-duration for calculation of charge state - after last time step. - If None, then last time increment of time_series is used. + timesteps : pd.DatetimeIndex + The timesteps of the model. + hours_of_last_step : Optional[float], optional + The duration of the last time step. Uses the last time interval if not specified previous_dt_in_hours : Union[int, float, np.ndarray] - The duration of previous time steps. + 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! + periods : Optional[List[int]], optional + The periods of the model. Every period has the same timesteps. + Usually years are used as periods. """ - self.time_series = time_series if isinstance(time_series, np.ndarray) else np.array(time_series) - if self.time_series.dtype == np.dtype('datetime64[ns]'): - self.time_series = self.time_series.astype('datetime64[us]') - - self.last_time_step_hours = ( - self.time_series[-1] - self.time_series[-2] if last_time_step_hours is None else last_time_step_hours + self.timesteps = timesteps + self.hours_of_last_step = hours_of_last_timestep + self.hours_of_previous_timesteps: Union[int, float, np.ndarray] = ( + ((self.timesteps[1] - self.timesteps[0]) / np.timedelta64(1, 'h')) + if hours_of_previous_timesteps is None + else hours_of_previous_timesteps ) - self.time_series_with_end = np.append(self.time_series, self.time_series[-1] + self.last_time_step_hours) - self.previous_dt_in_hours: Union[int, float, np.ndarray] = ( - ((self.time_series[1] - self.time_series[0]) / np.timedelta64(1, 'h')) - if previous_dt_in_hours is None - else previous_dt_in_hours - ) - - utils.check_time_series('time series of FlowSystem', self.time_series_with_end) + self.periods = periods # defaults: self.components: Dict[str, Component] = {} diff --git a/flixOpt/structure.py b/flixOpt/structure.py index 9aaef1e28..aa6b00432 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -9,11 +9,14 @@ import pathlib from datetime import datetime from io import StringIO -from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Union +from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Union, Tuple import numpy as np from rich.console import Console from rich.pretty import Pretty +import xarray as xr +import linopy +import pandas as pd from . import utils from .config import CONFIG @@ -27,171 +30,70 @@ logger = logging.getLogger('flixOpt') -class SystemModel(MathModel): - """ - Hier kommen die ModellingLanguage-spezifischen Sachen rein - """ +class SystemModel(linopy.Model): def __init__( - self, - label: str, - modeling_language: Literal['pyomo', 'linopy'], - flow_system: 'FlowSystem', - time_indices: Optional[Union[List[int], range]], + self, + flow_system: FlowSystem, + active_time_steps, ): - super().__init__(label, modeling_language) + super().__init__(force_dim_names=True) self.flow_system = flow_system - # Zeitdaten generieren: - self.time_series, self.time_series_with_end, self.dt_in_hours, self.dt_in_hours_total = ( - flow_system.get_time_data_from_indices(time_indices) - ) - self.previous_dt_in_hours = flow_system.previous_dt_in_hours - self.nr_of_time_steps = len(self.time_series) - self.indices = range(self.nr_of_time_steps) - - self.effect_collection_model = flow_system.effect_collection.create_model(self) - self.component_models: List['ComponentModel'] = [] - self.bus_models: List['BusModel'] = [] - self.other_models: List[ElementModel] = [] - - def do_modeling(self): - self.effect_collection_model.do_modeling(self) - self.component_models = [component.create_model() for component in self.flow_system.components.values()] - self.bus_models = [bus.create_model() for bus in self.flow_system.buses.values()] - for component_model in self.component_models: - component_model.do_modeling(self) - for bus_model in self.bus_models: # Buses after Components, because FlowModels are created in ComponentModels - bus_model.do_modeling(self) - - def solve(self, solver: Solver, excess_threshold: Union[int, float] = 0.1): - """ - Parameters - ---------- - solver : Solver - An Instance of the class Solver. Choose from flixOpt.solvers - excess_threshold : float, positive! - threshold for excess: If sum(Excess)>excess_threshold a warning is raised, that an excess occurs - """ + self.active_time_steps = active_time_steps - logger.info(f'{" starting solving ":#^80}') - logger.info(f'{self.describe_size()}') + self._order_dimensions() - super().solve(solver) - - logger.info(f'Termination message: "{self.solver.termination_message}"') + def _order_dimensions(self): + if self.flow_system.timesteps.dtype == np.dtype('datetime64[ns]'): + self.timesteps = self.flow_system.timesteps.astype('datetime64[us]') + else: + self.timesteps = self.flow_system.timesteps + self.timesteps.name = 'time' - logger.info(f'{" finished solving ":#^80}') - logger.info(f'{" Main Results ":#^80}') - for effect_name, effect_results in self.main_results['Effects'].items(): - logger.info( - f'{effect_name}:\n' - f' {"operation":<15}: {effect_results["operation"]:>10.2f}\n' - f' {"invest":<15}: {effect_results["invest"]:>10.2f}\n' - f' {"sum":<15}: {effect_results["sum"]:>10.2f}' - ) + self.periods = pd.Index(self.flow_system.periods, name='period') if self.flow_system.periods is not None else None - logger.info( - # f'{"SUM":<15}: ...todo...\n' - f'{"Penalty":<17}: {self.main_results["penalty"]:>10.2f}\n' - f'{"":-^80}\n' - f'{"Objective":<17}: {self.main_results["Objective"]:>10.2f}\n' - f'{"":-^80}' - ) - - logger.info('Investment Decisions:') - logger.info( - utils.apply_formating( - data_dict={ - **self.main_results['Invest-Decisions']['invested'], - **self.main_results['Invest-Decisions']['not invested'], - }, - key_format='<30', - indent=2, - sort_by='value', - ) + if self.flow_system.hours_of_last_step: + last_date = pd.DatetimeIndex([self.timesteps[-1] + pd.to_timedelta(self.flow_system.hours_of_last_step, 'h')]) + else: + last_date = pd.DatetimeIndex([self.timesteps[-1] + (self.timesteps[-1] - self.timesteps[-2])]) + self.timesteps_extra = self.timesteps.append(last_date) + self.timesteps_extra.name = 'time' + hours_per_step = self.timesteps_extra.to_series().diff()[1:].values / pd.to_timedelta(1, 'h') + self.hours_per_step = xr.DataArray( + data=np.tile(hours_per_step, (len(self.periods), 1)) if self.periods is not None else hours_per_step, + coords=self.coords, + name='hours_per_step' ) - for bus in self.main_results['buses with excess']: - logger.warning(f'A penalty occurred in Bus "{bus}"!') + # utils.check_time_series('time series of FlowSystem', self.timesteps_extra) - if self.main_results['penalty'] > 10: - logger.warning(f'A total penalty of {self.main_results["penalty"]} occurred.This might distort the results') - logger.info(f'{" End of Main Results ":#^80}') - - def description_of_variables(self, structured: bool = True) -> Dict[str, Union[str, List[str]]]: - return { - 'Components': { - label: comp.model.description_of_variables(structured) - for label, comp in self.flow_system.components.items() - }, - 'Buses': { - label: bus.model.description_of_variables(structured) for label, bus in self.flow_system.buses.items() - }, - 'Effects': self.flow_system.effect_collection.model.description_of_variables(structured), - 'Others': {model.element.label: model.description_of_variables(structured) for model in self.other_models}, - } - - def description_of_constraints(self, structured: bool = True) -> Dict[str, Union[str, List[str]]]: - return { - 'Components': { - label: comp.model.description_of_constraints(structured) - for label, comp in self.flow_system.components.items() - }, - 'Buses': { - label: bus.model.description_of_constraints(structured) for label, bus in self.flow_system.buses.items() - }, - 'Objective': self.objective.description(), - 'Effects': self.flow_system.effect_collection.model.description_of_constraints(structured), - 'Others': { - model.element.label: model.description_of_constraints(structured) for model in self.other_models - }, - } - - def results(self): - return { - 'Components': {model.element.label: model.results() for model in self.component_models}, - 'Effects': self.effect_collection_model.results(), - 'Buses': {model.element.label: model.results() for model in self.bus_models}, - 'Others': {model.element.label: model.results() for model in self.other_models}, - 'Objective': self.result_of_objective, - 'Time': self.time_series_with_end, - 'Time intervals in hours': self.dt_in_hours, - } + @property + def snapshots(self): + return xr.Dataset( + coords={'period': list(self.periods), 'time': list(self.timesteps)} if self.periods is not None else {'time': list(self.timesteps)}, + ) @property - def main_results(self) -> Dict[str, Union[Skalar, Dict]]: - main_results = {} - effect_results = {} - main_results['Effects'] = effect_results - for effect in self.flow_system.effect_collection.effects.values(): - effect_results[f'{effect.label} [{effect.unit}]'] = { - 'operation': float(effect.model.operation.sum.result), - 'invest': float(effect.model.invest.sum.result), - 'sum': float(effect.model.all.sum.result), - } - main_results['penalty'] = float(self.effect_collection_model.penalty.sum.result) - main_results['Objective'] = self.result_of_objective - main_results['lower bound'] = self.solver.best_bound - buses_with_excess = [] - main_results['buses with excess'] = buses_with_excess - for bus in self.flow_system.buses.values(): - if bus.with_excess: - if np.sum(bus.model.excess_input.result) > 1e-3 or np.sum(bus.model.excess_output.result) > 1e-3: - buses_with_excess.append(bus.label) - - invest_decisions = {'invested': {}, 'not invested': {}} - main_results['Invest-Decisions'] = invest_decisions - from flixOpt.features import InvestmentModel + def coords(self): + return self.snapshots.coords - for sub_model in self.sub_models: - if isinstance(sub_model, InvestmentModel): - invested_size = float(sub_model.size.result) # bei np.floats Probleme bei Speichern - if invested_size > 1e-3: - invest_decisions['invested'][sub_model.element.label_full] = invested_size - else: - invest_decisions['not invested'][sub_model.element.label_full] = invested_size + @property + def variables_filtered(self, filter_by: Optional[Literal['binary', 'continous', 'integer']] = None,): + if filter_by is None: + all_variables = super().variables + elif filter_by == 'binary': + all_variables = super().binaries + elif filter_by == 'integer': + all_variables = super().integers + elif filter_by == 'continous': + all_variables = super().continuous + else: + raise ValueError(f'Invalid filter_by "{filter_by}", must be one of "binary", "continous", "integer"') + return all_variables[[name for name in all_variables if 'time' in all_variables[name].dims]] - return main_results + @property + def index_shape(self) -> Tuple[int, int]: + return len(self.periods) if self.periods is not None else 1, len(self.timesteps) @property def infos(self) -> Dict: @@ -202,60 +104,6 @@ def infos(self) -> Dict: infos['Config'] = CONFIG.to_dict() return infos - @property - def all_variables(self) -> Dict[str, Variable]: - all_vars = {} - for model in self.sub_models: - for label, variable in model.variables.items(): - if label in all_vars: - raise KeyError(f'Duplicate Variable found in SystemModel:{model=} {label=}; {variable=}') - all_vars[label] = variable - return all_vars - - @property - def all_constraints(self) -> Dict[str, Union[Equation, Inequation]]: - all_constr = {} - for model in self.sub_models: - for label, constr in model.constraints.items(): - if label in all_constr: - raise KeyError(f'Duplicate Constraint found in SystemModel: {label=}; {constr=}') - else: - all_constr[label] = constr - return all_constr - - @property - def all_equations(self) -> Dict[str, Equation]: - return {key: value for key, value in self.all_constraints.items() if isinstance(value, Equation)} - - @property - def all_inequations(self) -> Dict[str, Inequation]: - return {key: value for key, value in self.all_constraints.items() if isinstance(value, Inequation)} - - @property - def sub_models(self) -> List['ElementModel']: - direct_models = [self.effect_collection_model] + self.component_models + self.bus_models + self.other_models - sub_models = [sub_model for direct_model in direct_models for sub_model in direct_model.all_sub_models] - return direct_models + sub_models - - @property - def variables(self) -> List[Variable]: - """Needed for Mother class""" - return list(self.all_variables.values()) - - @property - def equations(self) -> List[Equation]: - """Needed for Mother class""" - return list(self.all_equations.values()) - - @property - def inequations(self) -> List[Inequation]: - """Needed for Mother class""" - return list(self.all_inequations.values()) - - @property - def objective(self) -> Equation: - return self.effect_collection_model.objective - class Interface: """ From 8e6ef0325e2822db352c3c9f7e166ce59b834124 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 11 Feb 2025 10:43:17 +0100 Subject: [PATCH 022/507] COmbined EffectsCOllection and EffectsCollectionModel in one class --- flixOpt/effects.py | 128 +++++++++++++++++++---------------------- flixOpt/flow_system.py | 4 +- 2 files changed, 61 insertions(+), 71 deletions(-) diff --git a/flixOpt/effects.py b/flixOpt/effects.py index 1b359b874..e54447864 100644 --- a/flixOpt/effects.py +++ b/flixOpt/effects.py @@ -260,19 +260,16 @@ def effect_values_from_effect_time_series(effect_time_series: EffectTimeSeries) return {effect: time_series.active_data for effect, time_series in effect_time_series.items()} -class EffectCollection: +class EffectCollection(ElementModel): """ Handling all Effects """ - def __init__(self, label: str): - self.label = label - self.model: Optional[EffectCollectionModel] = None + def __init__(self): + super().__init__(Element('Effects')) self.effects: Dict[str, Effect] = {} - - def create_model(self, system_model: SystemModel) -> 'EffectCollectionModel': - self.model = EffectCollectionModel(self, system_model) - return self.model + self.penalty: Optional[ShareAllocationModel] = None + self.objective: Optional[Equation] = None def add_effect(self, effect: 'Effect') -> None: if effect.is_standard and self.standard_effect is not None: @@ -285,6 +282,46 @@ def add_effect(self, effect: 'Effect') -> None: raise Exception(f'Effect with label "{effect.label=}" already added!') self.effects[effect.label] = effect + def do_modeling(self, system_model: SystemModel): + for effect in self.effects.values(): + effect.create_model() + self.penalty = ShareAllocationModel(Element('Penalty'), 'penalty', False) + for model in [effect.model for effect in self.effects.values()] + [self.penalty]: + model.do_modeling(system_model) + + self.add_share_between_effects(system_model) + + # TODO: Move this to the SystemModel! + self.objective = Equation('OBJECTIVE', 'OBJECTIVE', is_objective=True) + self.objective.add_summand(self._objective_effect_model.operation.sum, 1) + self.objective.add_summand(self._objective_effect_model.invest.sum, 1) + self.objective.add_summand(self.penalty.sum, 1) + + def add_share_between_effects(self, system_model: SystemModel): + for origin_effect in self.effects.values(): + # 1. operation: -> hier sind es Zeitreihen (share_TS) + for target_effect, time_series in origin_effect.specific_share_to_other_effects_operation.items(): + target_effect.model.operation.add_share( + system_model, + f'{origin_effect.label_full}_operation', + origin_effect.model.operation.sum_TS, + time_series.active_data, + ) + # 2. invest: -> hier ist es Skalar (share) + for target_effect, factor in origin_effect.specific_share_to_other_effects_invest.items(): + target_effect.model.invest.add_share( + system_model, + f'{origin_effect.label_full}_invest', + origin_effect.model.invest.sum, + factor + ) + + def __getitem__(self, label: str) -> 'Effect': + return self.effects[label] + + def __contains__(self, label: str) -> bool: + return label in self.effects + @property def standard_effect(self) -> Optional[Effect]: for effect in self.effects.values(): @@ -301,37 +338,9 @@ def objective_effect(self) -> Optional[Effect]: def label_full(self): return self.label - -class EffectCollectionModel(ElementModel): - # TODO: Maybe all EffectModels should be sub_models of this Model? Including Objective and Penalty? - def __init__(self, element: EffectCollection, system_model: SystemModel): - super().__init__(element) - self.element = element - self._system_model = system_model - self._effect_models: Dict[Effect, EffectModel] = {} - self.penalty: Optional[ShareAllocationModel] = None - self.objective: Optional[Equation] = None - - def do_modeling(self, system_model: SystemModel): - self._effect_models = {effect: effect.create_model() for effect in self.element.effects.values()} - self.penalty = ShareAllocationModel(self.element, 'penalty', False) - self.sub_models.extend(list(self._effect_models.values()) + [self.penalty]) - for model in self.sub_models: - model.do_modeling(system_model) - - self.add_share_between_effects() - - self.objective = Equation('OBJECTIVE', 'OBJECTIVE', is_objective=True) - self.objective.add_summand(self._objective_effect_model.operation.sum, 1) - self.objective.add_summand(self._objective_effect_model.invest.sum, 1) - self.objective.add_summand(self.penalty.sum, 1) - - @property - def _objective_effect_model(self) -> EffectModel: - return self._effect_models[self.element.objective_effect] - def _add_share_to_effects( self, + system_model: SystemModel, name: str, element: Element, target: Literal['operation', 'invest'], @@ -342,22 +351,21 @@ def _add_share_to_effects( # an alle Effects, die einen Wert haben, anhängen: for effect, value in effect_values.items(): if effect is None: # Falls None, dann Standard-effekt nutzen: - effect = self.element.standard_effect - assert effect in self.element.effects.values(), f'Effect {effect.label} was used but not added to model!' + effect = self.standard_effect + assert effect in self.effects.values(), f'Effect {effect.label} was used but not added to model!' + name_of_share = f'{element.label_full}__{name}' + total_factor = np.multiply(value, factor) if target == 'operation': - model = self._effect_models[effect].operation - elif target == 'invest': - model = self._effect_models[effect].invest + effect.model.operation.add_share(system_model, name_of_share, variable, total_factor) + elif target =='invest': + effect.model.invest.add_share(system_model, name_of_share, variable, total_factor) else: raise ValueError(f'Target {target} not supported!') - name_of_share = f'{element.label_full}__{name}' - total_factor = np.multiply(value, factor) - model.add_share(self._system_model, name_of_share, variable, total_factor) - def add_share_to_invest( self, + system_model: SystemModel, name: str, element: Element, effect_values: EffectDictInvest, @@ -365,10 +373,11 @@ def add_share_to_invest( variable: Optional[Variable] = None, ) -> None: # TODO: Add checks - self._add_share_to_effects(name, element, 'invest', effect_values, factor, variable) + self._add_share_to_effects(system_model, name, element, 'invest', effect_values, factor, variable) def add_share_to_operation( self, + system_model: SystemModel, name: str, element: Element, effect_values: EffectTimeSeries, @@ -377,34 +386,15 @@ def add_share_to_operation( ) -> None: # TODO: Add checks self._add_share_to_effects( - name, element, 'operation', effect_values_from_effect_time_series(effect_values), factor, variable + system_model, name, element, 'operation', effect_values_from_effect_time_series(effect_values), factor, variable ) def add_share_to_penalty( self, + system_model: SystemModel, name: Optional[str], variable: Variable, factor: Numeric, ) -> None: assert variable is not None, 'A Variable must be passed to add a share to penalty! Else its a constant Penalty!' - self.penalty.add_share(self._system_model, name, variable, factor, True) - - def add_share_between_effects(self): - for origin_effect in self.element.effects.values(): - # 1. operation: -> hier sind es Zeitreihen (share_TS) - for target_effect, time_series in origin_effect.specific_share_to_other_effects_operation.items(): - target_model = self._effect_models[target_effect].operation - origin_model = self._effect_models[origin_effect].operation - target_model.add_share( - self._system_model, - f'{origin_effect.label_full}_operation', - origin_model.sum_TS, - time_series.active_data, - ) - # 2. invest: -> hier ist es Skalar (share) - for target_effect, factor in origin_effect.specific_share_to_other_effects_invest.items(): - target_model = self._effect_models[target_effect].invest - origin_model = self._effect_models[origin_effect].invest - target_model.add_share( - self._system_model, f'{origin_effect.label_full}_invest', origin_model.sum, factor - ) + self.penalty.add_share(system_model, name, variable, factor, True) \ No newline at end of file diff --git a/flixOpt/flow_system.py b/flixOpt/flow_system.py index 673d80483..1cfe1da19 100644 --- a/flixOpt/flow_system.py +++ b/flixOpt/flow_system.py @@ -61,13 +61,13 @@ def __init__( # defaults: self.components: Dict[str, Component] = {} - self.effect_collection: EffectCollection = EffectCollection('Effects') # Organizes Effects, Penalty & Objective + self.effects: EffectCollection = EffectCollection('Effects') # Organizes Effects, Penalty & Objective self.model: Optional[SystemModel] = None def add_effects(self, *args: Effect) -> None: for new_effect in list(args): logger.info(f'Registered new Effect: {new_effect.label}') - self.effect_collection.add_effect(new_effect) + self.effects.add_effect(new_effect) def add_components(self, *args: Component) -> None: # Komponenten registrieren: From d25bbaf22aa9fc2de696c37e8cb987ffa564eee7 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 11 Feb 2025 10:44:07 +0100 Subject: [PATCH 023/507] Move functions up in class --- flixOpt/effects.py | 72 +++++++++++++++++++++++----------------------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/flixOpt/effects.py b/flixOpt/effects.py index e54447864..3edfe3faa 100644 --- a/flixOpt/effects.py +++ b/flixOpt/effects.py @@ -271,6 +271,42 @@ def __init__(self): self.penalty: Optional[ShareAllocationModel] = None self.objective: Optional[Equation] = None + def add_share_to_invest( + self, + system_model: SystemModel, + name: str, + element: Element, + effect_values: EffectDictInvest, + factor: Numeric, + variable: Optional[Variable] = None, + ) -> None: + # TODO: Add checks + self._add_share_to_effects(system_model, name, element, 'invest', effect_values, factor, variable) + + def add_share_to_operation( + self, + system_model: SystemModel, + name: str, + element: Element, + effect_values: EffectTimeSeries, + factor: Numeric, + variable: Optional[Variable] = None, + ) -> None: + # TODO: Add checks + self._add_share_to_effects( + system_model, name, element, 'operation', effect_values_from_effect_time_series(effect_values), factor, variable + ) + + def add_share_to_penalty( + self, + system_model: SystemModel, + name: Optional[str], + variable: Variable, + factor: Numeric, + ) -> None: + assert variable is not None, 'A Variable must be passed to add a share to penalty! Else its a constant Penalty!' + self.penalty.add_share(system_model, name, variable, factor, True) + def add_effect(self, effect: 'Effect') -> None: if effect.is_standard and self.standard_effect is not None: raise Exception(f'A standard-effect already exists! ({self.standard_effect.label=})') @@ -362,39 +398,3 @@ def _add_share_to_effects( effect.model.invest.add_share(system_model, name_of_share, variable, total_factor) else: raise ValueError(f'Target {target} not supported!') - - def add_share_to_invest( - self, - system_model: SystemModel, - name: str, - element: Element, - effect_values: EffectDictInvest, - factor: Numeric, - variable: Optional[Variable] = None, - ) -> None: - # TODO: Add checks - self._add_share_to_effects(system_model, name, element, 'invest', effect_values, factor, variable) - - def add_share_to_operation( - self, - system_model: SystemModel, - name: str, - element: Element, - effect_values: EffectTimeSeries, - factor: Numeric, - variable: Optional[Variable] = None, - ) -> None: - # TODO: Add checks - self._add_share_to_effects( - system_model, name, element, 'operation', effect_values_from_effect_time_series(effect_values), factor, variable - ) - - def add_share_to_penalty( - self, - system_model: SystemModel, - name: Optional[str], - variable: Variable, - factor: Numeric, - ) -> None: - assert variable is not None, 'A Variable must be passed to add a share to penalty! Else its a constant Penalty!' - self.penalty.add_share(system_model, name, variable, factor, True) \ No newline at end of file From 9ae8b321774d8d891758fcddeb54b5cb85211596 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 11 Feb 2025 10:46:59 +0100 Subject: [PATCH 024/507] Rename all to total --- flixOpt/effects.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/flixOpt/effects.py b/flixOpt/effects.py index 3edfe3faa..3d3b9c5f0 100644 --- a/flixOpt/effects.py +++ b/flixOpt/effects.py @@ -151,7 +151,7 @@ def create_model(self) -> 'EffectModel': class EffectModel(ElementModel): def __init__(self, element: Effect): super().__init__(element) - self.element: Effect + self.element: Effect = element self.invest = ShareAllocationModel( self.element, 'invest', False, total_max=self.element.maximum_invest, total_min=self.element.minimum_invest ) @@ -168,17 +168,17 @@ def __init__(self, element: Effect): if self.element.maximum_operation_per_hour is not None else None, ) - self.all = ShareAllocationModel( - self.element, 'all', False, total_max=self.element.maximum_total, total_min=self.element.minimum_total + self.total = ShareAllocationModel( + self.element, 'total', False, total_max=self.element.maximum_total, total_min=self.element.minimum_total ) - self.sub_models.extend([self.invest, self.operation, self.all]) + self.sub_models.extend([self.invest, self.operation, self.total]) def do_modeling(self, system_model: SystemModel): for model in self.sub_models: model.do_modeling(system_model) - self.all.add_share(system_model, 'operation', self.operation.sum, 1) - self.all.add_share(system_model, 'invest', self.invest.sum, 1) + self.total.add_share(system_model, 'operation', self.operation.sum, 1) + self.total.add_share(system_model, 'invest', self.invest.sum, 1) EffectDict = Dict[Optional['Effect'], Numeric] From a7a1c574f29038cd64cf6d4b659f28bec39ad5be Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 11 Feb 2025 10:50:15 +0100 Subject: [PATCH 025/507] Improve checks if an effect is present --- flixOpt/effects.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/flixOpt/effects.py b/flixOpt/effects.py index 3d3b9c5f0..60fc8ea41 100644 --- a/flixOpt/effects.py +++ b/flixOpt/effects.py @@ -353,10 +353,16 @@ def add_share_between_effects(self, system_model: SystemModel): ) def __getitem__(self, label: str) -> 'Effect': + """Get an effect by label""" return self.effects[label] - def __contains__(self, label: str) -> bool: - return label in self.effects + def __contains__(self, item: Union[str, 'Effect']) -> bool: + """Check if the effect exists. Checks for label or object""" + if isinstance(item, str): + return item in self.effects # Check if the label exists + elif isinstance(item, Effect): + return item in self.effects.values() # Check if the object exists + return False @property def standard_effect(self) -> Optional[Effect]: From 21a5eeb10c5c8adaa130eabedfdb25c9699de316 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 11 Feb 2025 10:58:13 +0100 Subject: [PATCH 026/507] Improve the handling of effects in EffectsCollection() --- flixOpt/effects.py | 41 ++++++++++++++++------------------------- flixOpt/flow_system.py | 2 +- 2 files changed, 17 insertions(+), 26 deletions(-) diff --git a/flixOpt/effects.py b/flixOpt/effects.py index 60fc8ea41..8c391f36c 100644 --- a/flixOpt/effects.py +++ b/flixOpt/effects.py @@ -271,6 +271,9 @@ def __init__(self): self.penalty: Optional[ShareAllocationModel] = None self.objective: Optional[Equation] = None + self.standard_effect: Optional[Effect] = None + self.objective_effect: Optional[Effect] = None + def add_share_to_invest( self, system_model: SystemModel, @@ -308,10 +311,14 @@ def add_share_to_penalty( self.penalty.add_share(system_model, name, variable, factor, True) def add_effect(self, effect: 'Effect') -> None: - if effect.is_standard and self.standard_effect is not None: - raise Exception(f'A standard-effect already exists! ({self.standard_effect.label=})') - if effect.is_objective and self.objective_effect is not None: - raise Exception(f'A objective-effect already exists! ({self.objective_effect.label=})') + if effect.is_standard: + if self.standard_effect is not None: + raise Exception(f'A standard-effect already exists! ({self.standard_effect.label=})') + self.standard_effect = effect + if effect.is_objective: + if self.objective_effect is not None: + raise Exception(f'A objective-effect already exists! ({self.objective_effect.label=})') + self.objective_effect = effect if effect in self.effects.values(): raise Exception(f'Effect already added! ({effect.label=})') if effect.label in self.effects: @@ -329,8 +336,8 @@ def do_modeling(self, system_model: SystemModel): # TODO: Move this to the SystemModel! self.objective = Equation('OBJECTIVE', 'OBJECTIVE', is_objective=True) - self.objective.add_summand(self._objective_effect_model.operation.sum, 1) - self.objective.add_summand(self._objective_effect_model.invest.sum, 1) + self.objective.add_summand(self.objective_effect.model.operation.sum, 1) + self.objective.add_summand(self.objective_effect.model.invest.sum, 1) self.objective.add_summand(self.penalty.sum, 1) def add_share_between_effects(self, system_model: SystemModel): @@ -352,9 +359,9 @@ def add_share_between_effects(self, system_model: SystemModel): factor ) - def __getitem__(self, label: str) -> 'Effect': - """Get an effect by label""" - return self.effects[label] + def __getitem__(self, label: str) -> Optional['Effect']: + """Get an effect by label, or return the standart effect if not found""" + return self.effects.get(label, self.standard_effect) def __contains__(self, item: Union[str, 'Effect']) -> bool: """Check if the effect exists. Checks for label or object""" @@ -364,22 +371,6 @@ def __contains__(self, item: Union[str, 'Effect']) -> bool: return item in self.effects.values() # Check if the object exists return False - @property - def standard_effect(self) -> Optional[Effect]: - for effect in self.effects.values(): - if effect.is_standard: - return effect - - @property - def objective_effect(self) -> Optional[Effect]: - for effect in self.effects.values(): - if effect.is_objective: - return effect - - @property - def label_full(self): - return self.label - def _add_share_to_effects( self, system_model: SystemModel, diff --git a/flixOpt/flow_system.py b/flixOpt/flow_system.py index 1cfe1da19..c1e351dac 100644 --- a/flixOpt/flow_system.py +++ b/flixOpt/flow_system.py @@ -61,7 +61,7 @@ def __init__( # defaults: self.components: Dict[str, Component] = {} - self.effects: EffectCollection = EffectCollection('Effects') # Organizes Effects, Penalty & Objective + self.effects: EffectCollection = EffectCollection() # Organizes Effects, Penalty & Objective self.model: Optional[SystemModel] = None def add_effects(self, *args: Effect) -> None: From a9358ad320c152d75aad9d478556df519fc03210 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 11 Feb 2025 11:40:01 +0100 Subject: [PATCH 027/507] Rename ._on to .on_off --- flixOpt/components.py | 8 ++++---- flixOpt/elements.py | 20 ++++++++++---------- tests/test_functional.py | 26 +++++++++++++------------- tests/test_integration.py | 4 ++-- 4 files changed, 29 insertions(+), 29 deletions(-) diff --git a/flixOpt/components.py b/flixOpt/components.py index 23292fcef..82f922b51 100644 --- a/flixOpt/components.py +++ b/flixOpt/components.py @@ -323,7 +323,7 @@ class TransmissionModel(ComponentModel): def __init__(self, element: Transmission): super().__init__(element) self.element: Transmission = element - self._on: Optional[OnOffModel] = None + self.on_off: Optional[OnOffModel] = None def do_modeling(self, system_model: SystemModel): """Initiates all FlowModels""" @@ -365,7 +365,7 @@ def create_transmission_equation(self, name: str, in_flow: Flow, out_flow: Flow) eq_transmission.add_summand(in_flow.model.flow_rate, efficiency) eq_transmission.add_summand(out_flow.model.flow_rate, -1) if self.element.absolute_losses is not None: - eq_transmission.add_summand(in_flow.model._on.on, -1 * self.element.absolute_losses.active_data) + eq_transmission.add_summand(in_flow.model.on_off.on, -1 * self.element.absolute_losses.active_data) return eq_transmission @@ -373,7 +373,7 @@ class LinearConverterModel(ComponentModel): def __init__(self, element: LinearConverter): super().__init__(element) self.element: LinearConverter = element - self._on: Optional[OnOffModel] = None + self.on_off: Optional[OnOffModel] = None def do_modeling(self, system_model: SystemModel): super().do_modeling(system_model) @@ -414,7 +414,7 @@ def do_modeling(self, system_model: SystemModel): for flow in self.element.inputs + self.element.outputs } linear_segments = MultipleSegmentsModel( - self.element, segments, self._on.on if self._on is not None else None + self.element, segments, self.on_off.on if self.on_off is not None else None ) # TODO: Add Outside_segments Variable (On) linear_segments.do_modeling(system_model) self.sub_models.append(linear_segments) diff --git a/flixOpt/elements.py b/flixOpt/elements.py index c9e2a0575..4ce4d3a3a 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -298,7 +298,7 @@ def __init__(self, element: Flow): self.flow_rate: Optional[VariableTS] = None self.sum_flow_hours: Optional[Variable] = None - self._on: Optional[OnOffModel] = None + self.on_off: Optional[OnOffModel] = None self._investment: Optional[InvestmentModel] = None def do_modeling(self, system_model: SystemModel): @@ -315,11 +315,11 @@ def do_modeling(self, system_model: SystemModel): # OnOff if self.element.on_off_parameters is not None: - self._on = OnOffModel( + self.on_off = OnOffModel( self.element, self.element.on_off_parameters, [self.flow_rate], [self.absolute_flow_rate_bounds] ) - self._on.do_modeling(system_model) - self.sub_models.append(self._on) + self.on_off.do_modeling(system_model) + self.sub_models.append(self.on_off) # Investment if isinstance(self.element.size, InvestParameters): @@ -329,7 +329,7 @@ def do_modeling(self, system_model: SystemModel): self.flow_rate, self.relative_flow_rate_bounds, fixed_relative_profile=self.fixed_relative_flow_rate, - on_variable=self._on.on if self._on is not None else None, + on_variable=self.on_off.on if self.on_off is not None else None, ) self._investment.do_modeling(system_model) self.sub_models.append(self._investment) @@ -464,7 +464,7 @@ class ComponentModel(ElementModel): def __init__(self, element: Component): super().__init__(element) self.element: Component = element - self._on: Optional[OnOffModel] = None + self.on_off: Optional[OnOffModel] = None def do_modeling(self, system_model: SystemModel): """Initiates all FlowModels""" @@ -486,13 +486,13 @@ def do_modeling(self, system_model: SystemModel): if self.element.on_off_parameters: flow_rates: List[VariableTS] = [flow.model.flow_rate for flow in all_flows] bounds: List[Tuple[Numeric, Numeric]] = [flow.model.absolute_flow_rate_bounds for flow in all_flows] - self._on = OnOffModel(self.element, self.element.on_off_parameters, flow_rates, bounds) - self.sub_models.append(self._on) - self._on.do_modeling(system_model) + self.on_off = OnOffModel(self.element, self.element.on_off_parameters, flow_rates, bounds) + self.sub_models.append(self.on_off) + self.on_off.do_modeling(system_model) if self.element.prevent_simultaneous_flows: # Simultanious Useage --> Only One FLow is On at a time, but needs a Binary for every flow - on_variables = [flow.model._on.on for flow in self.element.prevent_simultaneous_flows] + on_variables = [flow.model.on_off.on for flow in self.element.prevent_simultaneous_flows] simultaneous_use = PreventSimultaneousUsageModel(self.element, on_variables) self.sub_models.append(simultaneous_use) simultaneous_use.do_modeling(system_model) diff --git a/tests/test_functional.py b/tests/test_functional.py index ba1e41661..655b302f9 100644 --- a/tests/test_functional.py +++ b/tests/test_functional.py @@ -367,7 +367,7 @@ def test_on(modeling_language_fixture, solver_fixture, time_steps_fixture): ) assert_allclose( - boiler.Q_th.model._on.on.result, + boiler.Q_th.model.on_off.on.result, [0, 1, 1, 0, 1], rtol=solver_fixture.mip_gap, atol=1e-10, @@ -411,15 +411,15 @@ def test_off(modeling_language_fixture, solver_fixture, time_steps_fixture): ) assert_allclose( - boiler.Q_th.model._on.on.result, + boiler.Q_th.model.on_off.on.result, [0, 1, 1, 0, 1], rtol=solver_fixture.mip_gap, atol=1e-10, err_msg='"Boiler__Q_th__on" does not have the right value', ) assert_allclose( - boiler.Q_th.model._on.off.result, - 1 - boiler.Q_th.model._on.on.result, + boiler.Q_th.model.on_off.off.result, + 1 - boiler.Q_th.model.on_off.on.result, rtol=solver_fixture.mip_gap, atol=1e-10, err_msg='"Boiler__Q_th__off" does not have the right value', @@ -462,21 +462,21 @@ def test_switch_on_off(modeling_language_fixture, solver_fixture, time_steps_fix ) assert_allclose( - boiler.Q_th.model._on.on.result, + boiler.Q_th.model.on_off.on.result, [0, 1, 1, 0, 1], rtol=solver_fixture.mip_gap, atol=1e-10, err_msg='"Boiler__Q_th__on" does not have the right value', ) assert_allclose( - boiler.Q_th.model._on.switch_on.result, + boiler.Q_th.model.on_off.switch_on.result, [0, 1, 0, 0, 1], rtol=solver_fixture.mip_gap, atol=1e-10, err_msg='"Boiler__Q_th__switch_on" does not have the right value', ) assert_allclose( - boiler.Q_th.model._on.switch_off.result, + boiler.Q_th.model.on_off.switch_off.result, [0, 0, 0, 1, 0], rtol=solver_fixture.mip_gap, atol=1e-10, @@ -526,7 +526,7 @@ def test_on_total_max(modeling_language_fixture, solver_fixture, time_steps_fixt ) assert_allclose( - boiler.Q_th.model._on.on.result, + boiler.Q_th.model.on_off.on.result, [0, 0, 1, 0, 0], rtol=solver_fixture.mip_gap, atol=1e-10, @@ -583,7 +583,7 @@ def test_on_total_bounds(modeling_language_fixture, solver_fixture, time_steps_f ) assert_allclose( - boiler.Q_th.model._on.on.result, + boiler.Q_th.model.on_off.on.result, [0, 0, 1, 0, 1], rtol=solver_fixture.mip_gap, atol=1e-10, @@ -598,7 +598,7 @@ def test_on_total_bounds(modeling_language_fixture, solver_fixture, time_steps_f ) assert_allclose( - sum(boiler_backup.Q_th.model._on.on.result), + sum(boiler_backup.Q_th.model.on_off.on.result), 3, rtol=solver_fixture.mip_gap, atol=1e-10, @@ -656,7 +656,7 @@ def test_consecutive_on_off(modeling_language_fixture, solver_fixture, time_step ) assert_allclose( - boiler.Q_th.model._on.on.result, + boiler.Q_th.model.on_off.on.result, [1, 1, 0, 1, 1], rtol=solver_fixture.mip_gap, atol=1e-10, @@ -717,14 +717,14 @@ def test_consecutive_off(modeling_language_fixture, solver_fixture, time_steps_f ) assert_allclose( - boiler_backup.Q_th.model._on.on.result, + boiler_backup.Q_th.model.on_off.on.result, [0, 0, 1, 0, 0], rtol=solver_fixture.mip_gap, atol=1e-10, err_msg='"Boiler_backup__Q_th__on" does not have the right value', ) assert_allclose( - boiler_backup.Q_th.model._on.off.result, + boiler_backup.Q_th.model.on_off.off.result, [1, 1, 0, 1, 1], rtol=solver_fixture.mip_gap, atol=1e-10, diff --git a/tests/test_integration.py b/tests/test_integration.py index d6d2a9135..e029069f6 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -226,7 +226,7 @@ def test_transmission_basic(self): calculation.solve(self.get_solver()) print(calculation.results()) self.assert_almost_equal_numeric( - transmission.in1.model._on.on.result, np.array([1, 1, 1, 1, 1, 1, 1, 1, 1, 1]), 'On does not work properly' + transmission.in1.model.on_off.on.result, np.array([1, 1, 1, 1, 1, 1, 1, 1, 1, 1]), 'On does not work properly' ) self.assert_almost_equal_numeric( @@ -281,7 +281,7 @@ def test_transmission_advanced(self): results = fx.results.CalculationResults(calculation.name, 'results') self.assert_almost_equal_numeric( - transmission.in1.model._on.on.result, np.array([1, 1, 1, 0, 0, 0, 0, 0, 0, 0]), 'On does not work properly' + transmission.in1.model.on_off.on.result, np.array([1, 1, 1, 0, 0, 0, 0, 0, 0, 0]), 'On does not work properly' ) self.assert_almost_equal_numeric( From 8d9b974e2746bc12b6cfbcc3e1bda41eea7f851c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 11 Feb 2025 12:25:43 +0100 Subject: [PATCH 028/507] Remove some functions and attributes of ElementModel --- flixOpt/structure.py | 43 ------------------------------------------- 1 file changed, 43 deletions(-) diff --git a/flixOpt/structure.py b/flixOpt/structure.py index aa6b00432..b573b55db 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -222,22 +222,6 @@ def __init__(self, element: Element, label: Optional[str] = None): self.sub_models = [] self._label = label - def add_variables(self, *variables: Variable) -> None: - for variable in variables: - if variable.label not in self.variables.keys(): - self.variables[variable.label] = variable - elif variable in self.variables.values(): - raise Exception(f'Variable "{variable.label}" already exists') - else: - raise Exception(f'A Variable with the label "{variable.label}" already exists') - - def add_constraints(self, *constraints: Union[Equation, Inequation]) -> None: - for constraint in constraints: - if constraint.label not in self.constraints.keys(): - self.constraints[constraint.label] = constraint - else: - raise Exception(f'Constraint "{constraint.label}" already exists') - def description_of_variables(self, structured: bool = True) -> Union[Dict[str, Union[List[str], Dict]], List[str]]: if structured: # Gather descriptions of this model's variables @@ -276,14 +260,6 @@ def overview_of_model_size(self) -> Dict[str, int]: 'no of Variables single': sum(var.length for var in all_vars.values()), } - @property - def inequations(self) -> Dict[str, Inequation]: - return {name: ineq for name, ineq in self.constraints.items() if isinstance(ineq, Inequation)} - - @property - def equations(self) -> Dict[str, Equation]: - return {name: eq for name, eq in self.constraints.items() if isinstance(eq, Equation)} - @property def all_variables(self) -> Dict[str, Variable]: all_vars = self.variables.copy() @@ -304,25 +280,6 @@ def all_constraints(self) -> Dict[str, Union[Equation, Inequation]]: all_constr[key] = value return all_constr - @property - def all_equations(self) -> Dict[str, Equation]: - all_eqs = self.equations.copy() - for sub_model in self.sub_models: - for key, value in sub_model.all_equations.items(): - if key in all_eqs: - raise KeyError(f"Duplicate key found: '{key}' in both main model and submodel!") - all_eqs[key] = value - return all_eqs - - @property - def all_inequations(self) -> Dict[str, Inequation]: - all_ineqs = self.inequations.copy() - for sub_model in self.sub_models: - for key in sub_model.all_inequations: - if key in all_ineqs: - raise KeyError(f"Duplicate key found: '{key}' in both main model and submodel!") - return all_ineqs - @property def all_sub_models(self) -> List['ElementModel']: all_subs = [] From 9f014c8fd4906a14828f01c786e1d404385182e9 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 11 Feb 2025 14:10:30 +0100 Subject: [PATCH 029/507] Update Bus --- flixOpt/elements.py | 127 +++++++++++++++++++++++++------------------- 1 file changed, 72 insertions(+), 55 deletions(-) diff --git a/flixOpt/elements.py b/flixOpt/elements.py index 4ce4d3a3a..62374f26f 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -6,13 +6,13 @@ from typing import Dict, List, Optional, Tuple, Union import numpy as np +import linopy from .config import CONFIG from .core import Numeric, Numeric_TS, Skalar from .effects import EffectValues, effect_values_to_time_series from .features import InvestmentModel, OnOffModel, PreventSimultaneousUsageModel from .interface import InvestParameters, OnOffParameters -from .math_modeling import Variable, VariableTS from .structure import ( Element, ElementModel, @@ -295,23 +295,25 @@ class FlowModel(ElementModel): def __init__(self, element: Flow): super().__init__(element) self.element: Flow = element - self.flow_rate: Optional[VariableTS] = None - self.sum_flow_hours: Optional[Variable] = None + self.flow_rate: Optional[linopy.Variable] = None + self.total_flow_hours: Optional[linopy.Variable] = None self.on_off: Optional[OnOffModel] = None self._investment: Optional[InvestmentModel] = None def do_modeling(self, system_model: SystemModel): # eq relative_minimum(t) * size <= flow_rate(t) <= relative_maximum(t) * size - self.flow_rate = create_variable( - 'flow_rate', - self, - system_model.nr_of_time_steps, - fixed_value=self.fixed_relative_flow_rate, - lower_bound=self.absolute_flow_rate_bounds[0] if self.element.on_off_parameters is None else 0, - upper_bound=self.absolute_flow_rate_bounds[1] if self.element.on_off_parameters is None else None, - previous_values=self.element.previous_flow_rate, + self.flow_rate = system_model.add_variables( + lower=self.absolute_flow_rate_bounds[0] if self.element.on_off_parameters is None else 0, + upper=self.absolute_flow_rate_bounds[1] if self.element.on_off_parameters is None else None, + coords=system_model.coords, + name='flow_rate', ) + if self.element.fixed_relative_profile is not None: + self.constraints['fix_flow_rate'] = system_model.add_constraints( + self.flow_rate == self.element.fixed_relative_profile.active_data, + f'{self.element.label}_fix_flow_rate' + ) # OnOff if self.element.on_off_parameters is not None: @@ -334,17 +336,17 @@ def do_modeling(self, system_model: SystemModel): self._investment.do_modeling(system_model) self.sub_models.append(self._investment) - # sumFLowHours - self.sum_flow_hours = create_variable( - 'sumFlowHours', - self, - 1, - lower_bound=self.element.flow_hours_total_min, - upper_bound=self.element.flow_hours_total_max, + self.total_flow_hours = system_model.add_variables( + lower=self.element.flow_hours_total_min, + upper=self.element.flow_hours_total_max, + coords=None, + name=f'{self.element.label_full}__total_flow_hours' + ) + + self.constraints['total_flow_hours'] = system_model.add_constraints( + self.total_flow_hours == (self.flow_rate * system_model.hours_per_step).sum(), + name=f'{self.element.label_full}__total_flow_hours' ) - eq_sum_flow_hours = create_equation('sumFlowHours', self, 'eq') - eq_sum_flow_hours.add_summand(self.flow_rate, system_model.dt_in_hours, as_sum=True) - eq_sum_flow_hours.add_summand(self.sum_flow_hours, -1) # Load factor self._create_bounds_for_load_factor(system_model) @@ -355,7 +357,8 @@ def do_modeling(self, system_model: SystemModel): def _create_shares(self, system_model: SystemModel): # Arbeitskosten: if self.element.effects_per_flow_hour != {}: - system_model.effect_collection_model.add_share_to_operation( + system_model.flow_system.effects.add_share_to_operation( + system_model, name='effects_per_flow_hour', element=self.element, variable=self.flow_rate, @@ -368,24 +371,37 @@ def _create_bounds_for_load_factor(self, system_model: SystemModel): # eq: var_sumFlowHours <= size * dt_tot * load_factor_max if self.element.load_factor_max is not None: - flow_hours_per_size_max = system_model.dt_in_hours_total * self.element.load_factor_max - eq_load_factor_max = create_equation('load_factor_max', self, 'ineq') - eq_load_factor_max.add_summand(self.sum_flow_hours, 1) - # if investment: + name_short = 'load_factor_max' + name = f'{self.element.label_full}__{name_short}' + flow_hours_per_size_max = system_model.hours_per_step * self.element.load_factor_max + if self._investment is not None: - eq_load_factor_max.add_summand(self._investment.size, -1 * flow_hours_per_size_max) + eq_load_factor_max = system_model.add_constraints( + self.total_flow_hours <= self._investment.size * flow_hours_per_size_max, name=name, + ) else: - eq_load_factor_max.add_constant(self.element.size * flow_hours_per_size_max) + eq_load_factor_max = system_model.add_constraints( + self.total_flow_hours <= self.element.size * flow_hours_per_size_max, name=name, + ) + self.constraints[name_short] = eq_load_factor_max # eq: size * sum(dt)* load_factor_min <= var_sumFlowHours if self.element.load_factor_min is not None: - flow_hours_per_size_min = system_model.dt_in_hours_total * self.element.load_factor_min - eq_load_factor_min = create_equation('load_factor_min', self, 'ineq') - eq_load_factor_min.add_summand(self.sum_flow_hours, -1) + name_short = 'load_factor_min' + name = f'{self.element.label_full}__{name_short}' + flow_hours_per_size_in = system_model.hours_per_step * self.element.load_factor_min + if self._investment is not None: - eq_load_factor_min.add_summand(self._investment.size, flow_hours_per_size_min) + eq_load_factor_min = system_model.add_constraints( + self.total_flow_hours >= self._investment.size * flow_hours_per_size_in, + name=name + ) else: - eq_load_factor_min.add_constant(-1 * self.element.size * flow_hours_per_size_min) + eq_load_factor_min = system_model.add_constraints( + self.total_flow_hours >= self.element.size * flow_hours_per_size_in, + name=name + ) + self.constraints[name] = eq_load_factor_min @property def with_investment(self) -> bool: @@ -426,37 +442,38 @@ def relative_flow_rate_bounds(self) -> Tuple[Numeric, Numeric]: class BusModel(ElementModel): def __init__(self, element: Bus): super().__init__(element) - self.element: Bus - self.excess_input: Optional[VariableTS] = None - self.excess_output: Optional[VariableTS] = None + self.element: Bus = element + self.excess_input: Optional[linopy.Variable] = None + self.excess_output: Optional[linopy.Variable] = None def do_modeling(self, system_model: SystemModel) -> None: - self.element: Bus - # inputs = outputs - eq_bus_balance = create_equation('busBalance', self) - for flow in self.element.inputs: - eq_bus_balance.add_summand(flow.model.flow_rate, 1) - for flow in self.element.outputs: - eq_bus_balance.add_summand(flow.model.flow_rate, -1) + # inputs == outputs + inputs = sum([flow.model.flow_rate for flow in self.element.inputs]) + outputs = sum([flow.model.flow_rate for flow in self.element.outputs]) + eq_bus_balance = system_model.add_constraints( + inputs == outputs, + name=f'{self.label_full}__balance' + ) + self.constraints['balance'] = eq_bus_balance # Fehlerplus/-minus: if self.element.with_excess: excess_penalty = np.multiply( - system_model.dt_in_hours, self.element.excess_penalty_per_flow_hour.active_data + system_model.hours_per_step, self.element.excess_penalty_per_flow_hour.active_data ) - self.excess_input = create_variable('excess_input', self, system_model.nr_of_time_steps, lower_bound=0) - self.excess_output = create_variable('excess_output', self, system_model.nr_of_time_steps, lower_bound=0) - - eq_bus_balance.add_summand(self.excess_output, -1) - eq_bus_balance.add_summand(self.excess_input, 1) - - fx_collection = system_model.effect_collection_model + self.excess_input = system_model.add_variables( + lower_bound=0, coords=system_model.coords, name=f'{self.label_full}__excess_input' + ) + self.excess_output = system_model.add_variables( + lower_bound=0, coords=system_model.coords, name=f'{self.label_full}__excess_output' + ) + eq_bus_balance.lhs += self.excess_input - self.excess_output - fx_collection.add_share_to_penalty( - f'{self.element.label_full}__excess_input', self.excess_input, excess_penalty + system_model.flow_system.effects.add_share_to_penalty( + system_model, f'{self.element.label_full}__excess_input', self.excess_input, excess_penalty ) - fx_collection.add_share_to_penalty( - f'{self.element.label_full}__excess_output', self.excess_output, excess_penalty + system_model.flow_system.effects.add_share_to_penalty( + system_model, f'{self.element.label_full}__excess_output', self.excess_output, excess_penalty ) From 7ba1bf121b3aba767016ad97181ba50ad5312451 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 11 Feb 2025 15:18:46 +0100 Subject: [PATCH 030/507] Improve logic of standard effects in EffectsCollection --- flixOpt/effects.py | 53 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/flixOpt/effects.py b/flixOpt/effects.py index 8c391f36c..6c8381e31 100644 --- a/flixOpt/effects.py +++ b/flixOpt/effects.py @@ -271,8 +271,8 @@ def __init__(self): self.penalty: Optional[ShareAllocationModel] = None self.objective: Optional[Equation] = None - self.standard_effect: Optional[Effect] = None - self.objective_effect: Optional[Effect] = None + self._standard_effect: Optional[Effect] = None + self._objective_effect: Optional[Effect] = None def add_share_to_invest( self, @@ -312,12 +312,8 @@ def add_share_to_penalty( def add_effect(self, effect: 'Effect') -> None: if effect.is_standard: - if self.standard_effect is not None: - raise Exception(f'A standard-effect already exists! ({self.standard_effect.label=})') self.standard_effect = effect if effect.is_objective: - if self.objective_effect is not None: - raise Exception(f'A objective-effect already exists! ({self.objective_effect.label=})') self.objective_effect = effect if effect in self.effects.values(): raise Exception(f'Effect already added! ({effect.label=})') @@ -359,9 +355,23 @@ def add_share_between_effects(self, system_model: SystemModel): factor ) - def __getitem__(self, label: str) -> Optional['Effect']: - """Get an effect by label, or return the standart effect if not found""" - return self.effects.get(label, self.standard_effect) + def __getitem__(self, label: str) -> 'Effect': + """ + Get an effect by label, or return the standard effect if None is passed + + Raises: + KeyError: If no effect with the given label is found. + KeyError: If no standard effect is specified. + """ + if label is None: + try: + return self.standard_effect + except: + raise KeyError(f'No Standard-effect specified!') + try: + return self.effects[label] + except: + raise KeyError(f'No effect with label {label} found!') def __contains__(self, item: Union[str, 'Effect']) -> bool: """Check if the effect exists. Checks for label or object""" @@ -395,3 +405,28 @@ def _add_share_to_effects( effect.model.invest.add_share(system_model, name_of_share, variable, total_factor) else: raise ValueError(f'Target {target} not supported!') + + @property + def standard_effect(self) -> Effect: + if self._standard_effect is None: + raise KeyError(f'No standard-effect specified!') + return self._standard_effect + + @standard_effect.setter + def standard_effect(self, value: Effect) -> None: + if self._standard_effect is not None: + raise ValueError(f'A standard-effect already exists! ({self._standard_effect.label=})') + self._standard_effect = value + + @property + def objective_effect(self) -> Effect: + if self._objective_effect is None: + raise KeyError(f'No objective-effect specified!') + return self._objective_effect + + @objective_effect.setter + def objective_effect(self, value: Effect) -> None: + if self._objective_effect is not None: + raise ValueError(f'An objective-effect already exists! ({self._objective_effect.label=})') + self._objective_effect = value + From 320214b39d3f51fe33591f7f33e08c864d56de05 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 11 Feb 2025 15:44:03 +0100 Subject: [PATCH 031/507] Improve logic of labels of Models --- flixOpt/structure.py | 45 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/flixOpt/structure.py b/flixOpt/structure.py index b573b55db..006305586 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -78,7 +78,7 @@ def coords(self): return self.snapshots.coords @property - def variables_filtered(self, filter_by: Optional[Literal['binary', 'continous', 'integer']] = None,): + def variables_filtered(self, filter_by: Optional[Literal['binary', 'continous', 'integer']] = None): if filter_by is None: all_variables = super().variables elif filter_by == 'binary': @@ -203,7 +203,7 @@ def _plausibility_checks(self) -> None: """This function is used to do some basic plausibility checks for each Element during initialization""" raise NotImplementedError('Every Element needs a _plausibility_checks() method') - def create_model(self) -> None: + def create_model(self) -> 'ElementModel': raise NotImplementedError('Every Element needs a create_model() method') @property @@ -212,15 +212,24 @@ def label_full(self) -> str: class ElementModel: - """Interface to create the mathematical Models for Elements""" + """Interface to create the mathematical Variables and Constraints for Elements""" - def __init__(self, element: Element, label: Optional[str] = None): - logger.debug(f'Created {self.__class__.__name__} for {element.label_full}') + def __init__(self, element: Element, labels: Optional[Union[str, List[str]]] = None): + """ + Parameters + ---------- + element : Element + The element this model is created for. + labels : Optional[Union[str, List[str]]], optional + Used to construct the label of the model. If None, the element label is used. + The labels are used as suffixes + """ self.element = element self.variables = {} self.constraints = {} self.sub_models = [] - self._label = label + self._labels = labels + logger.debug(f'Created {self.__class__.__name__} "{self.label_full}"') def description_of_variables(self, structured: bool = True) -> Union[Dict[str, Union[List[str], Dict]], List[str]]: if structured: @@ -297,11 +306,29 @@ def results(self) -> Dict: @property def label_full(self) -> str: - return f'{self.element.label_full}__{self._label}' if self._label else self.element.label_full + if self._labels is not None and self.element is not None: + if isinstance(self._labels, str) : + return f'{self.element.label_full}__{self._labels}' + else: + return f'{self.element.label_full}__' + '__'.join(self._labels) + if self._labels is not None and self.element is None: + if isinstance(self._labels, str): + return self._labels + else: + return '__'.join(self._labels) + if self.element is not None: + return self.element.label_full + raise Exception('This should not happen! Internal Error. Please create Issue on GitHub') @property - def label(self): - return self._label or self.element.label + def label(self) -> str: + if self._labels is not None: + if isinstance(self._labels, str): + return self._labels + else: + return self._labels[-1] + else: + return self.element.label def _create_time_series( From 463aab27ae07a565b36a484c1f80b7689d53ddca Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 11 Feb 2025 16:11:31 +0100 Subject: [PATCH 032/507] Simplifying functions to add Shares to Effects --- flixOpt/effects.py | 158 ++++++--------------------------------------- 1 file changed, 20 insertions(+), 138 deletions(-) diff --git a/flixOpt/effects.py b/flixOpt/effects.py index 6c8381e31..d59bdaa2b 100644 --- a/flixOpt/effects.py +++ b/flixOpt/effects.py @@ -9,6 +9,7 @@ from typing import Dict, Literal, Optional, Union import numpy as np +import linopy from .core import Numeric, Numeric_TS, Skalar, TimeSeries from .features import ShareAllocationModel @@ -177,87 +178,10 @@ def do_modeling(self, system_model: SystemModel): for model in self.sub_models: model.do_modeling(system_model) - self.total.add_share(system_model, 'operation', self.operation.sum, 1) - self.total.add_share(system_model, 'invest', self.invest.sum, 1) + self.total.add_share(system_model, 'operation', self.operation.total*1) + self.total.add_share(system_model, 'invest', self.invest.total*1) - -EffectDict = Dict[Optional['Effect'], Numeric] -EffectDictInvest = Dict[Optional['Effect'], Skalar] - -EffectValues = Optional[Union[Numeric_TS, EffectDict]] # Datatype for User Input -EffectValuesInvest = Optional[Union[Skalar, EffectDictInvest]] # Datatype for User Input - -EffectTimeSeries = Dict[Optional['Effect'], TimeSeries] # Final Internal Data Structure -ElementTimeSeries = Dict[Optional[Element], TimeSeries] # Final Internal Data Structure - - -def nested_values_to_time_series( - nested_values: Dict[Element, Numeric_TS], label_suffix: str, parent_element: Element -) -> ElementTimeSeries: - """ - Creates TimeSeries from nested values, which are a Dict of Elements to values. - The resulting label of the TimeSeries is the label of the parent_element, followed by the label of the element in - the nested_values and the label_suffix. - """ - return { - element: _create_time_series(f'{element.label}_{label_suffix}', value, parent_element) - for element, value in nested_values.items() - if element is not None - } - - -def effect_values_to_time_series( - label_suffix: str, nested_values: EffectValues, parent_element: Element -) -> Optional[EffectTimeSeries]: - """ - Creates TimeSeries from EffectValues. The resulting label of the TimeSeries is the label of the parent_element, - followed by the label of the Effect in the nested_values and the label_suffix. - If the key in the EffectValues is None, the alias 'Standart_Effect' is used - """ - nested_values = as_effect_dict(nested_values) - if nested_values is None: - return None - else: - standard_value = nested_values.pop(None, None) - transformed_values = nested_values_to_time_series(nested_values, label_suffix, parent_element) - if standard_value is not None: - transformed_values[None] = _create_time_series( - f'Standard_Effect_{label_suffix}', standard_value, parent_element - ) - return transformed_values - - -def as_effect_dict(effect_values: EffectValues) -> Optional[EffectDict]: - """ - Converts effect values into a dictionary. If a scalar is provided, it is associated with a default effect type. - - Examples - -------- - costs = 20 -> {None: 20} - costs = None -> None - costs = {effect1: 20, effect2: 0.3} -> {effect1: 20, effect2: 0.3} - - Parameters - ---------- - effect_values : None, int, float, TimeSeries, or dict - The effect values to convert, either a scalar, TimeSeries, or a dictionary. - - Returns - ------- - dict or None - A dictionary with None or Effect as the key, or None if input is None. - """ - return ( - effect_values - if isinstance(effect_values, dict) - else {None: effect_values} - if effect_values is not None - else None - ) - - -def effect_values_from_effect_time_series(effect_time_series: EffectTimeSeries) -> Dict[Optional[Effect], Numeric]: - return {effect: time_series.active_data for effect, time_series in effect_time_series.items()} +EffectValues = Dict[Optional[Union[str, Effect]], linopy.LinearExpression] # This is new class EffectCollection(ElementModel): @@ -274,41 +198,25 @@ def __init__(self): self._standard_effect: Optional[Effect] = None self._objective_effect: Optional[Effect] = None - def add_share_to_invest( + def add_share_to_effects( self, system_model: SystemModel, name: str, - element: Element, - effect_values: EffectDictInvest, - factor: Numeric, - variable: Optional[Variable] = None, - ) -> None: - # TODO: Add checks - self._add_share_to_effects(system_model, name, element, 'invest', effect_values, factor, variable) - - def add_share_to_operation( - self, - system_model: SystemModel, - name: str, - element: Element, - effect_values: EffectTimeSeries, - factor: Numeric, - variable: Optional[Variable] = None, + expressions: EffectValues, + target: Literal['operation', 'invest'], ) -> None: - # TODO: Add checks - self._add_share_to_effects( - system_model, name, element, 'operation', effect_values_from_effect_time_series(effect_values), factor, variable - ) + for effect, expression in expressions.items(): + if target == 'operation': + self[effect].model.operation.add_share(system_model, name, expression) + elif target =='invest': + self[effect].model.invest.add_share(system_model, name, expression) + else: + raise ValueError(f'Target {target} not supported!') - def add_share_to_penalty( - self, - system_model: SystemModel, - name: Optional[str], - variable: Variable, - factor: Numeric, - ) -> None: - assert variable is not None, 'A Variable must be passed to add a share to penalty! Else its a constant Penalty!' - self.penalty.add_share(system_model, name, variable, factor, True) + def add_share_to_penalty(self, system_model: SystemModel, name: str, expression: linopy.LinearExpression) -> None: + if expression.ndim != 0: + raise Exception(f'Penalty shares must be scalar expressions! ({expression.ndim=})') + self.penalty.add_share(system_model, name, expression) def add_effect(self, effect: 'Effect') -> None: if effect.is_standard: @@ -328,7 +236,7 @@ def do_modeling(self, system_model: SystemModel): for model in [effect.model for effect in self.effects.values()] + [self.penalty]: model.do_modeling(system_model) - self.add_share_between_effects(system_model) + self._add_share_between_effects(system_model) # TODO: Move this to the SystemModel! self.objective = Equation('OBJECTIVE', 'OBJECTIVE', is_objective=True) @@ -336,7 +244,7 @@ def do_modeling(self, system_model: SystemModel): self.objective.add_summand(self.objective_effect.model.invest.sum, 1) self.objective.add_summand(self.penalty.sum, 1) - def add_share_between_effects(self, system_model: SystemModel): + def _add_share_between_effects(self, system_model: SystemModel): for origin_effect in self.effects.values(): # 1. operation: -> hier sind es Zeitreihen (share_TS) for target_effect, time_series in origin_effect.specific_share_to_other_effects_operation.items(): @@ -381,31 +289,6 @@ def __contains__(self, item: Union[str, 'Effect']) -> bool: return item in self.effects.values() # Check if the object exists return False - def _add_share_to_effects( - self, - system_model: SystemModel, - name: str, - element: Element, - target: Literal['operation', 'invest'], - effect_values: EffectDict, - factor: Numeric, - variable: Optional[Variable] = None, - ) -> None: - # an alle Effects, die einen Wert haben, anhängen: - for effect, value in effect_values.items(): - if effect is None: # Falls None, dann Standard-effekt nutzen: - effect = self.standard_effect - assert effect in self.effects.values(), f'Effect {effect.label} was used but not added to model!' - - name_of_share = f'{element.label_full}__{name}' - total_factor = np.multiply(value, factor) - if target == 'operation': - effect.model.operation.add_share(system_model, name_of_share, variable, total_factor) - elif target =='invest': - effect.model.invest.add_share(system_model, name_of_share, variable, total_factor) - else: - raise ValueError(f'Target {target} not supported!') - @property def standard_effect(self) -> Effect: if self._standard_effect is None: @@ -429,4 +312,3 @@ def objective_effect(self, value: Effect) -> None: if self._objective_effect is not None: raise ValueError(f'An objective-effect already exists! ({self._objective_effect.label=})') self._objective_effect = value - From b635f9d7fe507dd3151f8bc4ecd595bdc7a0a1ce Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 11 Feb 2025 16:21:01 +0100 Subject: [PATCH 033/507] Improve share creation in Elements --- flixOpt/elements.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/flixOpt/elements.py b/flixOpt/elements.py index 62374f26f..b5eaec501 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -357,13 +357,14 @@ def do_modeling(self, system_model: SystemModel): def _create_shares(self, system_model: SystemModel): # Arbeitskosten: if self.element.effects_per_flow_hour != {}: - system_model.flow_system.effects.add_share_to_operation( + system_model.flow_system.effects.add_share_to_effects( system_model, - name='effects_per_flow_hour', - element=self.element, - variable=self.flow_rate, - effect_values=self.element.effects_per_flow_hour, - factor=system_model.dt_in_hours, + name=self.label_full, # Use the full label of the element + expressions={ + effect.model.operation: self.flow_rate * system_model.hours_per_step * factor + for effect, factor in self.element.effects_per_flow_hour.items() + }, + target='operation', ) def _create_bounds_for_load_factor(self, system_model: SystemModel): @@ -470,10 +471,10 @@ def do_modeling(self, system_model: SystemModel) -> None: eq_bus_balance.lhs += self.excess_input - self.excess_output system_model.flow_system.effects.add_share_to_penalty( - system_model, f'{self.element.label_full}__excess_input', self.excess_input, excess_penalty + system_model, self.element.label_full, self.excess_input * excess_penalty ) system_model.flow_system.effects.add_share_to_penalty( - system_model, f'{self.element.label_full}__excess_output', self.excess_output, excess_penalty + system_model, self.element.label_full, self.excess_output *excess_penalty ) From e336b9646fdb508fe4fcc4c299a56af5c5bec687 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 11 Feb 2025 16:28:03 +0100 Subject: [PATCH 034/507] Updated LinearConverter --- flixOpt/components.py | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/flixOpt/components.py b/flixOpt/components.py index 82f922b51..e5cb4dafa 100644 --- a/flixOpt/components.py +++ b/flixOpt/components.py @@ -384,25 +384,18 @@ def do_modeling(self, system_model: SystemModel): all_output_flows = set(self.element.outputs) # für alle linearen Gleichungen: - for i, conversion_factor in enumerate(self.element.conversion_factors): - # erstelle Gleichung für jedes t: - # sum(inputs * factor) = sum(outputs * factor) - # left = in1.flow_rate[t] * factor_in1[t] + in2.flow_rate[t] * factor_in2[t] + ... - # right = out1.flow_rate[t] * factor_out1[t] + out2.flow_rate[t] * factor_out2[t] + ... - # eq: left = right - used_flows = set(conversion_factor.keys()) + for i, conv_fact in enumerate(self.element.conversion_factors): + used_flows = set(conv_fact.keys()) used_inputs: Set = all_input_flows & used_flows used_outputs: Set = all_output_flows & used_flows - eq_conversion = create_equation(f'conversion_{i}', self) - for flow in used_inputs: - factor = conversion_factor[flow].active_data - eq_conversion.add_summand(flow.model.flow_rate, factor) # flow1.flow_rate[t] * factor[t] - for flow in used_outputs: - factor = conversion_factor[flow].active_data - eq_conversion.add_summand(flow.model.flow_rate, -1 * factor) # output.val[t] * -1 * factor[t] + self.constraints[f'conversion_{i}'] = system_model.add_constraints( + sum([flow.model.flow_rate * conv_fact[flow].active_data for flow in used_inputs]) + == + sum([flow.model.flow_rate * conv_fact[flow].active_data for flow in used_outputs]), + name=f'{self.label_full}__conversion_{i}' - eq_conversion.add_constant(0) # TODO: Is this necessary? + ) # (linear) segments: else: From ef27b247e70b6355e51aa8534a01821d60f62ffa Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 11 Feb 2025 16:42:04 +0100 Subject: [PATCH 035/507] Updating the Storage Model --- flixOpt/components.py | 57 +++++++++++++++++++------------------------ 1 file changed, 25 insertions(+), 32 deletions(-) diff --git a/flixOpt/components.py b/flixOpt/components.py index e5cb4dafa..b13eb0e1d 100644 --- a/flixOpt/components.py +++ b/flixOpt/components.py @@ -428,42 +428,35 @@ def do_modeling(self, system_model): super().do_modeling(system_model) lb, ub = self.absolute_charge_state_bounds - self.charge_state = create_variable( - 'charge_state', self, system_model.nr_of_time_steps + 1, lower_bound=lb, upper_bound=ub + self.charge_state = system_model.add_variables( + lower_bound=lb, upper_bound=ub, coords=(system_model.periods, system_model.timesteps_extra), + name=f'{self.label_full}__charge_state' + ) + self.netto_discharge = system_model.add_variables( + coords=system_model.coords, name=f'{self.label_full}__netto_discharge' ) - - self.netto_discharge = create_variable( - 'netto_discharge', self, system_model.nr_of_time_steps, lower_bound=-np.inf - ) # negative Werte zulässig! - # netto_discharge: # eq: nettoFlow(t) - discharging(t) + charging(t) = 0 - eq_netto = create_equation('netto_discharge', self, eq_type='eq') - eq_netto.add_summand(self.netto_discharge, 1) - eq_netto.add_summand(self.element.charging.model.flow_rate, 1) - eq_netto.add_summand(self.element.discharging.model.flow_rate, -1) - - indices_charge_state = range(system_model.indices.start, system_model.indices.stop + 1) # additional - - ############# Charge State Equation - # charge_state(n+1) - # + charge_state(n) * [relative_loss_per_hour * dt(n) - 1] - # - charging(n) * eta_charge * dt(n) - # + discharging(n) * 1 / eta_discharge * dt(n) - # = 0 - eq_charge_state = create_equation('charge_state', self, eq_type='eq') - eq_charge_state.add_summand(self.charge_state, 1, indices_charge_state[1:]) # 1:end - eq_charge_state.add_summand( - self.charge_state, - (self.element.relative_loss_per_hour.active_data * system_model.dt_in_hours) - 1, - indices_charge_state[:-1], - ) # sprich 0 .. end-1 % nach letztem Zeitschritt gibt es noch einen weiteren Ladezustand! - eq_charge_state.add_summand( - self.element.charging.model.flow_rate, -1 * self.element.eta_charge.active_data * system_model.dt_in_hours + self.constraints['netto_discharge'] = system_model.add_constraints( + self.netto_discharge == self.element.charging.model.flow_rate - self.element.discharging.model.flow_rate, + name=f'{self.label_full}__netto_discharge' ) - eq_charge_state.add_summand( - self.element.discharging.model.flow_rate, - 1 / self.element.eta_discharge.active_data * system_model.dt_in_hours, + + charge_state = self.charge_state + rel_loss = self.element.relative_loss_per_hour.active_data + hours_per_step = system_model.hours_per_step + charge_rate = self.element.charging.model.flow_rate + discharge_rate = self.element.discharging.model.flow_rate + eff_charge = self.element.eta_charge.active_data + eff_discharge = self.element.eta_discharge.active_data + + self.constraints['charge_state'] = system_model.add_constraints( + charge_state.isel(time=slice(1, None)) + == + charge_state.isel(time=slice(None, -1)) * (1 - rel_loss * hours_per_step) + + charge_rate * eff_charge * hours_per_step + - discharge_rate * eff_discharge * hours_per_step, + name=f'{self.label_full}__charge_state' ) if isinstance(self.element.capacity_in_flow_hours, InvestParameters): From 8b2530d6348f982ce79656a35e3f5c3398cb7541 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 11 Feb 2025 16:50:18 +0100 Subject: [PATCH 036/507] Updating the Storage Model --- flixOpt/components.py | 52 +++++++++++++++++++++---------------------- 1 file changed, 25 insertions(+), 27 deletions(-) diff --git a/flixOpt/components.py b/flixOpt/components.py index b13eb0e1d..bd566d35e 100644 --- a/flixOpt/components.py +++ b/flixOpt/components.py @@ -6,6 +6,7 @@ from typing import Dict, List, Literal, Optional, Set, Tuple, Union import numpy as np +import linopy from . import utils from .core import Numeric, Numeric_TS, Skalar, TimeSeries @@ -416,12 +417,11 @@ def do_modeling(self, system_model: SystemModel): class StorageModel(ComponentModel): """Model of Storage""" - # TODO: Add additional Timestep!!! def __init__(self, element: Storage): super().__init__(element) self.element: Storage = element - self.charge_state: Optional[VariableTS] = None - self.netto_discharge: Optional[VariableTS] = None + self.charge_state: Optional[linopy.Variable] = None + self.netto_discharge: Optional[linopy.Variable] = None self._investment: Optional[InvestmentModel] = None def do_modeling(self, system_model): @@ -467,39 +467,37 @@ def do_modeling(self, system_model): self._investment.do_modeling(system_model) # Initial charge state - if self.element.initial_charge_state is not None: - self._model_initial_and_final_charge_state(system_model) - - def _model_initial_and_final_charge_state(self, system_model): - indices_charge_state = range(system_model.indices.start, system_model.indices.stop + 1) # additional + self._initial_and_final_charge_state(system_model) + def _initial_and_final_charge_state(self, system_model): if self.element.initial_charge_state is not None: - eq_initial = create_equation('initial_charge_state', self, eq_type='eq') + name_short = f'initial_charge_state' + name = f'{self.label_full}__{name_short}' + if utils.is_number(self.element.initial_charge_state): - # eq: Q_Ladezustand(1) = Q_Ladezustand_Start; - eq_initial.add_constant(self.element.initial_charge_state) # chargeState_0 ! - eq_initial.add_summand(self.charge_state, 1, system_model.indices[0]) + self.constraints[name_short] = system_model.add_constraints( + self.charge_state.isel(time=0) == self.element.initial_charge_state, + name=name, + ) elif self.element.initial_charge_state == 'lastValueOfSim': - # eq: Q_Ladezustand(1) - Q_Ladezustand(end) = 0; - eq_initial.add_summand(self.charge_state, 1, system_model.indices[0]) - eq_initial.add_summand(self.charge_state, -1, system_model.indices[-1]) - else: + self.constraints[name_short] = system_model.add_constraints( + self.charge_state.isel(time=0) == self.charge_state.isel(time=-1), + name=name + ) + else: # TODO: Validation in Storage Class, not in Model raise Exception(f'initial_charge_state has undefined value: {self.element.initial_charge_state}') - # TODO: Validation in Storage Class, not in Model - #################################### - # Final Charge State - # 1: eq: Q_charge_state(end) <= Q_max if self.element.maximal_final_charge_state is not None: - eq_max = create_equation('eq_final_charge_state_max', self, eq_type='ineq') - eq_max.add_summand(self.charge_state, 1, indices_charge_state[-1]) - eq_max.add_constant(self.element.maximal_final_charge_state) + self.constraints['final_charge_max'] = system_model.add_constraints( + self.charge_state.isel(time=-1) <= self.element.maximal_final_charge_state, + name=f'{self.label_full}__final_charge_max' + ) - # 2: eq: - Q_charge_state(end) <= - Q_min if self.element.minimal_final_charge_state is not None: - eq_min = create_equation('eq_charge_state_end_min', self, eq_type='ineq') - eq_min.add_summand(self.charge_state, -1, indices_charge_state[-1]) - eq_min.add_constant(-self.element.minimal_final_charge_state) + self.constraints['final_charge_min'] = system_model.add_constraints( + self.charge_state.isel(time=-1) >= self.element.minimal_final_charge_state, + name=f'{self.label_full}__final_charge_min' + ) @property def absolute_charge_state_bounds(self) -> Tuple[Numeric, Numeric]: From 2bd2244ee8a840eec93f39d65bcf4f554543a5be Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 11 Feb 2025 16:52:01 +0100 Subject: [PATCH 037/507] Improve the ShareAllocationModel --- flixOpt/elements.py | 2 +- flixOpt/features.py | 113 ++++++++++++++++++-------------------------- 2 files changed, 46 insertions(+), 69 deletions(-) diff --git a/flixOpt/elements.py b/flixOpt/elements.py index b5eaec501..db9db04c2 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -502,7 +502,7 @@ def do_modeling(self, system_model: SystemModel): sub_model.do_modeling(system_model) if self.element.on_off_parameters: - flow_rates: List[VariableTS] = [flow.model.flow_rate for flow in all_flows] + flow_rates: List[linopy.Variable] = [flow.model.flow_rate for flow in all_flows] bounds: List[Tuple[Numeric, Numeric]] = [flow.model.absolute_flow_rate_bounds for flow in all_flows] self.on_off = OnOffModel(self.element, self.element.on_off_parameters, flow_rates, bounds) self.sub_models.append(self.on_off) diff --git a/flixOpt/features.py b/flixOpt/features.py index 861b59986..672b1662b 100644 --- a/flixOpt/features.py +++ b/flixOpt/features.py @@ -6,6 +6,7 @@ import logging from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union +import linopy import numpy as np from .config import CONFIG @@ -180,7 +181,7 @@ def __init__( self, element: Element, on_off_parameters: OnOffParameters, - defining_variables: List[VariableTS], + defining_variables: List[linopy.Variable], defining_bounds: List[Tuple[Numeric, Numeric]], label: str = 'OnOff', ): @@ -744,12 +745,13 @@ def __init__( 'Both max_per_hour and min_per_hour cannot be used when shares_are_time_series is False' ) self.element = element - self.sum_TS: Optional[VariableTS] = None - self.sum: Optional[Variable] = None - self.shares: Dict[str, Variable] = {} + self.total_per_timestep: Optional[linopy.Variable] = None + self.total: Optional[linopy.Variable] = None + self.shares: Dict[str, linopy.Variable] = {} + self.share_constraints: Dict[str, linopy.Constraint] = {} - self._eq_time_series: Optional[Equation] = None - self._eq_sum: Optional[Equation] = None + self._eq_total_per_timestep: Optional[linopy.Constraint] = None + self._eq_total: Optional[linopy.Constraint] = None # Parameters self._shares_are_time_series = shares_are_time_series @@ -759,54 +761,58 @@ def __init__( self._min_per_hour = min_per_hour def do_modeling(self, system_model: SystemModel): - self.sum = create_variable( - f'{self.label}_sum', self, 1, lower_bound=self._total_min, upper_bound=self._total_max + self.total = system_model.add_variables( + lower_bound=self._total_min, upper_bound=self._total_max, coords=system_model.coords, name=f'{self.label}_total' ) # eq: sum = sum(share_i) # skalar - self._eq_sum = create_equation(f'{self.label}_sum', self) - self._eq_sum.add_summand(self.sum, -1) + self._eq_total = system_model.add_constraints(self.total == 0, name=f'{self.label}__total') if self._shares_are_time_series: - lb_ts = None if (self._min_per_hour is None) else np.multiply(self._min_per_hour, system_model.dt_in_hours) - ub_ts = None if (self._max_per_hour is None) else np.multiply(self._max_per_hour, system_model.dt_in_hours) - self.sum_TS = create_variable( - f'{self.label}_sum_TS', self, system_model.nr_of_time_steps, lower_bound=lb_ts, upper_bound=ub_ts + self.total_per_timestep = system_model.add_variables( + lower_bound=None if (self._min_per_hour is None) else np.multiply(self._min_per_hour, system_model.hours_per_step), + upper_bound=None if (self._max_per_hour is None) else np.multiply(self._max_per_hour, system_model.hours_per_step), + coords=system_model.coords, + name=f'{self.label}_total_per_timestep' ) - # eq: sum_TS = sum(share_TS_i) # TS - self._eq_time_series = create_equation(f'{self.label}_time_series', self) - self._eq_time_series.add_summand(self.sum_TS, -1) + self._eq_total_per_timestep = system_model.add_constraints( + self.total_per_timestep == 0, name=f'{self.label}__total_per_timestep' + ) - # eq: sum = sum(sum_TS(t)) # additionaly to self.sum - self._eq_sum.add_summand(self.sum_TS, 1, as_sum=True) + # Add it to the total + self._eq_total.lhs += self.total_per_timestep.sum() def add_share( self, system_model: SystemModel, - name_of_share: str, - variable: Optional[Variable], - factor: Numeric, - share_as_sum: bool = False, + name: str, + expression: linopy.LinearExpression, ): """ - Adding a Share to a Share Allocation Model. - """ - # TODO: accept only one factor or accept unlimited factors -> *factors + Add a share to the share allocation model. If the share already exists, the expression is added to the existing share. + The expression is added to the left hand side (lhs) of the constraint. + The variable representing the total share is on the right hand side (rhs) of the constraint. + total = sum(shares) - # Check to which equation the share should be added - if share_as_sum or not self._shares_are_time_series: - target_eq = self._eq_sum + Parameters + ---------- + system_model : SystemModel + The system model. + name : str + The name of the share. + expression : linopy.LinearExpression + The expression of the share. Added to the right hand side of the constraint. + """ + if name in self.shares: + self.share_constraints[name].lhs += expression else: - target_eq = self._eq_time_series - - new_share = SingleShareModel(self.element, name_of_share, variable, factor, share_as_sum) - target_eq.add_summand(new_share.single_share, 1) - - self.sub_models.append(new_share) - assert new_share.label not in self.shares, ( - f'A Share with the label {new_share.label} was already present in {self.label}' - ) - self.shares[new_share.label] = new_share.single_share + self.shares[name] = system_model.add_variables( + coords=None if expression.ndim == 0 else system_model.coords, + name=f'{name}__{self.label_full}' + ) + self.share_constraints[name] = system_model.add_constraints( + self.shares[name] == expression, name=f'{name}__{self.label_full}' + ) def results(self): return { @@ -815,35 +821,6 @@ def results(self): } -class SingleShareModel(ElementModel): - """Holds a Variable and an Equation. Summands can be added to the Equation. Used to publish Shares""" - - def __init__(self, element: Element, name: str, variable: Optional[Variable], factor: Numeric, share_as_sum: bool): - super().__init__(element, name) - if variable is not None: - assert not (variable.length == 1 and share_as_sum), 'A Variable with the length 1 cannot be summed up!' - - if ( - share_as_sum - or (variable is not None and variable.length == 1) - or (variable is None and np.isscalar(factor)) - ): - self.single_share = Variable(self.label_full, 1, self.label) - elif variable is not None: - self.single_share = VariableTS(self.label_full, variable.length, self.label) - else: - raise Exception('This case is not yet covered for a SingleShareModel') - - self.add_variables(self.single_share) - self.single_equation = create_equation(self.label_full, self) - self.single_equation.add_summand(self.single_share, -1) - - if variable is None: - self.single_equation.add_constant(-1 * np.sum(factor) if share_as_sum else -1 * factor) - else: - self.single_equation.add_summand(variable, factor, as_sum=share_as_sum) - - class SegmentedSharesModel(ElementModel): # TODO: Length... def __init__( From 4a690e090d72df5d4a39eaafd89c677e537671d1 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 11 Feb 2025 16:54:19 +0100 Subject: [PATCH 038/507] Improve the PreventSimultaneousUsageModel --- flixOpt/features.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/flixOpt/features.py b/flixOpt/features.py index 672b1662b..01782e5c0 100644 --- a/flixOpt/features.py +++ b/flixOpt/features.py @@ -902,16 +902,16 @@ class PreventSimultaneousUsageModel(ElementModel): # --> könnte man auch umsetzen (statt force_on_variable() für die Flows, aber sollte aufs selbe wie "new" kommen) """ - def __init__(self, element: Element, variables: List[VariableTS], label: str = 'PreventSimultaneousUsage'): + def __init__(self, element: Element, variables: List[linopy.Variable], label: str = 'PreventSimultaneousUsage'): super().__init__(element, label) self._variables = variables assert len(self._variables) >= 2, f'Model {self.__class__.__name__} must get at least two variables' for variable in self._variables: # classic - assert variable.is_binary, f'Variable {variable} must be binary for use in {self.__class__.__name__}' + assert variable.attrs['binary'], f'Variable {variable} must be binary for use in {self.__class__.__name__}' def do_modeling(self, system_model: SystemModel): # eq: sum(flow_i.on(t)) <= 1.1 (1 wird etwas größer gewählt wg. Binärvariablengenauigkeit) - eq = create_equation('prevent_simultaneous_use', self, eq_type='ineq') - for variable in self._variables: - eq.add_summand(variable, 1) - eq.add_constant(1.1) + self.constraints['prevent_simultaneous_use'] = system_model.add_constraints( + sum([variable*1 for variable in self._variables]) <= 1 + CONFIG.modeling.EPSILON, + name=f'{self.label_full}__prevent_simultaneous_use' + ) From f227d23a49901d020deb6c1240ff2487265219da Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 11 Feb 2025 17:16:34 +0100 Subject: [PATCH 039/507] Reorganize how EffectsCollection is created --- flixOpt/effects.py | 39 ++++++++++++++++++++++----------------- flixOpt/flow_system.py | 14 ++++++++------ 2 files changed, 30 insertions(+), 23 deletions(-) diff --git a/flixOpt/effects.py b/flixOpt/effects.py index d59bdaa2b..57c364df8 100644 --- a/flixOpt/effects.py +++ b/flixOpt/effects.py @@ -6,7 +6,7 @@ """ import logging -from typing import Dict, Literal, Optional, Union +from typing import Dict, Literal, Optional, Union, List import numpy as np import linopy @@ -189,15 +189,16 @@ class EffectCollection(ElementModel): Handling all Effects """ - def __init__(self): + def __init__(self, effects: List[Effect]): super().__init__(Element('Effects')) - self.effects: Dict[str, Effect] = {} - self.penalty: Optional[ShareAllocationModel] = None - self.objective: Optional[Equation] = None - + self._effects = {} self._standard_effect: Optional[Effect] = None self._objective_effect: Optional[Effect] = None + self.effects: Dict[str, Effect] = effects # Performs some validation + self.penalty: Optional[ShareAllocationModel] = None + self.objective: Optional[Equation] = None + def add_share_to_effects( self, system_model: SystemModel, @@ -218,17 +219,6 @@ def add_share_to_penalty(self, system_model: SystemModel, name: str, expression: raise Exception(f'Penalty shares must be scalar expressions! ({expression.ndim=})') self.penalty.add_share(system_model, name, expression) - def add_effect(self, effect: 'Effect') -> None: - if effect.is_standard: - self.standard_effect = effect - if effect.is_objective: - self.objective_effect = effect - if effect in self.effects.values(): - raise Exception(f'Effect already added! ({effect.label=})') - if effect.label in self.effects: - raise Exception(f'Effect with label "{effect.label=}" already added!') - self.effects[effect.label] = effect - def do_modeling(self, system_model: SystemModel): for effect in self.effects.values(): effect.create_model() @@ -289,6 +279,21 @@ def __contains__(self, item: Union[str, 'Effect']) -> bool: return item in self.effects.values() # Check if the object exists return False + @property + def effects(self) -> Dict[str, Effect]: + return self._effects + + @effects.setter + def effects(self, value: List[Effect]): + for effect in value: + if effect.is_standard: + self.standard_effect = effect + if effect.is_objective: + self.objective_effect = effect + if effect in self: + raise Exception(f'Effect with label "{effect.label=}" already added!') + self._effects[effect.label] = effect + @property def standard_effect(self) -> Effect: if self._standard_effect is None: diff --git a/flixOpt/flow_system.py b/flixOpt/flow_system.py index c1e351dac..0c23381d5 100644 --- a/flixOpt/flow_system.py +++ b/flixOpt/flow_system.py @@ -12,7 +12,7 @@ from . import utils from .core import TimeSeries -from .effects import Effect, EffectCollection +from .effects import Effect from .elements import Bus, Component, Flow from .structure import Element, SystemModel, get_compact_representation, get_str_representation @@ -61,13 +61,15 @@ def __init__( # defaults: self.components: Dict[str, Component] = {} - self.effects: EffectCollection = EffectCollection() # Organizes Effects, Penalty & Objective + self.effects: Dict[str, Effect] = {} self.model: Optional[SystemModel] = None def add_effects(self, *args: Effect) -> None: for new_effect in list(args): + if new_effect.label in self.effects: + raise Exception(f'Effect with label "{new_effect.label=}" already added!') + self.effects[new_effect.label] = new_effect logger.info(f'Registered new Effect: {new_effect.label}') - self.effects.add_effect(new_effect) def add_components(self, *args: Component) -> None: # Komponenten registrieren: @@ -135,7 +137,7 @@ def infos(self, use_numpy=True, use_element_label=False) -> Dict: }, 'Effects': { effect.label: effect.infos(use_numpy, use_element_label) - for effect in sorted(self.effect_collection.effects.values(), key=lambda effect: effect.label.upper()) + for effect in sorted(self.effects.values(), key=lambda effect: effect.label.upper()) }, } return infos @@ -264,7 +266,7 @@ def get_time_data_from_indices( return time_series, time_series_with_end, dt_in_hours, dt_in_hours_total def __repr__(self): - return f'<{self.__class__.__name__} with {len(self.components)} components and {len(self.effect_collection.effects)} effects>' + return f'<{self.__class__.__name__} with {len(self.components)} components and {len(self.effects)} effects>' def __str__(self): return get_str_representation(self.infos(use_numpy=True, use_element_label=True)) @@ -280,7 +282,7 @@ def buses(self) -> Dict[str, Bus]: @property def all_elements(self) -> Dict[str, Element]: - return {**self.components, **self.effect_collection.effects, **self.flows, **self.buses} + return {**self.components, **self.effects, **self.flows, **self.buses} @property def all_time_series(self) -> List[TimeSeries]: From 2208e8c312f09e416f1e0ed5135b4e0efbbfe50d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 11 Feb 2025 17:20:39 +0100 Subject: [PATCH 040/507] Reorganize how EffectsCollection is created --- flixOpt/structure.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/flixOpt/structure.py b/flixOpt/structure.py index 006305586..3d59d4536 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -21,6 +21,7 @@ from . import utils from .config import CONFIG from .core import Numeric, Numeric_TS, Skalar, TimeSeries, TimeSeriesData +from .effects import EffectCollection from .math_modeling import Equation, Inequation, MathModel, Solver, Variable, VariableTS if TYPE_CHECKING: # for type checking and preventing circular imports @@ -35,14 +36,24 @@ class SystemModel(linopy.Model): def __init__( self, flow_system: FlowSystem, - active_time_steps, + active_time_steps: Optional = None, ): super().__init__(force_dim_names=True) self.flow_system = flow_system self.active_time_steps = active_time_steps - self._order_dimensions() + self.effects: Optional[EffectCollection] = None + + def do_modeling(self): + self.effects = EffectCollection(list(self.flow_system.effects.values())) + component_models = [component.create_model() for component in self.flow_system.components.values()] + bus_models = [bus.create_model() for bus in self.flow_system.buses.values()] + for component_model in component_models: + component_model.do_modeling(self) + for bus_model in bus_models: # Buses after Components, because FlowModels are created in ComponentModels + bus_model.do_modeling(self) + def _order_dimensions(self): if self.flow_system.timesteps.dtype == np.dtype('datetime64[ns]'): self.timesteps = self.flow_system.timesteps.astype('datetime64[us]') From 88a606413262a2271ffc36a8ea0e3ce7b9f5cb75 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 11 Feb 2025 17:21:45 +0100 Subject: [PATCH 041/507] Reorganize how EffectsCollection is created --- flixOpt/elements.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flixOpt/elements.py b/flixOpt/elements.py index db9db04c2..6c19eb5e1 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -357,7 +357,7 @@ def do_modeling(self, system_model: SystemModel): def _create_shares(self, system_model: SystemModel): # Arbeitskosten: if self.element.effects_per_flow_hour != {}: - system_model.flow_system.effects.add_share_to_effects( + system_model.effects.add_share_to_effects( system_model, name=self.label_full, # Use the full label of the element expressions={ @@ -470,10 +470,10 @@ def do_modeling(self, system_model: SystemModel) -> None: ) eq_bus_balance.lhs += self.excess_input - self.excess_output - system_model.flow_system.effects.add_share_to_penalty( + system_model.effects.add_share_to_penalty( system_model, self.element.label_full, self.excess_input * excess_penalty ) - system_model.flow_system.effects.add_share_to_penalty( + system_model.effects.add_share_to_penalty( system_model, self.element.label_full, self.excess_output *excess_penalty ) From 31fe2df06e5a00303cf8c3aa62e8862dc9ab7aff Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 11 Feb 2025 20:20:56 +0100 Subject: [PATCH 042/507] Improve Handling of effects Vlaues --- flixOpt/effects.py | 63 +++++++++++++++++++++++++++++++++++++++----- flixOpt/elements.py | 7 ++--- flixOpt/structure.py | 7 ++--- 3 files changed, 63 insertions(+), 14 deletions(-) diff --git a/flixOpt/effects.py b/flixOpt/effects.py index 57c364df8..1fa112cf1 100644 --- a/flixOpt/effects.py +++ b/flixOpt/effects.py @@ -33,8 +33,8 @@ def __init__( meta_data: Optional[Dict] = None, is_standard: bool = False, is_objective: bool = False, - specific_share_to_other_effects_operation: 'EffectValues' = None, - specific_share_to_other_effects_invest: 'EffectValuesInvest' = None, + specific_share_to_other_effects_operation: Optional['EffectValuesUser'] = None, + specific_share_to_other_effects_invest: Optional['EffectValuesUser'] = None, minimum_operation: Optional[Skalar] = None, maximum_operation: Optional[Skalar] = None, minimum_invest: Optional[Skalar] = None, @@ -93,10 +93,10 @@ def __init__( self.description = description self.is_standard = is_standard self.is_objective = is_objective - self.specific_share_to_other_effects_operation: Union[EffectValues, EffectTimeSeries] = ( + self.specific_share_to_other_effects_operation: EffectValuesUser = ( specific_share_to_other_effects_operation or {} ) - self.specific_share_to_other_effects_invest: Union[EffectValuesInvest, EffectDictInvest] = ( + self.specific_share_to_other_effects_invest: EffectValuesUser = ( specific_share_to_other_effects_invest or {} ) self.minimum_operation = minimum_operation @@ -181,7 +181,58 @@ def do_modeling(self, system_model: SystemModel): self.total.add_share(system_model, 'operation', self.operation.total*1) self.total.add_share(system_model, 'invest', self.invest.total*1) -EffectValues = Dict[Optional[Union[str, Effect]], linopy.LinearExpression] # This is new + +EffectValuesExpr = Dict[Optional[Union[str, Effect]], linopy.LinearExpression] # This is used to create Shares + +EffectValuesTS = Dict[Optional[Union[str, Effect]], TimeSeries] # This is used internally to index the values + +EffectValuesDict = Dict[Optional[Union[str, Effect]], Numeric_TS] # This is how The effect values are stored + +EffectValuesUser = Union[Numeric_TS, Dict[Optional[Union[str, Effect]], Numeric_TS]] # This is how the User can specify Shares to Effects + + +def effect_values_to_time_series(label_suffix: str, + effect_values: EffectValuesUser, + parent_element: Element) -> Optional[EffectValuesTS]: + """ + Transform EffectValues to EffectValuesTS. + Creates a TimeSeries for each key in the nested_values dictionary, using the value as the data. + + The resulting label of the TimeSeries is the label of the parent_element, + followed by the label of the Effect in the nested_values and the label_suffix. + If the key in the EffectValues is None, the alias 'Standard_Effect' is used + """ + effect_values: Optional[EffectValuesDict] = effect_values_to_dict(effect_values) + if effect_values is None: + return None + + standard_value = effect_values.pop(None, None) + effect_values_ts = { + effect: _create_time_series(f'{effect.label}_{label_suffix}', value, parent_element) + for effect, value in effect_values.items() if effect is not None + } + if standard_value is not None: + effect_values_ts[None] = _create_time_series(f'Standard_Effect_{label_suffix}', standard_value, parent_element) + return effect_values_ts + + +def effect_values_to_dict(effect_values_user: EffectValuesUser) -> Optional[EffectValuesDict]: + """ + Converts effect values into a dictionary. If a scalar is provided, it is associated with a default effect type. + + 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} + + Returns + ------- + dict or None + A dictionary with None or Effect as the key, or None if input is None. + """ + return effect_values_user if isinstance(effect_values_user, dict) else { + None: effect_values_user} if effect_values_user is not None else None class EffectCollection(ElementModel): @@ -203,7 +254,7 @@ def add_share_to_effects( self, system_model: SystemModel, name: str, - expressions: EffectValues, + expressions: EffectValuesExpr, target: Literal['operation', 'invest'], ) -> None: for effect, expression in expressions.items(): diff --git a/flixOpt/elements.py b/flixOpt/elements.py index 6c19eb5e1..93ca53d68 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -10,7 +10,7 @@ from .config import CONFIG from .core import Numeric, Numeric_TS, Skalar -from .effects import EffectValues, effect_values_to_time_series +from .effects import EffectValuesUser, effect_values_to_time_series from .features import InvestmentModel, OnOffModel, PreventSimultaneousUsageModel from .interface import InvestParameters, OnOffParameters from .structure import ( @@ -18,9 +18,6 @@ ElementModel, SystemModel, _create_time_series, - copy_and_convert_datatypes, - create_equation, - create_variable, ) logger = logging.getLogger('flixOpt') @@ -163,7 +160,7 @@ def __init__( fixed_relative_profile: Optional[Numeric_TS] = None, relative_minimum: Numeric_TS = 0, relative_maximum: Numeric_TS = 1, - effects_per_flow_hour: EffectValues = None, + effects_per_flow_hour: EffectValuesUser = None, on_off_parameters: Optional[OnOffParameters] = None, flow_hours_total_max: Optional[Skalar] = None, flow_hours_total_min: Optional[Skalar] = None, diff --git a/flixOpt/structure.py b/flixOpt/structure.py index 3d59d4536..2eb753dc2 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -21,12 +21,12 @@ from . import utils from .config import CONFIG from .core import Numeric, Numeric_TS, Skalar, TimeSeries, TimeSeriesData -from .effects import EffectCollection from .math_modeling import Equation, Inequation, MathModel, Solver, Variable, VariableTS if TYPE_CHECKING: # for type checking and preventing circular imports from .elements import BusModel, ComponentModel from .flow_system import FlowSystem + from .effects import EffectCollection logger = logging.getLogger('flixOpt') @@ -35,7 +35,7 @@ class SystemModel(linopy.Model): def __init__( self, - flow_system: FlowSystem, + flow_system: 'FlowSystem', active_time_steps: Optional = None, ): super().__init__(force_dim_names=True) @@ -46,6 +46,7 @@ def __init__( self.effects: Optional[EffectCollection] = None def do_modeling(self): + from .effects import EffectCollection self.effects = EffectCollection(list(self.flow_system.effects.values())) component_models = [component.create_model() for component in self.flow_system.components.values()] bus_models = [bus.create_model() for bus in self.flow_system.buses.values()] @@ -346,7 +347,7 @@ def _create_time_series( label: str, data: Optional[Union[Numeric_TS, TimeSeries]], element: Element ) -> Optional[TimeSeries]: """Creates a TimeSeries from Numeric Data and adds it to the list of time_series of an Element. - If the data already is a TimeSeries, nothing happens and the TimeSeries gets cleaned and returned""" + If the data already is a TimeSeries, nothing happens and the TimeSeries gets reset and returned""" if data is None: return None elif isinstance(data, TimeSeries): From f969a63a932950294c6576e4f199dbe79e4fa60e Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 11 Feb 2025 21:29:23 +0100 Subject: [PATCH 043/507] Make Effect.total a Variable instead of a ShareAllocationModel --- flixOpt/effects.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/flixOpt/effects.py b/flixOpt/effects.py index 1fa112cf1..935030263 100644 --- a/flixOpt/effects.py +++ b/flixOpt/effects.py @@ -153,6 +153,7 @@ class EffectModel(ElementModel): def __init__(self, element: Effect): super().__init__(element) self.element: Effect = element + self.total: Optional[linopy.Variable] = None self.invest = ShareAllocationModel( self.element, 'invest', False, total_max=self.element.maximum_invest, total_min=self.element.minimum_invest ) @@ -169,17 +170,22 @@ def __init__(self, element: Effect): if self.element.maximum_operation_per_hour is not None else None, ) - self.total = ShareAllocationModel( - self.element, 'total', False, total_max=self.element.maximum_total, total_min=self.element.minimum_total - ) - self.sub_models.extend([self.invest, self.operation, self.total]) + self.sub_models.extend([self.invest, self.operation]) def do_modeling(self, system_model: SystemModel): for model in self.sub_models: model.do_modeling(system_model) - self.total.add_share(system_model, 'operation', self.operation.total*1) - self.total.add_share(system_model, 'invest', self.invest.total*1) + self.total = system_model.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=None, + name=f'{self.element.label_full}__total' + ) + + self.constraints['total'] = system_model.add_constraints( + self.total == self.operation.total.sum() + self.invest.total.sum(), name=f'{self.element.label_full}__total' + ) EffectValuesExpr = Dict[Optional[Union[str, Effect]], linopy.LinearExpression] # This is used to create Shares From 924d1b76d6a22ffc95dfc07420fe89e76a3dcade Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 11 Feb 2025 21:29:51 +0100 Subject: [PATCH 044/507] Move the Objective Formulation to The SystemModel --- flixOpt/effects.py | 6 ------ flixOpt/structure.py | 4 ++++ 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/flixOpt/effects.py b/flixOpt/effects.py index 935030263..b18f614bb 100644 --- a/flixOpt/effects.py +++ b/flixOpt/effects.py @@ -285,12 +285,6 @@ def do_modeling(self, system_model: SystemModel): self._add_share_between_effects(system_model) - # TODO: Move this to the SystemModel! - self.objective = Equation('OBJECTIVE', 'OBJECTIVE', is_objective=True) - self.objective.add_summand(self.objective_effect.model.operation.sum, 1) - self.objective.add_summand(self.objective_effect.model.invest.sum, 1) - self.objective.add_summand(self.penalty.sum, 1) - def _add_share_between_effects(self, system_model: SystemModel): for origin_effect in self.effects.values(): # 1. operation: -> hier sind es Zeitreihen (share_TS) diff --git a/flixOpt/structure.py b/flixOpt/structure.py index 2eb753dc2..8187a8c87 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -55,6 +55,10 @@ def do_modeling(self): for bus_model in bus_models: # Buses after Components, because FlowModels are created in ComponentModels bus_model.do_modeling(self) + self.add_objective( + self.effects.objective_effect.model.total + self.effects.penalty.total + ) + def _order_dimensions(self): if self.flow_system.timesteps.dtype == np.dtype('datetime64[ns]'): self.timesteps = self.flow_system.timesteps.astype('datetime64[us]') From 4947b5b857c77e30ebe48501801b28fce7671311 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 11 Feb 2025 21:30:02 +0100 Subject: [PATCH 045/507] Missing effects.do_modeling() --- flixOpt/structure.py | 1 + 1 file changed, 1 insertion(+) diff --git a/flixOpt/structure.py b/flixOpt/structure.py index 8187a8c87..5df54312e 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -48,6 +48,7 @@ def __init__( def do_modeling(self): from .effects import EffectCollection self.effects = EffectCollection(list(self.flow_system.effects.values())) + self.effects.do_modeling(self) component_models = [component.create_model() for component in self.flow_system.components.values()] bus_models = [bus.create_model() for bus in self.flow_system.buses.values()] for component_model in component_models: From 06fb81a2b751f7463d5235da010d599f3d03dcb8 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 11 Feb 2025 21:30:32 +0100 Subject: [PATCH 046/507] Bugfixes in naming and bounds --- flixOpt/elements.py | 22 +++++++++++----------- flixOpt/features.py | 22 +++++++++++----------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/flixOpt/elements.py b/flixOpt/elements.py index 93ca53d68..fdc010d8e 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -302,14 +302,14 @@ def do_modeling(self, system_model: SystemModel): # eq relative_minimum(t) * size <= flow_rate(t) <= relative_maximum(t) * size self.flow_rate = system_model.add_variables( lower=self.absolute_flow_rate_bounds[0] if self.element.on_off_parameters is None else 0, - upper=self.absolute_flow_rate_bounds[1] if self.element.on_off_parameters is None else None, + upper=self.absolute_flow_rate_bounds[1] if self.element.on_off_parameters is None else np.inf, coords=system_model.coords, - name='flow_rate', + name=f'{self.label_full}__flow_rate', ) if self.element.fixed_relative_profile is not None: self.constraints['fix_flow_rate'] = system_model.add_constraints( self.flow_rate == self.element.fixed_relative_profile.active_data, - f'{self.element.label}_fix_flow_rate' + name=f'{self.element.label}_fix_flow_rate' ) # OnOff @@ -334,8 +334,8 @@ def do_modeling(self, system_model: SystemModel): self.sub_models.append(self._investment) self.total_flow_hours = system_model.add_variables( - lower=self.element.flow_hours_total_min, - upper=self.element.flow_hours_total_max, + lower=self.element.flow_hours_total_min if self.element.flow_hours_total_min is not None else -np.inf, + upper=self.element.flow_hours_total_max if self.element.flow_hours_total_max is not None else np.inf, coords=None, name=f'{self.element.label_full}__total_flow_hours' ) @@ -358,7 +358,7 @@ def _create_shares(self, system_model: SystemModel): system_model, name=self.label_full, # Use the full label of the element expressions={ - effect.model.operation: self.flow_rate * system_model.hours_per_step * factor + effect: self.flow_rate * system_model.hours_per_step * factor.active_data for effect, factor in self.element.effects_per_flow_hour.items() }, target='operation', @@ -460,18 +460,18 @@ def do_modeling(self, system_model: SystemModel) -> None: system_model.hours_per_step, self.element.excess_penalty_per_flow_hour.active_data ) self.excess_input = system_model.add_variables( - lower_bound=0, coords=system_model.coords, name=f'{self.label_full}__excess_input' + lower=0, coords=system_model.coords, name=f'{self.label_full}__excess_input' ) self.excess_output = system_model.add_variables( - lower_bound=0, coords=system_model.coords, name=f'{self.label_full}__excess_output' + lower=0, coords=system_model.coords, name=f'{self.label_full}__excess_output' ) - eq_bus_balance.lhs += self.excess_input - self.excess_output + eq_bus_balance.lhs -= -self.excess_input + self.excess_output system_model.effects.add_share_to_penalty( - system_model, self.element.label_full, self.excess_input * excess_penalty + system_model, self.element.label_full, (self.excess_input * excess_penalty).sum() ) system_model.effects.add_share_to_penalty( - system_model, self.element.label_full, self.excess_output *excess_penalty + system_model, self.element.label_full, (self.excess_output * excess_penalty).sum() ) diff --git a/flixOpt/features.py b/flixOpt/features.py index 01782e5c0..05b6b4cc3 100644 --- a/flixOpt/features.py +++ b/flixOpt/features.py @@ -755,32 +755,32 @@ def __init__( # Parameters self._shares_are_time_series = shares_are_time_series - self._total_max = total_max - self._total_min = total_min - self._max_per_hour = max_per_hour - self._min_per_hour = min_per_hour + self._total_max = total_max if total_min is not None else np.inf + self._total_min = total_min if total_min is not None else -np.inf + self._max_per_hour = max_per_hour if max_per_hour is not None else np.inf + self._min_per_hour = min_per_hour if min_per_hour is not None else -np.inf def do_modeling(self, system_model: SystemModel): self.total = system_model.add_variables( - lower_bound=self._total_min, upper_bound=self._total_max, coords=system_model.coords, name=f'{self.label}_total' + lower=self._total_min, upper=self._total_max, coords=None, name=f'{self.label_full}_total' ) # eq: sum = sum(share_i) # skalar - self._eq_total = system_model.add_constraints(self.total == 0, name=f'{self.label}__total') + self._eq_total = system_model.add_constraints(self.total == 0, name=f'{self.label_full}__total') if self._shares_are_time_series: self.total_per_timestep = system_model.add_variables( - lower_bound=None if (self._min_per_hour is None) else np.multiply(self._min_per_hour, system_model.hours_per_step), - upper_bound=None if (self._max_per_hour is None) else np.multiply(self._max_per_hour, system_model.hours_per_step), + lower=-np.inf if (self._min_per_hour is None) else np.multiply(self._min_per_hour, system_model.hours_per_step), + upper=np.inf if (self._max_per_hour is None) else np.multiply(self._max_per_hour, system_model.hours_per_step), coords=system_model.coords, - name=f'{self.label}_total_per_timestep' + name=f'{self.label_full}_total_per_timestep' ) self._eq_total_per_timestep = system_model.add_constraints( - self.total_per_timestep == 0, name=f'{self.label}__total_per_timestep' + self.total_per_timestep == 0, name=f'{self.label_full}__total_per_timestep' ) # Add it to the total - self._eq_total.lhs += self.total_per_timestep.sum() + self._eq_total.lhs -= self.total_per_timestep.sum() def add_share( self, From 2b3419974038a7aa7e3eecc1223b610bd69db99e Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 11 Feb 2025 21:31:05 +0100 Subject: [PATCH 047/507] Bugfix in ShareAllocationModel --- flixOpt/features.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/flixOpt/features.py b/flixOpt/features.py index 05b6b4cc3..e961f921f 100644 --- a/flixOpt/features.py +++ b/flixOpt/features.py @@ -790,9 +790,9 @@ def add_share( ): """ Add a share to the share allocation model. If the share already exists, the expression is added to the existing share. - The expression is added to the left hand side (lhs) of the constraint. - The variable representing the total share is on the right hand side (rhs) of the constraint. - total = sum(shares) + The expression is added to the right hand side (rhs) of the constraint. + The variable representing the total share is on the left hand side (lhs) of the constraint. + var_total = sum(expressions) Parameters ---------- @@ -813,6 +813,10 @@ def add_share( self.share_constraints[name] = system_model.add_constraints( self.shares[name] == expression, name=f'{name}__{self.label_full}' ) + if self.shares[name].ndim == 0: + self._eq_total.lhs -= self.shares[name] + else: + self._eq_total_per_timestep.lhs -= self.shares[name] def results(self): return { From 152b8bb0160c3ecf4fa86351a95e6effa576c259 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 11 Feb 2025 21:32:12 +0100 Subject: [PATCH 048/507] Remove .objective from EffectsCollection --- flixOpt/effects.py | 1 - 1 file changed, 1 deletion(-) diff --git a/flixOpt/effects.py b/flixOpt/effects.py index b18f614bb..19b477333 100644 --- a/flixOpt/effects.py +++ b/flixOpt/effects.py @@ -254,7 +254,6 @@ def __init__(self, effects: List[Effect]): self.effects: Dict[str, Effect] = effects # Performs some validation self.penalty: Optional[ShareAllocationModel] = None - self.objective: Optional[Equation] = None def add_share_to_effects( self, From 768819fe6d64c35810537f755e8f2d9bb63fa402 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 11 Feb 2025 21:33:06 +0100 Subject: [PATCH 049/507] Another Bugfix --- flixOpt/features.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixOpt/features.py b/flixOpt/features.py index e961f921f..8ab8db1c7 100644 --- a/flixOpt/features.py +++ b/flixOpt/features.py @@ -804,7 +804,7 @@ def add_share( The expression of the share. Added to the right hand side of the constraint. """ if name in self.shares: - self.share_constraints[name].lhs += expression + self.share_constraints[name].lhs -= expression else: self.shares[name] = system_model.add_variables( coords=None if expression.ndim == 0 else system_model.coords, From 908b4da7b00882a37f029e1c23efe2000d3e79f9 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 11 Feb 2025 22:47:05 +0100 Subject: [PATCH 050/507] Move coords handling to FlowSystem --- flixOpt/flow_system.py | 43 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/flixOpt/flow_system.py b/flixOpt/flow_system.py index 0c23381d5..901496c12 100644 --- a/flixOpt/flow_system.py +++ b/flixOpt/flow_system.py @@ -9,6 +9,7 @@ import numpy as np import pandas as pd +import xarray as xr from . import utils from .core import TimeSeries @@ -52,18 +53,58 @@ def __init__( """ self.timesteps = timesteps self.hours_of_last_step = hours_of_last_timestep + self.periods = periods + self._order_dimensions() + self.hours_of_previous_timesteps: Union[int, float, np.ndarray] = ( ((self.timesteps[1] - self.timesteps[0]) / np.timedelta64(1, 'h')) if hours_of_previous_timesteps is None else hours_of_previous_timesteps ) - self.periods = periods # defaults: self.components: Dict[str, Component] = {} self.effects: Dict[str, Effect] = {} self.model: Optional[SystemModel] = None + def _order_dimensions(self): + if self.timesteps.dtype == np.dtype('datetime64[ns]'): + self.timesteps = self.timesteps.astype('datetime64[us]') + else: + self.timesteps = self.timesteps + self.timesteps.name = 'time' + + self.periods = pd.Index(self.periods, name='period') if self.periods is not None else None + + if self.hours_of_last_step: + last_date = pd.DatetimeIndex( + [self.timesteps[-1] + pd.to_timedelta(self.hours_of_last_step, 'h')]) + else: + last_date = pd.DatetimeIndex([self.timesteps[-1] + (self.timesteps[-1] - self.timesteps[-2])]) + self.timesteps_extra = self.timesteps.append(last_date) + self.timesteps_extra.name = 'time' + hours_per_step = self.timesteps_extra.to_series().diff()[1:].values / pd.to_timedelta(1, 'h') + self.hours_per_step = xr.DataArray( + data=np.tile(hours_per_step, (len(self.periods), 1)) if self.periods is not None else hours_per_step, + coords=self.coords, + name='hours_per_step' + ) + + @property + def snapshots(self): + return xr.Dataset( + coords={'period': list(self.periods), 'time': list(self.timesteps)} if self.periods is not None else { + 'time': list(self.timesteps)}, + ) + + @property + def coords(self): + return self.snapshots.coords + + @property + def index_shape(self) -> Tuple[int, int]: + return len(self.periods) if self.periods is not None else 1, len(self.timesteps) + def add_effects(self, *args: Effect) -> None: for new_effect in list(args): if new_effect.label in self.effects: From b3e079ef0881be9ec26b91c478b7c7e04a80ac50 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 12 Feb 2025 08:59:24 +0100 Subject: [PATCH 051/507] Update TimeSeries --- flixOpt/core.py | 154 +++++++++++++++++++++++++++++++++++------------- 1 file changed, 113 insertions(+), 41 deletions(-) diff --git a/flixOpt/core.py b/flixOpt/core.py index 9ee489cda..5ea8bb408 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -8,6 +8,8 @@ from typing import Any, Dict, List, Optional, Union import numpy as np +import xarray as xr +import pandas as pd from . import utils @@ -95,21 +97,20 @@ class TimeSeries: Group for calculating the aggregation weigth for aggregation method. """ - def __init__(self, label: str, data: Optional[Numeric_TS]): + def __init__(self, + label: str, + data: Numeric, + index: pd.Index, + aggregation_weight: Optional[float] = None): self.label: str = label - if isinstance(data, TimeSeriesData): - self.data = self.make_scalar_if_possible(data.data) - self.aggregation_weight, self.aggregation_group = data.agg_weight, data.agg_group - data.label = self.label # Connecting User_time_series to real Time_series - else: - self.data = self.make_scalar_if_possible(data) - self.aggregation_weight, self.aggregation_group = None, None + self.data: pd.Series = self.as_series(data, index) + self.aggregation_weight = aggregation_weight - self.active_indices: Optional[Union[range, List[int]]] = None + self.active_coords: Optional[Dict] = None self.aggregated_data: Optional[Numeric] = None - def activate_indices(self, indices: Optional[Union[range, List[int]]], aggregated_data: Optional[Numeric] = None): - self.active_indices = indices + def activate_indices(self, coords: Dict, aggregated_data: Optional[Numeric] = None): + self.active_coords = coords if aggregated_data is not None: assert len(aggregated_data) == len(self.active_indices) or len(aggregated_data) == 1, ( @@ -119,24 +120,89 @@ def activate_indices(self, indices: Optional[Union[range, List[int]]], aggregate self.aggregated_data = self.make_scalar_if_possible(aggregated_data) def clear_indices_and_aggregated_data(self): - self.active_indices = None + self.active_coords = None self.aggregated_data = None @property - def active_data(self) -> Numeric: + def active_data(self) -> Union[int, float, xr.DataArray]: if self.aggregated_data is not None: # Aggregated data is always active, if present return self.aggregated_data - indices_not_applicable = np.isscalar(self.data) or (self.data is None) or (self.active_indices is None) - if indices_not_applicable: + if np.isscalar(self.data) or (self.active_coords is None): return self.data else: - return self.data[self.active_indices] + return self.data.sel(self.active_coords) - @property - def active_data_vector(self) -> np.ndarray: - # Always returns the active data as a vector. - return utils.as_vector(self.active_data, len(self.active_indices)) + @staticmethod + def as_series(data: Numeric, dims: Tuple[pd.Index, ...]) -> pd.Series: + """ + Converts the given data to a pd.Series with the specified index. + + - Arrays and Series are stacked across the period index. + - The length of the array must match the length of the time coordinate if applicable. + - If a 1D array is given but two indices are provided, it is reshaped to 2D automatically. + + Parameters: + - data: The input data (scalar, array, Series, or DataFrame). + - dims: A Tuple of pd.Index objects specifying the index dimensions. + + Returns: + - pd.Series: The resulting Series, possibly with a MultiIndex. + """ + + if not isinstance(dims, tuple) or not all(isinstance(idx, pd.Index) for idx in dims): + raise TypeError("dims must be a tuple of pandas Index objects") + + expected_shape = tuple(len(idx) for idx in dims) + index = pd.MultiIndex.from_product(dims) if len(dims) > 1 else dims[0] + + if isinstance(data, (int, float)): # Scalar case + return pd.Series(data, index=index) + + if isinstance(data, pd.DataFrame): + if len(dims) == 1: + if not data.index.equals(dims[0]): + raise ValueError("Series index does not match the provided index") + data = data.values.ravel() + else: + data = data.stack().swaplevel(0,1).sort_index() + if data.index != index: + raise ValueError("DataFrame index does not match the provided index") + return data + + if isinstance(data, pd.Series): + if len(dims) == 1: + if not data.index.equals(dims[0]): + raise ValueError("Series index does not match the provided index") + data = data.ravel() # Retrieve the data from the Series + + if isinstance(data, np.ndarray): + + if data.ndim == 1 and len(dims) == 2: # If 1D data but 2D index is given + if data.shape[0] == len(dims[0]): + data = np.tile(data[:, np.newaxis], (1, len(dims[1]))) # Expand along second dimension + elif data.shape[0] == len(dims[1]): + data = np.tile(data[np.newaxis, :], (len(dims[0]), 1)) # Expand along first dimension + else: + raise ValueError("1D array length does not match either dimension in dims") + + if data.shape != expected_shape: + raise ValueError(f"Shape of data {data.shape} does not match expected shape {expected_shape}") + + return pd.Series(data.ravel(), index=index) + + elif isinstance(data, pd.Series): + if not data.index.equals(dims[0]): + raise ValueError("Series index does not match the provided index") + return data + + elif isinstance(data, pd.DataFrame): + if len(dims) != 2 or data.shape != (len(dims[0]), len(dims[1])): + raise ValueError("DataFrame shape does not match provided indexes") + return data.stack() + + else: + raise TypeError("Unsupported data type. Must be scalar, np.ndarray, pd.Series, or pd.DataFrame.") @property def is_scalar(self) -> bool: @@ -157,26 +223,32 @@ def __repr__(self): def __str__(self): return str(self.active_data) - @staticmethod - def make_scalar_if_possible(data: Optional[Numeric]) -> Optional[Numeric]: - """ - Convert an array to a scalar if all values are equal, or return the array as-is. - Can Return None if the passed data is None + def _apply_op(self, other, op): + """Helper function to apply an operation using active_data.""" + if isinstance(other, TimeSeries): + return op(self.active_data, other.active_data) + return op(self.active_data, other) - Parameters - ---------- - data : Numeric, None - The data to process. + def __add__(self, other): + return self._apply_op(other, np.add) - Returns - ------- - Numeric - A scalar if all values in the array are equal, otherwise the array itself. None, if the passed value is None - """ - # TODO: Should this really return None Values? - if np.isscalar(data) or data is None: - return data - data = np.array(data) - if np.all(data == data[0]): - return data[0] - return data + def __sub__(self, other): + return self._apply_op(other, np.subtract) + + def __mul__(self, other): + return self._apply_op(other, np.multiply) + + def __truediv__(self, other): + return self._apply_op(other, np.divide) + + def __radd__(self, other): + return self.__add__(other) + + def __rsub__(self, other): + return self._apply_op(other, lambda x, y: x - y) + + def __rmul__(self, other): + return self.__mul__(other) + + def __rtruediv__(self, other): + return self._apply_op(other, lambda x, y: x / y) From 8241c1136f13ba7fb7447fccc16710cd9243750f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 12 Feb 2025 14:22:47 +0100 Subject: [PATCH 052/507] Redesing of class TimeSeries --- flixOpt/core.py | 304 ++++++++++++++++++++++++------------------------ 1 file changed, 154 insertions(+), 150 deletions(-) diff --git a/flixOpt/core.py b/flixOpt/core.py index 5ea8bb408..376bb8c7a 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -5,7 +5,7 @@ import inspect import logging -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, List, Optional, Union, Tuple import numpy as np import xarray as xr @@ -19,6 +19,89 @@ Numeric = Union[int, float, np.ndarray] # Datatype +class DataConverter: + @staticmethod + def as_series(data: Union[Numeric, pd.Series, pd.DataFrame], dims: Tuple[pd.Index, ...]) -> pd.Series: + """ + Converts the given data to a pd.Series with the specified index. + + - Arrays and Series are stacked across the period index. + - The length of the array must match the length of the time coordinate if applicable. + - If a 1D array is given but two indices are provided, it is reshaped to 2D automatically. + + Parameters: + - data: The input data (scalar, array, Series, or DataFrame). + - dims: A Tuple of pd.Index objects specifying the index dimensions. + + Returns: + - pd.Series: The resulting Series, possibly with a MultiIndex. + """ + + if not isinstance(dims, tuple) or not all(isinstance(idx, pd.Index) for idx in dims): + raise TypeError("dims must be a tuple of pandas Index objects") + + index = pd.MultiIndex.from_product(dims) if len(dims) > 1 else dims[0] + + if isinstance(data, (int, float)): # Scalar case + return DataConverter._handle_scalar(data, index) + + if isinstance(data, np.ndarray): + return DataConverter._handle_array(data, dims, index) + + if isinstance(data, pd.Series): + return DataConverter._handle_series(data, dims, index) + + if isinstance(data, pd.DataFrame): + return DataConverter._handle_dataframe(data, dims, index) + + raise TypeError("Unsupported data type. Must be scalar, np.ndarray, pd.Series, or pd.DataFrame.") + + @staticmethod + def _handle_scalar(data: Union[int, float], index: pd.Index) -> pd.Series: + """Handles scalar input.""" + return pd.Series(data, index=index) + + @staticmethod + def _handle_array(data: np.ndarray, dims: Tuple[pd.Index, ...], index: pd.Index) -> pd.Series: + """Handles NumPy array input.""" + expected_shape = tuple(len(idx) for idx in dims) + + if data.ndim == 1 and len(dims) == 2: # Automatically reshape 1D arrays + if data.shape[0] == len(dims[0]): + data = np.tile(data[:, np.newaxis], (1, len(dims[1]))) # Expand along second dimension + elif data.shape[0] == len(dims[1]): + data = np.tile(data[np.newaxis, :], (len(dims[0]), 1)) # Expand along first dimension + else: + raise ValueError("1D array length does not match either dimension in dims") + + if data.shape != expected_shape: + raise ValueError(f"Shape of data {data.shape} does not match expected shape {expected_shape}") + + return pd.Series(data.ravel(), index=index) + + @staticmethod + def _handle_series(data: pd.Series, dims: Tuple[pd.Index, ...], index: pd.Index) -> pd.Series: + """Handles pandas Series input.""" + if len(dims) == 1: + if not data.index.equals(dims[0]): + raise ValueError("Series index does not match the provided index") + return data + return pd.Series(data.values.ravel(), index=index) + + @staticmethod + def _handle_dataframe(data: pd.DataFrame, dims: Tuple[pd.Index, ...], index: pd.Index) -> pd.Series: + """Handles pandas DataFrame input.""" + if len(dims) != 2 or data.shape != (len(dims[0]), len(dims[1])): + raise ValueError("DataFrame shape does not match provided indexes") + + # Stack and ensure columns become level 0 + stacked = data.stack().swaplevel(0, 1).sort_index() + if not stacked.index.equals(index): + raise ValueError("Stacked DataFrame index does not match the provided index") + + return stacked + + class TimeSeriesData: # TODO: Move to Interface.py def __init__(self, data: Numeric, agg_group: Optional[str] = None, agg_weight: Optional[float] = None): @@ -74,181 +157,102 @@ def __str__(self): class TimeSeries: - """ - Class for data that applies to time series, stored as vector (np.ndarray) or scalar. - - This class represents a vector or scalar value that makes the handling of time series easier. - It supports various operations such as activation of specific time indices, setting explicit active data, and - aggregation weight management. - - Attributes - ---------- - label : str - The label for the time series. - data : Optional[Numeric] - The actual data for the time series. Can be None. - aggregated_data : Optional[Numeric] - aggregated_data to use instead of data if provided. - active_indices : Optional[np.ndarray] - Indices of the time steps to activate. - aggregation_weight : float - Weight for aggregation method, between 0 and 1, normally 1. - aggregation_group : str - Group for calculating the aggregation weigth for aggregation method. - """ - - def __init__(self, - label: str, - data: Numeric, - index: pd.Index, - aggregation_weight: Optional[float] = None): - self.label: str = label - self.data: pd.Series = self.as_series(data, index) - self.aggregation_weight = aggregation_weight - - self.active_coords: Optional[Dict] = None - self.aggregated_data: Optional[Numeric] = None - - def activate_indices(self, coords: Dict, aggregated_data: Optional[Numeric] = None): - self.active_coords = coords - - if aggregated_data is not None: - assert len(aggregated_data) == len(self.active_indices) or len(aggregated_data) == 1, ( - f'The aggregated_data has the wrong length for TimeSeries {self.label}. ' - f'Length should be: {len(self.active_indices)} or 1, but is {len(aggregated_data)}' - ) - self.aggregated_data = self.make_scalar_if_possible(aggregated_data) - - def clear_indices_and_aggregated_data(self): - self.active_coords = None - self.aggregated_data = None - - @property - def active_data(self) -> Union[int, float, xr.DataArray]: - if self.aggregated_data is not None: # Aggregated data is always active, if present - return self.aggregated_data - - if np.isscalar(self.data) or (self.active_coords is None): - return self.data - else: - return self.data.sel(self.active_coords) - - @staticmethod - def as_series(data: Numeric, dims: Tuple[pd.Index, ...]) -> pd.Series: + def __init__(self, data: pd.Series, aggregation_weight: Optional[float] = None): """ - Converts the given data to a pd.Series with the specified index. - - - Arrays and Series are stacked across the period index. - - The length of the array must match the length of the time coordinate if applicable. - - If a 1D array is given but two indices are provided, it is reshaped to 2D automatically. + Initialize the TimeSeriesManager with a Series. Parameters: - - data: The input data (scalar, array, Series, or DataFrame). - - dims: A Tuple of pd.Index objects specifying the index dimensions. - - Returns: - - pd.Series: The resulting Series, possibly with a MultiIndex. + - data (pd.Series): A Series with a DatetimeIndex and possibly a MultiIndex. + - aggregation_weight (float, optional): The weight of the data in the aggregation. Defaults to None. """ + self._stored_data = data.copy() # Store data + self._backup = self.stored_data # Single backup instance. Enables to temporarily overwrite the data. + self._active_data = None + self._active_index = None + self.aggregation_weight = aggregation_weight - if not isinstance(dims, tuple) or not all(isinstance(idx, pd.Index) for idx in dims): - raise TypeError("dims must be a tuple of pandas Index objects") - - expected_shape = tuple(len(idx) for idx in dims) - index = pd.MultiIndex.from_product(dims) if len(dims) > 1 else dims[0] - - if isinstance(data, (int, float)): # Scalar case - return pd.Series(data, index=index) - - if isinstance(data, pd.DataFrame): - if len(dims) == 1: - if not data.index.equals(dims[0]): - raise ValueError("Series index does not match the provided index") - data = data.values.ravel() - else: - data = data.stack().swaplevel(0,1).sort_index() - if data.index != index: - raise ValueError("DataFrame index does not match the provided index") - return data - - if isinstance(data, pd.Series): - if len(dims) == 1: - if not data.index.equals(dims[0]): - raise ValueError("Series index does not match the provided index") - data = data.ravel() # Retrieve the data from the Series + self.active_index = None # Initializes the active index and active data - if isinstance(data, np.ndarray): + def restore_data(self): + """Restore stored_data from the backup.""" + self.active_index = None + self._stored_data[:] = self._backup.copy() - if data.ndim == 1 and len(dims) == 2: # If 1D data but 2D index is given - if data.shape[0] == len(dims[0]): - data = np.tile(data[:, np.newaxis], (1, len(dims[1]))) # Expand along second dimension - elif data.shape[0] == len(dims[1]): - data = np.tile(data[np.newaxis, :], (len(dims[0]), 1)) # Expand along first dimension - else: - raise ValueError("1D array length does not match either dimension in dims") + def as_dataarray(self) -> xr.DataArray: + return self.active_data.to_xarray() - if data.shape != expected_shape: - raise ValueError(f"Shape of data {data.shape} does not match expected shape {expected_shape}") + @property + def active_index(self) -> pd.Index: + """Return the current active index.""" + return self._active_index + + @active_index.setter + def active_index(self, index: Optional[pd.Index]): + """Set a new active index and refresh active_data.""" + if index is None: + self._active_index = self._stored_data.index + self._active_data = self._stored_data + return + elif not isinstance(index, (pd.Index, pd.MultiIndex)): + raise TypeError("active_index must be a pandas Index or MultiIndex or None") + else: + self._active_index = index + self._active_data = self.stored_data.loc[self._active_index] # Refresh view - return pd.Series(data.ravel(), index=index) + @property + def active_data(self) -> pd.Series: + """Return a view of stored_data based on active_index.""" + return self._active_data - elif isinstance(data, pd.Series): - if not data.index.equals(dims[0]): - raise ValueError("Series index does not match the provided index") - return data + @active_data.setter + def active_data(self, value): + """Prevent direct modification of active_data.""" + raise AttributeError("active_data cannot be directly modified. Modify stored_data instead.") - elif isinstance(data, pd.DataFrame): - if len(dims) != 2 or data.shape != (len(dims[0]), len(dims[1])): - raise ValueError("DataFrame shape does not match provided indexes") - return data.stack() + @property + def stored_data(self) -> pd.Series: + """Return a copy of stored_data. Prevents modification of stored data""" + return self._stored_data.copy() - else: - raise TypeError("Unsupported data type. Must be scalar, np.ndarray, pd.Series, or pd.DataFrame.") + @stored_data.setter + def stored_data(self, value: pd.Series): + """Set stored_data and refresh active_index and active_data.""" + self._backup = self._stored_data + self._stored_data = value + self.active_index = None @property - def is_scalar(self) -> bool: - return np.isscalar(self.data) + def loc(self): + """Access active_data using loc.""" + return self.active_data.loc @property - def is_array(self) -> bool: - return not self.is_scalar and self.data is not None + def iloc(self): + """Access active_data using iloc.""" + return self.active_data.iloc - def __repr__(self): - # Retrieve all attributes and their values - attrs = vars(self) - # Format each attribute as 'key=value' - attrs_str = ', '.join(f'{key}={value!r}' for key, value in attrs.items()) - # Format the output as 'ClassName(attr1=value1, attr2=value2, ...)' - return f'{self.__class__.__name__}({attrs_str})' - - def __str__(self): - return str(self.active_data) - - def _apply_op(self, other, op): - """Helper function to apply an operation using active_data.""" + # Enable arithmetic operations using active_data + def _apply_operation(self, other, op): if isinstance(other, TimeSeries): - return op(self.active_data, other.active_data) + other = other.active_data + if isinstance(other, xr.DataArray): + return op(self.as_dataarray(), other) return op(self.active_data, other) def __add__(self, other): - return self._apply_op(other, np.add) + return self._apply_operation(other, lambda x, y: x + y) def __sub__(self, other): - return self._apply_op(other, np.subtract) + return self._apply_operation(other, lambda x, y: x - y) def __mul__(self, other): - return self._apply_op(other, np.multiply) + return self._apply_operation(other, lambda x, y: x * y) def __truediv__(self, other): - return self._apply_op(other, np.divide) - - def __radd__(self, other): - return self.__add__(other) - - def __rsub__(self, other): - return self._apply_op(other, lambda x, y: x - y) + return self._apply_operation(other, lambda x, y: x / y) - def __rmul__(self, other): - return self.__mul__(other) + def __floordiv__(self, other): + return self._apply_operation(other, lambda x, y: x // y) - def __rtruediv__(self, other): - return self._apply_op(other, lambda x, y: x / y) + def __pow__(self, other): + return self._apply_operation(other, lambda x, y: x ** y) From 2626d0a8cb54323f4d308ba2f3032caaec9a5f7a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 12 Feb 2025 15:26:44 +0100 Subject: [PATCH 053/507] Improve TimeSeries class and add tests --- flixOpt/core.py | 48 ++++++++++++- tests/test_timeseries.py | 150 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 196 insertions(+), 2 deletions(-) create mode 100644 tests/test_timeseries.py diff --git a/flixOpt/core.py b/flixOpt/core.py index 376bb8c7a..8464a0712 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -166,7 +166,7 @@ def __init__(self, data: pd.Series, aggregation_weight: Optional[float] = None): - aggregation_weight (float, optional): The weight of the data in the aggregation. Defaults to None. """ self._stored_data = data.copy() # Store data - self._backup = self.stored_data # Single backup instance. Enables to temporarily overwrite the data. + self._backup: pd.Series = self.stored_data # Single backup instance. Enables to temporarily overwrite the data. self._active_data = None self._active_index = None self.aggregation_weight = aggregation_weight @@ -175,8 +175,8 @@ def __init__(self, data: pd.Series, aggregation_weight: Optional[float] = None): def restore_data(self): """Restore stored_data from the backup.""" + self._stored_data = self._backup.copy() self.active_index = None - self._stored_data[:] = self._backup.copy() def as_dataarray(self) -> xr.DataArray: return self.active_data.to_xarray() @@ -256,3 +256,47 @@ def __floordiv__(self, other): def __pow__(self, other): return self._apply_operation(other, lambda x, y: x ** y) + + # Reflected arithmetic operations (to handle cases like `some_xarray + ts1`) + def __radd__(self, other): + return self.__add__(other) + + def __rsub__(self, other): + return self._apply_operation(other, lambda x, y: y - x) + + def __rmul__(self, other): + return self.__mul__(other) + + def __rtruediv__(self, other): + return self._apply_operation(other, lambda x, y: y / x) + + def __rfloordiv__(self, other): + return self._apply_operation(other, lambda x, y: y // x) + + def __rpow__(self, other): + return self._apply_operation(other, lambda x, y: y ** x) + + # Unary operations. Not sure if this is the best way... + def __neg__(self): + return -self.as_dataarray() + + def __pos__(self): + return +self.as_dataarray() + + def __abs__(self): + return abs(self.as_dataarray()) + + def __array_ufunc__(self, ufunc, method, *inputs, **kwargs): + """Ensures NumPy functions like np.add(TimeSeries, xarray) work correctly.""" + inputs = [x.as_dataarray() if isinstance(x, TimeSeries) else x for x in inputs] + result = getattr(ufunc, method)(*inputs, **kwargs) + + # Ensure return type consistency + if isinstance(result, xr.DataArray): + return result + elif isinstance(result, np.ndarray): # Handles cases like np.exp(ts) + return pd.Series(result, index=self.active_data.index) + else: + raise NotImplementedError(f"ufunc {ufunc} not implemented for TimeSeries") + + diff --git a/tests/test_timeseries.py b/tests/test_timeseries.py new file mode 100644 index 000000000..a3e001ccd --- /dev/null +++ b/tests/test_timeseries.py @@ -0,0 +1,150 @@ +import pytest +import pandas as pd +import xarray as xr + +from flixOpt.core import TimeSeries # Adjust import based on your module structure + +# Helper function to create a test TimeSeries object +def create_test_timeseries(): + data = pd.Series([10, 20, 30], index=pd.date_range('2023-01-01', periods=3)) + return TimeSeries(data) + +# Test initialization +def test_initialization(): + ts = create_test_timeseries() + assert isinstance(ts, TimeSeries) + assert isinstance(ts.stored_data, pd.Series) + assert ts.stored_data.equals(pd.Series([10, 20, 30], index=pd.date_range('2023-01-01', periods=3))) + +# Test active_index property setter and getter +def test_active_index_setter_getter(): + ts = create_test_timeseries() + new_index = pd.date_range('2023-01-02', periods=2) + ts.active_index = new_index + assert ts.active_index.equals(new_index) + assert ts.active_data.equals(ts.stored_data.loc[new_index]) + +# Test invalid active_index assignment +def test_invalid_active_index(): + ts = create_test_timeseries() + with pytest.raises(TypeError): + ts.active_index = "invalid_index" + +# Test restoring data +def test_restore_data(): + ts = create_test_timeseries() + ts.active_index = pd.date_range('2023-01-02', periods=2) + ts.restore_data() + assert ts.active_index.equals(ts.stored_data.index) + assert ts.active_data.equals(ts.stored_data) + + ts = create_test_timeseries() + old_data = ts.stored_data + new_data = pd.Series([1,2], pd.date_range("2023-01-02", periods=2)) + ts.stored_data = new_data + assert ts.active_data.equals(new_data) + + ts.restore_data() # Restore original data + + assert ts.active_index.equals(old_data.index) # Ensure active_index is reset to full index + + assert ts.active_data.equals(old_data) # Ensure active_data matches stored_data + +# Test arithmetic operations +def test_arithmetic_operations(): + ts1 = create_test_timeseries() + ts2 = create_test_timeseries() + + # Test addition + result = ts1 + ts2 + expected = ts1.active_data + ts2.active_data + pd.testing.assert_series_equal(result, expected) + + # Test subtraction + result = ts1 - ts2 + expected = ts1.active_data - ts2.active_data + pd.testing.assert_series_equal(result, expected) + + # Test multiplication + result = ts1 * ts2 + expected = ts1.active_data * ts2.active_data + pd.testing.assert_series_equal(result, expected) + + # Test division + result = ts1 / ts2 + expected = ts1.active_data / ts2.active_data + pd.testing.assert_series_equal(result, expected) + + # Test floordiv + result = ts1 // ts2 + expected = ts1.active_data // ts2.active_data + pd.testing.assert_series_equal(result, expected) + + # Test exponentiation + result = ts1 ** ts2 + expected = ts1.active_data ** ts2.active_data + pd.testing.assert_series_equal(result, expected) + +# Test setting stored_data +def test_stored_data_setter(): + ts = create_test_timeseries() + old_data = ts.stored_data + new_data = pd.Series([40, 50, 60], index=pd.date_range('2023-01-01', periods=3)) + ts.stored_data = new_data + assert ts.stored_data.equals(new_data) + assert ts.active_data.equals(new_data) + assert ts._backup.equals(old_data) + +# Test active_data direct modification prevention +def test_prevent_active_data_modification(): + ts = create_test_timeseries() + with pytest.raises(AttributeError): + ts.active_data = pd.Series([1, 2, 3], index=pd.date_range('2023-01-01', periods=3)) + +# Test loc and iloc properties +def test_loc_iloc_properties(): + ts = create_test_timeseries() + ts.active_index = pd.date_range('2023-01-01', periods=3) + assert ts.loc['2023-01-02'] == 20 + assert ts.iloc[1] == 20 + +# Test active_data default behavior +def test_active_data_default(): + ts = create_test_timeseries() + ts.active_index = None # Should default to the full stored_data + assert ts.active_data.equals(ts.stored_data) + + +# Test arithmetic operations with xarray.DataArray +def test_arithmetic_operations_xarray(): + time_idx = pd.date_range('2020-01-01', periods=3, freq='d', name='time') + periods = pd.Index([2020, 2030], name='period') + + arithmetric_operations( + xr.DataArray([10, 20, 30], coords=(time_idx,)), + TimeSeries(pd.Series([10, 20, 30], index=time_idx)) + ) + + arithmetric_operations( + xr.DataArray([[10, 20, 30], [1,2,3]], coords=(periods, time_idx)), + TimeSeries(pd.Series([10, 20, 30, 1, 2, 3], index=pd.MultiIndex.from_product([periods, time_idx]))) + ) + +def arithmetric_operations(data1: xr.DataArray, ts1: TimeSeries): + xr.testing.assert_equal(ts1 + data1, data1 + ts1, check_dim_order=True) + xr.testing.assert_equal(ts1 - data1, data1 - ts1, check_dim_order=True) + xr.testing.assert_equal(ts1 * data1, data1 * ts1, check_dim_order=True) + xr.testing.assert_equal(ts1 / data1, data1 / ts1, check_dim_order=True) + if data1.ndim > 1: + ts1_active = ts1.active_data.to_xarray() + else: + ts1_active = ts1.active_data + xr.testing.assert_equal(data1 + ts1_active, data1 + ts1, check_dim_order=True) + xr.testing.assert_equal(data1 - ts1_active, data1 - ts1, check_dim_order=True) + xr.testing.assert_equal(data1 * ts1_active, data1 * ts1, check_dim_order=True) + xr.testing.assert_equal(data1 / ts1_active, data1 / ts1, check_dim_order=True) + + + +if __name__ == "__main__": + pytest.main() From 2874aa34639089e21a71a25344d5dc3f7aa626c3 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 12 Feb 2025 15:46:57 +0100 Subject: [PATCH 054/507] Add tests for DataConverter and fixed Bug --- flixOpt/core.py | 20 +++++- tests/test_dataconverter.py | 121 ++++++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 tests/test_dataconverter.py diff --git a/flixOpt/core.py b/flixOpt/core.py index 8464a0712..2f6e7915b 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -91,7 +91,7 @@ def _handle_series(data: pd.Series, dims: Tuple[pd.Index, ...], index: pd.Index) @staticmethod def _handle_dataframe(data: pd.DataFrame, dims: Tuple[pd.Index, ...], index: pd.Index) -> pd.Series: """Handles pandas DataFrame input.""" - if len(dims) != 2 or data.shape != (len(dims[0]), len(dims[1])): + if len(dims) != 2 or data.shape != (len(dims[1]), len(dims[0])): raise ValueError("DataFrame shape does not match provided indexes") # Stack and ensure columns become level 0 @@ -157,6 +157,24 @@ def __str__(self): class TimeSeries: + + @classmethod + def from_datasource(cls, + data: Union[int, float, np.ndarray, pd.Series, pd.DataFrame, xr.DataArray], + dims: Tuple[pd.Index, ...], + aggregation_weight: Optional[float] = None): + """ + Initialize the TimeSeries from multiple datasources. + + Parameters: + - data (pd.Series): A Series with a DatetimeIndex and possibly a MultiIndex. + - dims (Tuple[pd.Index, ...]): The dimensions of the TimeSeries. + - aggregation_weight (float, optional): The weight of the data in the aggregation. Defaults to None. + """ + data = DataConverter.as_series(data, dims) + # TODO: Add validation for the dimensions + return cls(DataConverter.as_series(data, dims), aggregation_weight) + def __init__(self, data: pd.Series, aggregation_weight: Optional[float] = None): """ Initialize the TimeSeriesManager with a Series. diff --git a/tests/test_dataconverter.py b/tests/test_dataconverter.py new file mode 100644 index 000000000..1495b6a09 --- /dev/null +++ b/tests/test_dataconverter.py @@ -0,0 +1,121 @@ +import numpy as np +import pandas as pd +import pytest +from flixOpt.core import DataConverter # Update with actual module name + + +def test_as_series_scalar(): + """Test scalar input conversion.""" + index = pd.date_range("2023-01-01", periods=3) + result = DataConverter.as_series(42, (index,)) + + assert isinstance(result, pd.Series) + assert (result == 42).all() + assert result.index.equals(index) + +def test_as_series_scalar_2dims(): + """Test scalar input conversion.""" + index = pd.date_range("2023-01-01", periods=3) + period = pd.Index([2020, 2030]) + result = DataConverter.as_series(42, (period, index)) + + assert isinstance(result, pd.Series) + assert (result == 42).all() + assert result.index.equals(pd.MultiIndex.from_product([period, index])) + + +def test_as_series_1d_array(): + """Test 1D NumPy array conversion.""" + index = pd.date_range("2023-01-01", periods=3) + data = np.array([1, 2, 3]) + + result = DataConverter.as_series(data, (index,)) + + assert isinstance(result, pd.Series) + assert (result.values == data).all() + assert result.index.equals(index) + +def test_as_series_1d_array_broadcast(): + """Test 1D NumPy array conversion.""" + index = pd.date_range("2023-01-01", periods=6) + period = pd.Index([2020, 2030]) + data = np.array([1, 2, 3, 4, 5, 6]) + + result = DataConverter.as_series(data, (period, index)) + + assert isinstance(result, pd.Series) + assert (result.values == np.tile(data, 2)).all() + assert result.index.equals(pd.MultiIndex.from_product([period, index])) + + +def test_as_series_2d_array(): + """Test 2D NumPy array conversion.""" + index1 = pd.date_range("2023-01-01", periods=2) + index2 = pd.Index(["A", "B", "C"]) + + data = np.array([[1, 2, 3], [4, 5, 6]]) + result = DataConverter.as_series(data, (index1, index2)) + + expected_index = pd.MultiIndex.from_product([index1, index2]) + assert isinstance(result, pd.Series) + assert result.index.equals(expected_index) + assert (result.values == data.ravel()).all() + + +def test_as_series_series_matching_index(): + """Test Pandas Series input with matching index.""" + index = pd.date_range("2023-01-01", periods=3) + data = pd.Series([10, 20, 30], index=index) + + result = DataConverter.as_series(data, (index,)) + + assert isinstance(result, pd.Series) + assert result.equals(data) + + +def test_as_series_series_mismatching_index(): + """Test Pandas Series with a different index should raise an error.""" + index = pd.date_range("2023-01-01", periods=3) + wrong_index = pd.date_range("2023-01-02", periods=3) + data = pd.Series([10, 20, 30], index=wrong_index) + + with pytest.raises(ValueError, match="Series index does not match the provided index"): + DataConverter.as_series(data, (index,)) + + +def test_as_series_dataframe(): + """Test DataFrame conversion.""" + index1 = pd.date_range("2023-01-01", periods=2) + index2 = pd.Index(["A", "B", "C"]) + + data = pd.DataFrame([[1, 2, 3], [4, 5, 6]], index=index1, columns=index2) + result = DataConverter.as_series(data, (index2, index1)) + + expected_index = pd.MultiIndex.from_product([index2, index1]) + expected_series = data.stack().swaplevel(0, 1).sort_index() + + assert isinstance(result, pd.Series) + assert result.index.equals(expected_index) + assert result.equals(expected_series) + + +def test_invalid_dims(): + """Test invalid dims input.""" + with pytest.raises(TypeError, match="dims must be a tuple of pandas Index objects"): + DataConverter.as_series(10, ["not", "an", "index"]) + + +def test_invalid_data_type(): + """Test invalid data type handling.""" + with pytest.raises(TypeError, match="Unsupported data type"): + DataConverter.as_series({"a": 1}, (pd.Index([1, 2, 3]),)) + + +def test_shape_mismatch(): + """Test shape mismatch between data and index.""" + index1 = pd.Index(["A", "B"]) + index2 = pd.Index(["X", "Y", "Z"]) + data = np.array([[1, 2], [3, 4]]) # Wrong shape + + with pytest.raises(ValueError, match="Shape of data"): + DataConverter.as_series(data, (index1, index2)) From b0696087da1e6014f2b028ce25cd3628fe8398a3 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 14 Feb 2025 09:56:58 +0100 Subject: [PATCH 055/507] Improve arithmetrics in TImeSeries --- flixOpt/core.py | 27 ++++++--------------------- 1 file changed, 6 insertions(+), 21 deletions(-) diff --git a/flixOpt/core.py b/flixOpt/core.py index 2f6e7915b..b8d6e3ccd 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -11,7 +11,6 @@ import xarray as xr import pandas as pd -from . import utils logger = logging.getLogger('flixOpt') @@ -252,10 +251,8 @@ def iloc(self): # Enable arithmetic operations using active_data def _apply_operation(self, other, op): if isinstance(other, TimeSeries): - other = other.active_data - if isinstance(other, xr.DataArray): - return op(self.as_dataarray(), other) - return op(self.active_data, other) + other = other.as_dataarray() + return op(self.as_dataarray(), other) def __add__(self, other): return self._apply_operation(other, lambda x, y: x + y) @@ -269,30 +266,18 @@ def __mul__(self, other): def __truediv__(self, other): return self._apply_operation(other, lambda x, y: x / y) - def __floordiv__(self, other): - return self._apply_operation(other, lambda x, y: x // y) - - def __pow__(self, other): - return self._apply_operation(other, lambda x, y: x ** y) - # Reflected arithmetic operations (to handle cases like `some_xarray + ts1`) def __radd__(self, other): - return self.__add__(other) + return other + self.as_dataarray() def __rsub__(self, other): - return self._apply_operation(other, lambda x, y: y - x) + return other - self.as_dataarray() def __rmul__(self, other): - return self.__mul__(other) + return other * self.as_dataarray() def __rtruediv__(self, other): - return self._apply_operation(other, lambda x, y: y / x) - - def __rfloordiv__(self, other): - return self._apply_operation(other, lambda x, y: y // x) - - def __rpow__(self, other): - return self._apply_operation(other, lambda x, y: y ** x) + return other / self.as_dataarray() # Unary operations. Not sure if this is the best way... def __neg__(self): From 51a4b8fe83c0bd0ac403d118eeff036aaf5b96cf Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 14 Feb 2025 11:44:09 +0100 Subject: [PATCH 056/507] Simplify structure.py --- flixOpt/structure.py | 236 ++++++++++++++----------------------------- 1 file changed, 78 insertions(+), 158 deletions(-) diff --git a/flixOpt/structure.py b/flixOpt/structure.py index 5df54312e..56290d677 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -36,13 +36,9 @@ class SystemModel(linopy.Model): def __init__( self, flow_system: 'FlowSystem', - active_time_steps: Optional = None, ): super().__init__(force_dim_names=True) self.flow_system = flow_system - self.active_time_steps = active_time_steps - self._order_dimensions() - self.effects: Optional[EffectCollection] = None def do_modeling(self): @@ -60,74 +56,13 @@ def do_modeling(self): self.effects.objective_effect.model.total + self.effects.penalty.total ) - def _order_dimensions(self): - if self.flow_system.timesteps.dtype == np.dtype('datetime64[ns]'): - self.timesteps = self.flow_system.timesteps.astype('datetime64[us]') - else: - self.timesteps = self.flow_system.timesteps - self.timesteps.name = 'time' - - self.periods = pd.Index(self.flow_system.periods, name='period') if self.flow_system.periods is not None else None - - if self.flow_system.hours_of_last_step: - last_date = pd.DatetimeIndex([self.timesteps[-1] + pd.to_timedelta(self.flow_system.hours_of_last_step, 'h')]) - else: - last_date = pd.DatetimeIndex([self.timesteps[-1] + (self.timesteps[-1] - self.timesteps[-2])]) - self.timesteps_extra = self.timesteps.append(last_date) - self.timesteps_extra.name = 'time' - hours_per_step = self.timesteps_extra.to_series().diff()[1:].values / pd.to_timedelta(1, 'h') - self.hours_per_step = xr.DataArray( - data=np.tile(hours_per_step, (len(self.periods), 1)) if self.periods is not None else hours_per_step, - coords=self.coords, - name='hours_per_step' - ) - - # utils.check_time_series('time series of FlowSystem', self.timesteps_extra) - - @property - def snapshots(self): - return xr.Dataset( - coords={'period': list(self.periods), 'time': list(self.timesteps)} if self.periods is not None else {'time': list(self.timesteps)}, - ) - - @property - def coords(self): - return self.snapshots.coords - - @property - def variables_filtered(self, filter_by: Optional[Literal['binary', 'continous', 'integer']] = None): - if filter_by is None: - all_variables = super().variables - elif filter_by == 'binary': - all_variables = super().binaries - elif filter_by == 'integer': - all_variables = super().integers - elif filter_by == 'continous': - all_variables = super().continuous - else: - raise ValueError(f'Invalid filter_by "{filter_by}", must be one of "binary", "continous", "integer"') - return all_variables[[name for name in all_variables if 'time' in all_variables[name].dims]] - - @property - def index_shape(self) -> Tuple[int, int]: - return len(self.periods) if self.periods is not None else 1, len(self.timesteps) - - @property - def infos(self) -> Dict: - infos = super().infos - infos['Constraints'] = self.description_of_constraints() - infos['Variables'] = self.description_of_variables() - infos['Main Results'] = self.main_results - infos['Config'] = CONFIG.to_dict() - return infos - class Interface: """ This class is used to collect arguments about a Model. """ - def transform_data(self): + def transform_data(self, data_array: xr.DataArray): raise NotImplementedError('Every Interface needs a transform_data() method') def infos(self, use_numpy=True, use_element_label=False) -> Dict: @@ -228,124 +163,104 @@ def label_full(self) -> str: return self.label -class ElementModel: - """Interface to create the mathematical Variables and Constraints for Elements""" +class InterfaceModel: + """Stores the mathematical Variables and Constraints related to an Interface""" - def __init__(self, element: Element, labels: Optional[Union[str, List[str]]] = None): + def __init__(self, interface: Optional[Interface], label_of_parent: Optional[str], label: Optional[str] = None): """ Parameters ---------- - element : Element - The element this model is created for. - labels : Optional[Union[str, List[str]]], optional - Used to construct the label of the model. If None, the element label is used. - The labels are used as suffixes + interface : Interface + The interface this model is created for. + label : str + Used to construct the label of the model. If None, the interface label is used. """ - self.element = element - self.variables = {} - self.constraints = {} + self.interface = interface + self._model: Optional[linopy.Model] = None + self._variables: List[str] = [] + self._constraints: List[str] = [] self.sub_models = [] - self._labels = labels + self._label = label + self._label_of_parent = label_of_parent logger.debug(f'Created {self.__class__.__name__} "{self.label_full}"') - def description_of_variables(self, structured: bool = True) -> Union[Dict[str, Union[List[str], Dict]], List[str]]: - if structured: - # Gather descriptions of this model's variables - descriptions = {'_self': [var.description() for var in self.variables.values()]} - - # Recursively gather descriptions from sub-models - for sub_model in self.sub_models: - descriptions[sub_model.label] = sub_model.description_of_variables(structured=structured) - - return descriptions + def add(self, item: Union[linopy.Variable, linopy.Constraint, 'InterfaceModel'] + ) -> Union[linopy.Variable, linopy.Constraint, 'InterfaceModel']: + if isinstance(item, linopy.Variable): + self._variables.append(item.name) + elif isinstance(item, linopy.Constraint): + self._constraints.append(item.name) + elif isinstance(item, InterfaceModel): + self.sub_models.append(item) else: - return [var.description() for var in self.all_variables.values()] - - def description_of_constraints(self, structured: bool = True) -> Union[Dict[str, str], List[str]]: - if structured: - # Gather descriptions of this model's variables - descriptions = {'_self': [constr.description() for constr in self.constraints.values()]} + raise ValueError(f'Item must be a linopy.Variable or linopy.Constraint, got {type(item)}') + return item - # Recursively gather descriptions from sub-models - for sub_model in self.sub_models: - descriptions[sub_model.label] = sub_model.description_of_constraints(structured=structured) + @property + def label(self) -> str: + return self._label or self._label_of_parent - return descriptions + @property + def label_full(self) -> str: + if self._label and self._label_of_parent: + return f'{self._label_of_parent}__{self._label}' else: - return [eq.description() for eq in self.all_equations.values()] + return self.label @property - def overview_of_model_size(self) -> Dict[str, int]: - all_vars, all_eqs, all_ineqs = self.all_variables, self.all_equations, self.all_inequations - return { - 'no of Euations': len(all_eqs), - 'no of Equations single': sum(eq.nr_of_single_equations for eq in all_eqs.values()), - 'no of Inequations': len(all_ineqs), - 'no of Inequations single': sum(ineq.nr_of_single_equations for ineq in all_ineqs.values()), - 'no of Variables': len(all_vars), - 'no of Variables single': sum(var.length for var in all_vars.values()), - } + def variables(self) -> linopy.Variables: + return self._model.variables[self._variables] + + @property + def constraints(self) -> linopy.Constraints: + return self._model.constraints[self._constraints] @property - def all_variables(self) -> Dict[str, Variable]: - all_vars = self.variables.copy() + def _all_variables(self) -> List[str]: + all_variables = self._variables.copy() for sub_model in self.sub_models: - for key, value in sub_model.all_variables.items(): - if key in all_vars: - raise KeyError(f"Duplicate key found: '{key}' in both main model and submodel!") - all_vars[key] = value - return all_vars + for variable in sub_model._all_variables: + if variable in all_variables: + raise KeyError( + f"Duplicate key found: '{variable}' in both {self.label_full} and {sub_model.label_full}!" + ) + all_variables.append(variable) + return all_variables @property - def all_constraints(self) -> Dict[str, Union[Equation, Inequation]]: - all_constr = self.constraints.copy() + def _all_constraints(self) -> List[str]: + all_constraints = self._constraints.copy() for sub_model in self.sub_models: - for key, value in sub_model.all_constraints.items(): - if key in all_constr: - raise KeyError(f"Duplicate key found: '{key}' in both main model and submodel!") - all_constr[key] = value - return all_constr + for constraint in sub_model._all_variables: + if constraint in all_constraints: + raise KeyError(f"Duplicate key found: '{constraint}' in both main model and submodel!") + all_constraints.append(constraint) + return all_constraints @property - def all_sub_models(self) -> List['ElementModel']: - all_subs = [] - to_process = self.sub_models.copy() - for model in to_process: - all_subs.append(model) - to_process.extend(model.sub_models) - return all_subs - - def results(self) -> Dict: - return { - **{variable.label_short: variable.result for variable in self.variables.values()}, - **{model.label: model.results() for model in self.sub_models}, - } + def all_variables(self) -> linopy.Variables: + return self._model.variables[self._all_variables] @property - def label_full(self) -> str: - if self._labels is not None and self.element is not None: - if isinstance(self._labels, str) : - return f'{self.element.label_full}__{self._labels}' - else: - return f'{self.element.label_full}__' + '__'.join(self._labels) - if self._labels is not None and self.element is None: - if isinstance(self._labels, str): - return self._labels - else: - return '__'.join(self._labels) - if self.element is not None: - return self.element.label_full - raise Exception('This should not happen! Internal Error. Please create Issue on GitHub') + def all_constraints(self) -> linopy.Constraints: + return self._model.constraints[self._all_constraints] @property - def label(self) -> str: - if self._labels is not None: - if isinstance(self._labels, str): - return self._labels - else: - return self._labels[-1] - else: - return self.element.label + def all_sub_models(self) -> List['InterfaceModel']: + return [model for sub_model in self.sub_models for model in [sub_model] + sub_model.all_sub_models] + + +class ElementModel(InterfaceModel): + """Interface to create the mathematical Variables and Constraints for Elements""" + + def __init__(self, element: Optional[Element]): + """ + Parameters + ---------- + element : Element + The element this model is created for. + """ + super().__init__(element, element.label_full) def _create_time_series( @@ -358,6 +273,11 @@ def _create_time_series( elif isinstance(data, TimeSeries): data.clear_indices_and_aggregated_data() return data + elif isinstance(data, TimeSeriesData): + time_series = TimeSeries(label=f'{element.label_full}__{label}', data=data.data, aggregation_weight=data.agg_weight) + data.label = time_series.label # Connecting User_time_series to TimeSeries + element.used_time_series.append(time_series) + return time_series else: time_series = TimeSeries(label=f'{element.label_full}__{label}', data=data) element.used_time_series.append(time_series) From b7b85c83fa673b5780c69ec70b5f2c3bc95fd343 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 14 Feb 2025 11:56:27 +0100 Subject: [PATCH 057/507] Improve Naming of Models --- flixOpt/effects.py | 65 ++++++++++++++++++++++++++------------------ flixOpt/features.py | 11 ++++---- flixOpt/structure.py | 6 +++- 3 files changed, 50 insertions(+), 32 deletions(-) diff --git a/flixOpt/effects.py b/flixOpt/effects.py index 19b477333..c1dd35480 100644 --- a/flixOpt/effects.py +++ b/flixOpt/effects.py @@ -14,7 +14,7 @@ from .core import Numeric, Numeric_TS, Skalar, TimeSeries from .features import ShareAllocationModel from .math_modeling import Equation, Variable -from .structure import Element, ElementModel, SystemModel, _create_time_series +from .structure import Element, ElementModel, SystemModel, _create_time_series, InterfaceModel logger = logging.getLogger('flixOpt') @@ -154,37 +154,50 @@ def __init__(self, element: Effect): super().__init__(element) self.element: Effect = element self.total: Optional[linopy.Variable] = None - self.invest = ShareAllocationModel( - self.element, 'invest', False, total_max=self.element.maximum_invest, total_min=self.element.minimum_invest + self.invest = self.add( + ShareAllocationModel( + False, + self.element.label_full, + 'invest', + total_max=self.element.maximum_invest, + total_min=self.element.minimum_invest + ) ) - self.operation = ShareAllocationModel( - self.element, - 'operation', - True, - total_max=self.element.maximum_operation, - total_min=self.element.minimum_operation, - min_per_hour=self.element.minimum_operation_per_hour.active_data - if self.element.minimum_operation_per_hour is not None - else None, - max_per_hour=self.element.maximum_operation_per_hour.active_data - if self.element.maximum_operation_per_hour is not None - else None, + + self.operation = self.add( + ShareAllocationModel( + True, + self.element.label_full, + 'operation', + total_max=self.element.maximum_operation, + total_min=self.element.minimum_operation, + min_per_hour=self.element.minimum_operation_per_hour.active_data + if self.element.minimum_operation_per_hour is not None + else None, + max_per_hour=self.element.maximum_operation_per_hour.active_data + if self.element.maximum_operation_per_hour is not None + else None, + ) ) - self.sub_models.extend([self.invest, self.operation]) def do_modeling(self, system_model: SystemModel): for model in self.sub_models: model.do_modeling(system_model) - self.total = system_model.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=None, - name=f'{self.element.label_full}__total' + self.total = self.add( + system_model.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=None, + name=f'{self.element.label_full}__total' + ) ) - self.constraints['total'] = system_model.add_constraints( - self.total == self.operation.total.sum() + self.invest.total.sum(), name=f'{self.element.label_full}__total' + self.add( + system_model.add_constraints( + self.total == self.operation.total.sum() + self.invest.total.sum(), + name=f'{self.element.label_full}__total' + ) ) @@ -241,13 +254,13 @@ def effect_values_to_dict(effect_values_user: EffectValuesUser) -> Optional[Effe None: effect_values_user} if effect_values_user is not None else None -class EffectCollection(ElementModel): +class EffectCollection(InterfaceModel): """ Handling all Effects """ def __init__(self, effects: List[Effect]): - super().__init__(Element('Effects')) + super().__init__(label='Effects') self._effects = {} self._standard_effect: Optional[Effect] = None self._objective_effect: Optional[Effect] = None @@ -278,7 +291,7 @@ def add_share_to_penalty(self, system_model: SystemModel, name: str, expression: def do_modeling(self, system_model: SystemModel): for effect in self.effects.values(): effect.create_model() - self.penalty = ShareAllocationModel(Element('Penalty'), 'penalty', False) + self.penalty = self.add(ShareAllocationModel(shares_are_time_series=False, label='penalty')) for model in [effect.model for effect in self.effects.values()] + [self.penalty]: model.do_modeling(system_model) diff --git a/flixOpt/features.py b/flixOpt/features.py index 8ab8db1c7..7d76f1965 100644 --- a/flixOpt/features.py +++ b/flixOpt/features.py @@ -16,6 +16,8 @@ from .structure import ( Element, ElementModel, + InterfaceModel, + Interface, SystemModel, create_equation, create_variable, @@ -728,23 +730,22 @@ def _nr_of_segments(self): return len(next(iter(self._sample_points.values()))) -class ShareAllocationModel(ElementModel): +class ShareAllocationModel(InterfaceModel): def __init__( self, - element: Element, - label: str, shares_are_time_series: bool, + label_of_parent: Optional[str] = None, + label: Optional[str] = None, total_max: Optional[Skalar] = None, total_min: Optional[Skalar] = None, max_per_hour: Optional[Numeric] = None, min_per_hour: Optional[Numeric] = None, ): - super().__init__(element, label) + super().__init__(label_of_parent=label_of_parent, label=label) if not shares_are_time_series: # If the condition is True assert max_per_hour is None and min_per_hour is None, ( 'Both max_per_hour and min_per_hour cannot be used when shares_are_time_series is False' ) - self.element = element self.total_per_timestep: Optional[linopy.Variable] = None self.total: Optional[linopy.Variable] = None self.shares: Dict[str, linopy.Variable] = {} diff --git a/flixOpt/structure.py b/flixOpt/structure.py index 56290d677..1abf304d5 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -166,15 +166,19 @@ def label_full(self) -> str: class InterfaceModel: """Stores the mathematical Variables and Constraints related to an Interface""" - def __init__(self, interface: Optional[Interface], label_of_parent: Optional[str], label: Optional[str] = None): + def __init__(self, interface: Optional[Interface] = None, label_of_parent: Optional[str] = None, label: Optional[str] = None): """ Parameters ---------- interface : Interface The interface this model is created for. + label_of_parent : str + The label of the parent. Used to construct the full label of the model. label : str Used to construct the label of the model. If None, the interface label is used. """ + if label_of_parent is None and label is None: + raise ValueError('Either label_of_parent or label must be set') self.interface = interface self._model: Optional[linopy.Model] = None self._variables: List[str] = [] From 09d2f9c715f04da6633b043553db099954dcba94 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 14 Feb 2025 12:00:38 +0100 Subject: [PATCH 058/507] Add all variables and constraints to the ShareAllocationModel --- flixOpt/features.py | 38 +++++++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/flixOpt/features.py b/flixOpt/features.py index 7d76f1965..af5f1cc13 100644 --- a/flixOpt/features.py +++ b/flixOpt/features.py @@ -762,22 +762,26 @@ def __init__( self._min_per_hour = min_per_hour if min_per_hour is not None else -np.inf def do_modeling(self, system_model: SystemModel): - self.total = system_model.add_variables( - lower=self._total_min, upper=self._total_max, coords=None, name=f'{self.label_full}_total' + self.total = self.add( + system_model.add_variables( + lower=self._total_min, upper=self._total_max, coords=None, name=f'{self.label_full}_total' + ) ) # eq: sum = sum(share_i) # skalar - self._eq_total = system_model.add_constraints(self.total == 0, name=f'{self.label_full}__total') + self._eq_total = self.add(system_model.add_constraints(self.total == 0, name=f'{self.label_full}__total')) if self._shares_are_time_series: - self.total_per_timestep = system_model.add_variables( - lower=-np.inf if (self._min_per_hour is None) else np.multiply(self._min_per_hour, system_model.hours_per_step), - upper=np.inf if (self._max_per_hour is None) else np.multiply(self._max_per_hour, system_model.hours_per_step), - coords=system_model.coords, - name=f'{self.label_full}_total_per_timestep' + self.total_per_timestep = self.add( + system_model.add_variables( + lower=-np.inf if (self._min_per_hour is None) else np.multiply(self._min_per_hour, system_model.hours_per_step), + upper=np.inf if (self._max_per_hour is None) else np.multiply(self._max_per_hour, system_model.hours_per_step), + coords=system_model.coords, + name=f'{self.label_full}_total_per_timestep' + ) ) - self._eq_total_per_timestep = system_model.add_constraints( - self.total_per_timestep == 0, name=f'{self.label_full}__total_per_timestep' + self._eq_total_per_timestep = self.add( + system_model.add_constraints(self.total_per_timestep == 0, name=f'{self.label_full}__total_per_timestep') ) # Add it to the total @@ -807,12 +811,16 @@ def add_share( if name in self.shares: self.share_constraints[name].lhs -= expression else: - self.shares[name] = system_model.add_variables( - coords=None if expression.ndim == 0 else system_model.coords, - name=f'{name}__{self.label_full}' + self.shares[name] = self.add( + system_model.add_variables( + coords=None if expression.ndim == 0 else system_model.coords, + name=f'{name}__{self.label_full}' + ) ) - self.share_constraints[name] = system_model.add_constraints( - self.shares[name] == expression, name=f'{name}__{self.label_full}' + self.share_constraints[name] = self.add( + system_model.add_constraints( + self.shares[name] == expression, name=f'{name}__{self.label_full}' + ) ) if self.shares[name].ndim == 0: self._eq_total.lhs -= self.shares[name] From 8d4f7fa7861d3f964456e373feb44fc4be547957 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 14 Feb 2025 15:14:07 +0100 Subject: [PATCH 059/507] Made TimeSeries dependent on xr.DataArray --- flixOpt/core.py | 214 ++++++++++++++++++++++++++++-------------------- 1 file changed, 123 insertions(+), 91 deletions(-) diff --git a/flixOpt/core.py b/flixOpt/core.py index b8d6e3ccd..f6f733b08 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -20,85 +20,92 @@ class DataConverter: @staticmethod - def as_series(data: Union[Numeric, pd.Series, pd.DataFrame], dims: Tuple[pd.Index, ...]) -> pd.Series: + def as_dataarray(data: Union[Numeric, pd.Series, pd.DataFrame], time: pd.DatetimeIndex, period: Optional[pd.Index] = None) -> xr.DataArray: """ - Converts the given data to a pd.Series with the specified index. + Converts the given data to an xarray.DataArray with the specified time and period indexes. - - Arrays and Series are stacked across the period index. + - If period is provided, data will have both period and time coordinates. + - If period is not provided, data will have only time as the index. - The length of the array must match the length of the time coordinate if applicable. - If a 1D array is given but two indices are provided, it is reshaped to 2D automatically. Parameters: - data: The input data (scalar, array, Series, or DataFrame). - - dims: A Tuple of pd.Index objects specifying the index dimensions. + - time: A pd.DatetimeIndex for the time dimension. + - period: An optional pd.Index for the period dimension. Returns: - - pd.Series: The resulting Series, possibly with a MultiIndex. + - xr.DataArray: The resulting DataArray with time and optionally period as coordinates. """ - if not isinstance(dims, tuple) or not all(isinstance(idx, pd.Index) for idx in dims): - raise TypeError("dims must be a tuple of pandas Index objects") - - index = pd.MultiIndex.from_product(dims) if len(dims) > 1 else dims[0] + if period is not None: + coords = [period, time] + dims = ['period', 'time'] + else: + coords = [time] + dims = ['time'] if isinstance(data, (int, float)): # Scalar case - return DataConverter._handle_scalar(data, index) + return DataConverter._handle_scalar(data, coords, dims) if isinstance(data, np.ndarray): - return DataConverter._handle_array(data, dims, index) + return DataConverter._handle_array(data, coords, dims) if isinstance(data, pd.Series): - return DataConverter._handle_series(data, dims, index) + return DataConverter._handle_series(data, coords, dims) if isinstance(data, pd.DataFrame): - return DataConverter._handle_dataframe(data, dims, index) + return DataConverter._handle_dataframe(data, coords, dims) raise TypeError("Unsupported data type. Must be scalar, np.ndarray, pd.Series, or pd.DataFrame.") @staticmethod - def _handle_scalar(data: Union[int, float], index: pd.Index) -> pd.Series: + def _handle_scalar(data: Union[int, float], coords: list, dims: list) -> xr.DataArray: """Handles scalar input.""" - return pd.Series(data, index=index) + return xr.DataArray(data, coords=coords, dims=dims) @staticmethod - def _handle_array(data: np.ndarray, dims: Tuple[pd.Index, ...], index: pd.Index) -> pd.Series: + def _handle_array(data: np.ndarray, coords: list, dims: list) -> xr.DataArray: """Handles NumPy array input.""" - expected_shape = tuple(len(idx) for idx in dims) + expected_shape = tuple(len(coord) for coord in coords) - if data.ndim == 1 and len(dims) == 2: # Automatically reshape 1D arrays - if data.shape[0] == len(dims[0]): - data = np.tile(data[:, np.newaxis], (1, len(dims[1]))) # Expand along second dimension - elif data.shape[0] == len(dims[1]): - data = np.tile(data[np.newaxis, :], (len(dims[0]), 1)) # Expand along first dimension + if data.ndim == 1 and len(coords) == 2: # Automatically reshape 1D arrays + if data.shape[0] == len(coords[0]): + data = np.tile(data[:, np.newaxis], (1, len(coords[1]))) # Expand along second dimension + elif data.shape[0] == len(coords[1]): + data = np.tile(data[np.newaxis, :], (len(coords[0]), 1)) # Expand along first dimension else: - raise ValueError("1D array length does not match either dimension in dims") + raise ValueError("1D array length does not match either dimension in coords") if data.shape != expected_shape: raise ValueError(f"Shape of data {data.shape} does not match expected shape {expected_shape}") - return pd.Series(data.ravel(), index=index) + return xr.DataArray(data, coords=coords, dims=dims) @staticmethod - def _handle_series(data: pd.Series, dims: Tuple[pd.Index, ...], index: pd.Index) -> pd.Series: + def _handle_series(data: pd.Series, coords: list, dims: list) -> xr.DataArray: """Handles pandas Series input.""" - if len(dims) == 1: - if not data.index.equals(dims[0]): - raise ValueError("Series index does not match the provided index") - return data - return pd.Series(data.values.ravel(), index=index) + if len(coords) == 1: + if not data.index.equals(coords[0]): + raise ValueError("Series index does not match the provided time index") + return xr.DataArray(data.values, coords=coords, dims=dims) + + # Reshape if necessary and return as DataArray + return xr.DataArray(data.values.ravel(), coords=coords, dims=dims) @staticmethod - def _handle_dataframe(data: pd.DataFrame, dims: Tuple[pd.Index, ...], index: pd.Index) -> pd.Series: + def _handle_dataframe(data: pd.DataFrame, coords: list, dims: list) -> xr.DataArray: """Handles pandas DataFrame input.""" - if len(dims) != 2 or data.shape != (len(dims[1]), len(dims[0])): + if len(coords) != 2 or data.shape != (len(coords[1]), len(coords[0])): raise ValueError("DataFrame shape does not match provided indexes") # Stack and ensure columns become level 0 stacked = data.stack().swaplevel(0, 1).sort_index() - if not stacked.index.equals(index): + if not stacked.index.equals(coords[0]): raise ValueError("Stacked DataFrame index does not match the provided index") - return stacked + return xr.DataArray(stacked.values, coords=coords, dims=dims) + class TimeSeriesData: @@ -160,8 +167,10 @@ class TimeSeries: @classmethod def from_datasource(cls, data: Union[int, float, np.ndarray, pd.Series, pd.DataFrame, xr.DataArray], - dims: Tuple[pd.Index, ...], - aggregation_weight: Optional[float] = None): + name: str, + timesteps: pd.DatetimeIndex = None, + periods: Optional[pd.Index] = None, + aggregation_weight: Optional[float] = None) -> 'TimeSeries': """ Initialize the TimeSeries from multiple datasources. @@ -170,54 +179,86 @@ def from_datasource(cls, - dims (Tuple[pd.Index, ...]): The dimensions of the TimeSeries. - aggregation_weight (float, optional): The weight of the data in the aggregation. Defaults to None. """ - data = DataConverter.as_series(data, dims) - # TODO: Add validation for the dimensions - return cls(DataConverter.as_series(data, dims), aggregation_weight) + data = cls(DataConverter.as_dataarray(data, timesteps, periods), name, aggregation_weight) + return data - def __init__(self, data: pd.Series, aggregation_weight: Optional[float] = None): + def __init__(self, + data: xr.DataArray, + name: str, + aggregation_weight: Optional[float] = None): """ - Initialize the TimeSeriesManager with a Series. + Initialize a TimeSeries with a DataArray. Parameters: - - data (pd.Series): A Series with a DatetimeIndex and possibly a MultiIndex. + - data (xr.DataArray): A Series with a DatetimeIndex and possibly a MultiIndex. - aggregation_weight (float, optional): The weight of the data in the aggregation. Defaults to None. """ - self._stored_data = data.copy() # Store data - self._backup: pd.Series = self.stored_data # Single backup instance. Enables to temporarily overwrite the data. + if 'time' not in data.indexes: + raise ValueError(f'DataArray must have a "time" index. Got {data.indexes}') + if 'period' not in data.indexes and data.ndim > 1: + raise ValueError(f'Second index of DataArray must be "period". Got {data.indexes}') + self._active_data = None - self._active_index = None + self._active_timesteps = None + self._active_periods = None + self.name = name self.aggregation_weight = aggregation_weight - self.active_index = None # Initializes the active index and active data + self._stored_data = data.copy() + + self._backup: xr.DataArray = self.stored_data # Single backup instance. Enables to temporarily overwrite the data. + self.active_timesteps = None # Initializes the active timesteps and active data + self.active_periods = None # Initializes the active timesteps and active data def restore_data(self): """Restore stored_data from the backup.""" self._stored_data = self._backup.copy() - self.active_index = None + self.active_timesteps = None + self.active_periods = None - def as_dataarray(self) -> xr.DataArray: - return self.active_data.to_xarray() + def _update_active_data(self): + """Update the active data.""" + if 'period' in self._stored_data.indexes: + self._active_data = self._stored_data.sel(timesteps=self.active_timesteps, periods=self.active_periods) + else: + self._active_data = self._stored_data.sel(timesteps=self.active_timesteps) @property - def active_index(self) -> pd.Index: + def active_timesteps(self) -> pd.DatetimeIndex: """Return the current active index.""" - return self._active_index - - @active_index.setter - def active_index(self, index: Optional[pd.Index]): - """Set a new active index and refresh active_data.""" - if index is None: - self._active_index = self._stored_data.index - self._active_data = self._stored_data - return - elif not isinstance(index, (pd.Index, pd.MultiIndex)): + return self._active_timesteps + + @active_timesteps.setter + def active_timesteps(self, timesteps: Optional[pd.DatetimeIndex]): + """Set active_timesteps and refresh active_data.""" + if timesteps is None: + self._active_timesteps = slice(None) + elif isinstance(timesteps, pd.DatetimeIndex): + self._active_timesteps = timesteps + else: raise TypeError("active_index must be a pandas Index or MultiIndex or None") + + self._update_active_data() # Refresh view + + @property + def active_periods(self) -> pd.Index: + """Return the current active index.""" + return self._active_periods + + @active_periods.setter + def active_periods(self, periods: Optional[pd.Index]): + """Set new active periods and refresh active_data.""" + if periods is None: + self._active_periods = slice(None) + elif isinstance(periods, pd.Index): + self._active_periods = periods else: - self._active_index = index - self._active_data = self.stored_data.loc[self._active_index] # Refresh view + raise TypeError("periods must be a pd.Index or None") + + self._update_active_data() # Refresh view @property - def active_data(self) -> pd.Series: + def active_data(self) -> xr.DataArray: """Return a view of stored_data based on active_index.""" return self._active_data @@ -227,32 +268,31 @@ def active_data(self, value): raise AttributeError("active_data cannot be directly modified. Modify stored_data instead.") @property - def stored_data(self) -> pd.Series: + def stored_data(self) -> xr.DataArray: """Return a copy of stored_data. Prevents modification of stored data""" return self._stored_data.copy() @stored_data.setter - def stored_data(self, value: pd.Series): + def stored_data(self, value: xr.DataArray): """Set stored_data and refresh active_index and active_data.""" self._backup = self._stored_data self._stored_data = value - self.active_index = None + self.active_timesteps = None + self.active_periods = None @property - def loc(self): - """Access active_data using loc.""" - return self.active_data.loc + def sel(self): + return self.active_data.sel @property - def iloc(self): - """Access active_data using iloc.""" - return self.active_data.iloc + def isel(self): + return self.active_data.sel # Enable arithmetic operations using active_data def _apply_operation(self, other, op): if isinstance(other, TimeSeries): - other = other.as_dataarray() - return op(self.as_dataarray(), other) + other = other.active_data + return op(self.active_data, other) def __add__(self, other): return self._apply_operation(other, lambda x, y: x + y) @@ -268,38 +308,30 @@ def __truediv__(self, other): # Reflected arithmetic operations (to handle cases like `some_xarray + ts1`) def __radd__(self, other): - return other + self.as_dataarray() + return other + self.active_data def __rsub__(self, other): - return other - self.as_dataarray() + return other - self.active_data def __rmul__(self, other): - return other * self.as_dataarray() + return other * self.active_data def __rtruediv__(self, other): - return other / self.as_dataarray() + return other / self.active_data # Unary operations. Not sure if this is the best way... def __neg__(self): - return -self.as_dataarray() + return -self.active_data def __pos__(self): - return +self.as_dataarray() + return +self.active_data def __abs__(self): - return abs(self.as_dataarray()) + return abs(self.active_data) def __array_ufunc__(self, ufunc, method, *inputs, **kwargs): """Ensures NumPy functions like np.add(TimeSeries, xarray) work correctly.""" - inputs = [x.as_dataarray() if isinstance(x, TimeSeries) else x for x in inputs] - result = getattr(ufunc, method)(*inputs, **kwargs) - - # Ensure return type consistency - if isinstance(result, xr.DataArray): - return result - elif isinstance(result, np.ndarray): # Handles cases like np.exp(ts) - return pd.Series(result, index=self.active_data.index) - else: - raise NotImplementedError(f"ufunc {ufunc} not implemented for TimeSeries") + inputs = [x.active_data if isinstance(x, TimeSeries) else x for x in inputs] + return getattr(ufunc, method)(*inputs, **kwargs) From b78e80c2c1018c8e9dfcf65bd93f513308d4cb7e Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 14 Feb 2025 15:28:13 +0100 Subject: [PATCH 060/507] Adjust functions to create time_series --- flixOpt/structure.py | 61 ++++++++++++++++++++++++++++---------------- 1 file changed, 39 insertions(+), 22 deletions(-) diff --git a/flixOpt/structure.py b/flixOpt/structure.py index 1abf304d5..673a6f16d 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -62,7 +62,7 @@ class Interface: This class is used to collect arguments about a Model. """ - def transform_data(self, data_array: xr.DataArray): + def transform_data(self, timesteps: pd.DatetimeIndex, periods: Optional[pd.Index]): raise NotImplementedError('Every Interface needs a transform_data() method') def infos(self, use_numpy=True, use_element_label=False) -> Dict: @@ -128,6 +128,35 @@ def __repr__(self): def __str__(self): return get_str_representation(self.infos(use_numpy=True, use_element_label=True)) + @staticmethod + def _create_time_series( + element: 'Element', + name: str, + data: Optional[Union[Numeric_TS, TimeSeries]], + timesteps: pd.DatetimeIndex, + periods: Optional[pd.Index], + ) -> Optional[TimeSeries]: + """Creates a TimeSeries from Numeric Data and adds it to the list of time_series of an Element. + If the data already is a TimeSeries, nothing happens and the TimeSeries gets reset and returned""" + + if data is None: + return None + elif isinstance(data, TimeSeries): + data.restore_data() + return data + + time_series = TimeSeries.from_datasource( + name=f'{element.label_full}__{name}', + data=data.data if isinstance(data, TimeSeriesData) else data, + timesteps=timesteps, + periods=periods, + aggregation_weight=data.agg_weight if isinstance(data, TimeSeriesData) else None, + ) + element.used_time_series.append(time_series) + if isinstance(data, TimeSeriesData): + data.label = time_series.name # Connecting User_time_series to TimeSeries + return time_series + class Element(Interface): """Basic Element of flixOpt""" @@ -162,6 +191,15 @@ def create_model(self) -> 'ElementModel': def label_full(self) -> str: return self.label + def _create_time_series( + self, + name: str, + data: Optional[Union[Numeric_TS, TimeSeries]], + timesteps: pd.DatetimeIndex, + periods: Optional[pd.Index], + ) -> Optional[TimeSeries]: + return super()._create_time_series(self, name, data, timesteps, periods) + class InterfaceModel: """Stores the mathematical Variables and Constraints related to an Interface""" @@ -267,27 +305,6 @@ def __init__(self, element: Optional[Element]): super().__init__(element, element.label_full) -def _create_time_series( - label: str, data: Optional[Union[Numeric_TS, TimeSeries]], element: Element -) -> Optional[TimeSeries]: - """Creates a TimeSeries from Numeric Data and adds it to the list of time_series of an Element. - If the data already is a TimeSeries, nothing happens and the TimeSeries gets reset and returned""" - if data is None: - return None - elif isinstance(data, TimeSeries): - data.clear_indices_and_aggregated_data() - return data - elif isinstance(data, TimeSeriesData): - time_series = TimeSeries(label=f'{element.label_full}__{label}', data=data.data, aggregation_weight=data.agg_weight) - data.label = time_series.label # Connecting User_time_series to TimeSeries - element.used_time_series.append(time_series) - return time_series - else: - time_series = TimeSeries(label=f'{element.label_full}__{label}', data=data) - element.used_time_series.append(time_series) - return time_series - - def create_equation( label: str, element_model: ElementModel, eq_type: Literal['eq', 'ineq'] = 'eq' ) -> Union[Equation, Inequation]: From eaa1019cea1e590a153b374fb9fa931fc200dbcb Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 14 Feb 2025 16:36:14 +0100 Subject: [PATCH 061/507] Use the linopy.Model in all sub models for easy access to variables and constraints --- flixOpt/components.py | 64 ++++++++++----------- flixOpt/effects.py | 55 +++++++++++------- flixOpt/elements.py | 123 ++++++++++++++++++++++------------------- flixOpt/features.py | 5 +- flixOpt/flow_system.py | 2 +- flixOpt/interface.py | 41 +++++++------- flixOpt/structure.py | 24 +++++--- 7 files changed, 174 insertions(+), 140 deletions(-) diff --git a/flixOpt/components.py b/flixOpt/components.py index bd566d35e..85ba58f8b 100644 --- a/flixOpt/components.py +++ b/flixOpt/components.py @@ -6,11 +6,12 @@ from typing import Dict, List, Literal, Optional, Set, Tuple, Union import numpy as np +import pandas as pd import linopy from . import utils from .core import Numeric, Numeric_TS, Skalar, TimeSeries -from .elements import Component, ComponentModel, Flow, _create_time_series +from .elements import Component, ComponentModel, Flow from .features import InvestmentModel, MultipleSegmentsModel, OnOffModel from .interface import InvestParameters, OnOffParameters from .math_modeling import Equation, VariableTS @@ -60,8 +61,8 @@ def __init__( self.segmented_conversion_factors = segmented_conversion_factors or {} self._plausibility_checks() - def create_model(self) -> 'LinearConverterModel': - self.model = LinearConverterModel(self) + def create_model(self, model: linopy.Model) -> 'LinearConverterModel': + self.model = LinearConverterModel(model, self) return self.model def _plausibility_checks(self) -> None: @@ -92,29 +93,29 @@ def _plausibility_checks(self) -> None: f'(in flow {flow.label_full}) do not make sense together!' ) - def transform_data(self): - super().transform_data() + def transform_data(self, timesteps: pd.DatetimeIndex, periods: Optional[pd.Index]): + super().transform_data(timesteps, periods) if self.conversion_factors: - self.conversion_factors = self._transform_conversion_factors() + self.conversion_factors = self._transform_conversion_factors(timesteps, periods) else: segmented_conversion_factors = {} for flow, segments in self.segmented_conversion_factors.items(): segmented_conversion_factors[flow] = [ ( - _create_time_series('Stuetzstelle', segment[0], self), - _create_time_series('Stuetzstelle', segment[1], self), + self._create_time_series('Stützstelle', segment[0], timesteps, periods), + self._create_time_series('Stützstelle', segment[1], timesteps, periods), ) for segment in segments ] self.segmented_conversion_factors = segmented_conversion_factors - def _transform_conversion_factors(self) -> List[Dict[Flow, TimeSeries]]: + def _transform_conversion_factors(self, timesteps: pd.DatetimeIndex, periods: Optional[pd.Index]) -> List[Dict[Flow, TimeSeries]]: """macht alle Faktoren, die nicht TimeSeries sind, zu TimeSeries""" list_of_conversion_factors = [] for conversion_factor in self.conversion_factors: transformed_dict = {} for flow, values in conversion_factor.items(): - transformed_dict[flow] = _create_time_series(f'{flow.label}_factor', values, self) + transformed_dict[flow] = self._create_time_series(f'{flow.label}_factor', values, timesteps, periods) list_of_conversion_factors.append(transformed_dict) return list_of_conversion_factors @@ -215,19 +216,19 @@ def create_model(self) -> 'StorageModel': self.model = StorageModel(self) return self.model - def transform_data(self) -> None: + def transform_data(self, timesteps: pd.DatetimeIndex, periods: Optional[pd.Index]) -> None: super().transform_data() - self.relative_minimum_charge_state = _create_time_series( - 'relative_minimum_charge_state', self.relative_minimum_charge_state, self + self.relative_minimum_charge_state = self._create_time_series( + 'relative_minimum_charge_state', self.relative_minimum_charge_state, timesteps, periods ) - self.relative_maximum_charge_state = _create_time_series( - 'relative_maximum_charge_state', self.relative_maximum_charge_state, self + self.relative_maximum_charge_state = self._create_time_series( + 'relative_maximum_charge_state', self.relative_maximum_charge_state, timesteps, periods ) - self.eta_charge = _create_time_series('eta_charge', self.eta_charge, self) - self.eta_discharge = _create_time_series('eta_discharge', self.eta_discharge, self) - self.relative_loss_per_hour = _create_time_series('relative_loss_per_hour', self.relative_loss_per_hour, self) + self.eta_charge = self._create_time_series('eta_charge', self.eta_charge, timesteps, periods) + self.eta_discharge = self._create_time_series('eta_discharge', self.eta_discharge, timesteps, periods) + self.relative_loss_per_hour = self._create_time_series('relative_loss_per_hour', self.relative_loss_per_hour, timesteps, periods) if isinstance(self.capacity_in_flow_hours, InvestParameters): - self.capacity_in_flow_hours.transform_data() + self.capacity_in_flow_hours.transform_data(timesteps, periods) class Transmission(Component): @@ -314,10 +315,10 @@ def create_model(self) -> 'TransmissionModel': self.model = TransmissionModel(self) return self.model - def transform_data(self) -> None: - super().transform_data() - self.relative_losses = _create_time_series('relative_losses', self.relative_losses, self) - self.absolute_losses = _create_time_series('absolute_losses', self.absolute_losses, self) + def transform_data(self, timesteps: pd.DatetimeIndex, periods: Optional[pd.Index]) -> None: + super().transform_data(timesteps, periods) + self.relative_losses = self._create_time_series('relative_losses', self.relative_losses, timesteps, periods) + self.absolute_losses = self._create_time_series('absolute_losses', self.absolute_losses, timesteps, periods) class TransmissionModel(ComponentModel): @@ -371,8 +372,8 @@ def create_transmission_equation(self, name: str, in_flow: Flow, out_flow: Flow) class LinearConverterModel(ComponentModel): - def __init__(self, element: LinearConverter): - super().__init__(element) + def __init__(self, model: linopy.Model, element: LinearConverter): + super().__init__(model, element) self.element: LinearConverter = element self.on_off: Optional[OnOffModel] = None @@ -390,12 +391,13 @@ def do_modeling(self, system_model: SystemModel): used_inputs: Set = all_input_flows & used_flows used_outputs: Set = all_output_flows & used_flows - self.constraints[f'conversion_{i}'] = system_model.add_constraints( - sum([flow.model.flow_rate * conv_fact[flow].active_data for flow in used_inputs]) - == - sum([flow.model.flow_rate * conv_fact[flow].active_data for flow in used_outputs]), - name=f'{self.label_full}__conversion_{i}' - + self.add( + system_model.add_constraints( + sum([flow.model.flow_rate * conv_fact[flow].active_data for flow in used_inputs]) + == + sum([flow.model.flow_rate * conv_fact[flow].active_data for flow in used_outputs]), + name=f'{self.label_full}__conversion_{i}' + ) ) # (linear) segments: diff --git a/flixOpt/effects.py b/flixOpt/effects.py index c1dd35480..d32acdab9 100644 --- a/flixOpt/effects.py +++ b/flixOpt/effects.py @@ -9,12 +9,13 @@ from typing import Dict, Literal, Optional, Union, List import numpy as np +import pandas as pd import linopy from .core import Numeric, Numeric_TS, Skalar, TimeSeries from .features import ShareAllocationModel from .math_modeling import Equation, Variable -from .structure import Element, ElementModel, SystemModel, _create_time_series, InterfaceModel +from .structure import Element, ElementModel, SystemModel, InterfaceModel logger = logging.getLogger('flixOpt') @@ -132,30 +133,35 @@ def error_str(effect_label: str, share_ffect_label: str): f'Error: circular invest-shares \n{error_str(target_effect.label, target_effect.label)}' ) - def transform_data(self): - self.minimum_operation_per_hour = _create_time_series( - 'minimum_operation_per_hour', self.minimum_operation_per_hour, self + def transform_data(self, timesteps: pd.DatetimeIndex, periods: Optional[pd.Index]): + self.minimum_operation_per_hour = self._create_time_series( + 'minimum_operation_per_hour', self.minimum_operation_per_hour, timesteps, periods ) - self.maximum_operation_per_hour = _create_time_series( - 'maximum_operation_per_hour', self.maximum_operation_per_hour, self + self.maximum_operation_per_hour = self._create_time_series( + 'maximum_operation_per_hour', self.maximum_operation_per_hour, timesteps, periods ) self.specific_share_to_other_effects_operation = effect_values_to_time_series( - 'specific_share_to_other_effects_operation', self.specific_share_to_other_effects_operation, self + 'operation_to', + self.specific_share_to_other_effects_operation, + self, + timesteps, + periods ) - def create_model(self) -> 'EffectModel': - self.model = EffectModel(self) + def create_model(self, model: linopy.Model) -> 'EffectModel': + self.model = EffectModel(model, self) return self.model class EffectModel(ElementModel): - def __init__(self, element: Effect): - super().__init__(element) + def __init__(self, model: linopy.Model, element: Effect): + super().__init__(model, element) self.element: Effect = element self.total: Optional[linopy.Variable] = None self.invest = self.add( ShareAllocationModel( + self._model, False, self.element.label_full, 'invest', @@ -166,6 +172,7 @@ def __init__(self, element: Effect): self.operation = self.add( ShareAllocationModel( + self._model, True, self.element.label_full, 'operation', @@ -212,7 +219,9 @@ def do_modeling(self, system_model: SystemModel): def effect_values_to_time_series(label_suffix: str, effect_values: EffectValuesUser, - parent_element: Element) -> Optional[EffectValuesTS]: + parent_element: Element, + timesteps: pd.DatetimeIndex, + periods: Optional[pd.Index]) -> Optional[EffectValuesTS]: """ Transform EffectValues to EffectValuesTS. Creates a TimeSeries for each key in the nested_values dictionary, using the value as the data. @@ -225,13 +234,16 @@ def effect_values_to_time_series(label_suffix: str, if effect_values is None: return None - standard_value = effect_values.pop(None, None) - effect_values_ts = { - effect: _create_time_series(f'{effect.label}_{label_suffix}', value, parent_element) + effect_values_ts: EffectValuesTS = { + effect: parent_element._create_time_series( + f'{effect.label if effect is not None else "Standard_Effect"}_{label_suffix}', + value, + timesteps, + periods + ) for effect, value in effect_values.items() if effect is not None } - if standard_value is not None: - effect_values_ts[None] = _create_time_series(f'Standard_Effect_{label_suffix}', standard_value, parent_element) + return effect_values_ts @@ -259,8 +271,8 @@ class EffectCollection(InterfaceModel): Handling all Effects """ - def __init__(self, effects: List[Effect]): - super().__init__(label='Effects') + def __init__(self, model: linopy.Model, effects: List[Effect]): + super().__init__(model, label='Effects') self._effects = {} self._standard_effect: Optional[Effect] = None self._objective_effect: Optional[Effect] = None @@ -289,9 +301,10 @@ def add_share_to_penalty(self, system_model: SystemModel, name: str, expression: self.penalty.add_share(system_model, name, expression) def do_modeling(self, system_model: SystemModel): + self._model = system_model for effect in self.effects.values(): - effect.create_model() - self.penalty = self.add(ShareAllocationModel(shares_are_time_series=False, label='penalty')) + effect.create_model(self._model) + self.penalty = self.add(ShareAllocationModel(self._model,shares_are_time_series=False, label='penalty')) for model in [effect.model for effect in self.effects.values()] + [self.penalty]: model.do_modeling(system_model) diff --git a/flixOpt/elements.py b/flixOpt/elements.py index fdc010d8e..eea91b4c5 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -7,6 +7,7 @@ import numpy as np import linopy +import pandas as pd from .config import CONFIG from .core import Numeric, Numeric_TS, Skalar @@ -17,7 +18,6 @@ Element, ElementModel, SystemModel, - _create_time_series, ) logger = logging.getLogger('flixOpt') @@ -61,13 +61,13 @@ def __init__( self.flows: Dict[str, Flow] = {flow.label: flow for flow in self.inputs + self.outputs} - def create_model(self) -> 'ComponentModel': - self.model = ComponentModel(self) + def create_model(self, model: linopy.Model) -> 'ComponentModel': + self.model = ComponentModel(model, self) return self.model - def transform_data(self) -> None: + def transform_data(self, timesteps: pd.DatetimeIndex, periods: Optional[pd.Index]) -> None: if self.on_off_parameters is not None: - self.on_off_parameters.transform_data(self) + self.on_off_parameters.transform_data(timesteps, periods, self) def register_component_in_flows(self) -> None: for flow in self.inputs + self.outputs: @@ -112,13 +112,13 @@ def __init__( self.inputs: List[Flow] = [] self.outputs: List[Flow] = [] - def create_model(self) -> 'BusModel': - self.model = BusModel(self) + def create_model(self, model: linopy.Model) -> 'BusModel': + self.model = BusModel(model, self) return self.model - def transform_data(self): - self.excess_penalty_per_flow_hour = _create_time_series( - 'excess_penalty_per_flow_hour', self.excess_penalty_per_flow_hour, self + def transform_data(self, timesteps: pd.DatetimeIndex, periods: Optional[pd.Index]): + self.excess_penalty_per_flow_hour = self._create_time_series( + 'excess_penalty_per_flow_hour', self.excess_penalty_per_flow_hour, timesteps, periods ) def add_input(self, flow) -> None: @@ -234,19 +234,19 @@ def __init__( self._plausibility_checks() - def create_model(self) -> 'FlowModel': - self.model = FlowModel(self) + def create_model(self, model: linopy.Model) -> 'FlowModel': + self.model = FlowModel(model, self) return self.model - def transform_data(self): - self.relative_minimum = _create_time_series('relative_minimum', self.relative_minimum, self) - self.relative_maximum = _create_time_series('relative_maximum', self.relative_maximum, self) - self.fixed_relative_profile = _create_time_series('fixed_relative_profile', self.fixed_relative_profile, self) - self.effects_per_flow_hour = effect_values_to_time_series('per_flow_hour', self.effects_per_flow_hour, self) + def transform_data(self, timesteps: pd.DatetimeIndex, periods: Optional[pd.Index]): + self.relative_minimum = self._create_time_series('relative_minimum', self.relative_minimum, timesteps, periods) + self.relative_maximum = self._create_time_series('relative_maximum', self.relative_maximum, timesteps, periods) + self.fixed_relative_profile = self._create_time_series('fixed_relative_profile', self.fixed_relative_profile, timesteps, periods) + self.effects_per_flow_hour = effect_values_to_time_series('per_flow_hour', self.effects_per_flow_hour, self, timesteps, periods) if self.on_off_parameters is not None: - self.on_off_parameters.transform_data(self) + self.on_off_parameters.transform_data(timesteps, periods, self) if isinstance(self.size, InvestParameters): - self.size.transform_data() + self.size.transform_data(timesteps, periods) def infos(self, use_numpy=True, use_element_label=False) -> Dict: infos = super().infos(use_numpy, use_element_label) @@ -289,8 +289,8 @@ def invest_is_optional(self) -> bool: class FlowModel(ElementModel): - def __init__(self, element: Flow): - super().__init__(element) + def __init__(self, model: linopy.Model, element: Flow): + super().__init__(model, element) self.element: Flow = element self.flow_rate: Optional[linopy.Variable] = None self.total_flow_hours: Optional[linopy.Variable] = None @@ -307,42 +307,48 @@ def do_modeling(self, system_model: SystemModel): name=f'{self.label_full}__flow_rate', ) if self.element.fixed_relative_profile is not None: - self.constraints['fix_flow_rate'] = system_model.add_constraints( - self.flow_rate == self.element.fixed_relative_profile.active_data, - name=f'{self.element.label}_fix_flow_rate' + self.add( + system_model.add_constraints( + self.flow_rate == self.element.fixed_relative_profile.active_data, + name=f'{self.element.label}_fix_flow_rate' + ) ) # OnOff if self.element.on_off_parameters is not None: - self.on_off = OnOffModel( - self.element, self.element.on_off_parameters, [self.flow_rate], [self.absolute_flow_rate_bounds] + self.on_off = self.add( + OnOffModel( + self.element, self.element.on_off_parameters, [self.flow_rate], [self.absolute_flow_rate_bounds] + ) ) self.on_off.do_modeling(system_model) - self.sub_models.append(self.on_off) # Investment if isinstance(self.element.size, InvestParameters): - self._investment = InvestmentModel( - self.element, - self.element.size, - self.flow_rate, - self.relative_flow_rate_bounds, - fixed_relative_profile=self.fixed_relative_flow_rate, - on_variable=self.on_off.on if self.on_off is not None else None, + self._investment = self.add( + InvestmentModel( + self.element, + self.element.size, + self.flow_rate, + self.relative_flow_rate_bounds, + fixed_relative_profile=self.fixed_relative_flow_rate, + on_variable=self.on_off.on if self.on_off is not None else None, + ) ) self._investment.do_modeling(system_model) - self.sub_models.append(self._investment) - self.total_flow_hours = system_model.add_variables( + self.total_flow_hours = self.add(system_model.add_variables( lower=self.element.flow_hours_total_min if self.element.flow_hours_total_min is not None else -np.inf, upper=self.element.flow_hours_total_max if self.element.flow_hours_total_max is not None else np.inf, coords=None, name=f'{self.element.label_full}__total_flow_hours' - ) + )) - self.constraints['total_flow_hours'] = system_model.add_constraints( - self.total_flow_hours == (self.flow_rate * system_model.hours_per_step).sum(), - name=f'{self.element.label_full}__total_flow_hours' + self.add( + system_model.add_constraints( + self.total_flow_hours == (self.flow_rate * system_model.hours_per_step).sum(), + name=f'{self.element.label_full}__total_flow_hours' + ) ) # Load factor @@ -374,14 +380,17 @@ def _create_bounds_for_load_factor(self, system_model: SystemModel): flow_hours_per_size_max = system_model.hours_per_step * self.element.load_factor_max if self._investment is not None: - eq_load_factor_max = system_model.add_constraints( - self.total_flow_hours <= self._investment.size * flow_hours_per_size_max, name=name, + self.add( + system_model.add_constraints( + self.total_flow_hours <= self._investment.size * flow_hours_per_size_max, name=name, + ) ) else: - eq_load_factor_max = system_model.add_constraints( - self.total_flow_hours <= self.element.size * flow_hours_per_size_max, name=name, + self.add( + system_model.add_constraints( + self.total_flow_hours <= self.element.size * flow_hours_per_size_max, name=name, + ) ) - self.constraints[name_short] = eq_load_factor_max # eq: size * sum(dt)* load_factor_min <= var_sumFlowHours if self.element.load_factor_min is not None: @@ -390,16 +399,15 @@ def _create_bounds_for_load_factor(self, system_model: SystemModel): flow_hours_per_size_in = system_model.hours_per_step * self.element.load_factor_min if self._investment is not None: - eq_load_factor_min = system_model.add_constraints( + self.add(system_model.add_constraints( self.total_flow_hours >= self._investment.size * flow_hours_per_size_in, name=name - ) + )) else: - eq_load_factor_min = system_model.add_constraints( + self.add(system_model.add_constraints( self.total_flow_hours >= self.element.size * flow_hours_per_size_in, name=name - ) - self.constraints[name] = eq_load_factor_min + )) @property def with_investment(self) -> bool: @@ -438,8 +446,8 @@ def relative_flow_rate_bounds(self) -> Tuple[Numeric, Numeric]: class BusModel(ElementModel): - def __init__(self, element: Bus): - super().__init__(element) + def __init__(self, model: linopy.Model, element: Bus): + super().__init__(model, element) self.element: Bus = element self.excess_input: Optional[linopy.Variable] = None self.excess_output: Optional[linopy.Variable] = None @@ -448,11 +456,10 @@ def do_modeling(self, system_model: SystemModel) -> None: # inputs == outputs inputs = sum([flow.model.flow_rate for flow in self.element.inputs]) outputs = sum([flow.model.flow_rate for flow in self.element.outputs]) - eq_bus_balance = system_model.add_constraints( + eq_bus_balance = self.add(system_model.add_constraints( inputs == outputs, name=f'{self.label_full}__balance' - ) - self.constraints['balance'] = eq_bus_balance + )) # Fehlerplus/-minus: if self.element.with_excess: @@ -476,8 +483,8 @@ def do_modeling(self, system_model: SystemModel) -> None: class ComponentModel(ElementModel): - def __init__(self, element: Component): - super().__init__(element) + def __init__(self, model: linopy.Model, element: Component): + super().__init__(model, element) self.element: Component = element self.on_off: Optional[OnOffModel] = None @@ -494,7 +501,7 @@ def do_modeling(self, system_model: SystemModel): if flow.on_off_parameters is None: flow.on_off_parameters = OnOffParameters() - self.sub_models.extend([flow.create_model() for flow in all_flows]) + self.sub_models.extend([flow.create_model(system_model) for flow in all_flows]) for sub_model in self.sub_models: sub_model.do_modeling(system_model) diff --git a/flixOpt/features.py b/flixOpt/features.py index af5f1cc13..b77799500 100644 --- a/flixOpt/features.py +++ b/flixOpt/features.py @@ -733,6 +733,7 @@ def _nr_of_segments(self): class ShareAllocationModel(InterfaceModel): def __init__( self, + model: linopy.Model, shares_are_time_series: bool, label_of_parent: Optional[str] = None, label: Optional[str] = None, @@ -741,7 +742,7 @@ def __init__( max_per_hour: Optional[Numeric] = None, min_per_hour: Optional[Numeric] = None, ): - super().__init__(label_of_parent=label_of_parent, label=label) + super().__init__(model, label_of_parent=label_of_parent, label=label) if not shares_are_time_series: # If the condition is True assert max_per_hour is None and min_per_hour is None, ( 'Both max_per_hour and min_per_hour cannot be used when shares_are_time_series is False' @@ -764,7 +765,7 @@ def __init__( def do_modeling(self, system_model: SystemModel): self.total = self.add( system_model.add_variables( - lower=self._total_min, upper=self._total_max, coords=None, name=f'{self.label_full}_total' + lower=self._total_min, upper=self._total_max, coords=None, name=f'{self.label_full}__total' ) ) # eq: sum = sum(share_i) # skalar diff --git a/flixOpt/flow_system.py b/flixOpt/flow_system.py index 901496c12..cd14b6dab 100644 --- a/flixOpt/flow_system.py +++ b/flixOpt/flow_system.py @@ -142,7 +142,7 @@ def add_elements(self, *args: Element) -> None: def transform_data(self): for element in self.all_elements.values(): - element.transform_data() + element.transform_data(self.timesteps, self.periods) def network_infos(self) -> Tuple[Dict[str, Dict[str, str]], Dict[str, Dict[str, str]]]: nodes = { diff --git a/flixOpt/interface.py b/flixOpt/interface.py index 17ac02bd9..0eef314d1 100644 --- a/flixOpt/interface.py +++ b/flixOpt/interface.py @@ -6,12 +6,14 @@ import logging from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union +import pandas as pd + from .config import CONFIG from .core import Numeric, Numeric_TS, Skalar from .structure import Element, Interface if TYPE_CHECKING: - from .effects import Effect, EffectTimeSeries, EffectValues, EffectValuesInvest + from .effects import Effect, EffectValuesUser logger = logging.getLogger('flixOpt') @@ -69,21 +71,21 @@ def __init__( maximum_size : scalar, Optional Max nominal value (only if: size_is_fixed = False). """ - self.fix_effects: EffectValuesInvest = fix_effects or {} - self.divest_effects: EffectValuesInvest = divest_effects or {} + self.fix_effects: EffectValuesUser = fix_effects or {} + self.divest_effects: EffectValuesUser = divest_effects or {} self.fixed_size = fixed_size self.optional = optional - self.specific_effects: EffectValuesInvest = specific_effects or {} + self.specific_effects: EffectValuesUser = specific_effects or {} self.effects_in_segments = effects_in_segments self._minimum_size = minimum_size self._maximum_size = maximum_size or CONFIG.modeling.BIG # default maximum - def transform_data(self): - from .effects import as_effect_dict + def transform_data(self, timesteps: pd.DatetimeIndex, periods: Optional[pd.Index]): + from .effects import effect_values_to_dict - self.fix_effects = as_effect_dict(self.fix_effects) - self.divest_effects = as_effect_dict(self.divest_effects) - self.specific_effects = as_effect_dict(self.specific_effects) + self.fix_effects = effect_values_to_dict(self.fix_effects) + self.divest_effects = effect_values_to_dict(self.divest_effects) + self.specific_effects = effect_values_to_dict(self.specific_effects) @property def minimum_size(self): @@ -150,25 +152,24 @@ def __init__( self.switch_on_total_max: Skalar = switch_on_total_max self.force_switch_on: bool = force_switch_on - def transform_data(self, owner: 'Element'): + def transform_data(self, timesteps: pd.DatetimeIndex, periods: Optional[pd.Index], owner: 'Element'): from .effects import effect_values_to_time_series - from .structure import _create_time_series self.effects_per_switch_on = effect_values_to_time_series('per_switch_on', self.effects_per_switch_on, owner) self.effects_per_running_hour = effect_values_to_time_series( - 'per_running_hour', self.effects_per_running_hour, owner + 'per_running_hour', self.effects_per_running_hour, owner, timesteps, periods ) - self.consecutive_on_hours_min = _create_time_series( - 'consecutive_on_hours_min', self.consecutive_on_hours_min, owner + self.consecutive_on_hours_min = self._create_time_series( + owner, 'consecutive_on_hours_min', self.consecutive_on_hours_min, timesteps, periods ) - self.consecutive_on_hours_max = _create_time_series( - 'consecutive_on_hours_max', self.consecutive_on_hours_max, owner + self.consecutive_on_hours_max = self._create_time_series( + owner, 'consecutive_on_hours_max', self.consecutive_on_hours_max, timesteps, periods ) - self.consecutive_off_hours_min = _create_time_series( - 'consecutive_off_hours_min', self.consecutive_off_hours_min, owner + self.consecutive_off_hours_min = self._create_time_series( + owner, 'consecutive_off_hours_min', self.consecutive_off_hours_min, timesteps, periods ) - self.consecutive_off_hours_max = _create_time_series( - 'consecutive_off_hours_max', self.consecutive_off_hours_max, owner + self.consecutive_off_hours_max = self._create_time_series( + owner, 'consecutive_off_hours_max', self.consecutive_off_hours_max, timesteps, periods ) @property diff --git a/flixOpt/structure.py b/flixOpt/structure.py index 673a6f16d..e4948ab2a 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -43,10 +43,10 @@ def __init__( def do_modeling(self): from .effects import EffectCollection - self.effects = EffectCollection(list(self.flow_system.effects.values())) + self.effects = EffectCollection(self, list(self.flow_system.effects.values())) self.effects.do_modeling(self) - component_models = [component.create_model() for component in self.flow_system.components.values()] - bus_models = [bus.create_model() for bus in self.flow_system.buses.values()] + component_models = [component.create_model(self) for component in self.flow_system.components.values()] + bus_models = [bus.create_model(self) for bus in self.flow_system.buses.values()] for component_model in component_models: component_model.do_modeling(self) for bus_model in bus_models: # Buses after Components, because FlowModels are created in ComponentModels @@ -56,6 +56,15 @@ def do_modeling(self): self.effects.objective_effect.model.total + self.effects.penalty.total ) + @property + def hours_per_step(self): + return self.flow_system.hours_per_step + + @property + def coords(self): + return self.flow_system.coords + + class Interface: """ @@ -204,7 +213,7 @@ def _create_time_series( class InterfaceModel: """Stores the mathematical Variables and Constraints related to an Interface""" - def __init__(self, interface: Optional[Interface] = None, label_of_parent: Optional[str] = None, label: Optional[str] = None): + def __init__(self, model: linopy.Model, interface: Optional[Interface] = None, label_of_parent: Optional[str] = None, label: Optional[str] = None): """ Parameters ---------- @@ -217,8 +226,9 @@ def __init__(self, interface: Optional[Interface] = None, label_of_parent: Optio """ if label_of_parent is None and label is None: raise ValueError('Either label_of_parent or label must be set') + self.interface = interface - self._model: Optional[linopy.Model] = None + self._model = model self._variables: List[str] = [] self._constraints: List[str] = [] self.sub_models = [] @@ -295,14 +305,14 @@ def all_sub_models(self) -> List['InterfaceModel']: class ElementModel(InterfaceModel): """Interface to create the mathematical Variables and Constraints for Elements""" - def __init__(self, element: Optional[Element]): + def __init__(self, model: linopy.Model, element: Optional[Element]): """ Parameters ---------- element : Element The element this model is created for. """ - super().__init__(element, element.label_full) + super().__init__(model, element, element.label_full) def create_equation( From 37c4bd4347c6fcd1940e1d3a2092f0e4a1a22078 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 14 Feb 2025 16:36:31 +0100 Subject: [PATCH 062/507] Bugfixes --- flixOpt/core.py | 4 ++-- flixOpt/flow_system.py | 5 +---- flixOpt/structure.py | 2 +- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/flixOpt/core.py b/flixOpt/core.py index f6f733b08..afb53e225 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -219,9 +219,9 @@ def restore_data(self): def _update_active_data(self): """Update the active data.""" if 'period' in self._stored_data.indexes: - self._active_data = self._stored_data.sel(timesteps=self.active_timesteps, periods=self.active_periods) + self._active_data = self._stored_data.sel(time=self.active_timesteps, periods=self.active_periods) else: - self._active_data = self._stored_data.sel(timesteps=self.active_timesteps) + self._active_data = self._stored_data.sel(time=self.active_timesteps) @property def active_timesteps(self) -> pd.DatetimeIndex: diff --git a/flixOpt/flow_system.py b/flixOpt/flow_system.py index cd14b6dab..a7dec9aac 100644 --- a/flixOpt/flow_system.py +++ b/flixOpt/flow_system.py @@ -68,10 +68,7 @@ def __init__( self.model: Optional[SystemModel] = None def _order_dimensions(self): - if self.timesteps.dtype == np.dtype('datetime64[ns]'): - self.timesteps = self.timesteps.astype('datetime64[us]') - else: - self.timesteps = self.timesteps + self.timesteps = self.timesteps self.timesteps.name = 'time' self.periods = pd.Index(self.periods, name='period') if self.periods is not None else None diff --git a/flixOpt/structure.py b/flixOpt/structure.py index e4948ab2a..8407b0ae5 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -283,7 +283,7 @@ def _all_variables(self) -> List[str]: def _all_constraints(self) -> List[str]: all_constraints = self._constraints.copy() for sub_model in self.sub_models: - for constraint in sub_model._all_variables: + for constraint in sub_model._all_constraints: if constraint in all_constraints: raise KeyError(f"Duplicate key found: '{constraint}' in both main model and submodel!") all_constraints.append(constraint) From 1864af5596acd7881ff0cb9df21c530c4d220beb Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 14 Feb 2025 16:47:26 +0100 Subject: [PATCH 063/507] Bugfix --- flixOpt/effects.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixOpt/effects.py b/flixOpt/effects.py index d32acdab9..4df7f835d 100644 --- a/flixOpt/effects.py +++ b/flixOpt/effects.py @@ -241,7 +241,7 @@ def effect_values_to_time_series(label_suffix: str, timesteps, periods ) - for effect, value in effect_values.items() if effect is not None + for effect, value in effect_values.items() } return effect_values_ts From e3fe256e465b1efcf1941c375c9df62fc7e0b090 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 14 Feb 2025 20:49:06 +0100 Subject: [PATCH 064/507] Add functions to acess variables and results --- examples/00_Minmal/minimal_example.py | 2 +- flixOpt/structure.py | 33 ++++++++++++++++++++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/examples/00_Minmal/minimal_example.py b/examples/00_Minmal/minimal_example.py index efaed0dbf..1726eb409 100644 --- a/examples/00_Minmal/minimal_example.py +++ b/examples/00_Minmal/minimal_example.py @@ -46,7 +46,7 @@ # --- Build the Flow System --- # Add all components and effects to the system - flow_system = fx.FlowSystem(datetime_series) + flow_system = fx.FlowSystem(timesteps) flow_system.add_elements(cost_effect, boiler, heat_load, gas_source) # --- Define and Run Calculation --- diff --git a/flixOpt/structure.py b/flixOpt/structure.py index 8407b0ae5..2f08f322b 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -65,7 +65,6 @@ def coords(self): return self.flow_system.coords - class Interface: """ This class is used to collect arguments about a Model. @@ -189,6 +188,17 @@ def __init__(self, label: str, meta_data: Dict = None): self.used_time_series: List[TimeSeries] = [] # Used for better access self.model: Optional[ElementModel] = None + def solution_numeric( + self, + use_numpy: bool = True, + all_variables: bool = True + ) -> Union[Dict[str, np.ndarray], Dict[str, Union[List, int, float]]]: + vars = self.model.all_variables if all_variables else self.model.variables + results = {var: vars.solution[var].values for var in vars.solution.data_vars} + if use_numpy: + return {k: v.item() if v.ndim == 0 else v for k, v in results.items()} + return {k: v.tolist() for k, v in results.items()} + def _plausibility_checks(self) -> None: """This function is used to do some basic plausibility checks for each Element during initialization""" raise NotImplementedError('Every Element needs a _plausibility_checks() method') @@ -301,6 +311,27 @@ def all_constraints(self) -> linopy.Constraints: def all_sub_models(self) -> List['InterfaceModel']: return [model for sub_model in self.sub_models for model in [sub_model] + sub_model.all_sub_models] + def filter_variables(self, + filter_by: Optional[Literal['binary', 'continuous', 'integer']] = None, + length: Literal['scalar', 'time'] = None): + if filter_by is None: + all_variables = self.variables + elif filter_by == 'binary': + all_variables = self.variables.binaries + elif filter_by == 'integer': + all_variables = self.variables.integers + elif filter_by == 'continuous': + all_variables = self.variables.continuous + else: + raise ValueError(f'Invalid filter_by "{filter_by}", must be one of "binary", "continous", "integer"') + if length is None: + return all_variables + elif length == 'scalar': + return all_variables[[name for name in all_variables if all_variables[name].ndim == 0]] + elif length == 'time': + return all_variables[[name for name in all_variables if 'time' in all_variables[name].dims]] + raise ValueError(f'Invalid length "{length}", must be one of "scalar", "time" or None') + class ElementModel(InterfaceModel): """Interface to create the mathematical Variables and Constraints for Elements""" From b8936c960f1aa890eae89bd3499db09c69e45bc0 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 14 Feb 2025 21:16:43 +0100 Subject: [PATCH 065/507] Add abreviations for variables and a function to get numeric and structured results, comparable to the old structure --- flixOpt/structure.py | 37 +++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/flixOpt/structure.py b/flixOpt/structure.py index 2f08f322b..973ef86bf 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -244,16 +244,36 @@ def __init__(self, model: linopy.Model, interface: Optional[Interface] = None, l self.sub_models = [] self._label = label self._label_of_parent = label_of_parent + self._variables_short: Dict[str, str] = {} + self._constraints_short: Dict[str, str] = {} + self._sub_models_short: Dict[str, str] = {} logger.debug(f'Created {self.__class__.__name__} "{self.label_full}"') - def add(self, item: Union[linopy.Variable, linopy.Constraint, 'InterfaceModel'] - ) -> Union[linopy.Variable, linopy.Constraint, 'InterfaceModel']: + def add( + self, + item: Union[linopy.Variable, linopy.Constraint, 'InterfaceModel'], + short_name: Optional[str] = None + ) -> Union[linopy.Variable, linopy.Constraint, 'InterfaceModel']: + """ + Add a variable, constraint or sub-model to the model + + Parameters + ---------- + item : linopy.Variable, linopy.Constraint, InterfaceModel + The variable, constraint or sub-model to add to the model + short_name : str, optional + The short name of the variable, constraint or sub-model. If not provided, the full name is used. + """ + # TODO: Check uniquenes of short names if isinstance(item, linopy.Variable): self._variables.append(item.name) + self._variables_short[item.name] = short_name or item.name elif isinstance(item, linopy.Constraint): self._constraints.append(item.name) + self._constraints_short[item.name] = short_name or item.name elif isinstance(item, InterfaceModel): self.sub_models.append(item) + self._constraints_short[item.label_full] = short_name or item.label_full else: raise ValueError(f'Item must be a linopy.Variable or linopy.Constraint, got {type(item)}') return item @@ -332,6 +352,19 @@ def filter_variables(self, return all_variables[[name for name in all_variables if 'time' in all_variables[name].dims]] raise ValueError(f'Invalid length "{length}", must be one of "scalar", "time" or None') + def solution_structured( + self, + use_numpy: bool = True, + ) -> Dict[str, Union[np.ndarray, Dict]]: + results = { + self._variables_short[var_name]: var.values + for var_name, var in self.variables.solution.data_vars.items() + } + return { + **results, + **{sub_model.label: sub_model.solution_numeric(use_numpy) for sub_model in self.sub_models} + } + class ElementModel(InterfaceModel): """Interface to create the mathematical Variables and Constraints for Elements""" From 29c7a30b2cd3e57b99534dac92c69526ad3a1ecc Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 14 Feb 2025 21:40:44 +0100 Subject: [PATCH 066/507] Organize ShareAllocationModel --- flixOpt/features.py | 35 ++++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/flixOpt/features.py b/flixOpt/features.py index b77799500..4cd4d4f26 100644 --- a/flixOpt/features.py +++ b/flixOpt/features.py @@ -766,10 +766,11 @@ def do_modeling(self, system_model: SystemModel): self.total = self.add( system_model.add_variables( lower=self._total_min, upper=self._total_max, coords=None, name=f'{self.label_full}__total' - ) + ), + 'total' ) # eq: sum = sum(share_i) # skalar - self._eq_total = self.add(system_model.add_constraints(self.total == 0, name=f'{self.label_full}__total')) + self._eq_total = self.add(system_model.add_constraints(self.total == 0, name=f'{self.label_full}__total'), 'total') if self._shares_are_time_series: self.total_per_timestep = self.add( @@ -778,11 +779,13 @@ def do_modeling(self, system_model: SystemModel): upper=np.inf if (self._max_per_hour is None) else np.multiply(self._max_per_hour, system_model.hours_per_step), coords=system_model.coords, name=f'{self.label_full}_total_per_timestep' - ) + ), + 'total_per_timestep' ) self._eq_total_per_timestep = self.add( - system_model.add_constraints(self.total_per_timestep == 0, name=f'{self.label_full}__total_per_timestep') + system_model.add_constraints(self.total_per_timestep == 0, name=f'{self.label_full}__total_per_timestep'), + 'total_per_timestep' ) # Add it to the total @@ -816,22 +819,36 @@ def add_share( system_model.add_variables( coords=None if expression.ndim == 0 else system_model.coords, name=f'{name}__{self.label_full}' - ) + ), + name ) self.share_constraints[name] = self.add( system_model.add_constraints( self.shares[name] == expression, name=f'{name}__{self.label_full}' - ) + ), + name ) if self.shares[name].ndim == 0: self._eq_total.lhs -= self.shares[name] else: self._eq_total_per_timestep.lhs -= self.shares[name] - def results(self): + def solution_structured( + self, + use_numpy: bool = True, + ) -> Dict[str, Union[np.ndarray, Dict]]: + shares_var_names = [var.name for var in self.shares.values()] + results = { + self._variables_short[var_name]: var.values + for var_name, var in self.variables.solution.data_vars.items() if var_name not in shares_var_names + } + results['Shares'] = { + self._variables_short[var_name]: var.values + for var_name, var in self.variables.solution.data_vars.items() if var_name in shares_var_names + } return { - **{variable.label_short: variable.result for variable in self.variables.values()}, - **{'Shares': {variable.label_short: variable.result for variable in self.shares.values()}}, + **results, + **{sub_model.label: sub_model.solution_structured(use_numpy) for sub_model in self.sub_models} } From 034a9cc3bbaee6b3a495627ad96c0f5a966b00dd Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 14 Feb 2025 21:47:59 +0100 Subject: [PATCH 067/507] Add short names to EffectModel --- flixOpt/effects.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/flixOpt/effects.py b/flixOpt/effects.py index 4df7f835d..f55fdf1bf 100644 --- a/flixOpt/effects.py +++ b/flixOpt/effects.py @@ -159,7 +159,7 @@ def __init__(self, model: linopy.Model, element: Effect): super().__init__(model, element) self.element: Effect = element self.total: Optional[linopy.Variable] = None - self.invest = self.add( + self.invest: ShareAllocationModel = self.add( ShareAllocationModel( self._model, False, @@ -170,7 +170,7 @@ def __init__(self, model: linopy.Model, element: Effect): ) ) - self.operation = self.add( + self.operation: ShareAllocationModel = self.add( ShareAllocationModel( self._model, True, @@ -197,14 +197,16 @@ def do_modeling(self, system_model: SystemModel): upper=self.element.maximum_total if self.element.maximum_total is not None else np.inf, coords=None, name=f'{self.element.label_full}__total' - ) + ), + 'total' ) self.add( system_model.add_constraints( self.total == self.operation.total.sum() + self.invest.total.sum(), name=f'{self.element.label_full}__total' - ) + ), + 'total' ) @@ -316,17 +318,15 @@ def _add_share_between_effects(self, system_model: SystemModel): for target_effect, time_series in origin_effect.specific_share_to_other_effects_operation.items(): target_effect.model.operation.add_share( system_model, - f'{origin_effect.label_full}_operation', - origin_effect.model.operation.sum_TS, - time_series.active_data, + origin_effect.label_full, + origin_effect.model.operation.total_per_timestep * time_series.active_data, ) # 2. invest: -> hier ist es Skalar (share) for target_effect, factor in origin_effect.specific_share_to_other_effects_invest.items(): target_effect.model.invest.add_share( system_model, - f'{origin_effect.label_full}_invest', - origin_effect.model.invest.sum, - factor + origin_effect.label_full, + origin_effect.model.invest.total * factor, ) def __getitem__(self, label: str) -> 'Effect': From 09261321fc3272835065f87c7fb9243cdb343fed Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 14 Feb 2025 21:48:48 +0100 Subject: [PATCH 068/507] Add FlowSystem.results() --- flixOpt/flow_system.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/flixOpt/flow_system.py b/flixOpt/flow_system.py index a7dec9aac..4c21393a5 100644 --- a/flixOpt/flow_system.py +++ b/flixOpt/flow_system.py @@ -194,6 +194,22 @@ def to_json(self, path: Union[str, pathlib.Path]): with open(path, 'w', encoding='utf-8') as f: json.dump(data, f, indent=4, ensure_ascii=False) + def results(self): + return { + 'Components': { + comp.label: comp.model.solution_structured(use_numpy=True) + for comp in sorted(self.components.values(), key=lambda component: component.label.upper()) + }, + 'Buses': { + bus.label: bus.model.solution_structured(use_numpy=True) + for bus in sorted(self.buses.values(), key=lambda bus: bus.label.upper()) + }, + 'Effects': { + effect.label: effect.model.solution_structured(use_numpy=True) + for effect in sorted(self.effects.values(), key=lambda effect: effect.label.upper()) + } + } + def visualize_network( self, path: Union[bool, str, pathlib.Path] = 'flow_system.html', From 30a595c8eeb1a34d674f146d16b51435c43a5295 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 14 Feb 2025 21:59:23 +0100 Subject: [PATCH 069/507] Changed naming in ElementModel --- flixOpt/structure.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/flixOpt/structure.py b/flixOpt/structure.py index 973ef86bf..df20b83ec 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -362,21 +362,29 @@ def solution_structured( } return { **results, - **{sub_model.label: sub_model.solution_numeric(use_numpy) for sub_model in self.sub_models} + **{sub_model.label: sub_model.solution_structured(use_numpy) for sub_model in self.sub_models} } class ElementModel(InterfaceModel): """Interface to create the mathematical Variables and Constraints for Elements""" - def __init__(self, model: linopy.Model, element: Optional[Element]): + def __init__(self, model: linopy.Model, element: Element): """ Parameters ---------- element : Element The element this model is created for. """ - super().__init__(model, element, element.label_full) + super().__init__(model, element, label=element.label_full) + + @property + def label(self) -> str: + return self.interface.label + + @property + def label_full(self) -> str: + return self.interface.label_full def create_equation( From 92840d3effec3143bcc7eb85b8589dda02bb75f7 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 14 Feb 2025 23:06:13 +0100 Subject: [PATCH 070/507] Introduced another abstraction into the Models --- flixOpt/structure.py | 131 +++++++++++++++++++++++-------------------- 1 file changed, 71 insertions(+), 60 deletions(-) diff --git a/flixOpt/structure.py b/flixOpt/structure.py index df20b83ec..427aaea30 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -220,10 +220,10 @@ def _create_time_series( return super()._create_time_series(self, name, data, timesteps, periods) -class InterfaceModel: - """Stores the mathematical Variables and Constraints related to an Interface""" +class Model: + """Stores Variables and Constraints""" - def __init__(self, model: linopy.Model, interface: Optional[Interface] = None, label_of_parent: Optional[str] = None, label: Optional[str] = None): + def __init__(self, model: linopy.Model, label: str, label_full: Optional[str] = None): """ Parameters ---------- @@ -234,26 +234,25 @@ def __init__(self, model: linopy.Model, interface: Optional[Interface] = None, l label : str Used to construct the label of the model. If None, the interface label is used. """ - if label_of_parent is None and label is None: - raise ValueError('Either label_of_parent or label must be set') - self.interface = interface self._model = model + self._label = label + self._label_full = label_full + self._variables: List[str] = [] self._constraints: List[str] = [] - self.sub_models = [] - self._label = label - self._label_of_parent = label_of_parent + self.sub_models: List[Model] = [] + self._variables_short: Dict[str, str] = {} self._constraints_short: Dict[str, str] = {} self._sub_models_short: Dict[str, str] = {} - logger.debug(f'Created {self.__class__.__name__} "{self.label_full}"') + logger.debug(f'Created {self.__class__.__name__} "{self._label}"') def add( self, - item: Union[linopy.Variable, linopy.Constraint, 'InterfaceModel'], + item: Union[linopy.Variable, linopy.Constraint, 'Model'], short_name: Optional[str] = None - ) -> Union[linopy.Variable, linopy.Constraint, 'InterfaceModel']: + ) -> Union[linopy.Variable, linopy.Constraint, 'Model']: """ Add a variable, constraint or sub-model to the model @@ -278,16 +277,47 @@ def add( raise ValueError(f'Item must be a linopy.Variable or linopy.Constraint, got {type(item)}') return item + def filter_variables(self, + filter_by: Optional[Literal['binary', 'continuous', 'integer']] = None, + length: Literal['scalar', 'time'] = None): + if filter_by is None: + all_variables = self.variables + elif filter_by == 'binary': + all_variables = self.variables.binaries + elif filter_by == 'integer': + all_variables = self.variables.integers + elif filter_by == 'continuous': + all_variables = self.variables.continuous + else: + raise ValueError(f'Invalid filter_by "{filter_by}", must be one of "binary", "continous", "integer"') + if length is None: + return all_variables + elif length == 'scalar': + return all_variables[[name for name in all_variables if all_variables[name].ndim == 0]] + elif length == 'time': + return all_variables[[name for name in all_variables if 'time' in all_variables[name].dims]] + raise ValueError(f'Invalid length "{length}", must be one of "scalar", "time" or None') + + def solution_structured( + self, + use_numpy: bool = True, + ) -> Dict[str, Union[np.ndarray, Dict]]: + results = { + self._variables_short[var_name]: var.values + for var_name, var in self.variables.solution.data_vars.items() + } + return { + **results, + **{sub_model.label: sub_model.solution_structured(use_numpy) for sub_model in self.sub_models} + } + @property def label(self) -> str: - return self._label or self._label_of_parent + return self._label @property def label_full(self) -> str: - if self._label and self._label_of_parent: - return f'{self._label_of_parent}__{self._label}' - else: - return self.label + return self._label_full or self.label @property def variables(self) -> linopy.Variables: @@ -328,45 +358,33 @@ def all_constraints(self) -> linopy.Constraints: return self._model.constraints[self._all_constraints] @property - def all_sub_models(self) -> List['InterfaceModel']: + def all_sub_models(self) -> List['Model']: return [model for sub_model in self.sub_models for model in [sub_model] + sub_model.all_sub_models] - def filter_variables(self, - filter_by: Optional[Literal['binary', 'continuous', 'integer']] = None, - length: Literal['scalar', 'time'] = None): - if filter_by is None: - all_variables = self.variables - elif filter_by == 'binary': - all_variables = self.variables.binaries - elif filter_by == 'integer': - all_variables = self.variables.integers - elif filter_by == 'continuous': - all_variables = self.variables.continuous - else: - raise ValueError(f'Invalid filter_by "{filter_by}", must be one of "binary", "continous", "integer"') - if length is None: - return all_variables - elif length == 'scalar': - return all_variables[[name for name in all_variables if all_variables[name].ndim == 0]] - elif length == 'time': - return all_variables[[name for name in all_variables if 'time' in all_variables[name].dims]] - raise ValueError(f'Invalid length "{length}", must be one of "scalar", "time" or None') - def solution_structured( - self, - use_numpy: bool = True, - ) -> Dict[str, Union[np.ndarray, Dict]]: - results = { - self._variables_short[var_name]: var.values - for var_name, var in self.variables.solution.data_vars.items() - } - return { - **results, - **{sub_model.label: sub_model.solution_structured(use_numpy) for sub_model in self.sub_models} - } +class InterfaceModel(Model): + """Stores the mathematical Variables and Constraints related to an Interface""" + + def __init__(self, model: linopy.Model, interface: Optional[Interface] = None, label_of_parent: Optional[str] = None, label: Optional[str] = None): + """ + Parameters + ---------- + interface : Interface + The interface this model is created for. + label_of_parent : str + The label of the parent. Used to construct the full label of the model. + label : str + Used to construct the label of the model. If None, the interface label is used. + """ + if label_of_parent is None and label is None: + raise ValueError('Either label_of_parent or label must be set') + super().__init__(model, label, f'{label_of_parent}__{label}' if label_of_parent else None) + + self.interface = interface + logger.debug(f'Created {self.__class__.__name__} "{self.label_full}"') -class ElementModel(InterfaceModel): +class ElementModel(Model): """Interface to create the mathematical Variables and Constraints for Elements""" def __init__(self, model: linopy.Model, element: Element): @@ -376,15 +394,8 @@ def __init__(self, model: linopy.Model, element: Element): element : Element The element this model is created for. """ - super().__init__(model, element, label=element.label_full) - - @property - def label(self) -> str: - return self.interface.label - - @property - def label_full(self) -> str: - return self.interface.label_full + super().__init__(model, label=element.label, label_full=element.label_full) + self.element = element def create_equation( From 2b11f8895f36711b57270c356d4b8492c51394bd Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 14 Feb 2025 23:16:19 +0100 Subject: [PATCH 071/507] Add short labels to FlowModel --- flixOpt/elements.py | 70 ++++++++++++++++++++++++++++----------------- 1 file changed, 44 insertions(+), 26 deletions(-) diff --git a/flixOpt/elements.py b/flixOpt/elements.py index eea91b4c5..7962305a8 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -271,7 +271,7 @@ def _plausibility_checks(self) -> None: def label_full(self) -> str: # Wenn im Erstellungsprozess comp noch nicht bekannt: comp_label = 'unknownComp' if self.comp is None else self.comp.label - return f'{comp_label}__{self.label}' # z.B. für results_struct (deswegen auch _ statt . dazwischen) + return f'{self.label} ({comp_label})' @property # Richtung def is_input_in_comp(self) -> bool: @@ -300,18 +300,22 @@ def __init__(self, model: linopy.Model, element: Flow): def do_modeling(self, system_model: SystemModel): # eq relative_minimum(t) * size <= flow_rate(t) <= relative_maximum(t) * size - self.flow_rate = system_model.add_variables( - lower=self.absolute_flow_rate_bounds[0] if self.element.on_off_parameters is None else 0, - upper=self.absolute_flow_rate_bounds[1] if self.element.on_off_parameters is None else np.inf, - coords=system_model.coords, - name=f'{self.label_full}__flow_rate', + self.flow_rate = self.add( + system_model.add_variables( + lower=self.absolute_flow_rate_bounds[0] if self.element.on_off_parameters is None else 0, + upper=self.absolute_flow_rate_bounds[1] if self.element.on_off_parameters is None else np.inf, + coords=system_model.coords, + name=f'{self.label_full}__flow_rate' + ), + 'flow_rate' ) if self.element.fixed_relative_profile is not None: self.add( system_model.add_constraints( self.flow_rate == self.element.fixed_relative_profile.active_data, name=f'{self.element.label}_fix_flow_rate' - ) + ), + 'flow_rate (fix)' ) # OnOff @@ -319,7 +323,8 @@ def do_modeling(self, system_model: SystemModel): self.on_off = self.add( OnOffModel( self.element, self.element.on_off_parameters, [self.flow_rate], [self.absolute_flow_rate_bounds] - ) + ), + 'on_off' ) self.on_off.do_modeling(system_model) @@ -333,22 +338,27 @@ def do_modeling(self, system_model: SystemModel): self.relative_flow_rate_bounds, fixed_relative_profile=self.fixed_relative_flow_rate, on_variable=self.on_off.on if self.on_off is not None else None, - ) + ), + 'investment' ) self._investment.do_modeling(system_model) - self.total_flow_hours = self.add(system_model.add_variables( - lower=self.element.flow_hours_total_min if self.element.flow_hours_total_min is not None else -np.inf, - upper=self.element.flow_hours_total_max if self.element.flow_hours_total_max is not None else np.inf, - coords=None, - name=f'{self.element.label_full}__total_flow_hours' - )) + self.total_flow_hours = self.add( + system_model.add_variables( + lower=self.element.flow_hours_total_min if self.element.flow_hours_total_min is not None else -np.inf, + upper=self.element.flow_hours_total_max if self.element.flow_hours_total_max is not None else np.inf, + coords=None, + name=f'{self.element.label_full}__total_flow_hours' + ), + 'total_flow_hours' + ) self.add( system_model.add_constraints( self.total_flow_hours == (self.flow_rate * system_model.hours_per_step).sum(), name=f'{self.element.label_full}__total_flow_hours' - ) + ), + 'total_flow_hours' ) # Load factor @@ -383,13 +393,15 @@ def _create_bounds_for_load_factor(self, system_model: SystemModel): self.add( system_model.add_constraints( self.total_flow_hours <= self._investment.size * flow_hours_per_size_max, name=name, - ) + ), + name_short ) else: self.add( system_model.add_constraints( self.total_flow_hours <= self.element.size * flow_hours_per_size_max, name=name, - ) + ), + name_short ) # eq: size * sum(dt)* load_factor_min <= var_sumFlowHours @@ -399,15 +411,21 @@ def _create_bounds_for_load_factor(self, system_model: SystemModel): flow_hours_per_size_in = system_model.hours_per_step * self.element.load_factor_min if self._investment is not None: - self.add(system_model.add_constraints( - self.total_flow_hours >= self._investment.size * flow_hours_per_size_in, - name=name - )) + self.add( + system_model.add_constraints( + self.total_flow_hours >= self._investment.size * flow_hours_per_size_in, + name=name + ), + name_short + ) else: - self.add(system_model.add_constraints( - self.total_flow_hours >= self.element.size * flow_hours_per_size_in, - name=name - )) + self.add( + system_model.add_constraints( + self.total_flow_hours >= self.element.size * flow_hours_per_size_in, + name=name + ), + name_short + ) @property def with_investment(self) -> bool: From 812630776e8d1b8665009cdd1964555476c427b7 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 15 Feb 2025 09:57:59 +0100 Subject: [PATCH 072/507] Assigned SystemModel directly to FlowSystem, and other changes --- flixOpt/calculation.py | 39 ++++++++++------------ flixOpt/flow_system.py | 76 ++++++++++++++++++++++-------------------- flixOpt/structure.py | 53 ++++++++++++++++++++++++++--- 3 files changed, 106 insertions(+), 62 deletions(-) diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py index 54fae8dc0..44d4771ce 100644 --- a/flixOpt/calculation.py +++ b/flixOpt/calculation.py @@ -17,6 +17,7 @@ from typing import Any, Dict, List, Literal, Optional, Union import numpy as np +import pandas as pd import yaml from . import utils as utils @@ -39,10 +40,9 @@ class for defined way of solving a flow_system optimization def __init__( self, - name, + name: str, flow_system: FlowSystem, - modeling_language: Literal['pyomo', 'linopy'] = 'pyomo', - time_indices: Optional[Union[range, List[int]]] = None, + active_timesteps: Optional[Union[List[int], pd.DatetimeIndex]] = None, ): """ Parameters @@ -51,18 +51,14 @@ def __init__( name of calculation flow_system : FlowSystem flow_system which should be calculated - modeling_language : 'pyomo', 'linopy' - choose optimization modeling language - time_indices : List[int] or None + active_timesteps : List[int] or None list with indices, which should be used for calculation. If None, then all timesteps are used. """ self.name = name self.flow_system = flow_system - self.modeling_language = modeling_language - self.time_indices = time_indices + self.active_timesteps = active_timesteps - self.system_model: Optional[SystemModel] = None - self.durations = {'modeling': 0.0, 'solving': 0.0, 'saving': 0.0} # Dauer der einzelnen Dinge + self.durations = {'modeling': 0.0, 'solving': 0.0, 'saving': 0.0} self._paths: Dict[str, Optional[Union[pathlib.Path, List[pathlib.Path]]]] = { 'log': None, @@ -93,7 +89,7 @@ def _define_path_names(self, save_results: Union[bool, str, pathlib.Path], inclu def _save_solve_infos(self): t_start = timeit.default_timer() - indent = 4 if len(self.flow_system.time_series) < 50 else None + indent = 4 if len(self.flow_system.timesteps) < 50 else None with open(self._paths['results'], 'w', encoding='utf-8') as f: results = copy_and_convert_datatypes(self.results(), use_numpy=False, use_element_label=False) json.dump(results, f, indent=indent) @@ -108,7 +104,7 @@ def _save_solve_infos(self): nodes_info, edges_info = self.flow_system.network_infos() infos = { 'Calculation': self.infos, - 'Model': self.system_model.infos, + 'Model': self.flow_system.model.infos, 'FlowSystem': get_compact_representation(self.flow_system.infos(use_numpy=True, use_element_label=True)), 'Network': {'Nodes': nodes_info, 'Edges': edges_info}, } @@ -129,14 +125,14 @@ def _save_solve_infos(self): def results(self): if self._results is None: - self._results = self.system_model.results() + self._results = self.flow_system.results() return self._results @property def infos(self): return { 'Name': self.name, - 'Number of indices': len(self.time_indices) if self.time_indices else 'all', + 'Number of indices': len(self.active_timesteps) if self.active_timesteps else 'all', 'Calculation Type': self.__class__.__name__, 'Durations': self.durations, } @@ -152,20 +148,19 @@ def do_modeling(self) -> SystemModel: self.flow_system.transform_data() for time_series in self.flow_system.all_time_series: - time_series.activate_indices(self.time_indices) + time_series.active_periods = self.flow_system.periods + time_series.active_timesteps = self.flow_system.timesteps - self.system_model = SystemModel(self.name, self.modeling_language, self.flow_system, self.time_indices) - self.system_model.do_modeling() - self.system_model.translate_to_modeling_language() + self.flow_system.create_model() + self.flow_system.model.do_modeling() self.durations['modeling'] = round(timeit.default_timer() - t_start, 2) - return self.system_model + return self.flow_system.model - def solve(self, solver: Solver, save_results: Union[bool, str, pathlib.Path] = False): + def solve(self, solver_name: str, save_results: Union[bool, str, pathlib.Path] = False, solver_options: dict = None): self._define_path_names(save_results) t_start = timeit.default_timer() - solver.logfile_name = self._paths['log'] - self.system_model.solve(solver) + self.flow_system.model.solve(log_fn=self._paths['log'], solver_name=solver_name, solver_options=solver_options) self.durations['solving'] = round(timeit.default_timer() - t_start, 2) if save_results: diff --git a/flixOpt/flow_system.py b/flixOpt/flow_system.py index 4c21393a5..40d2c2f00 100644 --- a/flixOpt/flow_system.py +++ b/flixOpt/flow_system.py @@ -67,41 +67,6 @@ def __init__( self.effects: Dict[str, Effect] = {} self.model: Optional[SystemModel] = None - def _order_dimensions(self): - self.timesteps = self.timesteps - self.timesteps.name = 'time' - - self.periods = pd.Index(self.periods, name='period') if self.periods is not None else None - - if self.hours_of_last_step: - last_date = pd.DatetimeIndex( - [self.timesteps[-1] + pd.to_timedelta(self.hours_of_last_step, 'h')]) - else: - last_date = pd.DatetimeIndex([self.timesteps[-1] + (self.timesteps[-1] - self.timesteps[-2])]) - self.timesteps_extra = self.timesteps.append(last_date) - self.timesteps_extra.name = 'time' - hours_per_step = self.timesteps_extra.to_series().diff()[1:].values / pd.to_timedelta(1, 'h') - self.hours_per_step = xr.DataArray( - data=np.tile(hours_per_step, (len(self.periods), 1)) if self.periods is not None else hours_per_step, - coords=self.coords, - name='hours_per_step' - ) - - @property - def snapshots(self): - return xr.Dataset( - coords={'period': list(self.periods), 'time': list(self.timesteps)} if self.periods is not None else { - 'time': list(self.timesteps)}, - ) - - @property - def coords(self): - return self.snapshots.coords - - @property - def index_shape(self) -> Tuple[int, int]: - return len(self.periods) if self.periods is not None else 1, len(self.timesteps) - def add_effects(self, *args: Effect) -> None: for new_effect in list(args): if new_effect.label in self.effects: @@ -261,6 +226,10 @@ def visualize_network( node_infos, edge_infos = self.network_infos() return plotting.visualize_network(node_infos, edge_infos, path, controls, show) + def create_model(self) -> SystemModel: + self.model = SystemModel(self) + return self.model + def _check_if_element_is_unique(self, element: Element) -> None: """ checks if element or label of element already exists in list @@ -270,12 +239,32 @@ def _check_if_element_is_unique(self, element: Element) -> None: element : Element new element to check """ - if element in self.all_elements: + if element in self.all_elements.values(): raise Exception(f'Element {element.label} already added to FlowSystem!') # check if name is already used: if element.label_full in self.all_elements: raise Exception(f'Label of Element {element.label} already used in another element!') + def _order_dimensions(self): + self.timesteps = self.timesteps + self.timesteps.name = 'time' + + self.periods = pd.Index(self.periods, name='period') if self.periods is not None else None + + if self.hours_of_last_step: + last_date = pd.DatetimeIndex( + [self.timesteps[-1] + pd.to_timedelta(self.hours_of_last_step, 'h')]) + else: + last_date = pd.DatetimeIndex([self.timesteps[-1] + (self.timesteps[-1] - self.timesteps[-2])]) + self.timesteps_extra = self.timesteps.append(last_date) + self.timesteps_extra.name = 'time' + hours_per_step = self.timesteps_extra.to_series().diff()[1:].values / pd.to_timedelta(1, 'h') + self.hours_per_step = xr.DataArray( + data=np.tile(hours_per_step, (len(self.periods), 1)) if self.periods is not None else hours_per_step, + coords=self.coords, + name='hours_per_step' + ) + def get_time_data_from_indices( self, time_indices: Optional[Union[List[int], range]] = None ) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.float64]: @@ -342,6 +331,21 @@ def all_elements(self) -> Dict[str, Element]: def all_time_series(self) -> List[TimeSeries]: return [ts for element in self.all_elements.values() for ts in element.used_time_series] + @property + def snapshots(self): + return xr.Dataset( + coords={'period': list(self.periods), 'time': list(self.timesteps)} if self.periods is not None else { + 'time': list(self.timesteps)}, + ) + + @property + def coords(self): + return self.snapshots.coords + + @property + def index_shape(self) -> Tuple[int, int]: + return len(self.periods) if self.periods is not None else 1, len(self.timesteps) + def create_datetime_array( start: str, steps: Optional[int] = None, freq: str = '1h', end: Optional[str] = None diff --git a/flixOpt/structure.py b/flixOpt/structure.py index 427aaea30..5bfb0c4f3 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -33,10 +33,7 @@ class SystemModel(linopy.Model): - def __init__( - self, - flow_system: 'FlowSystem', - ): + def __init__(self, flow_system: 'FlowSystem'): super().__init__(force_dim_names=True) self.flow_system = flow_system self.effects: Optional[EffectCollection] = None @@ -56,6 +53,51 @@ def do_modeling(self): self.effects.objective_effect.model.total + self.effects.penalty.total ) + @property + def main_results(self) -> Dict[str, Union[Skalar, Dict]]: + main_results = {} + effect_results = {} + main_results['Effects'] = effect_results + for effect in self.flow_system.effects.values(): + effect_results[f'{effect.label} [{effect.unit}]'] = { + 'operation': float(effect.model.operation.total.solution.values), + 'invest': float(effect.model.invest.total.solution.values), + 'total': float(effect.model.total.solution.values), + } + main_results['penalty'] = float(self.effects.penalty.total.solution.values) + main_results['Objective'] = self.objective.value + main_results['lower bound'] = 'Not availlable' + buses_with_excess = [] + main_results['buses with excess'] = buses_with_excess + for bus in self.flow_system.buses.values(): + if bus.with_excess: + excess_in = np.sum(bus.model.excess_input.solution.values) + excess_out = np.sum(bus.model.excess_output.solution.values) + if excess_in > 1e-3 or excess_out > 1e-3: + buses_with_excess.append({bus.label_full: {'input': excess_in, 'output': excess_out}}) + + invest_decisions = {'invested': {}, 'not invested': {}} + main_results['Invest-Decisions'] = invest_decisions + from flixOpt.features import InvestmentModel + + for component in self.flow_system.components.values(): + for model in component.model.all_sub_models: + if isinstance(model, InvestmentModel): + invested_size = float(model.size.result) # bei np.floats Probleme bei Speichern + if invested_size >= CONFIG.modeling.EPSILON: + invest_decisions['invested'][model.element.label_full] = invested_size + else: + invest_decisions['not invested'][model.element.label_full] = invested_size + + return main_results + + @property + def infos(self) -> Dict: + return {'Constraints': self.constraints.ncons, + 'Variables': self.variables.nvars, + 'Main Results': self.main_results, + 'Config': CONFIG.to_dict()} + @property def hours_per_step(self): return self.flow_system.hours_per_step @@ -531,6 +573,9 @@ def copy_and_convert_datatypes(data: Any, use_numpy: bool = True, use_element_la if use_element_label and isinstance(data, Element): return data.label return data.infos(use_numpy, use_element_label) + elif isinstance(data, xr.DataArray): + #TODO: This is a temporary basic work around + return copy_and_convert_datatypes(data.values, use_numpy, use_element_label) else: raise TypeError(f'copy_and_convert_datatypes() did get unexpected data of type "{type(data)}": {data=}') From 15631081cde6f7712fe61de69736bb67ee3133b6 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 15 Feb 2025 11:05:58 +0100 Subject: [PATCH 073/507] Add Time to Results --- flixOpt/flow_system.py | 4 +++- flixOpt/structure.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/flixOpt/flow_system.py b/flixOpt/flow_system.py index 40d2c2f00..b841b9166 100644 --- a/flixOpt/flow_system.py +++ b/flixOpt/flow_system.py @@ -172,7 +172,9 @@ def results(self): 'Effects': { effect.label: effect.model.solution_structured(use_numpy=True) for effect in sorted(self.effects.values(), key=lambda effect: effect.label.upper()) - } + }, + 'Time': self.timesteps.tolist(), + 'Time intervals in hours': self.hours_per_step, } def visualize_network( diff --git a/flixOpt/structure.py b/flixOpt/structure.py index 5bfb0c4f3..303651a72 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -66,7 +66,7 @@ def main_results(self) -> Dict[str, Union[Skalar, Dict]]: } main_results['penalty'] = float(self.effects.penalty.total.solution.values) main_results['Objective'] = self.objective.value - main_results['lower bound'] = 'Not availlable' + main_results['lower bound'] = 'Not available' buses_with_excess = [] main_results['buses with excess'] = buses_with_excess for bus in self.flow_system.buses.values(): From e82f6b6dc43a8ce7bb1c76c7ec007b4641256454 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 15 Feb 2025 11:15:04 +0100 Subject: [PATCH 074/507] Change typehint to SystemModel --- flixOpt/components.py | 14 +++++++------- flixOpt/effects.py | 6 +++--- flixOpt/elements.py | 12 ++++++------ flixOpt/features.py | 2 +- flixOpt/structure.py | 8 ++++---- 5 files changed, 21 insertions(+), 21 deletions(-) diff --git a/flixOpt/components.py b/flixOpt/components.py index 85ba58f8b..e06a7bc71 100644 --- a/flixOpt/components.py +++ b/flixOpt/components.py @@ -61,7 +61,7 @@ def __init__( self.segmented_conversion_factors = segmented_conversion_factors or {} self._plausibility_checks() - def create_model(self, model: linopy.Model) -> 'LinearConverterModel': + def create_model(self, model: SystemModel) -> 'LinearConverterModel': self.model = LinearConverterModel(model, self) return self.model @@ -212,12 +212,12 @@ def __init__( self.eta_discharge: Numeric_TS = eta_discharge self.relative_loss_per_hour: Numeric_TS = relative_loss_per_hour - def create_model(self) -> 'StorageModel': - self.model = StorageModel(self) + def create_model(self, model: SystemModel) -> 'StorageModel': + self.model = StorageModel(self, model) return self.model def transform_data(self, timesteps: pd.DatetimeIndex, periods: Optional[pd.Index]) -> None: - super().transform_data() + super().transform_data(timesteps, periods) self.relative_minimum_charge_state = self._create_time_series( 'relative_minimum_charge_state', self.relative_minimum_charge_state, timesteps, periods ) @@ -372,7 +372,7 @@ def create_transmission_equation(self, name: str, in_flow: Flow, out_flow: Flow) class LinearConverterModel(ComponentModel): - def __init__(self, model: linopy.Model, element: LinearConverter): + def __init__(self, model: SystemModel, element: LinearConverter): super().__init__(model, element) self.element: LinearConverter = element self.on_off: Optional[OnOffModel] = None @@ -419,8 +419,8 @@ def do_modeling(self, system_model: SystemModel): class StorageModel(ComponentModel): """Model of Storage""" - def __init__(self, element: Storage): - super().__init__(element) + def __init__(self, model: SystemModel, element: Storage): + super().__init__(model, element) self.element: Storage = element self.charge_state: Optional[linopy.Variable] = None self.netto_discharge: Optional[linopy.Variable] = None diff --git a/flixOpt/effects.py b/flixOpt/effects.py index f55fdf1bf..ce80f80fd 100644 --- a/flixOpt/effects.py +++ b/flixOpt/effects.py @@ -149,13 +149,13 @@ def transform_data(self, timesteps: pd.DatetimeIndex, periods: Optional[pd.Index periods ) - def create_model(self, model: linopy.Model) -> 'EffectModel': + def create_model(self, model: SystemModel) -> 'EffectModel': self.model = EffectModel(model, self) return self.model class EffectModel(ElementModel): - def __init__(self, model: linopy.Model, element: Effect): + def __init__(self, model: SystemModel, element: Effect): super().__init__(model, element) self.element: Effect = element self.total: Optional[linopy.Variable] = None @@ -273,7 +273,7 @@ class EffectCollection(InterfaceModel): Handling all Effects """ - def __init__(self, model: linopy.Model, effects: List[Effect]): + def __init__(self, model: SystemModel, effects: List[Effect]): super().__init__(model, label='Effects') self._effects = {} self._standard_effect: Optional[Effect] = None diff --git a/flixOpt/elements.py b/flixOpt/elements.py index 7962305a8..6ea300931 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -61,7 +61,7 @@ def __init__( self.flows: Dict[str, Flow] = {flow.label: flow for flow in self.inputs + self.outputs} - def create_model(self, model: linopy.Model) -> 'ComponentModel': + def create_model(self, model: SystemModel) -> 'ComponentModel': self.model = ComponentModel(model, self) return self.model @@ -112,7 +112,7 @@ def __init__( self.inputs: List[Flow] = [] self.outputs: List[Flow] = [] - def create_model(self, model: linopy.Model) -> 'BusModel': + def create_model(self, model: SystemModel) -> 'BusModel': self.model = BusModel(model, self) return self.model @@ -234,7 +234,7 @@ def __init__( self._plausibility_checks() - def create_model(self, model: linopy.Model) -> 'FlowModel': + def create_model(self, model: SystemModel) -> 'FlowModel': self.model = FlowModel(model, self) return self.model @@ -289,7 +289,7 @@ def invest_is_optional(self) -> bool: class FlowModel(ElementModel): - def __init__(self, model: linopy.Model, element: Flow): + def __init__(self, model: SystemModel, element: Flow): super().__init__(model, element) self.element: Flow = element self.flow_rate: Optional[linopy.Variable] = None @@ -464,7 +464,7 @@ def relative_flow_rate_bounds(self) -> Tuple[Numeric, Numeric]: class BusModel(ElementModel): - def __init__(self, model: linopy.Model, element: Bus): + def __init__(self, model: SystemModel, element: Bus): super().__init__(model, element) self.element: Bus = element self.excess_input: Optional[linopy.Variable] = None @@ -501,7 +501,7 @@ def do_modeling(self, system_model: SystemModel) -> None: class ComponentModel(ElementModel): - def __init__(self, model: linopy.Model, element: Component): + def __init__(self, model: SystemModel, element: Component): super().__init__(model, element) self.element: Component = element self.on_off: Optional[OnOffModel] = None diff --git a/flixOpt/features.py b/flixOpt/features.py index 4cd4d4f26..fbb8c1801 100644 --- a/flixOpt/features.py +++ b/flixOpt/features.py @@ -733,7 +733,7 @@ def _nr_of_segments(self): class ShareAllocationModel(InterfaceModel): def __init__( self, - model: linopy.Model, + model: SystemModel, shares_are_time_series: bool, label_of_parent: Optional[str] = None, label: Optional[str] = None, diff --git a/flixOpt/structure.py b/flixOpt/structure.py index 303651a72..105c3e58d 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -245,7 +245,7 @@ def _plausibility_checks(self) -> None: """This function is used to do some basic plausibility checks for each Element during initialization""" raise NotImplementedError('Every Element needs a _plausibility_checks() method') - def create_model(self) -> 'ElementModel': + def create_model(self, model: SystemModel) -> 'ElementModel': raise NotImplementedError('Every Element needs a create_model() method') @property @@ -265,7 +265,7 @@ def _create_time_series( class Model: """Stores Variables and Constraints""" - def __init__(self, model: linopy.Model, label: str, label_full: Optional[str] = None): + def __init__(self, model: SystemModel, label: str, label_full: Optional[str] = None): """ Parameters ---------- @@ -407,7 +407,7 @@ def all_sub_models(self) -> List['Model']: class InterfaceModel(Model): """Stores the mathematical Variables and Constraints related to an Interface""" - def __init__(self, model: linopy.Model, interface: Optional[Interface] = None, label_of_parent: Optional[str] = None, label: Optional[str] = None): + def __init__(self, model: SystemModel, interface: Optional[Interface] = None, label_of_parent: Optional[str] = None, label: Optional[str] = None): """ Parameters ---------- @@ -429,7 +429,7 @@ def __init__(self, model: linopy.Model, interface: Optional[Interface] = None, l class ElementModel(Model): """Interface to create the mathematical Variables and Constraints for Elements""" - def __init__(self, model: linopy.Model, element: Element): + def __init__(self, model: SystemModel, element: Element): """ Parameters ---------- From 59c2501eeec5a2bf27fe6178693f0ed8faa604ab Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 15 Feb 2025 11:30:14 +0100 Subject: [PATCH 075/507] transform_data() now uses the FlowSystem directly --- flixOpt/components.py | 47 ++++++++++++++++++++++-------------------- flixOpt/effects.py | 15 ++++++++------ flixOpt/elements.py | 28 ++++++++++++++----------- flixOpt/flow_system.py | 2 +- flixOpt/interface.py | 15 +++++++------- flixOpt/structure.py | 3 ++- 6 files changed, 61 insertions(+), 49 deletions(-) diff --git a/flixOpt/components.py b/flixOpt/components.py index e06a7bc71..193c15857 100644 --- a/flixOpt/components.py +++ b/flixOpt/components.py @@ -3,7 +3,7 @@ """ import logging -from typing import Dict, List, Literal, Optional, Set, Tuple, Union +from typing import Dict, List, Literal, Optional, Set, Tuple, Union, TYPE_CHECKING import numpy as np import pandas as pd @@ -17,6 +17,9 @@ from .math_modeling import Equation, VariableTS from .structure import SystemModel, create_equation, create_variable +if TYPE_CHECKING: + from .flow_system import FlowSystem + logger = logging.getLogger('flixOpt') @@ -93,29 +96,29 @@ def _plausibility_checks(self) -> None: f'(in flow {flow.label_full}) do not make sense together!' ) - def transform_data(self, timesteps: pd.DatetimeIndex, periods: Optional[pd.Index]): - super().transform_data(timesteps, periods) + def transform_data(self, flow_system: 'FlowSystem'): + super().transform_data(flow_system) if self.conversion_factors: - self.conversion_factors = self._transform_conversion_factors(timesteps, periods) + self.conversion_factors = self._transform_conversion_factors(flow_system) else: segmented_conversion_factors = {} for flow, segments in self.segmented_conversion_factors.items(): segmented_conversion_factors[flow] = [ ( - self._create_time_series('Stützstelle', segment[0], timesteps, periods), - self._create_time_series('Stützstelle', segment[1], timesteps, periods), + self._create_time_series('Stützstelle', segment[0], flow_system.timesteps, flow_system.periods), + self._create_time_series('Stützstelle', segment[1], flow_system.timesteps, flow_system.periods), ) for segment in segments ] self.segmented_conversion_factors = segmented_conversion_factors - def _transform_conversion_factors(self, timesteps: pd.DatetimeIndex, periods: Optional[pd.Index]) -> List[Dict[Flow, TimeSeries]]: + def _transform_conversion_factors(self, flow_system: 'FlowSystem') -> List[Dict[Flow, TimeSeries]]: """macht alle Faktoren, die nicht TimeSeries sind, zu TimeSeries""" list_of_conversion_factors = [] for conversion_factor in self.conversion_factors: transformed_dict = {} for flow, values in conversion_factor.items(): - transformed_dict[flow] = self._create_time_series(f'{flow.label}_factor', values, timesteps, periods) + transformed_dict[flow] = self._create_time_series(f'{flow.label}_factor', values, flow_system.timesteps, flow_system.periods) list_of_conversion_factors.append(transformed_dict) return list_of_conversion_factors @@ -213,22 +216,22 @@ def __init__( self.relative_loss_per_hour: Numeric_TS = relative_loss_per_hour def create_model(self, model: SystemModel) -> 'StorageModel': - self.model = StorageModel(self, model) + self.model = StorageModel(model, self) return self.model - def transform_data(self, timesteps: pd.DatetimeIndex, periods: Optional[pd.Index]) -> None: - super().transform_data(timesteps, periods) + def transform_data(self, flow_system: 'FlowSystem') -> None: + super().transform_data(flow_system) self.relative_minimum_charge_state = self._create_time_series( - 'relative_minimum_charge_state', self.relative_minimum_charge_state, timesteps, periods + 'relative_minimum_charge_state', self.relative_minimum_charge_state, flow_system.timesteps_extra, flow_system.periods ) self.relative_maximum_charge_state = self._create_time_series( - 'relative_maximum_charge_state', self.relative_maximum_charge_state, timesteps, periods + 'relative_maximum_charge_state', self.relative_maximum_charge_state, flow_system.timesteps_extra, flow_system.periods ) - self.eta_charge = self._create_time_series('eta_charge', self.eta_charge, timesteps, periods) - self.eta_discharge = self._create_time_series('eta_discharge', self.eta_discharge, timesteps, periods) - self.relative_loss_per_hour = self._create_time_series('relative_loss_per_hour', self.relative_loss_per_hour, timesteps, periods) + self.eta_charge = self._create_time_series('eta_charge', self.eta_charge, flow_system.timesteps, flow_system.periods) + self.eta_discharge = self._create_time_series('eta_discharge', self.eta_discharge, flow_system.timesteps, flow_system.periods) + self.relative_loss_per_hour = self._create_time_series('relative_loss_per_hour', self.relative_loss_per_hour, flow_system.timesteps, flow_system.periods) if isinstance(self.capacity_in_flow_hours, InvestParameters): - self.capacity_in_flow_hours.transform_data(timesteps, periods) + self.capacity_in_flow_hours.transform_data(flow_system) class Transmission(Component): @@ -315,10 +318,10 @@ def create_model(self) -> 'TransmissionModel': self.model = TransmissionModel(self) return self.model - def transform_data(self, timesteps: pd.DatetimeIndex, periods: Optional[pd.Index]) -> None: - super().transform_data(timesteps, periods) - self.relative_losses = self._create_time_series('relative_losses', self.relative_losses, timesteps, periods) - self.absolute_losses = self._create_time_series('absolute_losses', self.absolute_losses, timesteps, periods) + def transform_data(self, flow_system: 'FlowSystem') -> None: + super().transform_data(flow_system) + self.relative_losses = self._create_time_series('relative_losses', self.relative_losses, flow_system.timesteps, flow_system.periods) + self.absolute_losses = self._create_time_series('absolute_losses', self.absolute_losses, flow_system.timesteps, flow_system.periods) class TransmissionModel(ComponentModel): @@ -431,7 +434,7 @@ def do_modeling(self, system_model): lb, ub = self.absolute_charge_state_bounds self.charge_state = system_model.add_variables( - lower_bound=lb, upper_bound=ub, coords=(system_model.periods, system_model.timesteps_extra), + lower_bound=lb, upper_bound=ub, coords=(self._model.periods, self._model.timesteps_extra), name=f'{self.label_full}__charge_state' ) self.netto_discharge = system_model.add_variables( diff --git a/flixOpt/effects.py b/flixOpt/effects.py index ce80f80fd..e15e29cd5 100644 --- a/flixOpt/effects.py +++ b/flixOpt/effects.py @@ -6,7 +6,7 @@ """ import logging -from typing import Dict, Literal, Optional, Union, List +from typing import Dict, Literal, Optional, Union, List, TYPE_CHECKING import numpy as np import pandas as pd @@ -17,6 +17,9 @@ from .math_modeling import Equation, Variable from .structure import Element, ElementModel, SystemModel, InterfaceModel +if TYPE_CHECKING: + from .flow_system import FlowSystem + logger = logging.getLogger('flixOpt') @@ -133,20 +136,20 @@ def error_str(effect_label: str, share_ffect_label: str): f'Error: circular invest-shares \n{error_str(target_effect.label, target_effect.label)}' ) - def transform_data(self, timesteps: pd.DatetimeIndex, periods: Optional[pd.Index]): + def transform_data(self, flow_system: 'FlowSystem'): self.minimum_operation_per_hour = self._create_time_series( - 'minimum_operation_per_hour', self.minimum_operation_per_hour, timesteps, periods + 'minimum_operation_per_hour', self.minimum_operation_per_hour, flow_system.timesteps, flow_system.periods ) self.maximum_operation_per_hour = self._create_time_series( - 'maximum_operation_per_hour', self.maximum_operation_per_hour, timesteps, periods + 'maximum_operation_per_hour', self.maximum_operation_per_hour, flow_system.timesteps, flow_system.periods ) self.specific_share_to_other_effects_operation = effect_values_to_time_series( 'operation_to', self.specific_share_to_other_effects_operation, self, - timesteps, - periods + flow_system.timesteps, + flow_system.periods ) def create_model(self, model: SystemModel) -> 'EffectModel': diff --git a/flixOpt/elements.py b/flixOpt/elements.py index 6ea300931..781636509 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -3,7 +3,7 @@ """ import logging -from typing import Dict, List, Optional, Tuple, Union +from typing import Dict, List, Optional, Tuple, Union, TYPE_CHECKING import numpy as np import linopy @@ -20,6 +20,10 @@ SystemModel, ) +if TYPE_CHECKING: + from .flow_system import FlowSystem + + logger = logging.getLogger('flixOpt') @@ -65,9 +69,9 @@ def create_model(self, model: SystemModel) -> 'ComponentModel': self.model = ComponentModel(model, self) return self.model - def transform_data(self, timesteps: pd.DatetimeIndex, periods: Optional[pd.Index]) -> None: + def transform_data(self, flow_system: 'FlowSystem') -> None: if self.on_off_parameters is not None: - self.on_off_parameters.transform_data(timesteps, periods, self) + self.on_off_parameters.transform_data(flow_system.timesteps, flow_system.periods, self) def register_component_in_flows(self) -> None: for flow in self.inputs + self.outputs: @@ -116,9 +120,9 @@ def create_model(self, model: SystemModel) -> 'BusModel': self.model = BusModel(model, self) return self.model - def transform_data(self, timesteps: pd.DatetimeIndex, periods: Optional[pd.Index]): + def transform_data(self, flow_system: 'FlowSystem'): self.excess_penalty_per_flow_hour = self._create_time_series( - 'excess_penalty_per_flow_hour', self.excess_penalty_per_flow_hour, timesteps, periods + 'excess_penalty_per_flow_hour', self.excess_penalty_per_flow_hour, flow_system.timesteps, flow_system.periods ) def add_input(self, flow) -> None: @@ -238,15 +242,15 @@ def create_model(self, model: SystemModel) -> 'FlowModel': self.model = FlowModel(model, self) return self.model - def transform_data(self, timesteps: pd.DatetimeIndex, periods: Optional[pd.Index]): - self.relative_minimum = self._create_time_series('relative_minimum', self.relative_minimum, timesteps, periods) - self.relative_maximum = self._create_time_series('relative_maximum', self.relative_maximum, timesteps, periods) - self.fixed_relative_profile = self._create_time_series('fixed_relative_profile', self.fixed_relative_profile, timesteps, periods) - self.effects_per_flow_hour = effect_values_to_time_series('per_flow_hour', self.effects_per_flow_hour, self, timesteps, periods) + def transform_data(self, flow_system: 'FlowSystem'): + self.relative_minimum = self._create_time_series('relative_minimum', self.relative_minimum, flow_system.timesteps, flow_system.periods) + self.relative_maximum = self._create_time_series('relative_maximum', self.relative_maximum, flow_system.timesteps, flow_system.periods) + self.fixed_relative_profile = self._create_time_series('fixed_relative_profile', self.fixed_relative_profile, flow_system.timesteps, flow_system.periods) + self.effects_per_flow_hour = effect_values_to_time_series('per_flow_hour', self.effects_per_flow_hour, self, flow_system.timesteps, flow_system.periods) if self.on_off_parameters is not None: - self.on_off_parameters.transform_data(timesteps, periods, self) + self.on_off_parameters.transform_data(flow_system, self) if isinstance(self.size, InvestParameters): - self.size.transform_data(timesteps, periods) + self.size.transform_data(flow_system) def infos(self, use_numpy=True, use_element_label=False) -> Dict: infos = super().infos(use_numpy, use_element_label) diff --git a/flixOpt/flow_system.py b/flixOpt/flow_system.py index b841b9166..df43cdfbf 100644 --- a/flixOpt/flow_system.py +++ b/flixOpt/flow_system.py @@ -104,7 +104,7 @@ def add_elements(self, *args: Element) -> None: def transform_data(self): for element in self.all_elements.values(): - element.transform_data(self.timesteps, self.periods) + element.transform_data(self) def network_infos(self) -> Tuple[Dict[str, Dict[str, str]], Dict[str, Dict[str, str]]]: nodes = { diff --git a/flixOpt/interface.py b/flixOpt/interface.py index 0eef314d1..b884252b0 100644 --- a/flixOpt/interface.py +++ b/flixOpt/interface.py @@ -14,6 +14,7 @@ if TYPE_CHECKING: from .effects import Effect, EffectValuesUser + from .flow_system import FlowSystem logger = logging.getLogger('flixOpt') @@ -80,7 +81,7 @@ def __init__( self._minimum_size = minimum_size self._maximum_size = maximum_size or CONFIG.modeling.BIG # default maximum - def transform_data(self, timesteps: pd.DatetimeIndex, periods: Optional[pd.Index]): + def transform_data(self, flow_system: 'FlowSystem'): from .effects import effect_values_to_dict self.fix_effects = effect_values_to_dict(self.fix_effects) @@ -152,24 +153,24 @@ def __init__( self.switch_on_total_max: Skalar = switch_on_total_max self.force_switch_on: bool = force_switch_on - def transform_data(self, timesteps: pd.DatetimeIndex, periods: Optional[pd.Index], owner: 'Element'): + def transform_data(self, flow_system: 'FlowSystem', owner: 'Element'): from .effects import effect_values_to_time_series self.effects_per_switch_on = effect_values_to_time_series('per_switch_on', self.effects_per_switch_on, owner) self.effects_per_running_hour = effect_values_to_time_series( - 'per_running_hour', self.effects_per_running_hour, owner, timesteps, periods + 'per_running_hour', self.effects_per_running_hour, owner, flow_system.timesteps, flow_system.periods ) self.consecutive_on_hours_min = self._create_time_series( - owner, 'consecutive_on_hours_min', self.consecutive_on_hours_min, timesteps, periods + owner, 'consecutive_on_hours_min', self.consecutive_on_hours_min, flow_system.timesteps, flow_system.periods ) self.consecutive_on_hours_max = self._create_time_series( - owner, 'consecutive_on_hours_max', self.consecutive_on_hours_max, timesteps, periods + owner, 'consecutive_on_hours_max', self.consecutive_on_hours_max, flow_system.timesteps, flow_system.periods ) self.consecutive_off_hours_min = self._create_time_series( - owner, 'consecutive_off_hours_min', self.consecutive_off_hours_min, timesteps, periods + owner, 'consecutive_off_hours_min', self.consecutive_off_hours_min, flow_system.timesteps, flow_system.periods ) self.consecutive_off_hours_max = self._create_time_series( - owner, 'consecutive_off_hours_max', self.consecutive_off_hours_max, timesteps, periods + owner, 'consecutive_off_hours_max', self.consecutive_off_hours_max, flow_system.timesteps, flow_system.periods ) @property diff --git a/flixOpt/structure.py b/flixOpt/structure.py index 105c3e58d..5c9f6e4e8 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -112,7 +112,8 @@ class Interface: This class is used to collect arguments about a Model. """ - def transform_data(self, timesteps: pd.DatetimeIndex, periods: Optional[pd.Index]): + def transform_data(self, flow_system: 'FlowSystem'): + """ Transforms the data of the interface to match the FlowSystem's dimensions""" raise NotImplementedError('Every Interface needs a transform_data() method') def infos(self, use_numpy=True, use_element_label=False) -> Dict: From ee6368bd455075b496d24bce5445409961399a4c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 15 Feb 2025 12:09:29 +0100 Subject: [PATCH 076/507] Rewriting th OnOffModel --- flixOpt/features.py | 164 +++++++++++++++++++++++++------------------- 1 file changed, 92 insertions(+), 72 deletions(-) diff --git a/flixOpt/features.py b/flixOpt/features.py index fbb8c1801..389886607 100644 --- a/flixOpt/features.py +++ b/flixOpt/features.py @@ -18,6 +18,7 @@ ElementModel, InterfaceModel, Interface, + Model, SystemModel, create_equation, create_variable, @@ -173,7 +174,7 @@ def _create_bounds_for_defining_variable(self, system_model: SystemModel): # Anmerkung: Glg bei Spezialfall relative_minimum = 0 redundant zu OnOff ?? -class OnOffModel(ElementModel): +class OnOffModel(Model): """ Class for modeling the on and off state of a variable If defining_bounds are given, creates sufficient lower bounds @@ -181,17 +182,19 @@ class OnOffModel(ElementModel): def __init__( self, - element: Element, + model: SystemModel, on_off_parameters: OnOffParameters, + label_of_parent: str, defining_variables: List[linopy.Variable], defining_bounds: List[Tuple[Numeric, Numeric]], - label: str = 'OnOff', + label: str = 'OnOffModel', ): """ defining_bounds: a list of Numeric, that can be used to create the bound for On/Off more efficiently """ - super().__init__(element, label) - self.element = element + super().__init__(model, label_of_parent, label) + assert len(defining_variables) == len(defining_bounds), 'Every defining Variable needs bounds to Model OnOff' + self.parameters = on_off_parameters self.on: Optional[VariableTS] = None self.total_on_hours: Optional[Variable] = None @@ -204,128 +207,145 @@ def __init__( self.switch_off: Optional[VariableTS] = None self.nr_switch_on: Optional[VariableTS] = None - self._on_off_parameters = on_off_parameters self._defining_variables = defining_variables self._defining_bounds = defining_bounds - assert len(defining_variables) == len(defining_bounds), 'Every defining Variable needs bounds to Model OnOff' def do_modeling(self, system_model: SystemModel): - self.on = create_variable( + self.on = self.add( + self._model.add_variables( + name=f'{self.label_full}__on', + binary=True, + #TODO: previous_values=self._previous_on_values(CONFIG.modeling.EPSILON) + ), 'on', - self, - system_model.nr_of_time_steps, - is_binary=True, - previous_values=self._previous_on_values(CONFIG.modeling.EPSILON), ) - self.total_on_hours = create_variable( - 'totalOnHours', - self, - 1, - lower_bound=self._on_off_parameters.on_hours_total_min, - upper_bound=self._on_off_parameters.on_hours_total_max, + self.total_on_hours = self.add( + self._model.add_variables( + lower=self.parameters.on_hours_total_min, + upper=self.parameters.on_hours_total_max, + name=f'{self.label_full}__on_hours_total' + ), + 'on_hours_total' + ) + + self.add( + self._model.add_constraints( + self.total_on_hours == (self.on * self._model.hours_per_step).sum(), + name=f'{self.label_full}__on_hours_total' + ), + 'on_hours_total' ) - eq_total_on = create_equation('totalOnHours', self) - eq_total_on.add_summand(self.on, system_model.dt_in_hours, as_sum=True) - eq_total_on.add_summand(self.total_on_hours, -1) self._add_on_constraints(system_model, system_model.indices) - if self._on_off_parameters.use_off: - self.off = create_variable( - 'off', - self, - system_model.nr_of_time_steps, - is_binary=True, - previous_values=1 - self._previous_on_values(CONFIG.modeling.EPSILON), + if self.parameters.use_off: + self.off = self.add( + self._model.add_variables( + name=f'{self.label_full}__off', + binary=True, + # TODO: previous_values=1 - self._previous_on_values(CONFIG.modeling.EPSILON), + ), + 'off' ) self._add_off_constraints(system_model, system_model.indices) - if self._on_off_parameters.use_consecutive_on_hours: + if self.parameters.use_consecutive_on_hours: self.consecutive_on_hours = self._get_duration_in_hours( 'consecutiveOnHours', self.on, - self._on_off_parameters.consecutive_on_hours_min, - self._on_off_parameters.consecutive_on_hours_max, + self.parameters.consecutive_on_hours_min, + self.parameters.consecutive_on_hours_max, system_model, system_model.indices, ) - if self._on_off_parameters.use_consecutive_off_hours: + if self.parameters.use_consecutive_off_hours: self.consecutive_off_hours = self._get_duration_in_hours( 'consecutiveOffHours', self.off, - self._on_off_parameters.consecutive_off_hours_min, - self._on_off_parameters.consecutive_off_hours_max, + self.parameters.consecutive_off_hours_min, + self.parameters.consecutive_off_hours_max, system_model, system_model.indices, ) - if self._on_off_parameters.use_switch_on: + if self.parameters.use_switch_on: self.switch_on = create_variable('switchOn', self, system_model.nr_of_time_steps, is_binary=True) self.switch_off = create_variable('switchOff', self, system_model.nr_of_time_steps, is_binary=True) self.nr_switch_on = create_variable( - 'nrSwitchOn', self, 1, upper_bound=self._on_off_parameters.switch_on_total_max + 'nrSwitchOn', self, 1, upper_bound=self.parameters.switch_on_total_max ) self._add_switch_constraints(system_model) self._create_shares(system_model) - def _add_on_constraints(self, system_model: SystemModel, time_indices: Union[list[int], range]): - assert self.on is not None, f'On variable of {self.element} must be defined to add constraints' + def _add_on_constraints(self): + assert self.on is not None, f'On variable of {self.label_full} must be defined to add constraints' # % Bedingungen 1) und 2) müssen erfüllt sein: # % Anmerkung: Falls "abschnittsweise linear" gewählt, dann ist eigentlich nur Bedingung 1) noch notwendig # % (und dann auch nur wenn erstes Segment bei Q_th=0 beginnt. Dann soll bei Q_th=0 (d.h. die Maschine ist Aus) On = 0 und segment1.onSeg = 0):) # % Fazit: Wenn kein Performance-Verlust durch mehr Gleichungen, dann egal! - nr_of_defining_variables = len(self._defining_variables) - assert nr_of_defining_variables > 0, 'Achtung: mindestens 1 Flow notwendig' + nr_of_def_vars = len(self._defining_variables) + assert nr_of_def_vars > 0, 'Achtung: mindestens 1 Flow notwendig' + EPSILON = CONFIG.modeling.EPSILON + + if nr_of_def_vars == 1: + def_var = self._defining_variables[0] + lb, ub = self._defining_bounds[0] - eq_on_1 = create_equation('On_Constraint_1', self, eq_type='ineq') - eq_on_2 = create_equation('On_Constraint_2', self, eq_type='ineq') - if nr_of_defining_variables == 1: - variable = self._defining_variables[0] - lower_bound, upper_bound = self._defining_bounds[0] - #### Bedingung 1) #### # eq: On(t) * max(epsilon, lower_bound) <= Q_th(t) - eq_on_1.add_summand(variable, -1, time_indices) - eq_on_1.add_summand(self.on, np.maximum(CONFIG.modeling.EPSILON, lower_bound), time_indices) + self.add( + self._model.add_constraints( + self.on * np.maximum(EPSILON, lb) <= def_var, + name=f'{self.label_full}__on_con1' + ), + 'on_con1' + ) - #### Bedingung 2) #### # eq: Q_th(t) <= Q_th_max * On(t) - eq_on_2.add_summand(variable, 1, time_indices) - eq_on_2.add_summand(self.on, -1 * upper_bound, time_indices) + self.add( + self._model.add_constraints( + self.on * np.maximum(EPSILON, ub) >= def_var, + name=f'{self.label_full}__on_con2' + ), + 'on_con2' + ) else: # Bei mehreren Leistungsvariablen: - #### Bedingung 1) #### + ub = sum(bound[1] for bound in self._defining_bounds) + lb = EPSILON + # When all defining variables are 0, On is 0 - # eq: - sum(alle Leistungen(t)) + Epsilon * On(t) <= 0 - for variable in self._defining_variables: - eq_on_1.add_summand(variable, -1, time_indices) - eq_on_1.add_summand(self.on, CONFIG.modeling.EPSILON, time_indices) + # eq: On(t) * Epsilon <= sum(alle Leistungen(t)) + self.add( + self._model.add_constraints( + self.on * lb <= sum(self._defining_variables), + name=f'{self.label_full}__on_con1' + ), + 'on_con1' + ) - #### Bedingung 2) #### ## sum(alle Leistung) >0 -> On = 1 | On=0 -> sum(Leistung)=0 # eq: sum( Leistung(t,i)) - sum(Leistung_max(i)) * On(t) <= 0 # --> damit Gleichungswerte nicht zu groß werden, noch durch nr_of_flows geteilt: # eq: sum( Leistung(t,i) / nr_of_flows ) - sum(Leistung_max(i)) / nr_of_flows * On(t) <= 0 - absolute_maximum: Numeric = 0 - for variable, bounds in zip(self._defining_variables, self._defining_bounds, strict=False): - eq_on_2.add_summand(variable, 1 / nr_of_defining_variables, time_indices) - absolute_maximum += bounds[ - 1 - ] # der maximale Nennwert reicht als Obergrenze hier aus. (immer noch math. günster als BigM) - - upper_bound = absolute_maximum / nr_of_defining_variables - eq_on_2.add_summand(self.on, -1 * upper_bound, time_indices) + self.add( + self._model.add_constraints( + self.on * ub >= sum([def_var / nr_of_def_vars for def_var in self._defining_variables]), + name=f'{self.label_full}__on_con2' + ), + 'on_con2' + ) - if np.max(upper_bound) > CONFIG.modeling.BIG_BINARY_BOUND: + if np.max(ub) > CONFIG.modeling.BIG_BINARY_BOUND: logger.warning( - f'In "{self.element.label_full}", a binary definition was created with a big upper bound ' - f'({np.max(upper_bound)}). This can lead to wrong results regarding the on and off variables. ' - f'Avoid this warning by reducing the size of {self.element.label_full} ' + f'In "{self.label_full}", a binary definition was created with a big upper bound ' + f'({np.max(ub)}). This can lead to wrong results regarding the on and off variables. ' + f'Avoid this warning by reducing the size of {self.label_full} ' f'(or the maximum_size of the corresponding InvestParameters). ' f'If its a Component, you might need to adjust the sizes of all of its flows.' ) @@ -531,14 +551,14 @@ def _add_switch_constraints(self, system_model: SystemModel): def _create_shares(self, system_model: SystemModel): # Anfahrkosten: effect_collection = system_model.effect_collection_model - effects_per_switch_on = self._on_off_parameters.effects_per_switch_on + effects_per_switch_on = self.parameters.effects_per_switch_on if effects_per_switch_on != {}: effect_collection.add_share_to_operation( 'switch_on_effects', self.element, effects_per_switch_on, 1, self.switch_on ) # Betriebskosten: - effects_per_running_hour = self._on_off_parameters.effects_per_running_hour + effects_per_running_hour = self.parameters.effects_per_running_hour if effects_per_running_hour != {}: effect_collection.add_share_to_operation( 'running_hour_effects', self.element, effects_per_running_hour, system_model.dt_in_hours, self.on From 604ddeabfadce7d4082683c1d62d1e3c6b37f13b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 15 Feb 2025 12:20:20 +0100 Subject: [PATCH 077/507] Rewriting th OnOffModel --- flixOpt/features.py | 86 +++++++++++++++++++++++++++++++-------------- 1 file changed, 59 insertions(+), 27 deletions(-) diff --git a/flixOpt/features.py b/flixOpt/features.py index 389886607..79688a46e 100644 --- a/flixOpt/features.py +++ b/flixOpt/features.py @@ -272,11 +272,30 @@ def do_modeling(self, system_model: SystemModel): ) if self.parameters.use_switch_on: - self.switch_on = create_variable('switchOn', self, system_model.nr_of_time_steps, is_binary=True) - self.switch_off = create_variable('switchOff', self, system_model.nr_of_time_steps, is_binary=True) - self.nr_switch_on = create_variable( - 'nrSwitchOn', self, 1, upper_bound=self.parameters.switch_on_total_max + self.switch_on = self.add( + self._model.add_variables( + binary=True, + name=f'{self.label_full}__switch_on', + ), + 'switch_on' + ) + + self.switch_off = self.add( + self._model.add_variables( + binary=True, + name=f'{self.label_full}__switch_off', + ), + 'switch_off' ) + self.nr_switch_on = self.add( + self._model.add_variables( + upper_bound=self.parameters.switch_on_total_max, + binary=True, + name=f'{self.label_full}__switch_on_nr', + ), + 'switch_on_nr' + ) + self._add_switch_constraints(system_model) self._create_shares(system_model) @@ -513,40 +532,53 @@ def _get_duration_in_hours( return duration_in_hours def _add_switch_constraints(self, system_model: SystemModel): - assert self.switch_on is not None, f'Switch On Variable of {self.element} must be defined to add constraints' - assert self.switch_off is not None, f'Switch Off Variable of {self.element} must be defined to add constraints' + assert self.switch_on is not None, f'Switch On Variable of {self.label_full} must be defined to add constraints' + assert self.switch_off is not None, f'Switch Off Variable of {self.label_full} must be defined to add constraints' assert self.nr_switch_on is not None, ( - f'Nr of Switch On Variable of {self.element} must be defined to add constraints' + f'Nr of Switch On Variable of {self.label_full} must be defined to add constraints' ) - assert self.on is not None, f'On Variable of {self.element} must be defined to add constraints' + assert self.on is not None, f'On Variable of {self.label_full} must be defined to add constraints' # % Schaltänderung aus On-Variable # % SwitchOn(t)-SwitchOff(t) = On(t)-On(t-1) - eq_switch = create_equation('Switch', self) - eq_switch.add_summand(self.switch_on, 1, system_model.indices[1:]) # SwitchOn(t) - eq_switch.add_summand(self.switch_off, -1, system_model.indices[1:]) # SwitchOff(t) - eq_switch.add_summand(self.on, -1, system_model.indices[1:]) # On(t) - eq_switch.add_summand(self.on, +1, system_model.indices[0:-1]) # On(t-1) - + self.add( + self._model.add_constraints( + self.switch_on.isel(time=slice(1, None)) - self.switch_off.isel(time=slice(1, None)) + == + self.on.isel(time=slice(1,None)) - self.on.isel(time=slice(None,-1)), + name=f'{self.label_full}__switch_con' + ), + 'switch_con' + ) # Initital switch on # eq: SwitchOn(t=0)-SwitchOff(t=0) = On(t=0) - On(t=-1) - eq_initial_switch = create_equation('Initial_Switch', self) - eq_initial_switch.add_summand(self.switch_on, 1, indices_of_variable=0) # SwitchOn(t=0) - eq_initial_switch.add_summand(self.switch_off, -1, indices_of_variable=0) # SwitchOff(t=0) - eq_initial_switch.add_summand(self.on, -1, indices_of_variable=0) # On(t=0) - eq_initial_switch.add_constant(-1 * self.on.previous_values[-1]) # On(t-1) - + self.add( + self._model.add_constraints( + self.switch_on.isel(time=0) - self.switch_off.isel(time=0) + == + self.on.isel(time=0), #TODO: - self.on.previous_values[-1] + name=f'{self.label_full}__initial_switch_con' + ), + 'initial_switch_con' + ) ## Entweder SwitchOff oder SwitchOn # eq: SwitchOn(t) + SwitchOff(t) <= 1.1 - eq_switch_on_or_off = create_equation('Switch_On_or_Off', self, eq_type='ineq') - eq_switch_on_or_off.add_summand(self.switch_on, 1) - eq_switch_on_or_off.add_summand(self.switch_off, 1) - eq_switch_on_or_off.add_constant(1.1) + self.add( + self._model.add_constraints( + self.switch_on + self.switch_off <= 1.1, + name=f'{self.label_full}__switch_on_or_off' + ), + 'switch_on_or_off' + ) ## Anzahl Starts: # eq: nrSwitchOn = sum(SwitchOn(t)) - eq_nr_switch_on = create_equation('NrSwitchOn', self) - eq_nr_switch_on.add_summand(self.nr_switch_on, 1) - eq_nr_switch_on.add_summand(self.switch_on, -1, as_sum=True) + self.add( + self._model.add_constraints( + self.nr_switch_on == self.switch_on.sum(), + name=f'{self.label_full}__switch_on_nr' + ), + 'switch_on_nr' + ) def _create_shares(self, system_model: SystemModel): # Anfahrkosten: From 00646c41f3b8f68d90694971b8c1a0f1678497bb Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 15 Feb 2025 12:26:19 +0100 Subject: [PATCH 078/507] Adjust the way how Model labels are handled --- flixOpt/structure.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/flixOpt/structure.py b/flixOpt/structure.py index 5c9f6e4e8..96adda69c 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -266,20 +266,23 @@ def _create_time_series( class Model: """Stores Variables and Constraints""" - def __init__(self, model: SystemModel, label: str, label_full: Optional[str] = None): + def __init__(self, model: SystemModel, label_of_parent: str, label: str, label_full: Optional[str] = None): """ Parameters ---------- interface : Interface The interface this model is created for. label_of_parent : str - The label of the parent. Used to construct the full label of the model. + The label of the parent (Element). Used to construct the full label of the model. label : str Used to construct the label of the model. If None, the interface label is used. + label_full : str + The full label of the model. If None, the full label is constructed using the other given labels. """ self._model = model self._label = label + self._label_of_parent = label_of_parent self._label_full = label_full self._variables: List[str] = [] @@ -360,7 +363,7 @@ def label(self) -> str: @property def label_full(self) -> str: - return self._label_full or self.label + return self._label_full or f'{self._label_of_parent}__{self.label}' @property def variables(self) -> linopy.Variables: From 1c36308cecff95fb70475211f03407781262ce46 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 15 Feb 2025 12:30:11 +0100 Subject: [PATCH 079/507] Add Shares in OnOffModel --- flixOpt/features.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/flixOpt/features.py b/flixOpt/features.py index 79688a46e..017049ea7 100644 --- a/flixOpt/features.py +++ b/flixOpt/features.py @@ -582,18 +582,24 @@ def _add_switch_constraints(self, system_model: SystemModel): def _create_shares(self, system_model: SystemModel): # Anfahrkosten: - effect_collection = system_model.effect_collection_model effects_per_switch_on = self.parameters.effects_per_switch_on if effects_per_switch_on != {}: - effect_collection.add_share_to_operation( - 'switch_on_effects', self.element, effects_per_switch_on, 1, self.switch_on + self._model.effects.add_share_to_effects( + system_model=self._model, + name=self._label_of_parent, + expressions={effect: self.switch_on * factor for effect, factor in effects_per_switch_on.items()}, + target='operation', ) # Betriebskosten: effects_per_running_hour = self.parameters.effects_per_running_hour if effects_per_running_hour != {}: - effect_collection.add_share_to_operation( - 'running_hour_effects', self.element, effects_per_running_hour, system_model.dt_in_hours, self.on + self._model.effects.add_share_to_effects( + system_model=self._model, + name=self._label_of_parent, + expressions={effect: self.on * factor * self._model.hours_per_step + for effect, factor in effects_per_running_hour.items()}, + target='operation', ) def _previous_on_values(self, epsilon: float = 1e-5) -> np.ndarray: From 23e0e8ba9cdf6300ac8cc092d0ecde175e3a19e7 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 15 Feb 2025 12:36:52 +0100 Subject: [PATCH 080/507] Change typehints and add off constraint --- flixOpt/features.py | 36 ++++++++++++++---------------------- 1 file changed, 14 insertions(+), 22 deletions(-) diff --git a/flixOpt/features.py b/flixOpt/features.py index 017049ea7..1423afd5d 100644 --- a/flixOpt/features.py +++ b/flixOpt/features.py @@ -195,17 +195,17 @@ def __init__( super().__init__(model, label_of_parent, label) assert len(defining_variables) == len(defining_bounds), 'Every defining Variable needs bounds to Model OnOff' self.parameters = on_off_parameters - self.on: Optional[VariableTS] = None + self.on: Optional[linopy.Variable] = None self.total_on_hours: Optional[Variable] = None - self.consecutive_on_hours: Optional[VariableTS] = None - self.consecutive_off_hours: Optional[VariableTS] = None + self.consecutive_on_hours: Optional[linopy.Variable] = None + self.consecutive_off_hours: Optional[linopy.Variable] = None - self.off: Optional[VariableTS] = None + self.off: Optional[linopy.Variable] = None - self.switch_on: Optional[VariableTS] = None - self.switch_off: Optional[VariableTS] = None - self.nr_switch_on: Optional[VariableTS] = None + self.switch_on: Optional[linopy.Variable] = None + self.switch_off: Optional[linopy.Variable] = None + self.nr_switch_on: Optional[linopy.Variable] = None self._defining_variables = defining_variables self._defining_bounds = defining_bounds @@ -237,7 +237,7 @@ def do_modeling(self, system_model: SystemModel): 'on_hours_total' ) - self._add_on_constraints(system_model, system_model.indices) + self._add_on_constraints() if self.parameters.use_off: self.off = self.add( @@ -249,7 +249,8 @@ def do_modeling(self, system_model: SystemModel): 'off' ) - self._add_off_constraints(system_model, system_model.indices) + # eq: var_on(t) + var_off(t) = 1 + self.add(self._model.add_constraints(self.on + self.off == 1, name=f'{self.label_full}__off'), 'off') if self.parameters.use_consecutive_on_hours: self.consecutive_on_hours = self._get_duration_in_hours( @@ -369,24 +370,15 @@ def _add_on_constraints(self): f'If its a Component, you might need to adjust the sizes of all of its flows.' ) - def _add_off_constraints(self, system_model: SystemModel, time_indices: Union[list[int], range]): - assert self.off is not None, f'Off variable of {self.element} must be defined to add constraints' - # Definition var_off: - # eq: var_on(t) + var_off(t) = 1 - eq_off = create_equation('var_off', self, eq_type='eq') - eq_off.add_summand(self.off, 1, time_indices) - eq_off.add_summand(self.on, 1, time_indices) - eq_off.add_constant(1) - def _get_duration_in_hours( self, variable_label: str, - binary_variable: VariableTS, + binary_variable: linopy.Variable, minimum_duration: Optional[TimeSeries], maximum_duration: Optional[TimeSeries], system_model: SystemModel, time_indices: Union[list[int], range], - ) -> VariableTS: + ) -> linopy.Variable: """ creates duration variable and adds constraints to a time-series variable to enforce duration limits based on binary activity. @@ -396,7 +388,7 @@ def _get_duration_in_hours( Parameters: variable_label (str): Label for the duration variable to be created. - binary_variable (VariableTS): + binary_variable (linopy.Variable): Time-series binary variable (e.g., [0, 0, 1, 1, 1, 0, ...]) representing activity states. minimum_duration (Optional[TimeSeries]): Minimum duration the activity must remain active once started. @@ -410,7 +402,7 @@ def _get_duration_in_hours( List or range of indices to which to apply the constraints. Returns: - VariableTS: The created duration variable representing consecutive active durations. + linopy.Variable: The created duration variable representing consecutive active durations. Example: binary_variable: [0, 0, 1, 1, 1, 1, 0, 1, 1, 1, 0, ...] From 29c5b3c8904e3b333c6b3a6dc85f4ff74b088ef4 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 15 Feb 2025 12:39:22 +0100 Subject: [PATCH 081/507] Rename and make more compact --- flixOpt/features.py | 37 ++++++++++++------------------------- 1 file changed, 12 insertions(+), 25 deletions(-) diff --git a/flixOpt/features.py b/flixOpt/features.py index 1423afd5d..123732ddb 100644 --- a/flixOpt/features.py +++ b/flixOpt/features.py @@ -205,7 +205,7 @@ def __init__( self.switch_on: Optional[linopy.Variable] = None self.switch_off: Optional[linopy.Variable] = None - self.nr_switch_on: Optional[linopy.Variable] = None + self.switch_on_nr: Optional[linopy.Variable] = None self._defining_variables = defining_variables self._defining_bounds = defining_bounds @@ -273,29 +273,16 @@ def do_modeling(self, system_model: SystemModel): ) if self.parameters.use_switch_on: - self.switch_on = self.add( - self._model.add_variables( - binary=True, - name=f'{self.label_full}__switch_on', - ), - 'switch_on' - ) + self.switch_on = self.add(self._model.add_variables(binary=True, name=f'{self.label_full}__switch_on'), + 'switch_on') - self.switch_off = self.add( - self._model.add_variables( - binary=True, - name=f'{self.label_full}__switch_off', - ), - 'switch_off' - ) - self.nr_switch_on = self.add( - self._model.add_variables( - upper_bound=self.parameters.switch_on_total_max, - binary=True, - name=f'{self.label_full}__switch_on_nr', - ), - 'switch_on_nr' - ) + self.switch_off = self.add(self._model.add_variables(binary=True, name=f'{self.label_full}__switch_off'), + 'switch_off') + + self.switch_on_nr = self.add(self._model.add_variables(upper_bound=self.parameters.switch_on_total_max, + binary=True, + name=f'{self.label_full}__switch_on_nr'), + 'switch_on_nr') self._add_switch_constraints(system_model) @@ -526,7 +513,7 @@ def _get_duration_in_hours( def _add_switch_constraints(self, system_model: SystemModel): assert self.switch_on is not None, f'Switch On Variable of {self.label_full} must be defined to add constraints' assert self.switch_off is not None, f'Switch Off Variable of {self.label_full} must be defined to add constraints' - assert self.nr_switch_on is not None, ( + assert self.switch_on_nr is not None, ( f'Nr of Switch On Variable of {self.label_full} must be defined to add constraints' ) assert self.on is not None, f'On Variable of {self.label_full} must be defined to add constraints' @@ -566,7 +553,7 @@ def _add_switch_constraints(self, system_model: SystemModel): # eq: nrSwitchOn = sum(SwitchOn(t)) self.add( self._model.add_constraints( - self.nr_switch_on == self.switch_on.sum(), + self.switch_on_nr == self.switch_on.sum(), name=f'{self.label_full}__switch_on_nr' ), 'switch_on_nr' From 1da8e0fd47e250707cbbe31e0cd295f25e684096 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 15 Feb 2025 12:52:02 +0100 Subject: [PATCH 082/507] Use new class Model --- flixOpt/elements.py | 2 +- flixOpt/features.py | 8 ++++---- flixOpt/structure.py | 11 ++++++++--- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/flixOpt/elements.py b/flixOpt/elements.py index 781636509..60dc72fb7 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -326,7 +326,7 @@ def do_modeling(self, system_model: SystemModel): if self.element.on_off_parameters is not None: self.on_off = self.add( OnOffModel( - self.element, self.element.on_off_parameters, [self.flow_rate], [self.absolute_flow_rate_bounds] + self._model, self.element.on_off_parameters, self.label_full, [self.flow_rate], [self.absolute_flow_rate_bounds] ), 'on_off' ) diff --git a/flixOpt/features.py b/flixOpt/features.py index 123732ddb..87ab4ad6d 100644 --- a/flixOpt/features.py +++ b/flixOpt/features.py @@ -222,8 +222,8 @@ def do_modeling(self, system_model: SystemModel): self.total_on_hours = self.add( self._model.add_variables( - lower=self.parameters.on_hours_total_min, - upper=self.parameters.on_hours_total_max, + lower=self.parameters.on_hours_total_min if self.parameters.on_hours_total_min is not None else 0, + upper=self.parameters.on_hours_total_max if self.parameters.on_hours_total_max is not None else np.inf, name=f'{self.label_full}__on_hours_total' ), 'on_hours_total' @@ -278,7 +278,7 @@ def do_modeling(self, system_model: SystemModel): self.switch_off = self.add(self._model.add_variables(binary=True, name=f'{self.label_full}__switch_off'), 'switch_off') - + self.switch_on_nr = self.add(self._model.add_variables(upper_bound=self.parameters.switch_on_total_max, binary=True, name=f'{self.label_full}__switch_on_nr'), @@ -767,7 +767,7 @@ def _nr_of_segments(self): return len(next(iter(self._sample_points.values()))) -class ShareAllocationModel(InterfaceModel): +class ShareAllocationModel(Model): def __init__( self, model: SystemModel, diff --git a/flixOpt/structure.py b/flixOpt/structure.py index 96adda69c..2e4ef14c3 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -102,6 +102,10 @@ def infos(self) -> Dict: def hours_per_step(self): return self.flow_system.hours_per_step + @property + def hours_of_previous_timesteps(self): + return self.flow_system.hours_of_previous_timesteps + @property def coords(self): return self.flow_system.coords @@ -316,11 +320,12 @@ def add( elif isinstance(item, linopy.Constraint): self._constraints.append(item.name) self._constraints_short[item.name] = short_name or item.name - elif isinstance(item, InterfaceModel): + elif isinstance(item, Model): self.sub_models.append(item) self._constraints_short[item.label_full] = short_name or item.label_full else: - raise ValueError(f'Item must be a linopy.Variable or linopy.Constraint, got {type(item)}') + raise ValueError( + f'Item must be a linopy.Variable, linopy.Constraint or flixOpt.structure.Model, got {type(item)}') return item def filter_variables(self, @@ -440,7 +445,7 @@ def __init__(self, model: SystemModel, element: Element): element : Element The element this model is created for. """ - super().__init__(model, label=element.label, label_full=element.label_full) + super().__init__(model, label=element.label, label_of_parent=element.label_full) self.element = element From c0f3f0bd54f840d8bd75b3f085788be6d4ef24dd Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 15 Feb 2025 14:03:03 +0100 Subject: [PATCH 083/507] Improvements all over the place --- flixOpt/components.py | 48 ++++++---- flixOpt/effects.py | 13 ++- flixOpt/elements.py | 62 ++++++------ flixOpt/features.py | 215 +++++++++++++++++++++++------------------- flixOpt/structure.py | 10 +- 5 files changed, 193 insertions(+), 155 deletions(-) diff --git a/flixOpt/components.py b/flixOpt/components.py index 193c15857..0499b2be0 100644 --- a/flixOpt/components.py +++ b/flixOpt/components.py @@ -433,18 +433,21 @@ def do_modeling(self, system_model): super().do_modeling(system_model) lb, ub = self.absolute_charge_state_bounds - self.charge_state = system_model.add_variables( - lower_bound=lb, upper_bound=ub, coords=(self._model.periods, self._model.timesteps_extra), - name=f'{self.label_full}__charge_state' + self.charge_state = self.add(self._model.add_variables( + lower=lb, upper=ub, coords=self._model.coords, + name=f'{self.label_full}__charge_state'), + 'charge_state' ) - self.netto_discharge = system_model.add_variables( - coords=system_model.coords, name=f'{self.label_full}__netto_discharge' + self.netto_discharge = self.add(self._model.add_variables( + coords=self._model.coords, name=f'{self.label_full}__netto_discharge'), + 'netto_discharge' ) # netto_discharge: # eq: nettoFlow(t) - discharging(t) + charging(t) = 0 - self.constraints['netto_discharge'] = system_model.add_constraints( + self.add(self._model.add_constraints( self.netto_discharge == self.element.charging.model.flow_rate - self.element.discharging.model.flow_rate, - name=f'{self.label_full}__netto_discharge' + name=f'{self.label_full}__netto_discharge'), + 'netto_discharge' ) charge_state = self.charge_state @@ -455,18 +458,23 @@ def do_modeling(self, system_model): eff_charge = self.element.eta_charge.active_data eff_discharge = self.element.eta_discharge.active_data - self.constraints['charge_state'] = system_model.add_constraints( + self.add(self._model.add_constraints( charge_state.isel(time=slice(1, None)) == charge_state.isel(time=slice(None, -1)) * (1 - rel_loss * hours_per_step) + charge_rate * eff_charge * hours_per_step - discharge_rate * eff_discharge * hours_per_step, - name=f'{self.label_full}__charge_state' + name=f'{self.label_full}__charge_state'), + 'charge_state' ) if isinstance(self.element.capacity_in_flow_hours, InvestParameters): self._investment = InvestmentModel( - self.element, self.element.capacity_in_flow_hours, self.charge_state, self.relative_charge_state_bounds + model=self._model, + label_of_parent=self.element.label_full, + parameters=self.element.capacity_in_flow_hours, + defining_variable=self.charge_state, + relative_bounds_of_defining_variable=self.relative_charge_state_bounds, ) self.sub_models.append(self._investment) self._investment.do_modeling(system_model) @@ -480,28 +488,32 @@ def _initial_and_final_charge_state(self, system_model): name = f'{self.label_full}__{name_short}' if utils.is_number(self.element.initial_charge_state): - self.constraints[name_short] = system_model.add_constraints( + self.add(self._model.add_constraints( self.charge_state.isel(time=0) == self.element.initial_charge_state, - name=name, + name=name), + name_short ) elif self.element.initial_charge_state == 'lastValueOfSim': - self.constraints[name_short] = system_model.add_constraints( + self.add(self._model.add_constraints( self.charge_state.isel(time=0) == self.charge_state.isel(time=-1), - name=name + name=name), + name_short ) else: # TODO: Validation in Storage Class, not in Model raise Exception(f'initial_charge_state has undefined value: {self.element.initial_charge_state}') if self.element.maximal_final_charge_state is not None: - self.constraints['final_charge_max'] = system_model.add_constraints( + self.add(self._model.add_constraints( self.charge_state.isel(time=-1) <= self.element.maximal_final_charge_state, - name=f'{self.label_full}__final_charge_max' + name=f'{self.label_full}__final_charge_max'), + 'final_charge_max' ) if self.element.minimal_final_charge_state is not None: - self.constraints['final_charge_min'] = system_model.add_constraints( + self.add(self._model.add_constraints( self.charge_state.isel(time=-1) >= self.element.minimal_final_charge_state, - name=f'{self.label_full}__final_charge_min' + name=f'{self.label_full}__final_charge_min'), + 'final_charge_min' ) @property diff --git a/flixOpt/effects.py b/flixOpt/effects.py index e15e29cd5..67e82cd81 100644 --- a/flixOpt/effects.py +++ b/flixOpt/effects.py @@ -332,7 +332,7 @@ def _add_share_between_effects(self, system_model: SystemModel): origin_effect.model.invest.total * factor, ) - def __getitem__(self, label: str) -> 'Effect': + def __getitem__(self, effect: Union[str, Effect]) -> 'Effect': """ Get an effect by label, or return the standard effect if None is passed @@ -340,15 +340,20 @@ def __getitem__(self, label: str) -> 'Effect': KeyError: If no effect with the given label is found. KeyError: If no standard effect is specified. """ - if label is None: + if effect is None: try: return self.standard_effect except: raise KeyError(f'No Standard-effect specified!') + if isinstance(effect, Effect): + if effect in self: + return effect + else: + raise KeyError(f'Effect {effect} not found!') try: - return self.effects[label] + return self.effects[effect] except: - raise KeyError(f'No effect with label {label} found!') + raise KeyError(f'No effect with label {effect} found!') def __contains__(self, item: Union[str, 'Effect']) -> bool: """Check if the effect exists. Checks for label or object""" diff --git a/flixOpt/elements.py b/flixOpt/elements.py index 60dc72fb7..d23ca30a9 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -305,17 +305,17 @@ def __init__(self, model: SystemModel, element: Flow): def do_modeling(self, system_model: SystemModel): # eq relative_minimum(t) * size <= flow_rate(t) <= relative_maximum(t) * size self.flow_rate = self.add( - system_model.add_variables( + self._model.add_variables( lower=self.absolute_flow_rate_bounds[0] if self.element.on_off_parameters is None else 0, upper=self.absolute_flow_rate_bounds[1] if self.element.on_off_parameters is None else np.inf, - coords=system_model.coords, + coords=self._model.coords, name=f'{self.label_full}__flow_rate' ), 'flow_rate' ) if self.element.fixed_relative_profile is not None: self.add( - system_model.add_constraints( + self._model.add_constraints( self.flow_rate == self.element.fixed_relative_profile.active_data, name=f'{self.element.label}_fix_flow_rate' ), @@ -348,7 +348,7 @@ def do_modeling(self, system_model: SystemModel): self._investment.do_modeling(system_model) self.total_flow_hours = self.add( - system_model.add_variables( + self._model.add_variables( lower=self.element.flow_hours_total_min if self.element.flow_hours_total_min is not None else -np.inf, upper=self.element.flow_hours_total_max if self.element.flow_hours_total_max is not None else np.inf, coords=None, @@ -358,8 +358,8 @@ def do_modeling(self, system_model: SystemModel): ) self.add( - system_model.add_constraints( - self.total_flow_hours == (self.flow_rate * system_model.hours_per_step).sum(), + self._model.add_constraints( + self.total_flow_hours == (self.flow_rate * self._model.hours_per_step).sum(), name=f'{self.element.label_full}__total_flow_hours' ), 'total_flow_hours' @@ -374,11 +374,11 @@ def do_modeling(self, system_model: SystemModel): def _create_shares(self, system_model: SystemModel): # Arbeitskosten: if self.element.effects_per_flow_hour != {}: - system_model.effects.add_share_to_effects( - system_model, + self._model.effects.add_share_to_effects( + self._model, name=self.label_full, # Use the full label of the element expressions={ - effect: self.flow_rate * system_model.hours_per_step * factor.active_data + effect: self.flow_rate * self._model.hours_per_step * factor.active_data for effect, factor in self.element.effects_per_flow_hour.items() }, target='operation', @@ -391,18 +391,18 @@ def _create_bounds_for_load_factor(self, system_model: SystemModel): if self.element.load_factor_max is not None: name_short = 'load_factor_max' name = f'{self.element.label_full}__{name_short}' - flow_hours_per_size_max = system_model.hours_per_step * self.element.load_factor_max + flow_hours_per_size_max = self._model.hours_per_step * self.element.load_factor_max if self._investment is not None: self.add( - system_model.add_constraints( + self._model.add_constraints( self.total_flow_hours <= self._investment.size * flow_hours_per_size_max, name=name, ), name_short ) else: self.add( - system_model.add_constraints( + self._model.add_constraints( self.total_flow_hours <= self.element.size * flow_hours_per_size_max, name=name, ), name_short @@ -412,11 +412,11 @@ def _create_bounds_for_load_factor(self, system_model: SystemModel): if self.element.load_factor_min is not None: name_short = 'load_factor_min' name = f'{self.element.label_full}__{name_short}' - flow_hours_per_size_in = system_model.hours_per_step * self.element.load_factor_min + flow_hours_per_size_in = self._model.hours_per_step * self.element.load_factor_min if self._investment is not None: self.add( - system_model.add_constraints( + self._model.add_constraints( self.total_flow_hours >= self._investment.size * flow_hours_per_size_in, name=name ), @@ -424,7 +424,7 @@ def _create_bounds_for_load_factor(self, system_model: SystemModel): ) else: self.add( - system_model.add_constraints( + self._model.add_constraints( self.total_flow_hours >= self.element.size * flow_hours_per_size_in, name=name ), @@ -478,7 +478,7 @@ def do_modeling(self, system_model: SystemModel) -> None: # inputs == outputs inputs = sum([flow.model.flow_rate for flow in self.element.inputs]) outputs = sum([flow.model.flow_rate for flow in self.element.outputs]) - eq_bus_balance = self.add(system_model.add_constraints( + eq_bus_balance = self.add(self._model.add_constraints( inputs == outputs, name=f'{self.label_full}__balance' )) @@ -486,21 +486,23 @@ def do_modeling(self, system_model: SystemModel) -> None: # Fehlerplus/-minus: if self.element.with_excess: excess_penalty = np.multiply( - system_model.hours_per_step, self.element.excess_penalty_per_flow_hour.active_data + self._model.hours_per_step, self.element.excess_penalty_per_flow_hour.active_data ) - self.excess_input = system_model.add_variables( - lower=0, coords=system_model.coords, name=f'{self.label_full}__excess_input' + self.excess_input = self.add(self._model.add_variables( + lower=0, coords=self._model.coords, name=f'{self.label_full}__excess_input'), + 'excess_input' ) - self.excess_output = system_model.add_variables( - lower=0, coords=system_model.coords, name=f'{self.label_full}__excess_output' + self.excess_output = self.add(self._model.add_variables( + lower=0, coords=self._model.coords, name=f'{self.label_full}__excess_output'), + 'excess_output' ) eq_bus_balance.lhs -= -self.excess_input + self.excess_output - system_model.effects.add_share_to_penalty( - system_model, self.element.label_full, (self.excess_input * excess_penalty).sum() + self._model.effects.add_share_to_penalty( + self._model, self.element.label_full, (self.excess_input * excess_penalty).sum() ) - system_model.effects.add_share_to_penalty( - system_model, self.element.label_full, (self.excess_output * excess_penalty).sum() + self._model.effects.add_share_to_penalty( + self._model, self.element.label_full, (self.excess_output * excess_penalty).sum() ) @@ -523,20 +525,20 @@ def do_modeling(self, system_model: SystemModel): if flow.on_off_parameters is None: flow.on_off_parameters = OnOffParameters() - self.sub_models.extend([flow.create_model(system_model) for flow in all_flows]) + self.sub_models.extend([flow.create_model(self._model) for flow in all_flows]) for sub_model in self.sub_models: - sub_model.do_modeling(system_model) + sub_model.do_modeling(self._model) if self.element.on_off_parameters: flow_rates: List[linopy.Variable] = [flow.model.flow_rate for flow in all_flows] bounds: List[Tuple[Numeric, Numeric]] = [flow.model.absolute_flow_rate_bounds for flow in all_flows] self.on_off = OnOffModel(self.element, self.element.on_off_parameters, flow_rates, bounds) self.sub_models.append(self.on_off) - self.on_off.do_modeling(system_model) + self.on_off.do_modeling(self._model) if self.element.prevent_simultaneous_flows: # Simultanious Useage --> Only One FLow is On at a time, but needs a Binary for every flow on_variables = [flow.model.on_off.on for flow in self.element.prevent_simultaneous_flows] - simultaneous_use = PreventSimultaneousUsageModel(self.element, on_variables) + simultaneous_use = PreventSimultaneousUsageModel(self._model, on_variables, self.label_full) self.sub_models.append(simultaneous_use) - simultaneous_use.do_modeling(system_model) + simultaneous_use.do_modeling(self._model) diff --git a/flixOpt/features.py b/flixOpt/features.py index 87ab4ad6d..b161e3ffd 100644 --- a/flixOpt/features.py +++ b/flixOpt/features.py @@ -33,24 +33,24 @@ logger = logging.getLogger('flixOpt') -class InvestmentModel(ElementModel): +class InvestmentModel(Model): """Class for modeling an investment""" def __init__( self, - element: Union['Flow', 'Storage'], - invest_parameters: InvestParameters, - defining_variable: [VariableTS], + model: SystemModel, + label_of_parent: str, + parameters: InvestParameters, + defining_variable: [linopy.Variable], relative_bounds_of_defining_variable: Tuple[Numeric, Numeric], fixed_relative_profile: Optional[Numeric] = None, label: str = 'Investment', - on_variable: Optional[VariableTS] = None, + on_variable: Optional[linopy.Variable] = None, ): """ If fixed relative profile is used, the relative bounds are ignored """ - super().__init__(element, label) - self.element: Union['Flow', 'Storage'] = element + super().__init__(model, label_of_parent, label) self.size: Optional[Union[Skalar, Variable]] = None self.is_invested: Optional[Variable] = None @@ -60,118 +60,135 @@ def __init__( self._defining_variable = defining_variable self._relative_bounds_of_defining_variable = relative_bounds_of_defining_variable self._fixed_relative_profile = fixed_relative_profile - self._invest_parameters = invest_parameters + self.parameters = parameters def do_modeling(self, system_model: SystemModel): - invest_parameters = self._invest_parameters - if invest_parameters.fixed_size and not invest_parameters.optional: - self.size = create_variable('size', self, 1, fixed_value=invest_parameters.fixed_size) + if self.parameters.fixed_size and not self.parameters.optional: + self.size = self.add(self._model.add_variables( + lower=self.parameters.fixed_size, + upper=self.parameters.fixed_size, + name=f'{self.label_full}__size'), + 'size') else: - lower_bound = 0 if invest_parameters.optional else invest_parameters.minimum_size - self.size = create_variable( - 'size', self, 1, lower_bound=lower_bound, upper_bound=invest_parameters.maximum_size - ) + self.size = self.add(self._model.add_variables( + lower=0 if self.parameters.optional else self.parameters.minimum_size, + upper=self.parameters.maximum_size, + name=f'{self.label_full}__size'), + 'size') + # Optional - if invest_parameters.optional: - self.is_invested = create_variable('isInvested', self, 1, is_binary=True) - self._create_bounds_for_optional_investment(system_model) + if self.parameters.optional: + self.is_invested = self.add(self._model.add_variables( + binary=True, + name=f'{self.label_full}__is_invested'), + 'is_invested') + + self._create_bounds_for_optional_investment() # Bounds for defining variable - self._create_bounds_for_defining_variable(system_model) + self._create_bounds_for_defining_variable() self._create_shares(system_model) def _create_shares(self, system_model: SystemModel): - effect_collection = system_model.effect_collection_model - invest_parameters = self._invest_parameters # fix_effects: - fix_effects = invest_parameters.fix_effects + fix_effects = self.parameters.fix_effects if fix_effects != {}: - if invest_parameters.optional: # share: + isInvested * fix_effects - variable_is_invested = self.is_invested - else: - variable_is_invested = None - effect_collection.add_share_to_invest('fix_effects', self.element, fix_effects, 1, variable_is_invested) - - # divest_effects: - divest_effects = invest_parameters.divest_effects - if divest_effects != {}: - if invest_parameters.optional: # share: [divest_effects - isInvested * divest_effects] - # 1. part of share [+ divest_effects]: - effect_collection.add_share_to_invest('divest_effects', self.element, divest_effects, 1, None) - # 2. part of share [- isInvested * divest_effects]: - effect_collection.add_share_to_invest( - 'divest_cancellation_effects', self.element, divest_effects, -1, self.is_invested - ) - # TODO : these 2 parts should be one share! -> SingleShareModel...? + self._model.effects.add_share_to_effects( + system_model=self._model, + name=self._label_of_parent, + expressions={effect: self.is_invested * factor if self.is_invested is not None else factor + for effect, factor in fix_effects.items()}, + target='invest', + ) + + if self.parameters.divest_effects != {} and self.parameters.optional: + # share: divest_effects - isInvested * divest_effects + self._model.effects.add_share_to_effects( + system_model=self._model, + name=self._label_of_parent, + expressions={effect: -self.is_invested * factor + factor for effect, factor in fix_effects.items()}, + target='invest', + ) + + if self.parameters.specific_effects != {}: + self._model.effects.add_share_to_effects( + system_model=self._model, + name=self._label_of_parent, + expressions={effect: self.size * factor for effect, factor in self.parameters.specific_effects.items()}, + target='invest', + ) + + if self.parameters.effects_in_segments: + self._segments = SegmentedSharesModel(self._model, self.size, self.parameters.effects_in_segments, self.is_invested) - # # specific_effects: - specific_effects = invest_parameters.specific_effects - if specific_effects != {}: - # share: + investment_size (=var) * specific_effects - effect_collection.add_share_to_invest('specific_effects', self.element, specific_effects, 1, self.size) # segmented Effects - invest_segments = invest_parameters.effects_in_segments - if invest_segments: + if self.parameters.effects_in_segments: self._segments = SegmentedSharesModel( self.element, (self.size, invest_segments[0]), invest_segments[1], self.is_invested ) self.sub_models.append(self._segments) self._segments.do_modeling(system_model) - def _create_bounds_for_optional_investment(self, system_model: SystemModel): - if self._invest_parameters.fixed_size: + def _create_bounds_for_optional_investment(self): + if self.parameters.fixed_size: # eq: investment_size = isInvested * fixed_size - eq_is_invested = create_equation('is_invested', self, 'eq') - eq_is_invested.add_summand(self.size, -1) - eq_is_invested.add_summand(self.is_invested, self._invest_parameters.fixed_size) + self.add(self._model.add_constraints( + self.size == self.is_invested * self.parameters.fixed_size, + name=f'{self.label_full}__is_invested'), + 'is_invested') + else: # eq1: P_invest <= isInvested * investSize_max - eq_is_invested_ub = create_equation('is_invested_ub', self, 'ineq') - eq_is_invested_ub.add_summand(self.size, 1) - eq_is_invested_ub.add_summand(self.is_invested, np.multiply(-1, self._invest_parameters.maximum_size)) + self.add(self._model.add_constraints( + self.size == self.is_invested * self.parameters.maximum_size, + name=f'{self.label_full}__is_invested_ub'), + 'is_invested_ub') # eq2: P_invest >= isInvested * max(epsilon, investSize_min) - eq_is_invested_lb = create_equation('is_invested_lb', self, 'ineq') - eq_is_invested_lb.add_summand(self.size, -1) - eq_is_invested_lb.add_summand( - self.is_invested, np.maximum(CONFIG.modeling.EPSILON, self._invest_parameters.minimum_size) - ) + self.add(self._model.add_constraints( + self.size >= self.is_invested * np.maximum(CONFIG.modeling.EPSILON,self.parameters.minimum_size), + name=f'{self.label_full}__is_invested_lb'), + 'is_invested_lb') - def _create_bounds_for_defining_variable(self, system_model: SystemModel): - label = self._defining_variable.label + def _create_bounds_for_defining_variable(self): + variable = self._defining_variable # fixed relative value if self._fixed_relative_profile is not None: - # TODO: Allow Off? Currently not... - eq_fixed = create_equation(f'fixed_{label}', self) - eq_fixed.add_summand(self._defining_variable, 1) - eq_fixed.add_summand(self.size, np.multiply(-1, self._fixed_relative_profile)) + # TODO: Allow Off? Currently not.. + self.add(self._model.add_constraints( + variable == self.size * self._fixed_relative_profile, + name=f'{self.label_full}__fixed_{variable.name}'), + f'fixed_{variable.name}') + else: - relative_minimum, relative_maximum = self._relative_bounds_of_defining_variable - eq_upper = create_equation(f'ub_{label}', self, 'ineq') + lb_relative, ub_relative = self._relative_bounds_of_defining_variable # eq: defining_variable(t) <= size * upper_bound(t) - eq_upper.add_summand(self._defining_variable, 1) - eq_upper.add_summand(self.size, np.multiply(-1, relative_maximum)) + self.add(self._model.add_constraints( + variable <= self.size * ub_relative, + name=f'{self.label_full}__ub_{variable.name}'), + f'ub_{variable.name}') - ## 2. Gleichung: Minimum durch Investmentgröße ## - eq_lower = create_equation(f'lb_{label}', self, 'ineq') if self._on_variable is None: # eq: defining_variable(t) >= investment_size * relative_minimum(t) - eq_lower.add_summand(self._defining_variable, -1) - eq_lower.add_summand(self.size, relative_minimum) + self.add(self._model.add_constraints( + variable >= self.size * lb_relative, + name=f'{self.label_full}__lb_{variable.name}'), + f'lb_{variable.name}') else: ## 2. Gleichung: Minimum durch Investmentgröße und On # eq: defining_variable(t) >= mega * (On(t)-1) + size * relative_minimum(t) # ... mit mega = relative_maximum * maximum_size # äquivalent zu:. # eq: - defining_variable(t) + mega * On(t) + size * relative_minimum(t) <= + mega - mega = relative_maximum * self._invest_parameters.maximum_size - eq_lower.add_summand(self._defining_variable, -1) - eq_lower.add_summand(self._on_variable, mega) - eq_lower.add_summand(self.size, relative_minimum) - eq_lower.add_constant(mega) - # Anmerkung: Glg bei Spezialfall relative_minimum = 0 redundant zu OnOff ?? + mega = lb_relative * self.parameters.maximum_size + on = self._on_variable + self.add(self._model.add_constraints( + variable >= mega * (on - 1) + self.size * lb_relative, + name=f'{self.label_full}__lb_{variable.name}'), + f'lb_{variable.name}') + # anmerkung: Glg bei Spezialfall relative_minimum = 0 redundant zu OnOff ?? class OnOffModel(Model): @@ -215,6 +232,7 @@ def do_modeling(self, system_model: SystemModel): self._model.add_variables( name=f'{self.label_full}__on', binary=True, + coords=system_model.coords, #TODO: previous_values=self._previous_on_values(CONFIG.modeling.EPSILON) ), 'on', @@ -244,6 +262,7 @@ def do_modeling(self, system_model: SystemModel): self._model.add_variables( name=f'{self.label_full}__off', binary=True, + coords=system_model.coords, # TODO: previous_values=1 - self._previous_on_values(CONFIG.modeling.EPSILON), ), 'off' @@ -273,16 +292,17 @@ def do_modeling(self, system_model: SystemModel): ) if self.parameters.use_switch_on: - self.switch_on = self.add(self._model.add_variables(binary=True, name=f'{self.label_full}__switch_on'), - 'switch_on') + self.switch_on = self.add(self._model.add_variables( + binary=True, name=f'{self.label_full}__switch_on', coords=system_model.coords),'switch_on') - self.switch_off = self.add(self._model.add_variables(binary=True, name=f'{self.label_full}__switch_off'), - 'switch_off') + self.switch_off = self.add(self._model.add_variables( + binary=True, name=f'{self.label_full}__switch_off', coords=system_model.coords), 'switch_off') - self.switch_on_nr = self.add(self._model.add_variables(upper_bound=self.parameters.switch_on_total_max, - binary=True, - name=f'{self.label_full}__switch_on_nr'), - 'switch_on_nr') + self.switch_on_nr = self.add(self._model.add_variables( + upper=self.parameters.switch_on_total_max if self.parameters.switch_on_total_max is not None else np.inf, + name=f'{self.label_full}__switch_on_nr', + coords=system_model.coords), + 'switch_on_nr') self._add_switch_constraints(system_model) @@ -854,7 +874,7 @@ def add_share( else: self.shares[name] = self.add( system_model.add_variables( - coords=None if expression.ndim == 0 else system_model.coords, + coords=None if isinstance(expression, linopy.LinearExpression) and expression.ndim == 0 or not isinstance(expression, linopy.LinearExpression) else system_model.coords, name=f'{name}__{self.label_full}' ), name @@ -952,7 +972,7 @@ def do_modeling(self, system_model: SystemModel): ) -class PreventSimultaneousUsageModel(ElementModel): +class PreventSimultaneousUsageModel(Model): """ Prevents multiple Multiple Binary variables from being 1 at the same time @@ -970,16 +990,15 @@ class PreventSimultaneousUsageModel(ElementModel): # --> könnte man auch umsetzen (statt force_on_variable() für die Flows, aber sollte aufs selbe wie "new" kommen) """ - def __init__(self, element: Element, variables: List[linopy.Variable], label: str = 'PreventSimultaneousUsage'): - super().__init__(element, label) - self._variables = variables - assert len(self._variables) >= 2, f'Model {self.__class__.__name__} must get at least two variables' - for variable in self._variables: # classic + def __init__(self, model: SystemModel, variables: List[linopy.Variable], label_of_parent: str, label: str = 'PreventSimultaneousUsage'): + super().__init__(model, label_of_parent, label) + self._simultanious_use_variables = variables + assert len(self._simultanious_use_variables) >= 2, f'Model {self.__class__.__name__} must get at least two variables' + for variable in self._simultanious_use_variables: # classic assert variable.attrs['binary'], f'Variable {variable} must be binary for use in {self.__class__.__name__}' def do_modeling(self, system_model: SystemModel): # eq: sum(flow_i.on(t)) <= 1.1 (1 wird etwas größer gewählt wg. Binärvariablengenauigkeit) - self.constraints['prevent_simultaneous_use'] = system_model.add_constraints( - sum([variable*1 for variable in self._variables]) <= 1 + CONFIG.modeling.EPSILON, - name=f'{self.label_full}__prevent_simultaneous_use' - ) + self.add(self._model.add_constraints(sum(self._simultanious_use_variables) <= 1.1, + name=f'{self.label_full}__prevent_simultaneous_use'), + 'prevent_simultaneous_use') diff --git a/flixOpt/structure.py b/flixOpt/structure.py index 2e4ef14c3..235828647 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -71,8 +71,8 @@ def main_results(self) -> Dict[str, Union[Skalar, Dict]]: main_results['buses with excess'] = buses_with_excess for bus in self.flow_system.buses.values(): if bus.with_excess: - excess_in = np.sum(bus.model.excess_input.solution.values) - excess_out = np.sum(bus.model.excess_output.solution.values) + excess_in = float(np.sum(bus.model.excess_input.solution.values)) + excess_out = float(np.sum(bus.model.excess_output.solution.values)) if excess_in > 1e-3 or excess_out > 1e-3: buses_with_excess.append({bus.label_full: {'input': excess_in, 'output': excess_out}}) @@ -83,11 +83,11 @@ def main_results(self) -> Dict[str, Union[Skalar, Dict]]: for component in self.flow_system.components.values(): for model in component.model.all_sub_models: if isinstance(model, InvestmentModel): - invested_size = float(model.size.result) # bei np.floats Probleme bei Speichern + invested_size = float(model.size.solution) # bei np.floats Probleme bei Speichern if invested_size >= CONFIG.modeling.EPSILON: - invest_decisions['invested'][model.element.label_full] = invested_size + invest_decisions['invested'][model._label_of_parent] = invested_size else: - invest_decisions['not invested'][model.element.label_full] = invested_size + invest_decisions['not invested'][model._label_of_parent] = invested_size return main_results From 38244fa9e38ff5db3d039542c70171871fed092a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 15 Feb 2025 14:17:38 +0100 Subject: [PATCH 084/507] Improvements at StorageModel --- flixOpt/components.py | 4 ++-- flixOpt/flow_system.py | 11 +++++++++++ flixOpt/structure.py | 4 ++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/flixOpt/components.py b/flixOpt/components.py index 0499b2be0..e20fb54ad 100644 --- a/flixOpt/components.py +++ b/flixOpt/components.py @@ -434,7 +434,7 @@ def do_modeling(self, system_model): lb, ub = self.absolute_charge_state_bounds self.charge_state = self.add(self._model.add_variables( - lower=lb, upper=ub, coords=self._model.coords, + lower=lb, upper=ub, coords=self._model.coords_extra, name=f'{self.label_full}__charge_state'), 'charge_state' ) @@ -461,7 +461,7 @@ def do_modeling(self, system_model): self.add(self._model.add_constraints( charge_state.isel(time=slice(1, None)) == - charge_state.isel(time=slice(None, -1)) * (1 - rel_loss * hours_per_step) + charge_state.isel(time=slice(None, -1)) * (1 - rel_loss) * hours_per_step + charge_rate * eff_charge * hours_per_step - discharge_rate * eff_discharge * hours_per_step, name=f'{self.label_full}__charge_state'), diff --git a/flixOpt/flow_system.py b/flixOpt/flow_system.py index df43cdfbf..5fe437ab8 100644 --- a/flixOpt/flow_system.py +++ b/flixOpt/flow_system.py @@ -340,10 +340,21 @@ def snapshots(self): 'time': list(self.timesteps)}, ) + @property + def snapshots_extra(self): + return xr.Dataset( + coords={'period': list(self.periods), 'time': list(self.timesteps_extra)} if self.periods is not None else { + 'time': list(self.timesteps_extra)}, + ) + @property def coords(self): return self.snapshots.coords + @property + def coords_extra(self): + return self.snapshots_extra.coords + @property def index_shape(self) -> Tuple[int, int]: return len(self.periods) if self.periods is not None else 1, len(self.timesteps) diff --git a/flixOpt/structure.py b/flixOpt/structure.py index 235828647..334adc5bf 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -110,6 +110,10 @@ def hours_of_previous_timesteps(self): def coords(self): return self.flow_system.coords + @property + def coords_extra(self): + return self.flow_system.coords_extra + class Interface: """ From 3ee1015c69129447ebc34baa5e1d3ac70bc31d4f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 15 Feb 2025 14:57:03 +0100 Subject: [PATCH 085/507] Re-Add logg of main results --- flixOpt/calculation.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py index 44d4771ce..ac00f5eaa 100644 --- a/flixOpt/calculation.py +++ b/flixOpt/calculation.py @@ -116,6 +116,7 @@ def _save_solve_infos(self): width=1000, # Verhinderung Zeilenumbruch für lange equations allow_unicode=True, sort_keys=False, + indent=4, ) message = f' Saved Calculation: {self.name} ' @@ -163,6 +164,12 @@ def solve(self, solver_name: str, save_results: Union[bool, str, pathlib.Path] = self.flow_system.model.solve(log_fn=self._paths['log'], solver_name=solver_name, solver_options=solver_options) self.durations['solving'] = round(timeit.default_timer() - t_start, 2) + # Log the formatted output + logger.info(f'{" Main Results ":#^80}') + logger.info("\n" + yaml.dump( + utils.round_floats(self.flow_system.model.infos), + default_flow_style=False, sort_keys=False, allow_unicode=True, indent=4)) + if save_results: self._save_solve_infos() From c13683c0b0100052695132265c5803c15999ee33 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 15 Feb 2025 14:57:47 +0100 Subject: [PATCH 086/507] TODO: Activation of TImeseries must work for not the same length for all --- flixOpt/calculation.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py index ac00f5eaa..f3bf79758 100644 --- a/flixOpt/calculation.py +++ b/flixOpt/calculation.py @@ -149,8 +149,9 @@ def do_modeling(self) -> SystemModel: self.flow_system.transform_data() for time_series in self.flow_system.all_time_series: - time_series.active_periods = self.flow_system.periods - time_series.active_timesteps = self.flow_system.timesteps + pass # TODO: This must work for timeseriews that are always one step longer + # time_series.active_periods = self.flow_system.periods + #time_series.active_timesteps = self.flow_system.timesteps self.flow_system.create_model() self.flow_system.model.do_modeling() From 48d192f40fc063cf8cda694172f73d6f60ecf2d5 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 15 Feb 2025 14:59:20 +0100 Subject: [PATCH 087/507] Improve Infos and results dict --- flixOpt/structure.py | 72 +++++++++++++++++++++++--------------------- flixOpt/utils.py | 9 ++++++ 2 files changed, 46 insertions(+), 35 deletions(-) diff --git a/flixOpt/structure.py b/flixOpt/structure.py index 334adc5bf..9d32b3ca9 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -55,47 +55,49 @@ def do_modeling(self): @property def main_results(self) -> Dict[str, Union[Skalar, Dict]]: - main_results = {} - effect_results = {} - main_results['Effects'] = effect_results - for effect in self.flow_system.effects.values(): - effect_results[f'{effect.label} [{effect.unit}]'] = { - 'operation': float(effect.model.operation.total.solution.values), - 'invest': float(effect.model.invest.total.solution.values), - 'total': float(effect.model.total.solution.values), - } - main_results['penalty'] = float(self.effects.penalty.total.solution.values) - main_results['Objective'] = self.objective.value - main_results['lower bound'] = 'Not available' - buses_with_excess = [] - main_results['buses with excess'] = buses_with_excess - for bus in self.flow_system.buses.values(): - if bus.with_excess: - excess_in = float(np.sum(bus.model.excess_input.solution.values)) - excess_out = float(np.sum(bus.model.excess_output.solution.values)) - if excess_in > 1e-3 or excess_out > 1e-3: - buses_with_excess.append({bus.label_full: {'input': excess_in, 'output': excess_out}}) - - invest_decisions = {'invested': {}, 'not invested': {}} - main_results['Invest-Decisions'] = invest_decisions from flixOpt.features import InvestmentModel - for component in self.flow_system.components.values(): - for model in component.model.all_sub_models: - if isinstance(model, InvestmentModel): - invested_size = float(model.size.solution) # bei np.floats Probleme bei Speichern - if invested_size >= CONFIG.modeling.EPSILON: - invest_decisions['invested'][model._label_of_parent] = invested_size - else: - invest_decisions['not invested'][model._label_of_parent] = invested_size - - return main_results + return { + "Objective": self.objective.value, + "Penalty": float(self.effects.penalty.total.solution.values), + "Effects": { + f"{effect.label} [{effect.unit}]": { + "operation": float(effect.model.operation.total.solution.values), + "invest": float(effect.model.invest.total.solution.values), + "total": float(effect.model.total.solution.values), + } + for effect in self.flow_system.effects.values() + }, + "Invest-Decisions": { + "Invested": { + model._label_of_parent: float(model.size.solution) + for component in self.flow_system.components.values() + for model in component.model.all_sub_models + if isinstance(model, InvestmentModel) and float(model.size.solution) >= CONFIG.modeling.EPSILON + }, + "Not invested": { + model._label_of_parent: float(model.size.solution) + for component in self.flow_system.components.values() + for model in component.model.all_sub_models + if isinstance(model, InvestmentModel) and float(model.size.solution) < CONFIG.modeling.EPSILON + }, + }, + "Buses with excess": [ + {bus.label_full: { + "input": float(np.sum(bus.model.excess_input.solution.values)), + "output": float(np.sum(bus.model.excess_output.solution.values)) + }} + for bus in self.flow_system.buses.values() + if bus.with_excess and (float(np.sum(bus.model.excess_input.solution.values)) > 1e-3 or + float(np.sum(bus.model.excess_output.solution.values)) > 1e-3) + ], + } @property def infos(self) -> Dict: - return {'Constraints': self.constraints.ncons, + return {'Main Results': self.main_results, + 'Constraints': self.constraints.ncons, 'Variables': self.variables.nvars, - 'Main Results': self.main_results, 'Config': CONFIG.to_dict()} @property diff --git a/flixOpt/utils.py b/flixOpt/utils.py index dcd5b96f4..721bbaa1d 100644 --- a/flixOpt/utils.py +++ b/flixOpt/utils.py @@ -65,6 +65,15 @@ def check_time_series(label: str, time_series: np.ndarray[np.datetime64]): raise Exception(label + ': Zeitreihe besitzt Zurücksprünge - vermutlich Zeitumstellung nicht beseitigt!') +def round_floats(obj, decimals=2): + if isinstance(obj, dict): + return {k: round_floats(v, decimals) for k, v in obj.items()} + elif isinstance(obj, list): + return [round_floats(v, decimals) for v in obj] + elif isinstance(obj, float): + return round(obj, decimals) + return obj + def apply_formating( data_dict: Dict[str, Union[int, float]], key_format: str = '<17', From 05040fe73379b51cce6a173f837531389cf4a2ef Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 15 Feb 2025 15:13:25 +0100 Subject: [PATCH 088/507] Adjust some Model creations --- flixOpt/elements.py | 15 ++++++++------- flixOpt/features.py | 36 ++++++++++++++++++++---------------- flixOpt/interface.py | 4 +++- 3 files changed, 31 insertions(+), 24 deletions(-) diff --git a/flixOpt/elements.py b/flixOpt/elements.py index d23ca30a9..ca88ecdfa 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -71,7 +71,7 @@ def create_model(self, model: SystemModel) -> 'ComponentModel': def transform_data(self, flow_system: 'FlowSystem') -> None: if self.on_off_parameters is not None: - self.on_off_parameters.transform_data(flow_system.timesteps, flow_system.periods, self) + self.on_off_parameters.transform_data(flow_system, self) def register_component_in_flows(self) -> None: for flow in self.inputs + self.outputs: @@ -330,16 +330,17 @@ def do_modeling(self, system_model: SystemModel): ), 'on_off' ) - self.on_off.do_modeling(system_model) + self.on_off.do_modeling(self._model) # Investment if isinstance(self.element.size, InvestParameters): self._investment = self.add( InvestmentModel( - self.element, - self.element.size, - self.flow_rate, - self.relative_flow_rate_bounds, + model=self._model, + label_of_parent=self.element.label_full, + parameters=self.element.size, + defining_variable=self.flow_rate, + relative_bounds_of_defining_variable=self.relative_flow_rate_bounds, fixed_relative_profile=self.fixed_relative_flow_rate, on_variable=self.on_off.on if self.on_off is not None else None, ), @@ -532,7 +533,7 @@ def do_modeling(self, system_model: SystemModel): if self.element.on_off_parameters: flow_rates: List[linopy.Variable] = [flow.model.flow_rate for flow in all_flows] bounds: List[Tuple[Numeric, Numeric]] = [flow.model.absolute_flow_rate_bounds for flow in all_flows] - self.on_off = OnOffModel(self.element, self.element.on_off_parameters, flow_rates, bounds) + self.on_off = OnOffModel(self._model, self.element.on_off_parameters, self.element.label_full, flow_rates, bounds) self.sub_models.append(self.on_off) self.on_off.do_modeling(self._model) diff --git a/flixOpt/features.py b/flixOpt/features.py index b161e3ffd..a107d3a35 100644 --- a/flixOpt/features.py +++ b/flixOpt/features.py @@ -272,24 +272,28 @@ def do_modeling(self, system_model: SystemModel): self.add(self._model.add_constraints(self.on + self.off == 1, name=f'{self.label_full}__off'), 'off') if self.parameters.use_consecutive_on_hours: - self.consecutive_on_hours = self._get_duration_in_hours( - 'consecutiveOnHours', - self.on, - self.parameters.consecutive_on_hours_min, - self.parameters.consecutive_on_hours_max, - system_model, - system_model.indices, - ) + # TODO: Implement consecutive_on_hours + if False: + self.consecutive_on_hours = self._get_duration_in_hours( + 'consecutiveOnHours', + self.on, + self.parameters.consecutive_on_hours_min, + self.parameters.consecutive_on_hours_max, + system_model, + system_model.indices, + ) if self.parameters.use_consecutive_off_hours: - self.consecutive_off_hours = self._get_duration_in_hours( - 'consecutiveOffHours', - self.off, - self.parameters.consecutive_off_hours_min, - self.parameters.consecutive_off_hours_max, - system_model, - system_model.indices, - ) + # TODO: Implement consecutive_on_hours + if False: + self.consecutive_off_hours = self._get_duration_in_hours( + 'consecutiveOffHours', + self.off, + self.parameters.consecutive_off_hours_min, + self.parameters.consecutive_off_hours_max, + system_model, + system_model.indices, + ) if self.parameters.use_switch_on: self.switch_on = self.add(self._model.add_variables( diff --git a/flixOpt/interface.py b/flixOpt/interface.py index b884252b0..8c4b0c84f 100644 --- a/flixOpt/interface.py +++ b/flixOpt/interface.py @@ -156,7 +156,9 @@ def __init__( def transform_data(self, flow_system: 'FlowSystem', owner: 'Element'): from .effects import effect_values_to_time_series - self.effects_per_switch_on = effect_values_to_time_series('per_switch_on', self.effects_per_switch_on, owner) + self.effects_per_switch_on = effect_values_to_time_series( + 'per_switch_on', self.effects_per_switch_on, owner, flow_system.timesteps, flow_system.periods + ) self.effects_per_running_hour = effect_values_to_time_series( 'per_running_hour', self.effects_per_running_hour, owner, flow_system.timesteps, flow_system.periods ) From f7566907fb1ddb666b72980d4efb0d3f56b56bc6 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 15 Feb 2025 16:05:09 +0100 Subject: [PATCH 089/507] Improve Features --- flixOpt/features.py | 154 +++++++++++++++++++++++--------------------- 1 file changed, 79 insertions(+), 75 deletions(-) diff --git a/flixOpt/features.py b/flixOpt/features.py index a107d3a35..a1891bde5 100644 --- a/flixOpt/features.py +++ b/flixOpt/features.py @@ -121,15 +121,15 @@ def _create_shares(self, system_model: SystemModel): ) if self.parameters.effects_in_segments: - self._segments = SegmentedSharesModel(self._model, self.size, self.parameters.effects_in_segments, self.is_invested) - - # segmented Effects - if self.parameters.effects_in_segments: - self._segments = SegmentedSharesModel( - self.element, (self.size, invest_segments[0]), invest_segments[1], self.is_invested + self._segments = self.add( + SegmentedSharesModel( + model=self._model, + label_of_parent=self._label_of_parent, + variable_segments=(self.size, self.parameters.effects_in_segments[0]), + share_segments=self.parameters.effects_in_segments[1], + can_be_outside_segments=self.is_invested), + 'segments' ) - self.sub_models.append(self._segments) - self._segments.do_modeling(system_model) def _create_bounds_for_optional_investment(self): if self.parameters.fixed_size: @@ -686,18 +686,18 @@ def get_consecutive_duration( ) -class SegmentModel(ElementModel): +class SegmentModel(Model): """Class for modeling a linear segment of one or more variables in parallel""" def __init__( self, - element: Element, + model: SystemModel, + label_of_parent: str, segment_index: Union[int, str], sample_points: Dict[Variable, Tuple[Union[Numeric, TimeSeries], Union[Numeric, TimeSeries]]], as_time_series: bool = True, ): - super().__init__(element, f'Segment_{segment_index}') - self.element = element + super().__init__(model, label_of_parent, f'Segment{segment_index}') self.in_segment: Optional[VariableTS] = None self.lambda0: Optional[VariableTS] = None self.lambda1: Optional[VariableTS] = None @@ -720,11 +720,12 @@ def do_modeling(self, system_model: SystemModel): equation.add_summand(self.lambda1, 1) -class MultipleSegmentsModel(ElementModel): +class MultipleSegmentsModel(Model): # TODO: Length... def __init__( self, - element: Element, + model: SystemModel, + label_of_parent: str, sample_points: Dict[Variable, List[Tuple[Numeric, Numeric]]], can_be_outside_segments: Optional[Union[bool, Variable]], as_time_series: bool = True, @@ -735,10 +736,8 @@ def __init__( False or None -> No Variable gets_created; Variable -> the Variable gets used """ - super().__init__(element, label) - self.element = element - - self.outside_segments: Optional[VariableTS] = None + super().__init__(model, label_of_parent, label) + self.outside_segments: Optional[linopy.Variable] = None self._as_time_series = as_time_series self._can_be_outside_segments = can_be_outside_segments @@ -751,40 +750,52 @@ def do_modeling(self, system_model: SystemModel): ] self._segment_models = [ - SegmentModel(self.element, i, sample_points, self._as_time_series) + self.add( + SegmentModel( + self._model, + label_of_parent=self._label_of_parent, + segment_index=i, + sample_points=sample_points, + as_time_series=self._as_time_series), + f'Segment_{i}') for i, sample_points in enumerate(restructured_variables_with_segments) ] - self.sub_models.extend(self._segment_models) - for segment_model in self._segment_models: segment_model.do_modeling(system_model) # eq: - v(t) + (v_0_0 * lambda_0_0 + v_0_1 * lambda_0_1) + (v_1_0 * lambda_1_0 + v_1_1 * lambda_1_1) ... = 0 # -> v_0_0, v_0_1 = Stützstellen des Segments 0 for variable in self._sample_points.keys(): - lambda_eq = create_equation(f'lambda_{variable.label}', self) - lambda_eq.add_summand(variable, -1) - for segment_model in self._segment_models: - lambda_eq.add_summand(segment_model.lambda0, segment_model.sample_points[variable][0]) - lambda_eq.add_summand(segment_model.lambda1, segment_model.sample_points[variable][1]) - - # a) eq: Segment1.onSeg(t) + Segment2.onSeg(t) + ... = 1 Aufenthalt nur in Segmenten erlaubt - # b) eq: -On(t) + Segment1.onSeg(t) + Segment2.onSeg(t) + ... = 0 zusätzlich kann alles auch Null sein - in_single_segment = create_equation('in_single_Segment', self) - for segment_model in self._segment_models: - in_single_segment.add_summand(segment_model.in_segment, 1) - - # a) or b) ? - if isinstance(self._can_be_outside_segments, Variable): # Use existing Variable - self.outside_segments = self._can_be_outside_segments - in_single_segment.add_summand(self.outside_segments, -1) - elif self._can_be_outside_segments is True: # Create Variable - length = system_model.nr_of_time_steps if self._as_time_series else 1 - self.outside_segments = create_variable('outside_segments', self, length, is_binary=True) - in_single_segment.add_summand(self.outside_segments, -1) - else: # Dont allow outside Segments - in_single_segment.add_constant(1) + self.add(self._model.add_constraints( + variable == sum([segment.lambda0 * segment.sample_points[variable][0] + + segment.lambda1 * segment.sample_points[variable][1] + for segment in self._segment_models]), + name=f'{self.label_full}__{variable.name}_lambda'), + f'{variable.name}_lambda' + ) + + # a) eq: Segment1.onSeg(t) + Segment2.onSeg(t) + ... = 1 Aufenthalt nur in Segmenten erlaubt + # b) eq: -On(t) + Segment1.onSeg(t) + Segment2.onSeg(t) + ... = 0 zusätzlich kann alles auch Null sein + if isinstance(self._can_be_outside_segments, linopy.Variable): + self.outside_segments = self._can_be_outside_segments + rhs = self.outside_segments + elif self._can_be_outside_segments is True: + self.outside_segments = self.add(self._model.add_variables( + coords=self._model.coords, + binary=True, + name=f'{self.label_full}__outside_segments'), + 'outside_segments' + ) + rhs = self.outside_segments + else: + rhs = 1 + + self.add(self._model.add_constraints( + sum([segment.in_segment for segment in self._segment_models]) <= rhs, + name=f'{self.label_full}__{variable.name}_single_segment'), + f'single_segment' + ) @property def _nr_of_segments(self): @@ -913,67 +924,60 @@ def solution_structured( } -class SegmentedSharesModel(ElementModel): +class SegmentedSharesModel(Model): # TODO: Length... def __init__( self, - element: Element, + model: SystemModel, + label_of_parent: str, variable_segments: Tuple[Variable, List[Tuple[Skalar, Skalar]]], share_segments: Dict['Effect', List[Tuple[Skalar, Skalar]]], can_be_outside_segments: Optional[Union[bool, Variable]], label: str = 'SegmentedShares', ): - super().__init__(element, label) + super().__init__(model, label_of_parent, label) assert len(variable_segments[1]) == len(list(share_segments.values())[0]), ( 'Segment length of variable_segments and share_segments must be equal' ) - self.element: Element self._can_be_outside_segments = can_be_outside_segments self._variable_segments = variable_segments self._share_segments = share_segments - self._shares: Optional[Dict['Effect', SingleShareModel]] = None + self._shares: Dict['Effect', linopy.Variable] = {} self._segments_model: Optional[MultipleSegmentsModel] = None self._as_tme_series: bool = isinstance(self._variable_segments[0], VariableTS) def do_modeling(self, system_model: SystemModel): - length = system_model.nr_of_time_steps if self._as_tme_series else 1 self._shares = { - effect: create_variable(f'{effect.label}_segmented', self, length) for effect in self._share_segments + effect: self.add(self._model.add_variables( + coords=self._model.coords if self._as_tme_series else None, + name=f'{self.label_full}__{effect.label}'), + f'{effect.label}' + ) for effect in self._share_segments } - segments: Dict[Variable, List[Tuple[Skalar, Skalar]]] = { + segments: Dict[linopy.Variable, List[Tuple[Skalar, Skalar]]] = { **{self._shares[effect]: segment for effect, segment in self._share_segments.items()}, **{self._variable_segments[0]: self._variable_segments[1]}, } - self._segments_model = MultipleSegmentsModel( - self.element, - segments, - can_be_outside_segments=self._can_be_outside_segments, - as_time_series=self._as_tme_series, + self._segments_model = self.add( + MultipleSegmentsModel( + model=self._model, + label_of_parent=self._label_of_parent, + sample_points=segments, + can_be_outside_segments=self._can_be_outside_segments, + as_time_series=self._as_tme_series), + 'segments' ) self._segments_model.do_modeling(system_model) - self.sub_models.append(self._segments_model) # Shares - effect_collection = system_model.effect_collection_model - for effect, variable in self._shares.items(): - if self._as_tme_series: - effect_collection.add_share_to_operation( - name='segmented_effects', - element=self.element, - effect_values={effect: 1}, - factor=1, - variable=variable, - ) - else: - effect_collection.add_share_to_invest( - name='segmented_effects', - element=self.element, - effect_values={effect: 1}, - factor=1, - variable=variable, - ) + self._model.effects.add_share_to_effects( + system_model=self._model, + name=self._label_of_parent, + expressions={effect: variable*1 for effect, variable in self._shares.items()}, + target='operation' if self._as_tme_series else 'invest', + ) class PreventSimultaneousUsageModel(Model): From e9f55c9334b7fc2dbea7c81b3b428879e3252acc Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 15 Feb 2025 16:08:05 +0100 Subject: [PATCH 090/507] Commit example changes --- examples/00_Minmal/minimal_example.py | 7 ++++--- examples/01_Simple/simple_example.py | 8 ++++---- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/examples/00_Minmal/minimal_example.py b/examples/00_Minmal/minimal_example.py index 1726eb409..b722c4aca 100644 --- a/examples/00_Minmal/minimal_example.py +++ b/examples/00_Minmal/minimal_example.py @@ -4,6 +4,7 @@ import numpy as np from rich.pretty import pprint +import pandas as pd import flixOpt as fx @@ -11,7 +12,7 @@ # --- Define Thermal Load Profile --- # Load profile (e.g., kW) for heating demand over time thermal_load_profile = np.array([30, 0, 20]) - datetime_series = fx.create_datetime_array('2020-01-01', 3, 'h') + timesteps = pd.date_range('2020-01-01', periods=3, freq='h') # --- Define Energy Buses --- # These represent the different energy carriers in the system @@ -54,7 +55,7 @@ calculation.do_modeling() # --- Solve the Calculation and Save Results --- - calculation.solve(fx.solvers.HighsSolver(), save_results=True) + calculation.solve('highs', save_results=True) # --- Load and Analyze Results --- # Load results and plot the operation of the District Heating Bus @@ -64,4 +65,4 @@ # Print results to the console. Check Results in file or perform more plotting pprint(calculation.results()) pprint('Look into .yaml and .json file for results') - pprint(calculation.system_model.main_results) + pprint(calculation.flow_system.model.main_results) diff --git a/examples/01_Simple/simple_example.py b/examples/01_Simple/simple_example.py index 317df2665..a8b841b70 100644 --- a/examples/01_Simple/simple_example.py +++ b/examples/01_Simple/simple_example.py @@ -3,6 +3,7 @@ """ import numpy as np +import pandas as pd from rich.pretty import pprint # Used for pretty printing import flixOpt as fx @@ -14,7 +15,7 @@ power_prices = 1 / 1000 * np.array([80, 80, 80, 80, 80, 80, 80, 80, 80]) # Create datetime array starting from '2020-01-01' for the given time period - time_series = fx.create_datetime_array('2020-01-01', len(heat_demand_per_h)) + timesteps = pd.date_range('2020-01-01', periods=len(heat_demand_per_h), freq='h') # --- Define Energy Buses --- # These represent nodes, where the used medias are balanced (electricity, heat, and gas) @@ -91,7 +92,7 @@ # --- Build the Flow System --- # Create the flow system and add all defined components and effects - flow_system = fx.FlowSystem(time_series=time_series) + flow_system = fx.FlowSystem(timesteps=timesteps) flow_system.add_elements(costs, CO2, boiler, storage, chp, heat_sink, gas_source, power_sink) # Visualize the flow system for validation purposes @@ -103,7 +104,7 @@ calculation.do_modeling() # Translate the model to a solvable form, creating equations and Variables # --- Solve the Calculation and Save Results --- - calculation.solve(fx.solvers.HighsSolver(), save_results=True) + calculation.solve('highs', save_results=True) # --- Load and Analyze Results --- # Load the results and plot the operation of the District Heating Bus @@ -117,4 +118,3 @@ # Convert the results for the storage component to a dataframe and display results.to_dataframe('Storage') - pprint(results.all_results) From 7c8eae5215aa7f16ca604bca89bb0597685e0efc Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 15 Feb 2025 16:08:15 +0100 Subject: [PATCH 091/507] Bugfix in structure.py --- flixOpt/structure.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixOpt/structure.py b/flixOpt/structure.py index 9d32b3ca9..f3a1093a3 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -328,7 +328,7 @@ def add( self._constraints_short[item.name] = short_name or item.name elif isinstance(item, Model): self.sub_models.append(item) - self._constraints_short[item.label_full] = short_name or item.label_full + self._sub_models_short[item.label_full] = short_name or item.label_full else: raise ValueError( f'Item must be a linopy.Variable, linopy.Constraint or flixOpt.structure.Model, got {type(item)}') From 674726135b0a72cecb5bcf92dba5b89657a0aba2 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 15 Feb 2025 16:08:48 +0100 Subject: [PATCH 092/507] Commit example changes --- examples/02_Complex/complex_example.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/examples/02_Complex/complex_example.py b/examples/02_Complex/complex_example.py index e5828583d..4f2b7bc67 100644 --- a/examples/02_Complex/complex_example.py +++ b/examples/02_Complex/complex_example.py @@ -26,7 +26,7 @@ ) electricity_price = np.array([40, 40, 40, 40, 40, 40, 40, 40, 40]) - time_series = fx.create_datetime_array('2020-01-01', len(heat_demand), freq='h') + timesteps = pd.date_range('2020-01-01', periods=len(heat_demand), freq='h') # --- Define Energy Buses --- # Represent different energy carriers (electricity, heat, gas) in the system @@ -170,7 +170,7 @@ # --- Build FlowSystem --- # Select components to be included in the final system model - flow_system = fx.FlowSystem(time_series, last_time_step_hours=None) # Create FlowSystem + flow_system = fx.FlowSystem(timesteps) # Create FlowSystem flow_system.add_elements(Costs, CO2, PE, Gaskessel, Waermelast, Gasbezug, Stromverkauf, speicher) flow_system.add_elements(bhkw_2) if use_chp_with_segments else flow_system.add_components(bhkw) @@ -178,17 +178,11 @@ pprint(flow_system) # Get a string representation of the FlowSystem # --- Solve FlowSystem --- - calculation = fx.FullCalculation('Sim1', flow_system, 'pyomo', time_indices) + calculation = fx.FullCalculation('Sim1', flow_system, time_indices) calculation.do_modeling() - # Show variables as str (else, you can find them in the results.yaml file - pprint(calculation.system_model.description_of_constraints()) - pprint(calculation.system_model.description_of_variables()) - calculation.solve( - fx.solvers.HighsSolver( - mip_gap=0.005, time_limit_seconds=30 - ), # Specify which solver you want to use and specify parameters + 'highs', save_results='results', # If and where to save results ) From 214f7cd6d0796f9cfdf0ae8c22725802ea23ccfc Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 15 Feb 2025 16:09:23 +0100 Subject: [PATCH 093/507] Changes in temporary files --- examples/linopy_native_experiments.py | 5 ++--- tests/test_timeseries.py | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/examples/linopy_native_experiments.py b/examples/linopy_native_experiments.py index 5502f17dd..e1fc42e59 100644 --- a/examples/linopy_native_experiments.py +++ b/examples/linopy_native_experiments.py @@ -74,7 +74,7 @@ def index_shape(self) -> Tuple[int, int]: return len(self.periods) if self.periods is not None else 1, len(self.timesteps) -m = SystemModel(pd.date_range(start='2025-01-01', end='2025-01-08', freq='h'), periods=[2025, 2030]) +m = SystemModel(pd.date_range(start='2025-01-01', end='2025-01-08', freq='h', name='time'), periods=[2025, 2030]) rng = np.random.default_rng(seed=42) random_array = rng.random(m.index_shape) @@ -123,7 +123,7 @@ def index_shape(self) -> Tuple[int, int]: # Start every period with 1000 kWh con_storage_start = m.add_constraints( - charge_state.isel(time=0) == 1000, + charge_state.isel(time=0) == xr.DataArray([1000, 2000], coords=(m.periods,)), name="con_storage_start" ) # Start = End for every period @@ -135,7 +135,6 @@ def index_shape(self) -> Tuple[int, int]: ) m.add_constraints(charge_state.isel(period=0, time=40) == 6*charge_state.isel(period=1, time=40), name="couple_periods") m.add_objective((x + 2 * y).sum() + z) -m.solve() m.solve() diff --git a/tests/test_timeseries.py b/tests/test_timeseries.py index a3e001ccd..d4e73dedf 100644 --- a/tests/test_timeseries.py +++ b/tests/test_timeseries.py @@ -1,6 +1,7 @@ import pytest import pandas as pd import xarray as xr +import linopy from flixOpt.core import TimeSeries # Adjust import based on your module structure @@ -145,6 +146,22 @@ def arithmetric_operations(data1: xr.DataArray, ts1: TimeSeries): xr.testing.assert_equal(data1 / ts1_active, data1 / ts1, check_dim_order=True) +def test_operations_with_linopy(): + index = pd.date_range("2023-01-01", periods=3, name="time") + period = pd.Index([2020, 2030], name="period") + + m = linopy.Model() + var1 = m.add_variables(coords=(period, index)) + timeseries1 = TimeSeries( + pd.Series([10, 20, 30,10, 20, 30], index=pd.MultiIndex.from_product([period, index])) + ) + expr = timeseries1 * var1 + expr + timeseries1 + (expr + timeseries1) / timeseries1 + expr = var1 * timeseries1 + + con = m.add_constraints((expr * timeseries1) <= 10) + if __name__ == "__main__": pytest.main() From 267a77a269b86ae26025f079e9017b6328502283 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 16 Feb 2025 08:43:48 +0100 Subject: [PATCH 094/507] Change segments to use variable names as keys, not the actual varaibles --- flixOpt/features.py | 75 +++++++++++++++++++++++++++++++-------------- 1 file changed, 52 insertions(+), 23 deletions(-) diff --git a/flixOpt/features.py b/flixOpt/features.py index a1891bde5..04656aa8c 100644 --- a/flixOpt/features.py +++ b/flixOpt/features.py @@ -130,6 +130,7 @@ def _create_shares(self, system_model: SystemModel): can_be_outside_segments=self.is_invested), 'segments' ) + self._segments.do_modeling(self._model) def _create_bounds_for_optional_investment(self): if self.parameters.fixed_size: @@ -694,7 +695,7 @@ def __init__( model: SystemModel, label_of_parent: str, segment_index: Union[int, str], - sample_points: Dict[Variable, Tuple[Union[Numeric, TimeSeries], Union[Numeric, TimeSeries]]], + sample_points: Dict[str, Tuple[Union[Numeric, TimeSeries], Union[Numeric, TimeSeries]]], as_time_series: bool = True, ): super().__init__(model, label_of_parent, f'Segment{segment_index}') @@ -707,17 +708,33 @@ def __init__( self.sample_points = sample_points def do_modeling(self, system_model: SystemModel): - length = system_model.nr_of_time_steps if self._as_time_series else 1 - self.in_segment = create_variable('inSegment', self, length, is_binary=True) - self.lambda0 = create_variable('lambda0', self, length, lower_bound=0, upper_bound=1) # Wertebereich 0..1 - self.lambda1 = create_variable('lambda1', self, length, lower_bound=0, upper_bound=1) # Wertebereich 0..1 + self.in_segment = self.add(self._model.add_variables( + binary=True, + name=f'{self.label_full}__in_segment', + coords=system_model.coords if self._as_time_series else None), + 'in_segment' + ) - # eq: -aSegment.onSeg(t) + aSegment.lambda1(t) + aSegment.lambda2(t) = 0 - equation = create_equation('inSegment', self) + self.lambda0 = self.add(self._model.add_variables( + lower=0, upper=1, + name=f'{self.label_full}__lambda0', + coords=system_model.coords if self._as_time_series else None), + 'lambda0' + ) - equation.add_summand(self.in_segment, -1) - equation.add_summand(self.lambda0, 1) - equation.add_summand(self.lambda1, 1) + self.lambda1 = self.add(self._model.add_variables( + lower=0, upper=1, + name=f'{self.label_full}__lambda1', + coords=system_model.coords if self._as_time_series else None), + 'lambda1' + ) + + # eq: lambda0(t) + lambda1(t) = in_segment(t) + self.add(self._model.add_constraints( + self.in_segment == self.lambda0 + self.lambda1, + name=f'{self.label_full}__in_segment'), + 'in_segment' + ) class MultipleSegmentsModel(Model): @@ -726,15 +743,25 @@ def __init__( self, model: SystemModel, label_of_parent: str, - sample_points: Dict[Variable, List[Tuple[Numeric, Numeric]]], + sample_points: Dict[str, List[Tuple[Numeric, Numeric]]], can_be_outside_segments: Optional[Union[bool, Variable]], as_time_series: bool = True, label: str = 'MultipleSegments', ): """ - can_be_outside_segments: True -> Variable gets created; - False or None -> No Variable gets_created; - Variable -> the Variable gets used + Parameters + ---------- + model : linopy.Model + Model to which the segmented variable belongs. + label_of_parent : str + Name of the parent variable. + sample_points : dict[str, list[tuple[float, float]]] + Dictionary mapping variables (names) to their sample points for each segment. + The sample points are tuples of the form (start, end). + can_be_outside_segments : bool or linopy.Variable, optional + Whether the variable can be outside the segments. If True, a variable is created. + If False or None, no variable is created. If a Variable is passed, it is used. + as_time_series : bool, optional """ super().__init__(model, label_of_parent, label) self.outside_segments: Optional[linopy.Variable] = None @@ -745,7 +772,7 @@ def __init__( self._segment_models: List[SegmentModel] = [] def do_modeling(self, system_model: SystemModel): - restructured_variables_with_segments: List[Dict[Variable, Tuple[Numeric, Numeric]]] = [ + restructured_variables_with_segments: List[Dict[str, Tuple[Numeric, Numeric]]] = [ {key: values[i] for key, values in self._sample_points.items()} for i in range(self._nr_of_segments) ] @@ -766,13 +793,14 @@ def do_modeling(self, system_model: SystemModel): # eq: - v(t) + (v_0_0 * lambda_0_0 + v_0_1 * lambda_0_1) + (v_1_0 * lambda_1_0 + v_1_1 * lambda_1_1) ... = 0 # -> v_0_0, v_0_1 = Stützstellen des Segments 0 - for variable in self._sample_points.keys(): + for var_name in self._sample_points.keys(): + variable = self._model.variables[var_name] self.add(self._model.add_constraints( - variable == sum([segment.lambda0 * segment.sample_points[variable][0] - + segment.lambda1 * segment.sample_points[variable][1] + variable == sum([segment.lambda0 * segment.sample_points[var_name][0] + + segment.lambda1 * segment.sample_points[var_name][1] for segment in self._segment_models]), - name=f'{self.label_full}__{variable.name}_lambda'), - f'{variable.name}_lambda' + name=f'{self.label_full}__{var_name}_lambda'), + f'{var_name}_lambda' ) # a) eq: Segment1.onSeg(t) + Segment2.onSeg(t) + ... = 1 Aufenthalt nur in Segmenten erlaubt @@ -955,9 +983,10 @@ def do_modeling(self, system_model: SystemModel): ) for effect in self._share_segments } - segments: Dict[linopy.Variable, List[Tuple[Skalar, Skalar]]] = { - **{self._shares[effect]: segment for effect, segment in self._share_segments.items()}, - **{self._variable_segments[0]: self._variable_segments[1]}, + # Mapping variable names to segments + segments: Dict[str, List[Tuple[Skalar, Skalar]]] = { + **{self._shares[effect].name: segment for effect, segment in self._share_segments.items()}, + **{self._variable_segments[0].name: self._variable_segments[1]}, } self._segments_model = self.add( From 882f4543a6a78acaab9d3f36fd80973095e93cdb Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 16 Feb 2025 09:01:00 +0100 Subject: [PATCH 095/507] Improve handling of previous values in OnOffModel --- flixOpt/features.py | 86 ++++++++++++++++++++++++++++++++------------- 1 file changed, 62 insertions(+), 24 deletions(-) diff --git a/flixOpt/features.py b/flixOpt/features.py index 04656aa8c..8d605bb01 100644 --- a/flixOpt/features.py +++ b/flixOpt/features.py @@ -205,10 +205,28 @@ def __init__( label_of_parent: str, defining_variables: List[linopy.Variable], defining_bounds: List[Tuple[Numeric, Numeric]], + previous_values: List[Numeric], label: str = 'OnOffModel', ): """ - defining_bounds: a list of Numeric, that can be used to create the bound for On/Off more efficiently + Constructor for OnOffModel + + Parameters + ---------- + model: SystemModel + Reference to the SystemModel + on_off_parameters: OnOffParameters + Parameters for the OnOffModel + label_of_parent: + Label of the Parent + defining_variables: + List of Variables that are used to define the OnOffModel + defining_bounds: + List of Tuples, defining the absolute bounds of each defining variable + previous_values: + List of previous values of the defining variables + label: + Label of the OnOffModel """ super().__init__(model, label_of_parent, label) assert len(defining_variables) == len(defining_bounds), 'Every defining Variable needs bounds to Model OnOff' @@ -227,6 +245,7 @@ def __init__( self._defining_variables = defining_variables self._defining_bounds = defining_bounds + self._previous_values = previous_values def do_modeling(self, system_model: SystemModel): self.on = self.add( @@ -606,12 +625,31 @@ def _create_shares(self, system_model: SystemModel): target='operation', ) - def _previous_on_values(self, epsilon: float = 1e-5) -> np.ndarray: + @property + def previous_on_values(self) -> np.ndarray: + return self.compute_previous_on_values(self._previous_values) + + @property + def previous_off_values(self) -> np.ndarray: + return 1 - self.previous_on_values + + @property + def previous_consecutive_on_hours(self) -> np.ndarray: + return self.compute_consecutive_duration(self.previous_on_values, self._model.hours_per_step) + + @property + def previous_consecutive_off_hours(self) -> np.ndarray: + return self.compute_consecutive_duration(self.previous_off_values, self._model.hours_per_step) + + @staticmethod + def compute_previous_on_values(previous_values: List[Numeric], epsilon: float = 1e-5) -> np.ndarray: """ - Returns the previous 'on' states of defining variables as a binary array. + Computes the previous 'on' states of defining variables as a binary array from their previous values. Parameters: ---------- + previous_values: List[Numeric] + List of previous values of the defining variables. In Range [0, inf] epsilon : float, optional Tolerance for equality to determine "off" state, default is 1e-5. @@ -621,7 +659,6 @@ def _previous_on_values(self, epsilon: float = 1e-5) -> np.ndarray: A binary array (0 and 1) indicating the previous on/off states of the variables. Returns `array([0])` if no previous values are available. """ - previous_values = [var.previous_values for var in self._defining_variables if var.previous_values is not None] if not previous_values: return np.array([0]) @@ -632,22 +669,23 @@ def _previous_on_values(self, epsilon: float = 1e-5) -> np.ndarray: else: return (~np.isclose(previous_values, 0, atol=epsilon)).astype(int) - @classmethod - def get_consecutive_duration( - cls, binary_values: Union[int, np.ndarray], dt_in_hours: Union[int, float, np.ndarray] + @staticmethod + def compute_consecutive_duration( + binary_values: Union[int, np.ndarray], + hours_per_timestep: Union[int, float, np.ndarray] ) -> Skalar: """ - Returns the current consecutive duration in hours, computed from binary values. - If only one binary value is availlable, the last dt_in_hours is used. - Of both binary_values and dt_in_hours are arrays, checks that the length of dt_in_hours has at least as - many elements as the last consecutive duration in binary_values. + Computes the final consecutive duration in State 'on' (=1) in hours, from a binary. + + hours_per_timestep is handled in a way, that maximizes compatability. + Its length must only be as long as the last consecutive duration in binary_values. Parameters ---------- binary_values : int, np.ndarray An int or 1D binary array containing only `0`s and `1`s. - dt_in_hours : int, float, np.ndarray - The duration of each time step in hours. + hours_per_timestep : int, float, np.ndarray + The duration of each timestep in hours. Returns ------- @@ -659,31 +697,31 @@ def get_consecutive_duration( TypeError If the length of binary_values and dt_in_hours is not equal, but None is a scalar. """ - if np.isscalar(binary_values) and np.isscalar(dt_in_hours): - return binary_values * dt_in_hours - elif np.isscalar(binary_values) and not np.isscalar(dt_in_hours): - return binary_values * dt_in_hours[-1] + if np.isscalar(binary_values) and np.isscalar(hours_per_timestep): + return binary_values * hours_per_timestep + elif np.isscalar(binary_values) and not np.isscalar(hours_per_timestep): + return binary_values * hours_per_timestep[-1] # Find the indexes where value=`0` in a 1D-array zero_indices = np.where(np.isclose(binary_values, 0, atol=CONFIG.modeling.EPSILON))[0] length_of_last_duration = zero_indices[-1] + 1 if zero_indices.size > 0 else len(binary_values) - if not np.isscalar(binary_values) and np.isscalar(dt_in_hours): - return np.sum(binary_values[-length_of_last_duration:] * dt_in_hours) + if not np.isscalar(binary_values) and np.isscalar(hours_per_timestep): + return np.sum(binary_values[-length_of_last_duration:] * hours_per_timestep) - elif not np.isscalar(binary_values) and not np.isscalar(dt_in_hours): - if length_of_last_duration > len(dt_in_hours): # check that lengths are compatible + elif not np.isscalar(binary_values) and not np.isscalar(hours_per_timestep): + if length_of_last_duration > len(hours_per_timestep): # check that lengths are compatible raise TypeError( f'When trying to calculate the consecutive duration, the length of the last duration ' - f'({len(length_of_last_duration)}) is longer than the dt_in_hours ({len(dt_in_hours)}), ' + f'({len(length_of_last_duration)}) is longer than the hours_per_timestep ({len(hours_per_timestep)}), ' f'as {binary_values=}' ) - return np.sum(binary_values[-length_of_last_duration:] * dt_in_hours[-length_of_last_duration:]) + return np.sum(binary_values[-length_of_last_duration:] * hours_per_timestep[-length_of_last_duration:]) else: raise Exception( f'Unexpected state reached in function get_consecutive_duration(). binary_values={binary_values}; ' - f'dt_in_hours={dt_in_hours}' + f'hours_per_timestep={hours_per_timestep}' ) From f973dc15c5821956cfbb4bad495b668b15a87df8 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 16 Feb 2025 09:26:34 +0100 Subject: [PATCH 096/507] Add modeling of consecutive_hours --- flixOpt/features.py | 148 +++++++++++++++++++------------------------- 1 file changed, 65 insertions(+), 83 deletions(-) diff --git a/flixOpt/features.py b/flixOpt/features.py index 8d605bb01..20b44677e 100644 --- a/flixOpt/features.py +++ b/flixOpt/features.py @@ -231,6 +231,10 @@ def __init__( super().__init__(model, label_of_parent, label) assert len(defining_variables) == len(defining_bounds), 'Every defining Variable needs bounds to Model OnOff' self.parameters = on_off_parameters + self._defining_variables = defining_variables + self._defining_bounds = defining_bounds + self._previous_values = previous_values + self.on: Optional[linopy.Variable] = None self.total_on_hours: Optional[Variable] = None @@ -243,10 +247,6 @@ def __init__( self.switch_off: Optional[linopy.Variable] = None self.switch_on_nr: Optional[linopy.Variable] = None - self._defining_variables = defining_variables - self._defining_bounds = defining_bounds - self._previous_values = previous_values - def do_modeling(self, system_model: SystemModel): self.on = self.add( self._model.add_variables( @@ -297,10 +297,9 @@ def do_modeling(self, system_model: SystemModel): self.consecutive_on_hours = self._get_duration_in_hours( 'consecutiveOnHours', self.on, + self.previous_consecutive_on_hours, self.parameters.consecutive_on_hours_min, self.parameters.consecutive_on_hours_max, - system_model, - system_model.indices, ) if self.parameters.use_consecutive_off_hours: @@ -309,10 +308,9 @@ def do_modeling(self, system_model: SystemModel): self.consecutive_off_hours = self._get_duration_in_hours( 'consecutiveOffHours', self.off, + self.previous_consecutive_off_hours, self.parameters.consecutive_off_hours_min, self.parameters.consecutive_off_hours_max, - system_model, - system_model.indices, ) if self.parameters.use_switch_on: @@ -403,12 +401,12 @@ def _add_on_constraints(self): def _get_duration_in_hours( self, - variable_label: str, + variable_name: str, + variable_name_short: str, binary_variable: linopy.Variable, + previous_duration: Skalar, minimum_duration: Optional[TimeSeries], maximum_duration: Optional[TimeSeries], - system_model: SystemModel, - time_indices: Union[list[int], range], ) -> linopy.Variable: """ creates duration variable and adds constraints to a time-series variable to enforce duration limits based on @@ -427,10 +425,6 @@ def _get_duration_in_hours( maximum_duration (Optional[TimeSeries]): Maximum duration the activity can remain active. If None, the maximum duration is set to the total available time. - system_model (SystemModel): - The system model containing time step information. - time_indices (Union[list[int], range]): - List or range of indices to which to apply the constraints. Returns: linopy.Variable: The created duration variable representing consecutive active durations. @@ -451,63 +445,59 @@ def _get_duration_in_hours( AssertionError: If the binary_variable is None, indicating the duration constraints cannot be applied. """ - try: - previous_duration: Skalar = self.get_consecutive_duration( - binary_variable.previous_values, system_model.previous_dt_in_hours - ) - except TypeError as e: - raise TypeError(f'The consecutive_duration of "{variable_label}" could not be calculated. {e}') from e - mega = system_model.dt_in_hours_total + previous_duration + assert binary_variable is not None, f'Duration Variable of {self.label_full} must be defined to add constraints' + + mega = self._model.hours_per_step + previous_duration if maximum_duration is not None: - first_step_max: Skalar = ( - maximum_duration.active_data[0] if maximum_duration.is_array else maximum_duration.active_data - ) - if previous_duration + system_model.dt_in_hours[0] > first_step_max: + first_step_max: Skalar = maximum_duration.isel(time=0) + + if previous_duration + self._model.hours_per_step[0] > first_step_max: logger.warning( - f'The maximum duration of "{variable_label}" is set to {maximum_duration.active_data}h, ' + f'The maximum duration of "{variable_name}" is set to {maximum_duration.active_data}h, ' f'but the consecutive_duration previous to this model is {previous_duration}h. ' - f'This forces "{binary_variable.label} = 0" in the first time step ' - f'(dt={system_model.dt_in_hours[0]}h)!' + f'This forces "{binary_variable.name} = 0" in the first time step ' + f'(dt={self._model.hours_per_step[0]}h)!' ) - duration_in_hours = create_variable( - variable_label, - self, - system_model.nr_of_time_steps, - lower_bound=0, - upper_bound=maximum_duration.active_data if maximum_duration is not None else mega, - previous_values=previous_duration, + duration_in_hours = self.add(self._model.add_variables( + lower=0, + upper=maximum_duration if maximum_duration is not None else mega, + coords=self._model.coords, + name=variable_name), + variable_name_short ) - label_prefix = duration_in_hours.label - - assert binary_variable is not None, f'Duration Variable of {self.element} must be defined to add constraints' - # TODO: Einfachere Variante von Peter umsetzen! # 1) eq: duration(t) - On(t) * BIG <= 0 - constraint_1 = create_equation(f'{label_prefix}_constraint_1', self, eq_type='ineq') - constraint_1.add_summand(duration_in_hours, 1) - constraint_1.add_summand(binary_variable, -1 * mega) + self.add(self._model.add_constraints( + duration_in_hours <= binary_variable * mega, + name=f'{self.label_full}__{variable_name_short}_con1'), + f'{variable_name_short}_con1' + ) # 2a) eq: duration(t) - duration(t-1) <= dt(t) # on(t)=1 -> duration(t) - duration(t-1) <= dt(t) # on(t)=0 -> duration(t-1) >= negat. value - constraint_2a = create_equation(f'{label_prefix}_constraint_2a', self, eq_type='ineq') - constraint_2a.add_summand(duration_in_hours, 1, time_indices[1:]) # duration(t) - constraint_2a.add_summand(duration_in_hours, -1, time_indices[0:-1]) # duration(t-1) - constraint_2a.add_constant(system_model.dt_in_hours[1:]) # dt(t) + self.add(self._model.add_constraints( + duration_in_hours.isel(time=slice(1, None)) + == + duration_in_hours.isel(time=slice(None, -1)) + self._model.hours_per_step.isel(time=slice(None, -1)), + name=f'{self.label_full}__{variable_name_short}_con2a'), + f'{variable_name_short}_con2a' + ) # 2b) eq: dt(t) - BIG * ( 1-On(t) ) <= duration(t) - duration(t-1) # eq: -duration(t) + duration(t-1) + On(t) * BIG <= -dt(t) + BIG # with BIG = dt_in_hours_total. # on(t)=1 -> duration(t)- duration(t-1) >= dt(t) # on(t)=0 -> duration(t)- duration(t-1) >= negat. value - - constraint_2b = create_equation(f'{label_prefix}_constraint_2b', self, eq_type='ineq') - constraint_2b.add_summand(duration_in_hours, -1, time_indices[1:]) # duration(t) - constraint_2b.add_summand(duration_in_hours, 1, time_indices[0:-1]) # duration(t-1) - constraint_2b.add_summand(binary_variable, mega, time_indices[1:]) # on(t) - constraint_2b.add_constant(-1 * system_model.dt_in_hours[1:] + mega) # dt(t) + self.add(self._model.add_constraints( + (binary_variable + 1) * mega + self._model.hours_per_step.isel(time=slice(None, -1)) + <= + duration_in_hours.isel(time=slice(1, None)) - duration_in_hours.isel(time=slice(None, -1)), + name=f'{self.label_full}__{variable_name_short}_con2b'), + f'{variable_name_short}_con2b' + ) # 3) check minimum_duration before switchOff-step @@ -517,40 +507,32 @@ def _get_duration_in_hours( # Note: (previous values before t=1 are not recognised!) # eq: duration(t) >= minimum_duration(t) * [On(t) - On(t+1)] for t=1..(n-1) # eq: -duration(t) + minimum_duration(t) * On(t) - minimum_duration(t) * On(t+1) <= 0 - if minimum_duration.is_scalar: - minimum_duration_used = minimum_duration.active_data - else: - minimum_duration_used = minimum_duration.active_data[0:-1] # only checked for t=1...(n-1) - eq_min_duration = create_equation(f'{label_prefix}_minimum_duration', self, eq_type='ineq') - eq_min_duration.add_summand(duration_in_hours, -1, time_indices[0:-1]) # -duration(t) - eq_min_duration.add_summand( - binary_variable, -1 * minimum_duration_used, time_indices[1:] - ) # - minimum_duration (t) * On(t+1) - eq_min_duration.add_summand( - binary_variable, minimum_duration_used, time_indices[0:-1] - ) # minimum_duration * On(t) - - first_step_min: Skalar = ( - minimum_duration.active_data[0] if minimum_duration.is_array else minimum_duration.active_data + self.add(self._model.add_constraints( + duration_in_hours + >= + (binary_variable.isel(time=slice(None, -1)) - binary_variable.isel(time=slice(1, None))) + * minimum_duration.isel(time=slice(None, -1)), + name=f'{self.label_full}__{variable_name_short}_minimum_duration'), + f'{variable_name_short}_minimum_duration' ) - if duration_in_hours.previous_values < first_step_min: + + if previous_duration < minimum_duration.isel(time=0): # Force the first step to be = 1, if the minimum_duration is not reached in previous_values # Note: Only if the previous consecutive_duration is smaller than the minimum duration # eq: On(t=0) = 1 - eq_min_duration_inital = create_equation(f'{label_prefix}_minimum_duration_inital', self, eq_type='eq') - eq_min_duration_inital.add_summand(binary_variable, 1, time_indices[0]) - eq_min_duration_inital.add_constant(1) - - # 4) first index: - # eq: duration(t=0)= dt(0) * On(0) - first_index = time_indices[0] # only first element - eq_first = create_equation(f'{label_prefix}_initial', self) - eq_first.add_summand(duration_in_hours, 1, first_index) - eq_first.add_summand( - binary_variable, - -1 * (system_model.dt_in_hours[first_index] + duration_in_hours.previous_values), - first_index, - ) + self.add(self._model.add_constraints( + binary_variable.isel(time=0) == 1, + name=f'{self.label_full}__{variable_name_short}_minimum_inital'), + f'{variable_name_short}_minimum_inital' + ) + + # 4) first index: + # eq: duration(t=0)= dt(0) * On(0) + self.add(self._model.add_constraints( + duration_in_hours.isel(time=0) == self._model.hours_per_step.isel(time=0) * binary_variable.isel(time=0), + name=f'{self.label_full}__{variable_name_short}_initial'), + f'{variable_name_short}_initial' + ) return duration_in_hours From 78532a40b6fae59ab28abd6a882aeb59bcb1a8fc Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 16 Feb 2025 09:39:15 +0100 Subject: [PATCH 097/507] Bugfix in TimeSeries --- flixOpt/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixOpt/core.py b/flixOpt/core.py index afb53e225..0b72a0ca4 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -286,7 +286,7 @@ def sel(self): @property def isel(self): - return self.active_data.sel + return self.active_data.isel # Enable arithmetic operations using active_data def _apply_operation(self, other, op): From b303206b8b8287f46479a13670fd737ac441820f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 16 Feb 2025 09:45:32 +0100 Subject: [PATCH 098/507] Improve Model handling in elements.py --- flixOpt/elements.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/flixOpt/elements.py b/flixOpt/elements.py index ca88ecdfa..23287f74b 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -326,7 +326,8 @@ def do_modeling(self, system_model: SystemModel): if self.element.on_off_parameters is not None: self.on_off = self.add( OnOffModel( - self._model, self.element.on_off_parameters, self.label_full, [self.flow_rate], [self.absolute_flow_rate_bounds] + self._model, self.element.on_off_parameters, self.label_full, [self.flow_rate], [self.absolute_flow_rate_bounds], + [self.element.previous_flow_rate] ), 'on_off' ) @@ -526,20 +527,25 @@ def do_modeling(self, system_model: SystemModel): if flow.on_off_parameters is None: flow.on_off_parameters = OnOffParameters() - self.sub_models.extend([flow.create_model(self._model) for flow in all_flows]) + for flow in all_flows: + self.add(flow.create_model(self._model), flow.label) + for sub_model in self.sub_models: sub_model.do_modeling(self._model) if self.element.on_off_parameters: - flow_rates: List[linopy.Variable] = [flow.model.flow_rate for flow in all_flows] - bounds: List[Tuple[Numeric, Numeric]] = [flow.model.absolute_flow_rate_bounds for flow in all_flows] - self.on_off = OnOffModel(self._model, self.element.on_off_parameters, self.element.label_full, flow_rates, bounds) - self.sub_models.append(self.on_off) + self.on_off = self.add(OnOffModel( + self._model, + self.element.on_off_parameters, + self.element.label_full, + defining_variables=[flow.model.flow_rate for flow in all_flows], + defining_bounds=[flow.model.absolute_flow_rate_bounds for flow in all_flows], + previous_values=[flow.previous_flow_rate for flow in all_flows])) + self.on_off.do_modeling(self._model) if self.element.prevent_simultaneous_flows: # Simultanious Useage --> Only One FLow is On at a time, but needs a Binary for every flow on_variables = [flow.model.on_off.on for flow in self.element.prevent_simultaneous_flows] - simultaneous_use = PreventSimultaneousUsageModel(self._model, on_variables, self.label_full) - self.sub_models.append(simultaneous_use) + simultaneous_use = self.add(PreventSimultaneousUsageModel(self._model, on_variables, self.label_full)) simultaneous_use.do_modeling(self._model) From b3a53b12486a92fbca6d17bfdc146e9b96304763 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 16 Feb 2025 09:45:45 +0100 Subject: [PATCH 099/507] Adjust naming and other stuff in OnOffModel --- flixOpt/features.py | 79 +++++++++++++++++++++------------------------ 1 file changed, 36 insertions(+), 43 deletions(-) diff --git a/flixOpt/features.py b/flixOpt/features.py index 20b44677e..c789fe0ad 100644 --- a/flixOpt/features.py +++ b/flixOpt/features.py @@ -205,7 +205,7 @@ def __init__( label_of_parent: str, defining_variables: List[linopy.Variable], defining_bounds: List[Tuple[Numeric, Numeric]], - previous_values: List[Numeric], + previous_values: List[Optional[Numeric]], label: str = 'OnOffModel', ): """ @@ -253,7 +253,6 @@ def do_modeling(self, system_model: SystemModel): name=f'{self.label_full}__on', binary=True, coords=system_model.coords, - #TODO: previous_values=self._previous_on_values(CONFIG.modeling.EPSILON) ), 'on', ) @@ -283,7 +282,6 @@ def do_modeling(self, system_model: SystemModel): name=f'{self.label_full}__off', binary=True, coords=system_model.coords, - # TODO: previous_values=1 - self._previous_on_values(CONFIG.modeling.EPSILON), ), 'off' ) @@ -292,26 +290,22 @@ def do_modeling(self, system_model: SystemModel): self.add(self._model.add_constraints(self.on + self.off == 1, name=f'{self.label_full}__off'), 'off') if self.parameters.use_consecutive_on_hours: - # TODO: Implement consecutive_on_hours - if False: - self.consecutive_on_hours = self._get_duration_in_hours( - 'consecutiveOnHours', - self.on, - self.previous_consecutive_on_hours, - self.parameters.consecutive_on_hours_min, - self.parameters.consecutive_on_hours_max, - ) + self.consecutive_on_hours = self._get_duration_in_hours( + 'consecutive_on_hours', + self.on, + self.previous_consecutive_on_hours, + self.parameters.consecutive_on_hours_min, + self.parameters.consecutive_on_hours_max, + ) if self.parameters.use_consecutive_off_hours: - # TODO: Implement consecutive_on_hours - if False: - self.consecutive_off_hours = self._get_duration_in_hours( - 'consecutiveOffHours', - self.off, - self.previous_consecutive_off_hours, - self.parameters.consecutive_off_hours_min, - self.parameters.consecutive_off_hours_max, - ) + self.consecutive_off_hours = self._get_duration_in_hours( + 'consecutive_off_hours', + self.off, + self.previous_consecutive_off_hours, + self.parameters.consecutive_off_hours_min, + self.parameters.consecutive_off_hours_max, + ) if self.parameters.use_switch_on: self.switch_on = self.add(self._model.add_variables( @@ -402,7 +396,6 @@ def _add_on_constraints(self): def _get_duration_in_hours( self, variable_name: str, - variable_name_short: str, binary_variable: linopy.Variable, previous_duration: Skalar, minimum_duration: Optional[TimeSeries], @@ -462,17 +455,17 @@ def _get_duration_in_hours( duration_in_hours = self.add(self._model.add_variables( lower=0, - upper=maximum_duration if maximum_duration is not None else mega, + upper=maximum_duration.active_data if maximum_duration is not None else mega, coords=self._model.coords, - name=variable_name), - variable_name_short + name=f'{self.label_full}__{variable_name}'), + variable_name ) # 1) eq: duration(t) - On(t) * BIG <= 0 self.add(self._model.add_constraints( duration_in_hours <= binary_variable * mega, - name=f'{self.label_full}__{variable_name_short}_con1'), - f'{variable_name_short}_con1' + name=f'{self.label_full}__{variable_name}_con1'), + f'{variable_name}_con1' ) # 2a) eq: duration(t) - duration(t-1) <= dt(t) @@ -482,8 +475,8 @@ def _get_duration_in_hours( duration_in_hours.isel(time=slice(1, None)) == duration_in_hours.isel(time=slice(None, -1)) + self._model.hours_per_step.isel(time=slice(None, -1)), - name=f'{self.label_full}__{variable_name_short}_con2a'), - f'{variable_name_short}_con2a' + name=f'{self.label_full}__{variable_name}_con2a'), + f'{variable_name}_con2a' ) # 2b) eq: dt(t) - BIG * ( 1-On(t) ) <= duration(t) - duration(t-1) @@ -495,8 +488,8 @@ def _get_duration_in_hours( (binary_variable + 1) * mega + self._model.hours_per_step.isel(time=slice(None, -1)) <= duration_in_hours.isel(time=slice(1, None)) - duration_in_hours.isel(time=slice(None, -1)), - name=f'{self.label_full}__{variable_name_short}_con2b'), - f'{variable_name_short}_con2b' + name=f'{self.label_full}__{variable_name}_con2b'), + f'{variable_name}_con2b' ) # 3) check minimum_duration before switchOff-step @@ -512,8 +505,8 @@ def _get_duration_in_hours( >= (binary_variable.isel(time=slice(None, -1)) - binary_variable.isel(time=slice(1, None))) * minimum_duration.isel(time=slice(None, -1)), - name=f'{self.label_full}__{variable_name_short}_minimum_duration'), - f'{variable_name_short}_minimum_duration' + name=f'{self.label_full}__{variable_name}_minimum_duration'), + f'{variable_name}_minimum_duration' ) if previous_duration < minimum_duration.isel(time=0): @@ -522,16 +515,16 @@ def _get_duration_in_hours( # eq: On(t=0) = 1 self.add(self._model.add_constraints( binary_variable.isel(time=0) == 1, - name=f'{self.label_full}__{variable_name_short}_minimum_inital'), - f'{variable_name_short}_minimum_inital' + name=f'{self.label_full}__{variable_name}_minimum_inital'), + f'{variable_name}_minimum_inital' ) # 4) first index: # eq: duration(t=0)= dt(0) * On(0) self.add(self._model.add_constraints( duration_in_hours.isel(time=0) == self._model.hours_per_step.isel(time=0) * binary_variable.isel(time=0), - name=f'{self.label_full}__{variable_name_short}_initial'), - f'{variable_name_short}_initial' + name=f'{self.label_full}__{variable_name}_initial'), + f'{variable_name}_initial' ) return duration_in_hours @@ -616,22 +609,22 @@ def previous_off_values(self) -> np.ndarray: return 1 - self.previous_on_values @property - def previous_consecutive_on_hours(self) -> np.ndarray: + def previous_consecutive_on_hours(self) -> Skalar: return self.compute_consecutive_duration(self.previous_on_values, self._model.hours_per_step) @property - def previous_consecutive_off_hours(self) -> np.ndarray: + def previous_consecutive_off_hours(self) -> Skalar: return self.compute_consecutive_duration(self.previous_off_values, self._model.hours_per_step) @staticmethod - def compute_previous_on_values(previous_values: List[Numeric], epsilon: float = 1e-5) -> np.ndarray: + def compute_previous_on_values(previous_values: List[Optional[Numeric]], epsilon: float = 1e-5) -> np.ndarray: """ Computes the previous 'on' states of defining variables as a binary array from their previous values. Parameters: ---------- previous_values: List[Numeric] - List of previous values of the defining variables. In Range [0, inf] + List of previous values of the defining variables. In Range [0, inf] or None (ignored) epsilon : float, optional Tolerance for equality to determine "off" state, default is 1e-5. @@ -645,7 +638,7 @@ def compute_previous_on_values(previous_values: List[Numeric], epsilon: float = if not previous_values: return np.array([0]) else: # Convert to 2D-array and compute binary on/off states - previous_values = np.array(previous_values) + previous_values = np.array([values for values in previous_values if values is not None]) # Filter out None if previous_values.ndim > 1: return np.any(~np.isclose(previous_values, 0, atol=epsilon), axis=0).astype(int) else: @@ -653,7 +646,7 @@ def compute_previous_on_values(previous_values: List[Numeric], epsilon: float = @staticmethod def compute_consecutive_duration( - binary_values: Union[int, np.ndarray], + binary_values: Numeric, hours_per_timestep: Union[int, float, np.ndarray] ) -> Skalar: """ From 5c6d84a62dde19cb8a17ca77082cc6bb7021f457 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 16 Feb 2025 10:16:55 +0100 Subject: [PATCH 100/507] Fixing consecutive hours constraints --- flixOpt/features.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/flixOpt/features.py b/flixOpt/features.py index c789fe0ad..90271f32c 100644 --- a/flixOpt/features.py +++ b/flixOpt/features.py @@ -440,7 +440,7 @@ def _get_duration_in_hours( """ assert binary_variable is not None, f'Duration Variable of {self.label_full} must be defined to add constraints' - mega = self._model.hours_per_step + previous_duration + mega = self._model.hours_per_step.sum() + previous_duration if maximum_duration is not None: first_step_max: Skalar = maximum_duration.isel(time=0) @@ -473,7 +473,7 @@ def _get_duration_in_hours( # on(t)=0 -> duration(t-1) >= negat. value self.add(self._model.add_constraints( duration_in_hours.isel(time=slice(1, None)) - == + <= duration_in_hours.isel(time=slice(None, -1)) + self._model.hours_per_step.isel(time=slice(None, -1)), name=f'{self.label_full}__{variable_name}_con2a'), f'{variable_name}_con2a' @@ -484,10 +484,12 @@ def _get_duration_in_hours( # with BIG = dt_in_hours_total. # on(t)=1 -> duration(t)- duration(t-1) >= dt(t) # on(t)=0 -> duration(t)- duration(t-1) >= negat. value + self.add(self._model.add_constraints( - (binary_variable + 1) * mega + self._model.hours_per_step.isel(time=slice(None, -1)) - <= - duration_in_hours.isel(time=slice(1, None)) - duration_in_hours.isel(time=slice(None, -1)), + duration_in_hours.isel(time=slice(1, None)) + >= + duration_in_hours.isel(time=slice(None, -1)) + self._model.hours_per_step.isel(time=slice(None, -1)) + + (binary_variable.isel(time=slice(1, None)) - 1) * mega, name=f'{self.label_full}__{variable_name}_con2b'), f'{variable_name}_con2b' ) From 1192caf96c5a88f05abb431de5880a4b685154d8 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 16 Feb 2025 11:03:02 +0100 Subject: [PATCH 101/507] Fixing previous values in OnOff --- flixOpt/features.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/flixOpt/features.py b/flixOpt/features.py index 90271f32c..02ec8e8ef 100644 --- a/flixOpt/features.py +++ b/flixOpt/features.py @@ -555,7 +555,7 @@ def _add_switch_constraints(self, system_model: SystemModel): self._model.add_constraints( self.switch_on.isel(time=0) - self.switch_off.isel(time=0) == - self.on.isel(time=0), #TODO: - self.on.previous_values[-1] + self.on.isel(time=0) - self.previous_on_values[-1], name=f'{self.label_full}__initial_switch_con' ), 'initial_switch_con' @@ -604,7 +604,7 @@ def _create_shares(self, system_model: SystemModel): @property def previous_on_values(self) -> np.ndarray: - return self.compute_previous_on_values(self._previous_values) + return self.compute_previous_on_states(self._previous_values) @property def previous_off_values(self) -> np.ndarray: @@ -619,9 +619,9 @@ def previous_consecutive_off_hours(self) -> Skalar: return self.compute_consecutive_duration(self.previous_off_values, self._model.hours_per_step) @staticmethod - def compute_previous_on_values(previous_values: List[Optional[Numeric]], epsilon: float = 1e-5) -> np.ndarray: + def compute_previous_on_states(previous_values: List[Optional[Numeric]], epsilon: float = 1e-5) -> np.ndarray: """ - Computes the previous 'on' states of defining variables as a binary array from their previous values. + Computes the previous 'on' states {0, 1} of defining variables as a binary array from their previous values. Parameters: ---------- @@ -637,7 +637,7 @@ def compute_previous_on_values(previous_values: List[Optional[Numeric]], epsilon Returns `array([0])` if no previous values are available. """ - if not previous_values: + if not previous_values or all([val is None for val in previous_values]): return np.array([0]) else: # Convert to 2D-array and compute binary on/off states previous_values = np.array([values for values in previous_values if values is not None]) # Filter out None From 1a2d8548a50f03b7ee99faf6b860e41ecfa12478 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 16 Feb 2025 11:03:22 +0100 Subject: [PATCH 102/507] Add rounding to solution_numeric --- flixOpt/structure.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/flixOpt/structure.py b/flixOpt/structure.py index f3a1093a3..6ecf6e8e7 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -244,10 +244,29 @@ def __init__(self, label: str, meta_data: Dict = None): def solution_numeric( self, use_numpy: bool = True, - all_variables: bool = True + all_variables: bool = True, + decimals: Optional[int] = None ) -> Union[Dict[str, np.ndarray], Dict[str, Union[List, int, float]]]: + """ + Returns the solution of the element as a dictionary of numeric values. + + Parameters: + ----------- + use_numpy bool: + Whether to return the solution as a numpy array. Defaults to True. + If True, numeric numpy arrays (`np.ndarray`) are preserved as-is. + If False, they are converted to lists. + all_variables bool: + Whether to return the solution for all variables (including sub-models) or only the variables of the element. + Defaults to True. + decimals int: + Number of decimal places to round the solution to. Defaults to None. + """ vars = self.model.all_variables if all_variables else self.model.variables - results = {var: vars.solution[var].values for var in vars.solution.data_vars} + if decimals is not None: + results = {var: vars.solution[var].round(decimals).values for var in vars.solution.data_vars} + else: + results = {var: vars.solution[var].values for var in vars.solution.data_vars} if use_numpy: return {k: v.item() if v.ndim == 0 else v for k, v in results.items()} return {k: v.tolist() for k, v in results.items()} From 9f71c3e4b947a20924732b30d8185de778936c16 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 16 Feb 2025 11:12:52 +0100 Subject: [PATCH 103/507] Adjust logic of labels in Models --- flixOpt/effects.py | 8 ++++---- flixOpt/features.py | 3 ++- flixOpt/structure.py | 20 ++++++++++++-------- 3 files changed, 18 insertions(+), 13 deletions(-) diff --git a/flixOpt/effects.py b/flixOpt/effects.py index 67e82cd81..711ad5e47 100644 --- a/flixOpt/effects.py +++ b/flixOpt/effects.py @@ -15,7 +15,7 @@ from .core import Numeric, Numeric_TS, Skalar, TimeSeries from .features import ShareAllocationModel from .math_modeling import Equation, Variable -from .structure import Element, ElementModel, SystemModel, InterfaceModel +from .structure import Element, ElementModel, SystemModel, Model if TYPE_CHECKING: from .flow_system import FlowSystem @@ -271,13 +271,13 @@ def effect_values_to_dict(effect_values_user: EffectValuesUser) -> Optional[Effe None: effect_values_user} if effect_values_user is not None else None -class EffectCollection(InterfaceModel): +class EffectCollection(Model): """ Handling all Effects """ def __init__(self, model: SystemModel, effects: List[Effect]): - super().__init__(model, label='Effects') + super().__init__(model, label_full='Effects') self._effects = {} self._standard_effect: Optional[Effect] = None self._objective_effect: Optional[Effect] = None @@ -309,7 +309,7 @@ def do_modeling(self, system_model: SystemModel): self._model = system_model for effect in self.effects.values(): effect.create_model(self._model) - self.penalty = self.add(ShareAllocationModel(self._model,shares_are_time_series=False, label='penalty')) + self.penalty = self.add(ShareAllocationModel(self._model,shares_are_time_series=False, label_full='penalty')) for model in [effect.model for effect in self.effects.values()] + [self.penalty]: model.do_modeling(system_model) diff --git a/flixOpt/features.py b/flixOpt/features.py index 02ec8e8ef..44acb66a0 100644 --- a/flixOpt/features.py +++ b/flixOpt/features.py @@ -852,12 +852,13 @@ def __init__( shares_are_time_series: bool, label_of_parent: Optional[str] = None, label: Optional[str] = None, + label_full: Optional[str] = None, total_max: Optional[Skalar] = None, total_min: Optional[Skalar] = None, max_per_hour: Optional[Numeric] = None, min_per_hour: Optional[Numeric] = None, ): - super().__init__(model, label_of_parent=label_of_parent, label=label) + super().__init__(model, label_of_parent=label_of_parent, label=label, label_full=label_full) if not shares_are_time_series: # If the condition is True assert max_per_hour is None and min_per_hour is None, ( 'Both max_per_hour and min_per_hour cannot be used when shares_are_time_series is False' diff --git a/flixOpt/structure.py b/flixOpt/structure.py index 6ecf6e8e7..194e9954a 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -295,20 +295,20 @@ def _create_time_series( class Model: """Stores Variables and Constraints""" - def __init__(self, model: SystemModel, label_of_parent: str, label: str, label_full: Optional[str] = None): + def __init__(self, model: SystemModel, label_of_parent: Optional[str] = None, label: Optional[str] = None, label_full: Optional[str] = None): """ Parameters ---------- - interface : Interface - The interface this model is created for. label_of_parent : str The label of the parent (Element). Used to construct the full label of the model. label : str - Used to construct the label of the model. If None, the interface label is used. + The label of the model. Used to construct the full label of the model. label_full : str - The full label of the model. If None, the full label is constructed using the other given labels. + The full label of the model. Can overwrite the full label constructed from label_of_parent and label. """ - + if not label_full and not (label_of_parent and label): + raise ValueError('Either label_full or label_of_parent and label must be set. ' + 'Got {label_full=}, {label_of_parent=}, {label=}') self._model = model self._label = label self._label_of_parent = label_of_parent @@ -389,12 +389,16 @@ def solution_structured( @property def label(self) -> str: - return self._label + return self._label if self._label is not None else self.label_full @property def label_full(self) -> str: return self._label_full or f'{self._label_of_parent}__{self.label}' + @property + def label_of_parent(self) -> str: + return self._label_of_parent or self.label_full + @property def variables(self) -> linopy.Variables: return self._model.variables[self._variables] @@ -470,7 +474,7 @@ def __init__(self, model: SystemModel, element: Element): element : Element The element this model is created for. """ - super().__init__(model, label=element.label, label_of_parent=element.label_full) + super().__init__(model, label_full=element.label_full) self.element = element From 742bbbb4ca1cc3b7f4276883cc964a44573889a7 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 16 Feb 2025 11:29:10 +0100 Subject: [PATCH 104/507] Bugfix: make variable scalar --- flixOpt/features.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/flixOpt/features.py b/flixOpt/features.py index 44acb66a0..ed7cf85fc 100644 --- a/flixOpt/features.py +++ b/flixOpt/features.py @@ -316,8 +316,7 @@ def do_modeling(self, system_model: SystemModel): self.switch_on_nr = self.add(self._model.add_variables( upper=self.parameters.switch_on_total_max if self.parameters.switch_on_total_max is not None else np.inf, - name=f'{self.label_full}__switch_on_nr', - coords=system_model.coords), + name=f'{self.label_full}__switch_on_nr'), 'switch_on_nr') self._add_switch_constraints(system_model) From 59a552b8572bbdbbaacba89661cdfdcc788a4c56 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 16 Feb 2025 11:29:21 +0100 Subject: [PATCH 105/507] Improve filter_variables() --- flixOpt/structure.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/flixOpt/structure.py b/flixOpt/structure.py index 194e9954a..ffb8204f4 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -357,13 +357,13 @@ def filter_variables(self, filter_by: Optional[Literal['binary', 'continuous', 'integer']] = None, length: Literal['scalar', 'time'] = None): if filter_by is None: - all_variables = self.variables + all_variables = self.all_variables elif filter_by == 'binary': - all_variables = self.variables.binaries + all_variables = self.all_variables.binaries elif filter_by == 'integer': - all_variables = self.variables.integers + all_variables = self.all_variables.integers elif filter_by == 'continuous': - all_variables = self.variables.continuous + all_variables = self.all_variables.continuous else: raise ValueError(f'Invalid filter_by "{filter_by}", must be one of "binary", "continous", "integer"') if length is None: From ea14b1d97c93f3d9c768253235ee889d753d6d28 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 16 Feb 2025 11:50:21 +0100 Subject: [PATCH 106/507] Fix load_factor --- flixOpt/elements.py | 31 +++++++++---------------------- 1 file changed, 9 insertions(+), 22 deletions(-) diff --git a/flixOpt/elements.py b/flixOpt/elements.py index 23287f74b..fcad4b5b1 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -392,20 +392,14 @@ def _create_bounds_for_load_factor(self, system_model: SystemModel): # eq: var_sumFlowHours <= size * dt_tot * load_factor_max if self.element.load_factor_max is not None: name_short = 'load_factor_max' - name = f'{self.element.label_full}__{name_short}' - flow_hours_per_size_max = self._model.hours_per_step * self.element.load_factor_max + flow_hours_per_size_max = self._model.hours_per_step.sum() * self.element.load_factor_max + size = self.element.size if self._investment is None else self._investment.size if self._investment is not None: self.add( self._model.add_constraints( - self.total_flow_hours <= self._investment.size * flow_hours_per_size_max, name=name, - ), - name_short - ) - else: - self.add( - self._model.add_constraints( - self.total_flow_hours <= self.element.size * flow_hours_per_size_max, name=name, + self.total_flow_hours <= size * flow_hours_per_size_max, + name=f'{self.element.label_full}__{name_short}', ), name_short ) @@ -413,26 +407,19 @@ def _create_bounds_for_load_factor(self, system_model: SystemModel): # eq: size * sum(dt)* load_factor_min <= var_sumFlowHours if self.element.load_factor_min is not None: name_short = 'load_factor_min' - name = f'{self.element.label_full}__{name_short}' - flow_hours_per_size_in = self._model.hours_per_step * self.element.load_factor_min + flow_hours_per_size_min = self._model.hours_per_step.sum() * self.element.load_factor_min + size = self.element.size if self._investment is None else self._investment.size if self._investment is not None: self.add( self._model.add_constraints( - self.total_flow_hours >= self._investment.size * flow_hours_per_size_in, - name=name - ), - name_short - ) - else: - self.add( - self._model.add_constraints( - self.total_flow_hours >= self.element.size * flow_hours_per_size_in, - name=name + self.total_flow_hours >= size * flow_hours_per_size_min, + name=f'{self.element.label_full}__{name_short}', ), name_short ) + @property def with_investment(self) -> bool: """Checks if the element's size is investment-driven.""" From 95a37ace88fc762b197067cf1964427aec997154 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 16 Feb 2025 13:01:42 +0100 Subject: [PATCH 107/507] Adjust how ElementModel is labeled --- flixOpt/structure.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixOpt/structure.py b/flixOpt/structure.py index ffb8204f4..b35da7661 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -474,7 +474,7 @@ def __init__(self, model: SystemModel, element: Element): element : Element The element this model is created for. """ - super().__init__(model, label_full=element.label_full) + super().__init__(model, label_full=element.label_full, label=element.label) self.element = element From 5df2c46ea5254f1a3a11bdf1dcd1a001cf142296 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 16 Feb 2025 13:02:17 +0100 Subject: [PATCH 108/507] Remove modeling language from tests. Add pd.date_range. remove gurobi (for now) --- tests/test_functional.py | 81 +++++++++++++++++++--------------------- 1 file changed, 38 insertions(+), 43 deletions(-) diff --git a/tests/test_functional.py b/tests/test_functional.py index 655b302f9..9490e68ee 100644 --- a/tests/test_functional.py +++ b/tests/test_functional.py @@ -18,6 +18,7 @@ """ import numpy as np +import pandas as pd import pytest from numpy.testing import assert_allclose @@ -60,10 +61,10 @@ def _adjust_length(self, array, new_length: int): return extended_array[:new_length] # Truncate to exact length -def flow_system_base(datetime_array: np.ndarray[np.datetime64]) -> fx.FlowSystem: - data = Data(len(datetime_array)) +def flow_system_base(timesteps: pd.DatetimeIndex) -> fx.FlowSystem: + data = Data(len(timesteps)) - flow_system = fx.FlowSystem(datetime_array) + flow_system = fx.FlowSystem(timesteps) buses = { 'Fernwärme': fx.Bus('Fernwärme', excess_penalty_per_flow_hour=None), 'Gas': fx.Bus('Gas', excess_penalty_per_flow_hour=None), @@ -79,8 +80,8 @@ def flow_system_base(datetime_array: np.ndarray[np.datetime64]) -> fx.FlowSystem return flow_system -def flow_system_minimal(datetime_array) -> fx.FlowSystem: - flow_system = flow_system_base(datetime_array) +def flow_system_minimal(timesteps) -> fx.FlowSystem: + flow_system = flow_system_base(timesteps) buses = flow_system.buses flow_system.add_elements( fx.linear_converters.Boiler( @@ -94,38 +95,32 @@ def flow_system_minimal(datetime_array) -> fx.FlowSystem: def solve_and_load( - flow_system: fx.FlowSystem, modeling_language: str, solver: fx.solvers.Solver + flow_system: fx.FlowSystem, solver: str ) -> fx.results.CalculationResults: - calculation = fx.FullCalculation('Calculation', flow_system, modeling_language) + calculation = fx.FullCalculation('Calculation', flow_system) calculation.do_modeling() calculation.solve(solver, True) results = fx.results.CalculationResults('Calculation', 'results') return results -@pytest.fixture(params=['pyomo', 'linopy']) -def modeling_language_fixture(request): - return request.param - - -@pytest.fixture(params=['highs', 'gurobi']) +@pytest.fixture(params=['highs'])#, 'gurobi']) def solver_fixture(request): - solvers = {'highs': fx.solvers.HighsSolver, 'gurobi': fx.solvers.GurobiSolver} - return solvers[request.param](mip_gap=0.0001) + return request.param @pytest.fixture def time_steps_fixture(request): - return fx.create_datetime_array('2020-01-01', 5, 'h') + return pd.date_range('2020-01-01', periods=5, freq='h') -def test_solve_and_load(modeling_language_fixture, solver_fixture, time_steps_fixture): - results = solve_and_load(flow_system_minimal(time_steps_fixture), modeling_language_fixture, solver_fixture) +def test_solve_and_load(solver_fixture, time_steps_fixture): + results = solve_and_load(flow_system_minimal(time_steps_fixture), solver_fixture) assert results is not None -def test_minimal_model(modeling_language_fixture, solver_fixture, time_steps_fixture): - results = solve_and_load(flow_system_minimal(time_steps_fixture), modeling_language_fixture, solver_fixture) +def test_minimal_model(solver_fixture, time_steps_fixture): + results = solve_and_load(flow_system_minimal(time_steps_fixture), solver_fixture) assert_allclose( results.effect_results['costs'].all_results['all']['all_sum'], 80, rtol=solver_fixture.mip_gap, atol=1e-10 @@ -153,7 +148,7 @@ def test_minimal_model(modeling_language_fixture, solver_fixture, time_steps_fix ) -def test_fixed_size(modeling_language_fixture, solver_fixture, time_steps_fixture): +def test_fixed_size(solver_fixture, time_steps_fixture): flow_system = flow_system_base(time_steps_fixture) flow_system.add_elements( fx.linear_converters.Boiler( @@ -168,7 +163,7 @@ def test_fixed_size(modeling_language_fixture, solver_fixture, time_steps_fixtur ) ) - solve_and_load(flow_system, modeling_language_fixture, solver_fixture) + solve_and_load(flow_system, solver_fixture) boiler = flow_system.all_elements['Boiler'] costs = flow_system.all_elements['costs'] assert_allclose( @@ -194,7 +189,7 @@ def test_fixed_size(modeling_language_fixture, solver_fixture, time_steps_fixtur ) -def test_optimize_size(modeling_language_fixture, solver_fixture, time_steps_fixture): +def test_optimize_size(solver_fixture, time_steps_fixture): flow_system = flow_system_base(time_steps_fixture) flow_system.add_elements( fx.linear_converters.Boiler( @@ -209,7 +204,7 @@ def test_optimize_size(modeling_language_fixture, solver_fixture, time_steps_fix ) ) - solve_and_load(flow_system, modeling_language_fixture, solver_fixture) + solve_and_load(flow_system, solver_fixture) boiler = flow_system.all_elements['Boiler'] costs = flow_system.all_elements['costs'] assert_allclose( @@ -235,7 +230,7 @@ def test_optimize_size(modeling_language_fixture, solver_fixture, time_steps_fix ) -def test_size_bounds(modeling_language_fixture, solver_fixture, time_steps_fixture): +def test_size_bounds(solver_fixture, time_steps_fixture): flow_system = flow_system_base(time_steps_fixture) flow_system.add_elements( fx.linear_converters.Boiler( @@ -250,7 +245,7 @@ def test_size_bounds(modeling_language_fixture, solver_fixture, time_steps_fixtu ) ) - solve_and_load(flow_system, modeling_language_fixture, solver_fixture) + solve_and_load(flow_system, solver_fixture) boiler = flow_system.all_elements['Boiler'] costs = flow_system.all_elements['costs'] assert_allclose( @@ -276,7 +271,7 @@ def test_size_bounds(modeling_language_fixture, solver_fixture, time_steps_fixtu ) -def test_optional_invest(modeling_language_fixture, solver_fixture, time_steps_fixture): +def test_optional_invest(solver_fixture, time_steps_fixture): flow_system = flow_system_base(time_steps_fixture) flow_system.add_elements( fx.linear_converters.Boiler( @@ -301,7 +296,7 @@ def test_optional_invest(modeling_language_fixture, solver_fixture, time_steps_f ), ) - solve_and_load(flow_system, modeling_language_fixture, solver_fixture) + solve_and_load(flow_system, solver_fixture) boiler = flow_system.all_elements['Boiler'] boiler_optional = flow_system.all_elements['Boiler_optional'] costs = flow_system.all_elements['costs'] @@ -343,7 +338,7 @@ def test_optional_invest(modeling_language_fixture, solver_fixture, time_steps_f ) -def test_on(modeling_language_fixture, solver_fixture, time_steps_fixture): +def test_on(solver_fixture, time_steps_fixture): """Tests if the On Variable is correctly created and calculated in a Flow""" flow_system = flow_system_base(time_steps_fixture) flow_system.add_elements( @@ -355,7 +350,7 @@ def test_on(modeling_language_fixture, solver_fixture, time_steps_fixture): ), )) - solve_and_load(flow_system, modeling_language_fixture, solver_fixture) + solve_and_load(flow_system, solver_fixture) boiler = flow_system.all_elements['Boiler'] costs = flow_system.all_elements['costs'] assert_allclose( @@ -382,7 +377,7 @@ def test_on(modeling_language_fixture, solver_fixture, time_steps_fixture): ) -def test_off(modeling_language_fixture, solver_fixture, time_steps_fixture): +def test_off(solver_fixture, time_steps_fixture): """Tests if the Off Variable is correctly created and calculated in a Flow""" flow_system = flow_system_base(time_steps_fixture) flow_system.add_elements( @@ -399,7 +394,7 @@ def test_off(modeling_language_fixture, solver_fixture, time_steps_fixture): ) ) - solve_and_load(flow_system, modeling_language_fixture, solver_fixture) + solve_and_load(flow_system, solver_fixture) boiler = flow_system.all_elements['Boiler'] costs = flow_system.all_elements['costs'] assert_allclose( @@ -433,7 +428,7 @@ def test_off(modeling_language_fixture, solver_fixture, time_steps_fixture): ) -def test_switch_on_off(modeling_language_fixture, solver_fixture, time_steps_fixture): +def test_switch_on_off(solver_fixture, time_steps_fixture): """Tests if the Switch On/Off Variable is correctly created and calculated in a Flow""" flow_system = flow_system_base(time_steps_fixture) flow_system.add_elements( @@ -450,7 +445,7 @@ def test_switch_on_off(modeling_language_fixture, solver_fixture, time_steps_fix ) ) - solve_and_load(flow_system, modeling_language_fixture, solver_fixture) + solve_and_load(flow_system, solver_fixture) boiler = flow_system.all_elements['Boiler'] costs = flow_system.all_elements['costs'] assert_allclose( @@ -491,7 +486,7 @@ def test_switch_on_off(modeling_language_fixture, solver_fixture, time_steps_fix ) -def test_on_total_max(modeling_language_fixture, solver_fixture, time_steps_fixture): +def test_on_total_max(solver_fixture, time_steps_fixture): """Tests if the On Total Max Variable is correctly created and calculated in a Flow""" flow_system = flow_system_base(time_steps_fixture) flow_system.add_elements( @@ -514,7 +509,7 @@ def test_on_total_max(modeling_language_fixture, solver_fixture, time_steps_fixt ), ) - solve_and_load(flow_system, modeling_language_fixture, solver_fixture) + solve_and_load(flow_system, solver_fixture) boiler = flow_system.all_elements['Boiler'] costs = flow_system.all_elements['costs'] assert_allclose( @@ -541,7 +536,7 @@ def test_on_total_max(modeling_language_fixture, solver_fixture, time_steps_fixt ) -def test_on_total_bounds(modeling_language_fixture, solver_fixture, time_steps_fixture): +def test_on_total_bounds(solver_fixture, time_steps_fixture): """Tests if the On Hours min and max are correctly created and calculated in a Flow""" flow_system = flow_system_base(time_steps_fixture) flow_system.add_elements( @@ -570,7 +565,7 @@ def test_on_total_bounds(modeling_language_fixture, solver_fixture, time_steps_f ) flow_system.all_elements['Wärmelast'].sink.fixed_relative_profile = [0, 10, 20, 0, 12] # Else its non deterministic - solve_and_load(flow_system, modeling_language_fixture, solver_fixture) + solve_and_load(flow_system, solver_fixture) boiler = flow_system.all_elements['Boiler'] boiler_backup = flow_system.all_elements['Boiler_backup'] costs = flow_system.all_elements['costs'] @@ -613,7 +608,7 @@ def test_on_total_bounds(modeling_language_fixture, solver_fixture, time_steps_f ) -def test_consecutive_on_off(modeling_language_fixture, solver_fixture, time_steps_fixture): +def test_consecutive_on_off(solver_fixture, time_steps_fixture): """Tests if the consecutive on/off hours are correctly created and calculated in a Flow""" flow_system = flow_system_base(time_steps_fixture) flow_system.add_elements( @@ -643,7 +638,7 @@ def test_consecutive_on_off(modeling_language_fixture, solver_fixture, time_step 12, ] # Else its non deterministic - solve_and_load(flow_system, modeling_language_fixture, solver_fixture) + solve_and_load(flow_system, solver_fixture) boiler = flow_system.all_elements['Boiler'] boiler_backup = flow_system.all_elements['Boiler_backup'] costs = flow_system.all_elements['costs'] @@ -679,7 +674,7 @@ def test_consecutive_on_off(modeling_language_fixture, solver_fixture, time_step ) -def test_consecutive_off(modeling_language_fixture, solver_fixture, time_steps_fixture): +def test_consecutive_off(solver_fixture, time_steps_fixture): """Tests if the consecutive on hours are correctly created and calculated in a Flow""" flow_system = flow_system_base(time_steps_fixture) flow_system.add_elements( @@ -702,9 +697,9 @@ def test_consecutive_off(modeling_language_fixture, solver_fixture, time_steps_f ), ), ) - flow_system.all_elements['Wärmelast'].sink.fixed_relative_profile = [5, 0, 20, 18, 12] # Else its non deterministic + flow_system.all_elements['Wärmelast'].sink.fixed_relative_profile = np.array([5, 0, 20, 18, 12]) # Else its non deterministic - solve_and_load(flow_system, modeling_language_fixture, solver_fixture) + solve_and_load(flow_system, solver_fixture) boiler = flow_system.all_elements['Boiler'] boiler_backup = flow_system.all_elements['Boiler_backup'] costs = flow_system.all_elements['costs'] From 9ea2febbecc340f6acc0b9baa745185e9590ce88 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 16 Feb 2025 13:46:20 +0100 Subject: [PATCH 109/507] Bugfix in InvestmentModel --- flixOpt/features.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixOpt/features.py b/flixOpt/features.py index ed7cf85fc..38a06a7c3 100644 --- a/flixOpt/features.py +++ b/flixOpt/features.py @@ -143,7 +143,7 @@ def _create_bounds_for_optional_investment(self): else: # eq1: P_invest <= isInvested * investSize_max self.add(self._model.add_constraints( - self.size == self.is_invested * self.parameters.maximum_size, + self.size <= self.is_invested * self.parameters.maximum_size, name=f'{self.label_full}__is_invested_ub'), 'is_invested_ub') From 69ba59c866b2ed192dd36e88f7cc371146962270 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 16 Feb 2025 13:46:46 +0100 Subject: [PATCH 110/507] Update test_functional.py --- tests/test_functional.py | 219 +++++++++++++++++++-------------------- 1 file changed, 107 insertions(+), 112 deletions(-) diff --git a/tests/test_functional.py b/tests/test_functional.py index 9490e68ee..1c0c97644 100644 --- a/tests/test_functional.py +++ b/tests/test_functional.py @@ -123,27 +123,27 @@ def test_minimal_model(solver_fixture, time_steps_fixture): results = solve_and_load(flow_system_minimal(time_steps_fixture), solver_fixture) assert_allclose( - results.effect_results['costs'].all_results['all']['all_sum'], 80, rtol=solver_fixture.mip_gap, atol=1e-10 + results.effect_results['costs'].all_results['total'], 80, rtol=1e-5, atol=1e-10 ) assert_allclose( results.component_results['Boiler'].all_results['Q_th']['flow_rate'], [-0.0, 10.0, 20.0, -0.0, 10.0], - rtol=solver_fixture.mip_gap, + rtol=1e-5, atol=1e-10, ) assert_allclose( - results.effect_results['costs'].all_results['operation']['operation_sum_TS'], + results.effect_results['costs'].all_results['operation']['total_per_timestep'], [-0.0, 20.0, 40.0, -0.0, 20.0], - rtol=solver_fixture.mip_gap, + rtol=1e-5, atol=1e-10, ) assert_allclose( - results.effect_results['costs'].all_results['operation']['Shares']['Gastarif__Gas__effects_per_flow_hour'], + results.effect_results['costs'].all_results['operation']['Shares']['Gas (Gastarif)'], [-0.0, 20.0, 40.0, -0.0, 20.0], - rtol=solver_fixture.mip_gap, + rtol=1e-5, atol=1e-10, ) @@ -165,25 +165,25 @@ def test_fixed_size(solver_fixture, time_steps_fixture): solve_and_load(flow_system, solver_fixture) boiler = flow_system.all_elements['Boiler'] - costs = flow_system.all_elements['costs'] + costs = flow_system.effects['costs'] assert_allclose( - costs.model.all.sum.result, + costs.model.total.solution.item(), 80 + 1000 * 1 + 10, - rtol=solver_fixture.mip_gap, + rtol=1e-5, atol=1e-10, err_msg='The total costs does not have the right value', ) assert_allclose( - boiler.model.all_variables['Boiler__Q_th__Investment_size'].result, + boiler.Q_th.model._investment.size.solution.item(), 1000, - rtol=solver_fixture.mip_gap, + rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__Investment_size" does not have the right value', ) assert_allclose( - boiler.model.all_variables['Boiler__Q_th__Investment_isInvested'].result, + boiler.Q_th.model._investment.is_invested.solution.item(), 1, - rtol=solver_fixture.mip_gap, + rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__Investment_size" does not have the right value', ) @@ -206,25 +206,25 @@ def test_optimize_size(solver_fixture, time_steps_fixture): solve_and_load(flow_system, solver_fixture) boiler = flow_system.all_elements['Boiler'] - costs = flow_system.all_elements['costs'] + costs = flow_system.effects['costs'] assert_allclose( - costs.model.all.sum.result, + costs.model.total.solution.item(), 80 + 20 * 1 + 10, - rtol=solver_fixture.mip_gap, + rtol=1e-5, atol=1e-10, err_msg='The total costs does not have the right value', ) assert_allclose( - boiler.Q_th.model._investment.size.result, + boiler.Q_th.model._investment.size.solution.item(), 20, - rtol=solver_fixture.mip_gap, + rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__Investment_size" does not have the right value', ) assert_allclose( - boiler.Q_th.model._investment.is_invested.result, + boiler.Q_th.model._investment.is_invested.solution.item(), 1, - rtol=solver_fixture.mip_gap, + rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__IsInvested" does not have the right value', ) @@ -247,25 +247,25 @@ def test_size_bounds(solver_fixture, time_steps_fixture): solve_and_load(flow_system, solver_fixture) boiler = flow_system.all_elements['Boiler'] - costs = flow_system.all_elements['costs'] + costs = flow_system.effects['costs'] assert_allclose( - costs.model.all.sum.result, + costs.model.total.solution.item(), 80 + 40 * 1 + 10, - rtol=solver_fixture.mip_gap, + rtol=1e-5, atol=1e-10, err_msg='The total costs does not have the right value', ) assert_allclose( - boiler.Q_th.model._investment.size.result, + boiler.Q_th.model._investment.size.solution.item(), 40, - rtol=solver_fixture.mip_gap, + rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__Investment_size" does not have the right value', ) assert_allclose( - boiler.Q_th.model._investment.is_invested.result, + boiler.Q_th.model._investment.is_invested.solution.item(), 1, - rtol=solver_fixture.mip_gap, + rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__IsInvested" does not have the right value', ) @@ -299,40 +299,40 @@ def test_optional_invest(solver_fixture, time_steps_fixture): solve_and_load(flow_system, solver_fixture) boiler = flow_system.all_elements['Boiler'] boiler_optional = flow_system.all_elements['Boiler_optional'] - costs = flow_system.all_elements['costs'] + costs = flow_system.effects['costs'] assert_allclose( - costs.model.all.sum.result, + costs.model.total.solution.item(), 80 + 40 * 1 + 10, - rtol=solver_fixture.mip_gap, + rtol=1e-5, atol=1e-10, err_msg='The total costs does not have the right value', ) assert_allclose( - boiler.Q_th.model._investment.size.result, + boiler.Q_th.model._investment.size.solution.item(), 40, - rtol=solver_fixture.mip_gap, + rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__Investment_size" does not have the right value', ) assert_allclose( - boiler.Q_th.model._investment.is_invested.result, + boiler.Q_th.model._investment.is_invested.solution.item(), 1, - rtol=solver_fixture.mip_gap, + rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__IsInvested" does not have the right value', ) assert_allclose( - boiler_optional.Q_th.model._investment.size.result, + boiler_optional.Q_th.model._investment.size.solution.item(), 0, - rtol=solver_fixture.mip_gap, + rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__Investment_size" does not have the right value', ) assert_allclose( - boiler_optional.Q_th.model._investment.is_invested.result, + boiler_optional.Q_th.model._investment.is_invested.solution.item(), 0, - rtol=solver_fixture.mip_gap, + rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__IsInvested" does not have the right value', ) @@ -352,26 +352,26 @@ def test_on(solver_fixture, time_steps_fixture): solve_and_load(flow_system, solver_fixture) boiler = flow_system.all_elements['Boiler'] - costs = flow_system.all_elements['costs'] + costs = flow_system.effects['costs'] assert_allclose( - costs.model.all.sum.result, + costs.model.total.solution.item(), 80, - rtol=solver_fixture.mip_gap, + rtol=1e-5, atol=1e-10, err_msg='The total costs does not have the right value', ) assert_allclose( - boiler.Q_th.model.on_off.on.result, + boiler.Q_th.model.on_off.on.solution.values, [0, 1, 1, 0, 1], - rtol=solver_fixture.mip_gap, + rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__on" does not have the right value', ) assert_allclose( - boiler.Q_th.model.flow_rate.result, + boiler.Q_th.model.flow_rate.solution.values, [0, 10, 20, 0, 10], - rtol=solver_fixture.mip_gap, + rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__flow_rate" does not have the right value', ) @@ -396,33 +396,33 @@ def test_off(solver_fixture, time_steps_fixture): solve_and_load(flow_system, solver_fixture) boiler = flow_system.all_elements['Boiler'] - costs = flow_system.all_elements['costs'] + costs = flow_system.effects['costs'] assert_allclose( - costs.model.all.sum.result, + costs.model.total.solution.item(), 80, - rtol=solver_fixture.mip_gap, + rtol=1e-5, atol=1e-10, err_msg='The total costs does not have the right value', ) assert_allclose( - boiler.Q_th.model.on_off.on.result, + boiler.Q_th.model.on_off.on.solution.values, [0, 1, 1, 0, 1], - rtol=solver_fixture.mip_gap, + rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__on" does not have the right value', ) assert_allclose( - boiler.Q_th.model.on_off.off.result, - 1 - boiler.Q_th.model.on_off.on.result, - rtol=solver_fixture.mip_gap, + boiler.Q_th.model.on_off.off.solution.values, + 1 - boiler.Q_th.model.on_off.on.solution.values, + rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__off" does not have the right value', ) assert_allclose( - boiler.Q_th.model.flow_rate.result, + boiler.Q_th.model.flow_rate.solution.values, [0, 10, 20, 0, 10], - rtol=solver_fixture.mip_gap, + rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__flow_rate" does not have the right value', ) @@ -447,40 +447,40 @@ def test_switch_on_off(solver_fixture, time_steps_fixture): solve_and_load(flow_system, solver_fixture) boiler = flow_system.all_elements['Boiler'] - costs = flow_system.all_elements['costs'] + costs = flow_system.effects['costs'] assert_allclose( - costs.model.all.sum.result, + costs.model.total.solution.item(), 80, - rtol=solver_fixture.mip_gap, + rtol=1e-5, atol=1e-10, err_msg='The total costs does not have the right value', ) assert_allclose( - boiler.Q_th.model.on_off.on.result, + boiler.Q_th.model.on_off.on.solution.values, [0, 1, 1, 0, 1], - rtol=solver_fixture.mip_gap, + rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__on" does not have the right value', ) assert_allclose( - boiler.Q_th.model.on_off.switch_on.result, + boiler.Q_th.model.on_off.switch_on.solution.values, [0, 1, 0, 0, 1], - rtol=solver_fixture.mip_gap, + rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__switch_on" does not have the right value', ) assert_allclose( - boiler.Q_th.model.on_off.switch_off.result, + boiler.Q_th.model.on_off.switch_off.solution.values, [0, 0, 0, 1, 0], - rtol=solver_fixture.mip_gap, + rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__switch_on" does not have the right value', ) assert_allclose( - boiler.Q_th.model.flow_rate.result, + boiler.Q_th.model.flow_rate.solution.values, [0, 10, 20, 0, 10], - rtol=solver_fixture.mip_gap, + rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__flow_rate" does not have the right value', ) @@ -511,26 +511,26 @@ def test_on_total_max(solver_fixture, time_steps_fixture): solve_and_load(flow_system, solver_fixture) boiler = flow_system.all_elements['Boiler'] - costs = flow_system.all_elements['costs'] + costs = flow_system.effects['costs'] assert_allclose( - costs.model.all.sum.result, + costs.model.total.solution.item(), 140, - rtol=solver_fixture.mip_gap, + rtol=1e-5, atol=1e-10, err_msg='The total costs does not have the right value', ) assert_allclose( - boiler.Q_th.model.on_off.on.result, + boiler.Q_th.model.on_off.on.solution.values, [0, 0, 1, 0, 0], - rtol=solver_fixture.mip_gap, + rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__on" does not have the right value', ) assert_allclose( - boiler.Q_th.model.flow_rate.result, + boiler.Q_th.model.flow_rate.solution.values, [0, 0, 20, 0, 0], - rtol=solver_fixture.mip_gap, + rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__flow_rate" does not have the right value', ) @@ -563,46 +563,46 @@ def test_on_total_bounds(solver_fixture, time_steps_fixture): ), ), ) - flow_system.all_elements['Wärmelast'].sink.fixed_relative_profile = [0, 10, 20, 0, 12] # Else its non deterministic + flow_system.all_elements['Wärmelast'].sink.fixed_relative_profile = np.array([0, 10, 20, 0, 12]) # Else its non deterministic solve_and_load(flow_system, solver_fixture) boiler = flow_system.all_elements['Boiler'] boiler_backup = flow_system.all_elements['Boiler_backup'] - costs = flow_system.all_elements['costs'] + costs = flow_system.effects['costs'] assert_allclose( - costs.model.all.sum.result, + costs.model.total.solution.item(), 114, - rtol=solver_fixture.mip_gap, + rtol=1e-5, atol=1e-10, err_msg='The total costs does not have the right value', ) assert_allclose( - boiler.Q_th.model.on_off.on.result, + boiler.Q_th.model.on_off.on.solution.values, [0, 0, 1, 0, 1], - rtol=solver_fixture.mip_gap, + rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__on" does not have the right value', ) assert_allclose( - boiler.Q_th.model.flow_rate.result, + boiler.Q_th.model.flow_rate.solution.values, [0, 0, 20, 0, 12 - 1e-5], - rtol=solver_fixture.mip_gap, + rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__flow_rate" does not have the right value', ) assert_allclose( - sum(boiler_backup.Q_th.model.on_off.on.result), + sum(boiler_backup.Q_th.model.on_off.on.solution.values), 3, - rtol=solver_fixture.mip_gap, + rtol=1e-5, atol=1e-10, err_msg='"Boiler_backup__Q_th__on" does not have the right value', ) assert_allclose( - boiler_backup.Q_th.model.flow_rate.result, + boiler_backup.Q_th.model.flow_rate.solution.values, [0, 10, 1.0e-05, 0, 1.0e-05], - rtol=solver_fixture.mip_gap, + rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__flow_rate" does not have the right value', ) @@ -630,45 +630,40 @@ def test_consecutive_on_off(solver_fixture, time_steps_fixture): Q_th=fx.Flow('Q_th', bus=flow_system.buses['Fernwärme'], size=100), ), ) - flow_system.all_elements['Wärmelast'].sink.fixed_relative_profile = [ - 5, - 10, - 20, - 18, - 12, - ] # Else its non deterministic + flow_system.all_elements['Wärmelast'].sink.fixed_relative_profile = np.array([5, 10, 20, 18, 12]) + # Else its non deterministic solve_and_load(flow_system, solver_fixture) boiler = flow_system.all_elements['Boiler'] boiler_backup = flow_system.all_elements['Boiler_backup'] - costs = flow_system.all_elements['costs'] + costs = flow_system.effects['costs'] assert_allclose( - costs.model.all.sum.result, + costs.model.total.solution.item(), 190, - rtol=solver_fixture.mip_gap, + rtol=1e-5, atol=1e-10, err_msg='The total costs does not have the right value', ) assert_allclose( - boiler.Q_th.model.on_off.on.result, + boiler.Q_th.model.on_off.on.solution.values, [1, 1, 0, 1, 1], - rtol=solver_fixture.mip_gap, + rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__on" does not have the right value', ) assert_allclose( - boiler.Q_th.model.flow_rate.result, + boiler.Q_th.model.flow_rate.solution.values, [5, 10, 0, 18, 12], - rtol=solver_fixture.mip_gap, + rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__flow_rate" does not have the right value', ) assert_allclose( - boiler_backup.Q_th.model.flow_rate.result, + boiler_backup.Q_th.model.flow_rate.solution.values, [0, 0, 20, 0, 0], - rtol=solver_fixture.mip_gap, + rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__flow_rate" does not have the right value', ) @@ -702,41 +697,41 @@ def test_consecutive_off(solver_fixture, time_steps_fixture): solve_and_load(flow_system, solver_fixture) boiler = flow_system.all_elements['Boiler'] boiler_backup = flow_system.all_elements['Boiler_backup'] - costs = flow_system.all_elements['costs'] + costs = flow_system.effects['costs'] assert_allclose( - costs.model.all.sum.result, + costs.model.total.solution.item(), 110, - rtol=solver_fixture.mip_gap, + rtol=1e-5, atol=1e-10, err_msg='The total costs does not have the right value', ) assert_allclose( - boiler_backup.Q_th.model.on_off.on.result, + boiler_backup.Q_th.model.on_off.on.solution.values, [0, 0, 1, 0, 0], - rtol=solver_fixture.mip_gap, + rtol=1e-5, atol=1e-10, err_msg='"Boiler_backup__Q_th__on" does not have the right value', ) assert_allclose( - boiler_backup.Q_th.model.on_off.off.result, + boiler_backup.Q_th.model.on_off.off.solution.values, [1, 1, 0, 1, 1], - rtol=solver_fixture.mip_gap, + rtol=1e-5, atol=1e-10, err_msg='"Boiler_backup__Q_th__off" does not have the right value', ) assert_allclose( - boiler_backup.Q_th.model.flow_rate.result, + boiler_backup.Q_th.model.flow_rate.solution.values, [0, 0, 1e-5, 0, 0], - rtol=solver_fixture.mip_gap, + rtol=1e-5, atol=1e-10, err_msg='"Boiler_backup__Q_th__flow_rate" does not have the right value', ) assert_allclose( - boiler.Q_th.model.flow_rate.result, + boiler.Q_th.model.flow_rate.solution.values, [5, 0, 20 - 1e-5, 18, 12], - rtol=solver_fixture.mip_gap, + rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__flow_rate" does not have the right value', ) From 91c133e12a0e941125ca005af0f06a1fb8601e23 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 16 Feb 2025 14:27:12 +0100 Subject: [PATCH 111/507] Improve the DataConverter class --- flixOpt/core.py | 102 +++++++++++++++++++++++------------------------- 1 file changed, 49 insertions(+), 53 deletions(-) diff --git a/flixOpt/core.py b/flixOpt/core.py index 0b72a0ca4..600d35d4f 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -19,25 +19,30 @@ class DataConverter: + """ + A utility class for converting various data types into an xarray.DataArray + with specified time and optional period indexes. + + Supported input types: + - int or float: Generates a DataArray filled with the given scalar value. + - pd.Series: Index should be time steps; expands over periods if provided. + - pd.DataFrame: Columns represent periods, and the index represents time steps. + If a single column is passed but periods exist, the data is expanded over periods. + - np.ndarray: + - If 1D, attempts to reshape based on time steps and periods. + - If 2D, ensures dimensions match time steps and periods, transposing if necessary. + - Logs a warning if periods and time steps have the same length to prevent confusion. + + Raises: + - TypeError if an unsupported data type is provided. + - ValueError if data dimensions do not match expected time and period indexes. + """ @staticmethod - def as_dataarray(data: Union[Numeric, pd.Series, pd.DataFrame], time: pd.DatetimeIndex, period: Optional[pd.Index] = None) -> xr.DataArray: + def as_dataarray(data: Union[Numeric, pd.Series, pd.DataFrame, np.ndarray], time: pd.DatetimeIndex, + period: Optional[pd.Index] = None) -> xr.DataArray: """ Converts the given data to an xarray.DataArray with the specified time and period indexes. - - - If period is provided, data will have both period and time coordinates. - - If period is not provided, data will have only time as the index. - - The length of the array must match the length of the time coordinate if applicable. - - If a 1D array is given but two indices are provided, it is reshaped to 2D automatically. - - Parameters: - - data: The input data (scalar, array, Series, or DataFrame). - - time: A pd.DatetimeIndex for the time dimension. - - period: An optional pd.Index for the period dimension. - - Returns: - - xr.DataArray: The resulting DataArray with time and optionally period as coordinates. """ - if period is not None: coords = [period, time] dims = ['period', 'time'] @@ -45,35 +50,51 @@ def as_dataarray(data: Union[Numeric, pd.Series, pd.DataFrame], time: pd.Datetim coords = [time] dims = ['time'] - if isinstance(data, (int, float)): # Scalar case + if isinstance(data, (int, float)): return DataConverter._handle_scalar(data, coords, dims) - - if isinstance(data, np.ndarray): - return DataConverter._handle_array(data, coords, dims) - - if isinstance(data, pd.Series): - return DataConverter._handle_series(data, coords, dims) - if isinstance(data, pd.DataFrame): return DataConverter._handle_dataframe(data, coords, dims) + if isinstance(data, pd.Series): + return DataConverter._handle_series(data, coords, dims) + if isinstance(data, np.ndarray): + return DataConverter._handle_array(data, coords, dims) raise TypeError("Unsupported data type. Must be scalar, np.ndarray, pd.Series, or pd.DataFrame.") @staticmethod - def _handle_scalar(data: Union[int, float], coords: list, dims: list) -> xr.DataArray: - """Handles scalar input.""" + def _handle_scalar(data: Numeric, coords: list, dims: list) -> xr.DataArray: + """Handles scalar input by filling the array with the value.""" return xr.DataArray(data, coords=coords, dims=dims) + @staticmethod + def _handle_dataframe(data: pd.DataFrame, coords: list, dims: list) -> xr.DataArray: + """Handles pandas DataFrame input.""" + if len(coords) == 2: + if data.shape[1] == 1: + return DataConverter._handle_series(data.iloc[:, 0], coords, dims) + elif data.shape != (len(coords[1]), len(coords[0])): + raise ValueError("DataFrame shape does not match provided indexes") + return xr.DataArray(data.T, coords=coords, dims=dims) + + @staticmethod + def _handle_series(data: pd.Series, coords: list, dims: list) -> xr.DataArray: + """Handles pandas Series input.""" + if len(coords) == 2: + if data.shape[0] != len(coords[1]): + raise ValueError(f"Series index does not match the shape of the provided timsteps: {data.shape[0]= } != {len(coords[1])=}") + return xr.DataArray(np.tile(data.values, (len(coords[0]), 1)), coords=coords, dims=dims) + return xr.DataArray(data.values, coords=coords, dims=dims) + @staticmethod def _handle_array(data: np.ndarray, coords: list, dims: list) -> xr.DataArray: """Handles NumPy array input.""" expected_shape = tuple(len(coord) for coord in coords) - if data.ndim == 1 and len(coords) == 2: # Automatically reshape 1D arrays + if data.ndim == 1 and len(coords) == 2: if data.shape[0] == len(coords[0]): - data = np.tile(data[:, np.newaxis], (1, len(coords[1]))) # Expand along second dimension + data = np.tile(data[:, np.newaxis], (1, len(coords[1]))) elif data.shape[0] == len(coords[1]): - data = np.tile(data[np.newaxis, :], (len(coords[0]), 1)) # Expand along first dimension + data = np.tile(data[np.newaxis, :], (len(coords[0]), 1)) else: raise ValueError("1D array length does not match either dimension in coords") @@ -82,31 +103,6 @@ def _handle_array(data: np.ndarray, coords: list, dims: list) -> xr.DataArray: return xr.DataArray(data, coords=coords, dims=dims) - @staticmethod - def _handle_series(data: pd.Series, coords: list, dims: list) -> xr.DataArray: - """Handles pandas Series input.""" - if len(coords) == 1: - if not data.index.equals(coords[0]): - raise ValueError("Series index does not match the provided time index") - return xr.DataArray(data.values, coords=coords, dims=dims) - - # Reshape if necessary and return as DataArray - return xr.DataArray(data.values.ravel(), coords=coords, dims=dims) - - @staticmethod - def _handle_dataframe(data: pd.DataFrame, coords: list, dims: list) -> xr.DataArray: - """Handles pandas DataFrame input.""" - if len(coords) != 2 or data.shape != (len(coords[1]), len(coords[0])): - raise ValueError("DataFrame shape does not match provided indexes") - - # Stack and ensure columns become level 0 - stacked = data.stack().swaplevel(0, 1).sort_index() - if not stacked.index.equals(coords[0]): - raise ValueError("Stacked DataFrame index does not match the provided index") - - return xr.DataArray(stacked.values, coords=coords, dims=dims) - - class TimeSeriesData: # TODO: Move to Interface.py From f615613366a8568a02b555f478f0b9498488e856 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 16 Feb 2025 14:36:20 +0100 Subject: [PATCH 112/507] Update Tests for DataConverter --- tests/test_dataconverter.py | 178 +++++++++++++++++------------------- 1 file changed, 84 insertions(+), 94 deletions(-) diff --git a/tests/test_dataconverter.py b/tests/test_dataconverter.py index 1495b6a09..2634f7730 100644 --- a/tests/test_dataconverter.py +++ b/tests/test_dataconverter.py @@ -1,121 +1,111 @@ +import pytest import numpy as np import pandas as pd -import pytest -from flixOpt.core import DataConverter # Update with actual module name - - -def test_as_series_scalar(): - """Test scalar input conversion.""" - index = pd.date_range("2023-01-01", periods=3) - result = DataConverter.as_series(42, (index,)) - - assert isinstance(result, pd.Series) - assert (result == 42).all() - assert result.index.equals(index) - -def test_as_series_scalar_2dims(): - """Test scalar input conversion.""" - index = pd.date_range("2023-01-01", periods=3) - period = pd.Index([2020, 2030]) - result = DataConverter.as_series(42, (period, index)) - - assert isinstance(result, pd.Series) - assert (result == 42).all() - assert result.index.equals(pd.MultiIndex.from_product([period, index])) - - -def test_as_series_1d_array(): - """Test 1D NumPy array conversion.""" - index = pd.date_range("2023-01-01", periods=3) - data = np.array([1, 2, 3]) - - result = DataConverter.as_series(data, (index,)) - - assert isinstance(result, pd.Series) - assert (result.values == data).all() - assert result.index.equals(index) - -def test_as_series_1d_array_broadcast(): - """Test 1D NumPy array conversion.""" - index = pd.date_range("2023-01-01", periods=6) - period = pd.Index([2020, 2030]) - data = np.array([1, 2, 3, 4, 5, 6]) +import xarray as xr +from flixOpt.core import DataConverter # Adjust this import to match your project structure - result = DataConverter.as_series(data, (period, index)) - assert isinstance(result, pd.Series) - assert (result.values == np.tile(data, 2)).all() - assert result.index.equals(pd.MultiIndex.from_product([period, index])) +@pytest.fixture +def sample_time_index(request): + return pd.date_range("2024-01-01", periods=5, freq="D") + +@pytest.fixture +def sample_period_index(request): + return pd.Index(["A", "B", "C"]) -def test_as_series_2d_array(): - """Test 2D NumPy array conversion.""" - index1 = pd.date_range("2023-01-01", periods=2) - index2 = pd.Index(["A", "B", "C"]) +def test_scalar_conversion(sample_time_index, sample_period_index): + # Test scalar conversion without periods + result = DataConverter.as_dataarray(42, sample_time_index) + assert isinstance(result, xr.DataArray) + assert result.shape == (len(sample_time_index),) + assert np.all(result.values == 42) - data = np.array([[1, 2, 3], [4, 5, 6]]) - result = DataConverter.as_series(data, (index1, index2)) + # Test scalar conversion with periods + result = DataConverter.as_dataarray(42, sample_time_index, sample_period_index) + assert result.shape == (len(sample_period_index), len(sample_time_index)) + assert np.all(result.values == 42) - expected_index = pd.MultiIndex.from_product([index1, index2]) - assert isinstance(result, pd.Series) - assert result.index.equals(expected_index) - assert (result.values == data.ravel()).all() +def test_series_conversion(sample_time_index, sample_period_index): + series = pd.Series([1, 2, 3, 4, 5], index=sample_time_index) -def test_as_series_series_matching_index(): - """Test Pandas Series input with matching index.""" - index = pd.date_range("2023-01-01", periods=3) - data = pd.Series([10, 20, 30], index=index) + # Test Series conversion without periods + result = DataConverter.as_dataarray(series, sample_time_index) + assert isinstance(result, xr.DataArray) + assert result.shape == (5,) + assert np.array_equal(result.values, series.values) - result = DataConverter.as_series(data, (index,)) + # Test Series conversion with periods (should expand) + result = DataConverter.as_dataarray(series, sample_time_index, sample_period_index) + assert result.shape == (3, 5) + assert np.all(result.values[:, 0] == 1) # Ensure expansion + assert np.all(result.isel(period=0).values == series.values) - assert isinstance(result, pd.Series) - assert result.equals(data) +def test_dataframe_conversion(sample_time_index, sample_period_index): + df = pd.DataFrame( + np.arange(15).reshape(5, 3), + index=sample_time_index, + columns=sample_period_index, + ) -def test_as_series_series_mismatching_index(): - """Test Pandas Series with a different index should raise an error.""" - index = pd.date_range("2023-01-01", periods=3) - wrong_index = pd.date_range("2023-01-02", periods=3) - data = pd.Series([10, 20, 30], index=wrong_index) + # Test DataFrame conversion + result = DataConverter.as_dataarray(df, sample_time_index, sample_period_index) + assert isinstance(result, xr.DataArray) + assert result.shape == (3, 5) + assert np.array_equal(result.values.T, df.values) - with pytest.raises(ValueError, match="Series index does not match the provided index"): - DataConverter.as_series(data, (index,)) +def test_dataframe_single_column_expansion(sample_time_index, sample_period_index): + df = pd.DataFrame( + {"A": [1, 2, 3, 4, 5]}, + index=sample_time_index + ) -def test_as_series_dataframe(): - """Test DataFrame conversion.""" - index1 = pd.date_range("2023-01-01", periods=2) - index2 = pd.Index(["A", "B", "C"]) + # Test expansion + result = DataConverter.as_dataarray(df, sample_time_index, sample_period_index) + assert result.shape == (3, 5) + assert np.all(result.values[:, 0] == 1) + assert np.all(result.isel(period=0).values == df.values.flatten()) - data = pd.DataFrame([[1, 2, 3], [4, 5, 6]], index=index1, columns=index2) - result = DataConverter.as_series(data, (index2, index1)) - expected_index = pd.MultiIndex.from_product([index2, index1]) - expected_series = data.stack().swaplevel(0, 1).sort_index() +def test_ndarray_conversion(sample_time_index, sample_period_index): + # Test 1D array conversion (should expand into each period) + arr_1d = np.array([1, 2, 3, 4, 5]) + result = DataConverter.as_dataarray(arr_1d, sample_time_index, sample_period_index) + assert result.shape == (3, 5) - assert isinstance(result, pd.Series) - assert result.index.equals(expected_index) - assert result.equals(expected_series) + # Test 1D array conversion (should expand into each timestep) + arr_1d_period = np.array([1, 2, 3]) + result = DataConverter.as_dataarray(arr_1d_period, sample_time_index, sample_period_index) + assert result.shape == (3, 5) + # Test 2D array conversion + arr_2d = np.random.rand(3, 5) + result = DataConverter.as_dataarray(arr_2d, sample_time_index, sample_period_index) + assert result.shape == (3, 5) -def test_invalid_dims(): - """Test invalid dims input.""" - with pytest.raises(TypeError, match="dims must be a tuple of pandas Index objects"): - DataConverter.as_series(10, ["not", "an", "index"]) +def test_invalid_inputs(sample_time_index, sample_period_index): + # Test invalid input type + with pytest.raises(TypeError): + DataConverter.as_dataarray("invalid_string", sample_time_index) -def test_invalid_data_type(): - """Test invalid data type handling.""" - with pytest.raises(TypeError, match="Unsupported data type"): - DataConverter.as_series({"a": 1}, (pd.Index([1, 2, 3]),)) + # Test mismatched Series index + mismatched_series = pd.Series([1, 2, 3, 4, 5, 6], index=pd.date_range("2025-01-01", periods=6, freq="D")) + with pytest.raises(ValueError): + DataConverter.as_dataarray(mismatched_series, sample_time_index) + # Test mismatched DataFrame shape + df_invalid = pd.DataFrame(np.random.rand(4, 2), index=sample_time_index[:4], columns=sample_period_index[:2]) + with pytest.raises(ValueError): + DataConverter.as_dataarray(df_invalid, sample_time_index, sample_period_index) + + with pytest.raises(ValueError): + # Test mismatched Shape. Array should be (3, 5) + DataConverter.as_dataarray(np.random.rand(5, 3), sample_time_index, sample_period_index) -def test_shape_mismatch(): - """Test shape mismatch between data and index.""" - index1 = pd.Index(["A", "B"]) - index2 = pd.Index(["X", "Y", "Z"]) - data = np.array([[1, 2], [3, 4]]) # Wrong shape - with pytest.raises(ValueError, match="Shape of data"): - DataConverter.as_series(data, (index1, index2)) +if __name__ == "__main__": + pytest.main() From 3b24161d8fc671b71db4d452ab1d0adfb68f787e Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 16 Feb 2025 15:10:49 +0100 Subject: [PATCH 113/507] Update Tests for TimeSeries --- tests/test_timeseries.py | 84 +++++++++++++++------------------------- 1 file changed, 31 insertions(+), 53 deletions(-) diff --git a/tests/test_timeseries.py b/tests/test_timeseries.py index d4e73dedf..7c9cec649 100644 --- a/tests/test_timeseries.py +++ b/tests/test_timeseries.py @@ -7,47 +7,47 @@ # Helper function to create a test TimeSeries object def create_test_timeseries(): - data = pd.Series([10, 20, 30], index=pd.date_range('2023-01-01', periods=3)) - return TimeSeries(data) + data = xr.DataArray([10, 20, 30], coords={'time': pd.date_range('2023-01-01', periods=3)}) + return TimeSeries(data, 'Name') # Test initialization def test_initialization(): ts = create_test_timeseries() assert isinstance(ts, TimeSeries) - assert isinstance(ts.stored_data, pd.Series) - assert ts.stored_data.equals(pd.Series([10, 20, 30], index=pd.date_range('2023-01-01', periods=3))) + assert isinstance(ts.stored_data, xr.DataArray) + assert ts.stored_data.equals(xr.DataArray([10, 20, 30], coords={'time': pd.date_range('2023-01-01', periods=3)})) -# Test active_index property setter and getter -def test_active_index_setter_getter(): +# Test active_timesteps property setter and getter +def test_active_timesteps_setter_getter(): ts = create_test_timeseries() new_index = pd.date_range('2023-01-02', periods=2) - ts.active_index = new_index - assert ts.active_index.equals(new_index) - assert ts.active_data.equals(ts.stored_data.loc[new_index]) + ts.active_timesteps = new_index + assert ts.active_timesteps.equals(new_index) + assert ts.active_data.equals(ts.stored_data.sel(time=new_index)) -# Test invalid active_index assignment -def test_invalid_active_index(): +# Test invalid active_timesteps assignment +def test_invalid_active_timesteps(): ts = create_test_timeseries() with pytest.raises(TypeError): - ts.active_index = "invalid_index" + ts.active_timesteps = "invalid_index" # Test restoring data def test_restore_data(): ts = create_test_timeseries() - ts.active_index = pd.date_range('2023-01-02', periods=2) + ts.active_timesteps = pd.date_range('2023-01-02', periods=2) ts.restore_data() - assert ts.active_index.equals(ts.stored_data.index) + assert ts.active_timesteps.equals(ts.stored_data.indexes['time']) assert ts.active_data.equals(ts.stored_data) ts = create_test_timeseries() old_data = ts.stored_data - new_data = pd.Series([1,2], pd.date_range("2023-01-02", periods=2)) + new_data = xr.DataArray([1,2], coords=(pd.date_range("2023-01-02", periods=2, name='time'),)) ts.stored_data = new_data assert ts.active_data.equals(new_data) ts.restore_data() # Restore original data - assert ts.active_index.equals(old_data.index) # Ensure active_index is reset to full index + assert ts.active_timesteps.equals(old_data.indexes['time']) # Ensure active_timesteps is reset to full index assert ts.active_data.equals(old_data) # Ensure active_data matches stored_data @@ -59,38 +59,29 @@ def test_arithmetic_operations(): # Test addition result = ts1 + ts2 expected = ts1.active_data + ts2.active_data - pd.testing.assert_series_equal(result, expected) + xr.testing.assert_equal(result, expected) # Test subtraction result = ts1 - ts2 expected = ts1.active_data - ts2.active_data - pd.testing.assert_series_equal(result, expected) + xr.testing.assert_equal(result, expected) # Test multiplication result = ts1 * ts2 expected = ts1.active_data * ts2.active_data - pd.testing.assert_series_equal(result, expected) + xr.testing.assert_equal(result, expected) # Test division result = ts1 / ts2 expected = ts1.active_data / ts2.active_data - pd.testing.assert_series_equal(result, expected) + xr.testing.assert_equal(result, expected) - # Test floordiv - result = ts1 // ts2 - expected = ts1.active_data // ts2.active_data - pd.testing.assert_series_equal(result, expected) - - # Test exponentiation - result = ts1 ** ts2 - expected = ts1.active_data ** ts2.active_data - pd.testing.assert_series_equal(result, expected) # Test setting stored_data def test_stored_data_setter(): ts = create_test_timeseries() old_data = ts.stored_data - new_data = pd.Series([40, 50, 60], index=pd.date_range('2023-01-01', periods=3)) + new_data = xr.DataArray([40, 50, 60], coords={'time': pd.date_range('2023-01-01', periods=3)}) ts.stored_data = new_data assert ts.stored_data.equals(new_data) assert ts.active_data.equals(new_data) @@ -100,19 +91,12 @@ def test_stored_data_setter(): def test_prevent_active_data_modification(): ts = create_test_timeseries() with pytest.raises(AttributeError): - ts.active_data = pd.Series([1, 2, 3], index=pd.date_range('2023-01-01', periods=3)) - -# Test loc and iloc properties -def test_loc_iloc_properties(): - ts = create_test_timeseries() - ts.active_index = pd.date_range('2023-01-01', periods=3) - assert ts.loc['2023-01-02'] == 20 - assert ts.iloc[1] == 20 + ts.active_data = object() # Test active_data default behavior def test_active_data_default(): ts = create_test_timeseries() - ts.active_index = None # Should default to the full stored_data + ts.active_timesteps = None # Should default to the full stored_data assert ts.active_data.equals(ts.stored_data) @@ -123,12 +107,12 @@ def test_arithmetic_operations_xarray(): arithmetric_operations( xr.DataArray([10, 20, 30], coords=(time_idx,)), - TimeSeries(pd.Series([10, 20, 30], index=time_idx)) + TimeSeries(xr.DataArray([10, 20, 30], coords=(time_idx,)), 'Name') ) arithmetric_operations( - xr.DataArray([[10, 20, 30], [1,2,3]], coords=(periods, time_idx)), - TimeSeries(pd.Series([10, 20, 30, 1, 2, 3], index=pd.MultiIndex.from_product([periods, time_idx]))) + xr.DataArray([[10, 20, 30], [1,2,3]], coords={'period': periods, 'time': time_idx}), + TimeSeries(xr.DataArray([[10, 20, 30], [1,2,3]], coords={'period': periods, 'time': time_idx}),'Name') ) def arithmetric_operations(data1: xr.DataArray, ts1: TimeSeries): @@ -136,14 +120,10 @@ def arithmetric_operations(data1: xr.DataArray, ts1: TimeSeries): xr.testing.assert_equal(ts1 - data1, data1 - ts1, check_dim_order=True) xr.testing.assert_equal(ts1 * data1, data1 * ts1, check_dim_order=True) xr.testing.assert_equal(ts1 / data1, data1 / ts1, check_dim_order=True) - if data1.ndim > 1: - ts1_active = ts1.active_data.to_xarray() - else: - ts1_active = ts1.active_data - xr.testing.assert_equal(data1 + ts1_active, data1 + ts1, check_dim_order=True) - xr.testing.assert_equal(data1 - ts1_active, data1 - ts1, check_dim_order=True) - xr.testing.assert_equal(data1 * ts1_active, data1 * ts1, check_dim_order=True) - xr.testing.assert_equal(data1 / ts1_active, data1 / ts1, check_dim_order=True) + xr.testing.assert_equal(data1 + ts1.active_data, data1 + ts1, check_dim_order=True) + xr.testing.assert_equal(data1 - ts1.active_data, data1 - ts1, check_dim_order=True) + xr.testing.assert_equal(data1 * ts1.active_data, data1 * ts1, check_dim_order=True) + xr.testing.assert_equal(data1 / ts1.active_data, data1 / ts1, check_dim_order=True) def test_operations_with_linopy(): @@ -152,9 +132,7 @@ def test_operations_with_linopy(): m = linopy.Model() var1 = m.add_variables(coords=(period, index)) - timeseries1 = TimeSeries( - pd.Series([10, 20, 30,10, 20, 30], index=pd.MultiIndex.from_product([period, index])) - ) + timeseries1 = TimeSeries(xr.DataArray([[10, 20, 30], [1,2,3]], coords={'period': period, 'time':index}),'Name') expr = timeseries1 * var1 expr + timeseries1 (expr + timeseries1) / timeseries1 From f671393f7b1a41ef1336b38868245ae33f757515 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 16 Feb 2025 15:11:07 +0100 Subject: [PATCH 114/507] Bugfixes in TimeSeries --- flixOpt/core.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/flixOpt/core.py b/flixOpt/core.py index 600d35d4f..42e2da7c6 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -194,17 +194,16 @@ def __init__(self, if 'period' not in data.indexes and data.ndim > 1: raise ValueError(f'Second index of DataArray must be "period". Got {data.indexes}') - self._active_data = None - self._active_timesteps = None - self._active_periods = None self.name = name self.aggregation_weight = aggregation_weight self._stored_data = data.copy() - self._backup: xr.DataArray = self.stored_data # Single backup instance. Enables to temporarily overwrite the data. - self.active_timesteps = None # Initializes the active timesteps and active data - self.active_periods = None # Initializes the active timesteps and active data + self._active_data = None + + self._active_timesteps = self.stored_data.indexes['time'] + self._active_periods = self.stored_data.indexes['period'] if 'period' in self.stored_data.indexes else None + self._update_active_data() def restore_data(self): """Restore stored_data from the backup.""" @@ -215,7 +214,7 @@ def restore_data(self): def _update_active_data(self): """Update the active data.""" if 'period' in self._stored_data.indexes: - self._active_data = self._stored_data.sel(time=self.active_timesteps, periods=self.active_periods) + self._active_data = self._stored_data.sel(time=self.active_timesteps, period=self.active_periods) else: self._active_data = self._stored_data.sel(time=self.active_timesteps) @@ -228,7 +227,7 @@ def active_timesteps(self) -> pd.DatetimeIndex: def active_timesteps(self, timesteps: Optional[pd.DatetimeIndex]): """Set active_timesteps and refresh active_data.""" if timesteps is None: - self._active_timesteps = slice(None) + self._active_timesteps = self.stored_data.indexes['time'] elif isinstance(timesteps, pd.DatetimeIndex): self._active_timesteps = timesteps else: @@ -245,7 +244,7 @@ def active_periods(self) -> pd.Index: def active_periods(self, periods: Optional[pd.Index]): """Set new active periods and refresh active_data.""" if periods is None: - self._active_periods = slice(None) + self._active_periods = self.stored_data.indexes['period'] if 'period' in self.stored_data.indexes else None elif isinstance(periods, pd.Index): self._active_periods = periods else: @@ -329,5 +328,3 @@ def __array_ufunc__(self, ufunc, method, *inputs, **kwargs): """Ensures NumPy functions like np.add(TimeSeries, xarray) work correctly.""" inputs = [x.active_data if isinstance(x, TimeSeries) else x for x in inputs] return getattr(ufunc, method)(*inputs, **kwargs) - - From cf5d64c64aebc1fab9e12cfb1c13e2a9e1809236 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 16 Feb 2025 16:16:49 +0100 Subject: [PATCH 115/507] Updating test_integration.py --- tests/test_integration.py | 64 +++++++++++++++++++-------------------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/tests/test_integration.py b/tests/test_integration.py index e029069f6..c44a45878 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -17,7 +17,7 @@ def setUp(self): fx.change_logging_level('DEBUG') def get_solver(self): - return fx.solvers.HighsSolver(mip_gap=0.0001, time_limit_seconds=3600, solver_output_to_console=False) + return 'highs' def assert_almost_equal_numeric( self, actual, desired, err_msg, relative_error_range_in_percent=0.011, absolute_tolerance=1e-9 @@ -45,23 +45,23 @@ def setUp(self): def test_model(self): calculation = self.model() - effects = calculation.flow_system.effect_collection.effects + effects = calculation.flow_system.effects comps = calculation.flow_system.components # Compare expected values with actual values self.assert_almost_equal_numeric( - effects['costs'].model.all.sum.result, 81.88394666666667, 'costs doesnt match expected value' + effects['costs'].model.total.solution.item(), 81.88394666666667, 'costs doesnt match expected value' ) self.assert_almost_equal_numeric( - effects['CO2'].model.all.sum.result, 255.09184, 'CO2 doesnt match expected value' + effects['CO2'].model.total.solution.item(), 255.09184, 'CO2 doesnt match expected value' ) self.assert_almost_equal_numeric( - comps['Boiler'].Q_th.model.flow_rate.result, + comps['Boiler'].Q_th.model.flow_rate.solution.values, [0, 0, 0, 28.4864, 35, 0, 0, 0, 0], 'Q_th doesnt match expected value', ) self.assert_almost_equal_numeric( - comps['CHP_unit'].Q_th.model.flow_rate.result, + comps['CHP_unit'].Q_th.model.flow_rate.solution.values, [30.0, 26.66666667, 75.0, 75.0, 75.0, 20.0, 20.0, 20.0, 20.0], 'Q_th doesnt match expected value', ) @@ -94,7 +94,7 @@ def test_from_results(self): df = results.to_dataframe('Fernwärme', with_last_time_step=False) comps = calculation.flow_system.components self.assert_almost_equal_numeric( - comps['Wärmelast'].sink.model.flow_rate.result, + comps['Wärmelast'].sink.model.flow_rate.solution.values, df['Wärmelast__Q_th_Last'], 'Loaded Results and directly used results dont match, or loading didnt work properly', ) @@ -226,12 +226,12 @@ def test_transmission_basic(self): calculation.solve(self.get_solver()) print(calculation.results()) self.assert_almost_equal_numeric( - transmission.in1.model.on_off.on.result, np.array([1, 1, 1, 1, 1, 1, 1, 1, 1, 1]), 'On does not work properly' + transmission.in1.model.on_off.on.solution.values, np.array([1, 1, 1, 1, 1, 1, 1, 1, 1, 1]), 'On does not work properly' ) self.assert_almost_equal_numeric( - transmission.in1.model.flow_rate.result * 0.8 - 20, - transmission.out1.model.flow_rate.result, + transmission.in1.model.flow_rate.solution.values * 0.8 - 20, + transmission.out1.model.flow_rate.solution.values, 'Losses are not computed correctly', ) @@ -281,25 +281,25 @@ def test_transmission_advanced(self): results = fx.results.CalculationResults(calculation.name, 'results') self.assert_almost_equal_numeric( - transmission.in1.model.on_off.on.result, np.array([1, 1, 1, 0, 0, 0, 0, 0, 0, 0]), 'On does not work properly' + transmission.in1.model.on_off.on.solution.values, np.array([1, 1, 1, 0, 0, 0, 0, 0, 0, 0]), 'On does not work properly' ) self.assert_almost_equal_numeric( results.to_dataframe('Rohr', with_last_time_step=False)['Rohr__Rohr1b'].values, - transmission.out1.model.flow_rate.result, + transmission.out1.model.flow_rate.solution.values, 'Flow rate of Rohr__Rohr1b is not correct', ) self.assert_almost_equal_numeric( - transmission.in1.model.flow_rate.result * 0.8 - - np.array([20 if val > 0.1 else 0 for val in transmission.in1.model.flow_rate.result]), - transmission.out1.model.flow_rate.result, + transmission.in1.model.flow_rate.solution.values * 0.8 + - np.array([20 if val > 0.1 else 0 for val in transmission.in1.model.flow_rate.solution.values]), + transmission.out1.model.flow_rate.solution.values, 'Losses are not computed correctly', ) self.assert_almost_equal_numeric( - transmission.in1.model._investment.size.result, - transmission.in2.model._investment.size.result, + transmission.in1.model._investment.size.solution.item, + transmission.in2.model._investment.size.solution.item, 'THe Investments are not equated correctly', ) @@ -325,12 +325,12 @@ def setUp(self): def test_basic(self): calculation = self.basic_model() - effects = calculation.flow_system.effect_collection.effects + effects = calculation.flow_system.effects comps = calculation.flow_system.components # Compare expected values with actual values self.assert_almost_equal_numeric( - effects['costs'].model.all.sum.result, -11597.873624489237, 'costs doesnt match expected value' + effects['costs'].model.total.solution.item(), -11597.873624489237, 'costs doesnt match expected value' ) self.assert_almost_equal_numeric( effects['costs'].model.operation.sum_TS.result, @@ -407,13 +407,13 @@ def test_basic(self): effects['CO2'].model.all.shares['invest'].result, 0.9999999999999994, 'CO2 doesnt match expected value' ) self.assert_almost_equal_numeric( - comps['Kessel'].Q_th.model.flow_rate.result, + comps['Kessel'].Q_th.model.flow_rate.solution.values, [0, 0, 0, 45, 0, 0, 0, 0, 0], 'Kessel doesnt match expected value', ) self.assert_almost_equal_numeric( - comps['KWK'].Q_th.model.flow_rate.result, + comps['KWK'].Q_th.model.flow_rate.solution.values, [ 7.50000000e01, 6.97111111e01, @@ -428,7 +428,7 @@ def test_basic(self): 'KWK Q_th doesnt match expected value', ) self.assert_almost_equal_numeric( - comps['KWK'].P_el.model.flow_rate.result, + comps['KWK'].P_el.model.flow_rate.solution.values, [ 6.00000000e01, 5.57688889e01, @@ -462,29 +462,29 @@ def test_basic(self): def test_segments_of_flows(self): calculation = self.segments_of_flows_model() - effects = calculation.flow_system.effect_collection.effects + effects = calculation.flow_system.effects comps = calculation.flow_system.components # Compare expected values with actual values self.assert_almost_equal_numeric( - effects['costs'].model.all.sum.result, -10710.997365760755, 'costs doesnt match expected value' + effects['costs'].model.total.solution.item(), -10710.997365760755, 'costs doesnt match expected value' ) self.assert_almost_equal_numeric( - effects['CO2'].model.all.sum.result, 1278.7939026086956, 'CO2 doesnt match expected value' + effects['CO2'].model.total.solution.item(), 1278.7939026086956, 'CO2 doesnt match expected value' ) self.assert_almost_equal_numeric( - comps['Kessel'].Q_th.model.flow_rate.result, + comps['Kessel'].Q_th.model.flow_rate.solution.values, [0, 0, 0, 45, 0, 0, 0, 0, 0], 'Kessel doesnt match expected value', ) kwk_flows = {flow.label: flow for flow in comps['KWK'].inputs + comps['KWK'].outputs} self.assert_almost_equal_numeric( - kwk_flows['Q_th'].model.flow_rate.result, + kwk_flows['Q_th'].model.flow_rate.solution.values, [45.0, 45.0, 64.5962087, 100.0, 61.3136, 45.0, 45.0, 12.86469565, 0.0], 'KWK Q_th doesnt match expected value', ) self.assert_almost_equal_numeric( - kwk_flows['P_el'].model.flow_rate.result, + kwk_flows['P_el'].model.flow_rate.solution.values, [40.0, 40.0, 47.12589407, 60.0, 45.93221818, 40.0, 40.0, 10.91784108, -0.0], 'KWK P_el doesnt match expected value', ) @@ -716,16 +716,16 @@ def setUp(self): def test_full(self): calculation = self.calculate('full') - effects = calculation.flow_system.effect_collection.effects + effects = calculation.flow_system.effects self.assert_almost_equal_numeric( - effects['costs'].model.all.sum.result, 343613, 'costs doesnt match expected value' + effects['costs'].model.total.solution.item(), 343613, 'costs doesnt match expected value' ) def test_aggregated(self): calculation = self.calculate('aggregated') - effects = calculation.flow_system.effect_collection.effects + effects = calculation.flow_system.effects self.assert_almost_equal_numeric( - effects['costs'].model.all.sum.result, 342967.0, 'costs doesnt match expected value' + effects['costs'].model.total.solution.item(), 342967.0, 'costs doesnt match expected value' ) def test_segmented(self): From 7f79f8aea9e4039c33e1ec28b8979597737e0e8b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 16 Feb 2025 16:23:14 +0100 Subject: [PATCH 116/507] Updating test_integration.py --- tests/test_integration.py | 33 +++++++++++++-------------------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/tests/test_integration.py b/tests/test_integration.py index c44a45878..7ebd3f94b 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -40,8 +40,7 @@ def setUp(self): self.Q_th_Last = np.array([30.0, 0.0, 90.0, 110, 110, 20, 20, 20, 20]) self.p_el = 1 / 1000 * np.array([80.0, 80.0, 80.0, 80, 80, 80, 80, 80, 80]) - self.aTimeSeries = datetime.datetime(2020, 1, 1) + np.arange(len(self.Q_th_Last)) * datetime.timedelta(hours=1) - self.aTimeSeries = self.aTimeSeries.astype('datetime64') + self.timesteps = pd.date_range('2020-01-01', periods=len(self.Q_th_Last), freq='h', name='time') def test_model(self): calculation = self.model() @@ -157,7 +156,7 @@ def model(self, save_results=False) -> fx.FullCalculation: 'Einspeisung', sink=fx.Flow('P_el', bus=Strom, effects_per_flow_hour=-1 * self.p_el) ) - es = fx.FlowSystem(self.aTimeSeries, last_time_step_hours=None) + es = fx.FlowSystem(self.timesteps) es.add_components(aSpeicher) es.add_effects(costs, CO2) es.add_components(aBoiler, aWaermeLast, aGasTarif) @@ -182,10 +181,7 @@ def setUp(self): super().setUp() self.Q_th_Last = np.array([np.random.random() for _ in range(10)]) * 180 self.p_el = (np.array([np.random.random() for _ in range(10)]) + 0.5) / 1.5 * 50 - self.datetime_array = datetime.datetime(2020, 1, 1) + np.arange(len(self.Q_th_Last)) * datetime.timedelta( - hours=1 - ) - self.datetime_array = self.datetime_array.astype('datetime64') + self.timesteps = pd.date_range('2020-01-01', periods=len(self.Q_th_Last), freq='h', name='time') def create_basic_elements(self): self.busses = {label: fx.Bus(label) for label in ['Strom', 'Fernwärme', 'Gas']} @@ -205,7 +201,7 @@ def create_basic_elements(self): def test_transmission_basic(self): self.create_basic_elements() - flow_system = fx.FlowSystem(self.datetime_array, last_time_step_hours=None) + flow_system = fx.FlowSystem(self.timesteps) flow_system.add_elements(*(list(self.effects.values()) + list(self.components.values()))) extra_bus = fx.Bus('Wärme lokal') boiler = fx.linear_converters.Boiler( @@ -237,7 +233,7 @@ def test_transmission_basic(self): def test_transmission_advanced(self): self.create_basic_elements() - flow_system = fx.FlowSystem(self.datetime_array, last_time_step_hours=None) + flow_system = fx.FlowSystem(self.timesteps) flow_system.add_elements(*(list(self.effects.values()) + list(self.components.values()))) extra_bus = fx.Bus('Wärme lokal') @@ -307,10 +303,10 @@ def tearDown(self): self.busses = None self.effects = None self.components = None - self.datetime_array = None + self.timesteps = None self.Q_th_Last = None self.p_el = None - self.datetime_array = None + self.timesteps = None class TestComplex(BaseTest): @@ -318,8 +314,7 @@ def setUp(self): super().setUp() self.Q_th_Last = np.array([30.0, 0.0, 90.0, 110, 110, 20, 20, 20, 20]) self.P_el_Last = np.array([40.0, 40.0, 40.0, 40, 40, 40, 40, 40, 40]) - self.aTimeSeries = datetime.datetime(2020, 1, 1) + np.arange(len(self.Q_th_Last)) * datetime.timedelta(hours=1) - self.aTimeSeries = self.aTimeSeries.astype('datetime64') + self.timesteps = pd.date_range('2020-01-01', periods=len(self.Q_th_Last), freq='h', name='time') self.excessCosts = None self.useCHPwithLinearSegments = False @@ -585,7 +580,7 @@ def basic_model(self) -> fx.FullCalculation: 'Einspeisung', sink=fx.Flow('P_el', bus=Strom, effects_per_flow_hour=-1 * np.array(self.P_el_Last)) ) - es = fx.FlowSystem(self.aTimeSeries, last_time_step_hours=None) + es = fx.FlowSystem(self.timesteps) es.add_effects(costs, CO2, PE) es.add_components(aGaskessel, aWaermeLast, aGasTarif, aStromEinspeisung, aKWK, aSpeicher) @@ -689,7 +684,7 @@ def segments_of_flows_model(self): 'Einspeisung', sink=fx.Flow('P_el', bus=Strom, effects_per_flow_hour=-1 * np.array(self.P_el_Last)) ) - es = fx.FlowSystem(self.aTimeSeries, last_time_step_hours=None) + es = fx.FlowSystem(self.timesteps) es.add_effects(costs, CO2, PE) es.add_components(aGaskessel, aWaermeLast, aGasTarif, aStromEinspeisung, aKWK) es.add_components(aSpeicher) @@ -709,7 +704,7 @@ def setUp(self): super().setUp() self.Q_th_Last = np.array([30.0, 0.0, 90.0, 110, 110, 20, 20, 20, 20]) self.p_el = 1 / 1000 * np.array([80.0, 80.0, 80.0, 80, 80, 80, 80, 80, 80]) - self.aTimeSeries = ( + self.timesteps = ( datetime.datetime(2020, 1, 1) + np.arange(len(self.Q_th_Last)) * datetime.timedelta(hours=1) ).astype('datetime64') self.max_emissions_per_hour = 1000 @@ -754,9 +749,7 @@ def calculate(self, modeling_type: Literal['full', 'segmented', 'aggregated']): data['Strompr.€/MWh'].values, data['Gaspr.€/MWh'].values, ) - aTimeSeries = ( - datetime.datetime(2020, 1, 1) + np.arange(len(P_el_Last)) * datetime.timedelta(hours=0.25) - ).astype('datetime64') + timesteps = pd.date_range('2020-01-01', periods=len(P_el_Last), freq='h', name='time') Strom, Fernwaerme, Gas, Kohle = fx.Bus('Strom'), fx.Bus('Fernwärme'), fx.Bus('Gas'), fx.Bus('Kohle') costs, CO2, PE = ( @@ -830,7 +823,7 @@ def calculate(self, modeling_type: Literal['full', 'segmented', 'aggregated']): ), ) - es = fx.FlowSystem(aTimeSeries, last_time_step_hours=None) + es = fx.FlowSystem(timesteps) es.add_effects(costs, CO2, PE) es.add_components( aGaskessel, aWaermeLast, aStromLast, aGasTarif, aKohleTarif, aStromEinspeisung, aStromTarif, aKWK, aSpeicher From 0ed24b879a6ccb57dd996ec9e60940d6a04f30f1 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 16 Feb 2025 16:35:32 +0100 Subject: [PATCH 117/507] Update Transmission --- flixOpt/components.py | 40 ++++++++++++++++++++++------------------ 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/flixOpt/components.py b/flixOpt/components.py index e20fb54ad..3a720429f 100644 --- a/flixOpt/components.py +++ b/flixOpt/components.py @@ -314,8 +314,8 @@ def _plausibility_checks(self): 'Please use Flow in1. The size of in2 is equal to in1. THis is handled internally' ) - def create_model(self) -> 'TransmissionModel': - self.model = TransmissionModel(self) + def create_model(self, model) -> 'TransmissionModel': + self.model = TransmissionModel(model, self) return self.model def transform_data(self, flow_system: 'FlowSystem') -> None: @@ -325,8 +325,8 @@ def transform_data(self, flow_system: 'FlowSystem') -> None: class TransmissionModel(ComponentModel): - def __init__(self, element: Transmission): - super().__init__(element) + def __init__(self, model: SystemModel, element: Transmission): + super().__init__(model, element) self.element: Transmission = element self.on_off: Optional[OnOffModel] = None @@ -348,30 +348,34 @@ def do_modeling(self, system_model: SystemModel): super().do_modeling(system_model) # first direction - self.create_transmission_equation('direction_1', self.element.in1, self.element.out1) + self.create_transmission_equation('dir1', self.element.in1, self.element.out1) # second direction: if self.element.in2 is not None: - self.create_transmission_equation('direction_2', self.element.in2, self.element.out2) + self.create_transmission_equation('dir2', self.element.in2, self.element.out2) # equate size of both directions if isinstance(self.element.in1.size, InvestParameters) and self.element.in2 is not None: # eq: in1.size = in2.size - eq_equal_size = create_equation('equal_size_in_both_directions', self, 'eq') - eq_equal_size.add_summand(self.element.in1.model._investment.size, 1) - eq_equal_size.add_summand(self.element.in2.model._investment.size, -1) + self.add(self._model.add_constraints( + self.element.in1.model._investment.size == self.element.in2.model._investment.size, + name=f'{self.label_full}__same_size'), + 'same_size' + ) - def create_transmission_equation(self, name: str, in_flow: Flow, out_flow: Flow) -> Equation: + def create_transmission_equation(self, name: str, in_flow: Flow, out_flow: Flow) -> linopy.Constraint: """Creates an Equation for the Transmission efficiency and adds it to the model""" - # first direction - # eq: in(t)*(1-loss_rel(t)) = out(t) + on(t)*loss_abs(t) - eq_transmission = create_equation(name, self, 'eq') - efficiency = 1 if self.element.relative_losses is None else (1 - self.element.relative_losses.active_data) - eq_transmission.add_summand(in_flow.model.flow_rate, efficiency) - eq_transmission.add_summand(out_flow.model.flow_rate, -1) + # eq: out(t) + on(t)*loss_abs(t) = in(t)*(1 - loss_rel(t)) + con_transmission = self.add(self._model.add_constraints( + out_flow.model.flow_rate == -in_flow.model.flow_rate * (self.element.relative_losses.active_data - 1), + name=f'{self.label_full}__{name}'), + name + ) + if self.element.absolute_losses is not None: - eq_transmission.add_summand(in_flow.model.on_off.on, -1 * self.element.absolute_losses.active_data) - return eq_transmission + con_transmission.lhs += in_flow.model.on_off.on * self.element.absolute_losses.active_data + + return con_transmission class LinearConverterModel(ComponentModel): From 6e01b5084cd5f330ebb4681e6828d4bae8a95c56 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 16 Feb 2025 16:40:20 +0100 Subject: [PATCH 118/507] Bugfix in FlowModel --- flixOpt/elements.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixOpt/elements.py b/flixOpt/elements.py index fcad4b5b1..699de4e67 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -317,7 +317,7 @@ def do_modeling(self, system_model: SystemModel): self.add( self._model.add_constraints( self.flow_rate == self.element.fixed_relative_profile.active_data, - name=f'{self.element.label}_fix_flow_rate' + name=f'{self.label_full}_fix_flow_rate' ), 'flow_rate (fix)' ) From 5fd853f6503748395c90e7e2e6181eb01e9174c4 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 16 Feb 2025 16:41:20 +0100 Subject: [PATCH 119/507] Updating test_integration.py --- tests/test_integration.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/tests/test_integration.py b/tests/test_integration.py index 7ebd3f94b..a9f02e7c0 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -72,12 +72,12 @@ def test_from_results(self): # test effect results self.assert_almost_equal_numeric( - results.effect_results['costs'].all_results['all']['all_sum'], + results.effect_results['costs'].all_results['total'], 81.88394666666667, 'costs doesnt match expected value', ) self.assert_almost_equal_numeric( - results.effect_results['CO2'].all_results['all']['all_sum'], 255.09184, 'CO2 doesnt match expected value' + results.effect_results['CO2'].all_results['total'], 255.09184, 'CO2 doesnt match expected value' ) self.assert_almost_equal_numeric( results.component_results['Boiler'].variables_flat['Q_th__flow_rate'], @@ -163,12 +163,10 @@ def model(self, save_results=False) -> fx.FullCalculation: es.add_components(aStromEinspeisung) es.add_components(aKWK) - time_indices = None - print(es) es.visualize_network() - aCalc = fx.FullCalculation('Test_Sim', es, 'pyomo', time_indices) + aCalc = fx.FullCalculation('Test_Sim', es) aCalc.do_modeling() aCalc.solve(self.get_solver(), save_results=save_results) @@ -294,8 +292,8 @@ def test_transmission_advanced(self): ) self.assert_almost_equal_numeric( - transmission.in1.model._investment.size.solution.item, - transmission.in2.model._investment.size.solution.item, + transmission.in1.model._investment.size.solution.item(), + transmission.in2.model._investment.size.solution.item(), 'THe Investments are not equated correctly', ) From 5b8766e5eb742e84874b9749af0de9d1762410a9 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 16 Feb 2025 16:46:19 +0100 Subject: [PATCH 120/507] Updating test_integration.py --- tests/test_integration.py | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/tests/test_integration.py b/tests/test_integration.py index a9f02e7c0..234e99900 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -326,7 +326,7 @@ def test_basic(self): effects['costs'].model.total.solution.item(), -11597.873624489237, 'costs doesnt match expected value' ) self.assert_almost_equal_numeric( - effects['costs'].model.operation.sum_TS.result, + effects['costs'].model.operation.total_per_timestep.solution.values, [ -2.38500000e03, -2.21681333e03, @@ -342,62 +342,62 @@ def test_basic(self): ) self.assert_almost_equal_numeric( - sum(effects['costs'].model.operation.shares['CO2_operation'].result), + sum(effects['costs'].model.operation.shares['CO2_operation'].solution.item()), 258.63729669618675, 'costs doesnt match expected value', ) self.assert_almost_equal_numeric( - sum(effects['costs'].model.operation.shares['Kessel__Q_th__switch_on_effects'].result), + sum(effects['costs'].model.operation.shares['Kessel__Q_th__switch_on_effects'].solution.values), 0.01, 'costs doesnt match expected value', ) self.assert_almost_equal_numeric( - sum(effects['costs'].model.operation.shares['Kessel__running_hour_effects'].result), + sum(effects['costs'].model.operation.shares['Kessel__running_hour_effects'].solution.values), -0.0, 'costs doesnt match expected value', ) self.assert_almost_equal_numeric( - sum(effects['costs'].model.operation.shares['Gastarif__Q_Gas__effects_per_flow_hour'].result), + sum(effects['costs'].model.operation.shares['Gastarif__Q_Gas__effects_per_flow_hour'].solution.values), 39.09153113079115, 'costs doesnt match expected value', ) self.assert_almost_equal_numeric( - sum(effects['costs'].model.operation.shares['Einspeisung__P_el__effects_per_flow_hour'].result), + sum(effects['costs'].model.operation.shares['Einspeisung__P_el__effects_per_flow_hour'].solution.values), -14196.61245231646, 'costs doesnt match expected value', ) self.assert_almost_equal_numeric( - sum(effects['costs'].model.operation.shares['KWK__switch_on_effects'].result), + sum(effects['costs'].model.operation.shares['KWK__switch_on_effects'].solution.values), 0.0, 'costs doesnt match expected value', ) self.assert_almost_equal_numeric( - effects['costs'].model.invest.shares['Kessel__Q_th__fix_effects'].result, + effects['costs'].model.invest.shares['Kessel__Q_th__fix_effects'].solution.values, 1000, 'costs doesnt match expected value', ) self.assert_almost_equal_numeric( - effects['costs'].model.invest.shares['Kessel__Q_th__specific_effects'].result, + effects['costs'].model.invest.shares['Kessel__Q_th__specific_effects'].solution.values, 500, 'costs doesnt match expected value', ) self.assert_almost_equal_numeric( - effects['costs'].model.invest.shares['Speicher__specific_effects'].result, + effects['costs'].model.invest.shares['Speicher__specific_effects'].solution.values, 1, 'costs doesnt match expected value', ) self.assert_almost_equal_numeric( - effects['costs'].model.invest.shares['Speicher__segmented_effects'].result, + effects['costs'].model.invest.shares['Speicher__segmented_effects'].solution.values, 800, 'costs doesnt match expected value', ) self.assert_almost_equal_numeric( - effects['CO2'].model.all.shares['operation'].result, 1293.1864834809337, 'CO2 doesnt match expected value' + effects['CO2'].model.all.shares['operation'].solution.values, 1293.1864834809337, 'CO2 doesnt match expected value' ) self.assert_almost_equal_numeric( - effects['CO2'].model.all.shares['invest'].result, 0.9999999999999994, 'CO2 doesnt match expected value' + effects['CO2'].model.all.shares['invest'].solution.values, 0.9999999999999994, 'CO2 doesnt match expected value' ) self.assert_almost_equal_numeric( comps['Kessel'].Q_th.model.flow_rate.solution.values, @@ -437,12 +437,12 @@ def test_basic(self): ) self.assert_almost_equal_numeric( - comps['Speicher'].model.netto_discharge.result, + comps['Speicher'].model.netto_discharge.solution.values, [-45.0, -69.71111111, 15.0, -10.0, 36.06697198, -55.0, 20.0, 20.0, 20.0], 'Speicher nettoFlow doesnt match expected value', ) self.assert_almost_equal_numeric( - comps['Speicher'].model.charge_state.result, + comps['Speicher'].model.charge_state.solution.values, [0.0, 40.5, 100.0, 77.0, 79.84, 37.38582802, 83.89496178, 57.18336484, 32.60869565, 10.0], 'Speicher nettoFlow doesnt match expected value', ) @@ -585,7 +585,7 @@ def basic_model(self) -> fx.FullCalculation: print(es) es.visualize_network() - aCalc = fx.FullCalculation('Sim1', es, 'pyomo', None) + aCalc = fx.FullCalculation('Sim1', es) aCalc.do_modeling() aCalc.solve(self.get_solver()) @@ -689,7 +689,7 @@ def segments_of_flows_model(self): print(es) es.visualize_network() - aCalc = fx.FullCalculation('Sim1', es, 'pyomo', None) + aCalc = fx.FullCalculation('Sim1', es) aCalc.do_modeling() aCalc.solve(self.get_solver()) @@ -724,7 +724,7 @@ def test_aggregated(self): def test_segmented(self): calculation = self.calculate('segmented') self.assert_almost_equal_numeric( - sum(calculation.results(combined_arrays=True)['Effects']['costs']['operation']['operation_sum_TS']), + sum(calculation.results(combined_arrays=True)['Effects']['costs']['operation']['total_per_timestep']), 343613, 'costs doesnt match expected value', ) From fe5bc399331a2375f49c460ddffffc86f95d395c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 16 Feb 2025 16:54:46 +0100 Subject: [PATCH 121/507] Bugfix in Storage --- flixOpt/components.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixOpt/components.py b/flixOpt/components.py index 3a720429f..3ca188910 100644 --- a/flixOpt/components.py +++ b/flixOpt/components.py @@ -449,7 +449,7 @@ def do_modeling(self, system_model): # netto_discharge: # eq: nettoFlow(t) - discharging(t) + charging(t) = 0 self.add(self._model.add_constraints( - self.netto_discharge == self.element.charging.model.flow_rate - self.element.discharging.model.flow_rate, + self.netto_discharge == self.element.discharging.model.flow_rate - self.element.charging.model.flow_rate, name=f'{self.label_full}__netto_discharge'), 'netto_discharge' ) From 37a45872a3d5bc46290cad23665eb4c235c7cf6a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 16 Feb 2025 17:07:04 +0100 Subject: [PATCH 122/507] Bugfix in Linear Segments --- flixOpt/components.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flixOpt/components.py b/flixOpt/components.py index 3ca188910..83951934e 100644 --- a/flixOpt/components.py +++ b/flixOpt/components.py @@ -410,14 +410,14 @@ def do_modeling(self, system_model: SystemModel): # (linear) segments: else: # TODO: Improve Inclusion of OnOffParameters. Instead of creating a Binary in every flow, the binary could only be part of the Segment itself - segments = { - flow.model.flow_rate: [ + segments: Dict[str, List[Tuple[Numeric, Numeric]]] = { + flow.model.flow_rate.name: [ (ts1.active_data, ts2.active_data) for ts1, ts2 in self.element.segmented_conversion_factors[flow] ] for flow in self.element.inputs + self.element.outputs } linear_segments = MultipleSegmentsModel( - self.element, segments, self.on_off.on if self.on_off is not None else None + self._model, self.label_of_parent, segments, self.on_off.on if self.on_off is not None else None ) # TODO: Add Outside_segments Variable (On) linear_segments.do_modeling(system_model) self.sub_models.append(linear_segments) From bf8f7b33790aa5138edcb93bb7dc6fa156719b91 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 16 Feb 2025 17:07:21 +0100 Subject: [PATCH 123/507] Update test_integration.py --- tests/test_integration.py | 41 +++++++++++++++------------------------ 1 file changed, 16 insertions(+), 25 deletions(-) diff --git a/tests/test_integration.py b/tests/test_integration.py index 234e99900..ab47f23f9 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -342,62 +342,53 @@ def test_basic(self): ) self.assert_almost_equal_numeric( - sum(effects['costs'].model.operation.shares['CO2_operation'].solution.item()), + sum(effects['costs'].model.operation.shares['CO2'].solution.values), 258.63729669618675, 'costs doesnt match expected value', ) self.assert_almost_equal_numeric( - sum(effects['costs'].model.operation.shares['Kessel__Q_th__switch_on_effects'].solution.values), + sum(effects['costs'].model.operation.shares['Q_th (Kessel)'].solution.values), 0.01, 'costs doesnt match expected value', ) self.assert_almost_equal_numeric( - sum(effects['costs'].model.operation.shares['Kessel__running_hour_effects'].solution.values), + sum(effects['costs'].model.operation.shares['Kessel'].solution.values), -0.0, 'costs doesnt match expected value', ) self.assert_almost_equal_numeric( - sum(effects['costs'].model.operation.shares['Gastarif__Q_Gas__effects_per_flow_hour'].solution.values), + sum(effects['costs'].model.operation.shares['Q_Gas (Gastarif)'].solution.values), 39.09153113079115, 'costs doesnt match expected value', ) self.assert_almost_equal_numeric( - sum(effects['costs'].model.operation.shares['Einspeisung__P_el__effects_per_flow_hour'].solution.values), + sum(effects['costs'].model.operation.shares['P_el (Einspeisung)'].solution.values), -14196.61245231646, 'costs doesnt match expected value', ) self.assert_almost_equal_numeric( - sum(effects['costs'].model.operation.shares['KWK__switch_on_effects'].solution.values), + sum(effects['costs'].model.operation.shares['KWK'].solution.values), 0.0, 'costs doesnt match expected value', ) self.assert_almost_equal_numeric( - effects['costs'].model.invest.shares['Kessel__Q_th__fix_effects'].solution.values, - 1000, - 'costs doesnt match expected value', - ) - self.assert_almost_equal_numeric( - effects['costs'].model.invest.shares['Kessel__Q_th__specific_effects'].solution.values, - 500, - 'costs doesnt match expected value', - ) - self.assert_almost_equal_numeric( - effects['costs'].model.invest.shares['Speicher__specific_effects'].solution.values, - 1, + effects['costs'].model.invest.shares['Q_th (Kessel)'].solution.values, + 1000 + 500, 'costs doesnt match expected value', ) + self.assert_almost_equal_numeric( - effects['costs'].model.invest.shares['Speicher__segmented_effects'].solution.values, - 800, + effects['costs'].model.invest.shares['Speicher'].solution.values, + 800 + 1, 'costs doesnt match expected value', ) self.assert_almost_equal_numeric( - effects['CO2'].model.all.shares['operation'].solution.values, 1293.1864834809337, 'CO2 doesnt match expected value' + effects['CO2'].model.operation.total.solution.values, 1293.1864834809337, 'CO2 doesnt match expected value' ) self.assert_almost_equal_numeric( - effects['CO2'].model.all.shares['invest'].solution.values, 0.9999999999999994, 'CO2 doesnt match expected value' + effects['CO2'].model.invest.total.solution.values, 0.9999999999999994, 'CO2 doesnt match expected value' ) self.assert_almost_equal_numeric( comps['Kessel'].Q_th.model.flow_rate.solution.values, @@ -448,7 +439,7 @@ def test_basic(self): ) self.assert_almost_equal_numeric( - comps['Speicher'].model.results()['Investment']['SegmentedShares']['costs_segmented'], + comps['Speicher'].model.all_variables['Speicher__SegmentedShares__costs'].solution.values, 800, 'Speicher investCosts_segmented_costs doesnt match expected value', ) @@ -483,13 +474,13 @@ def test_segments_of_flows(self): ) self.assert_almost_equal_numeric( - comps['Speicher'].model.netto_discharge.result, + comps['Speicher'].model.netto_discharge.solution.values, [-15.0, -45.0, 25.4037913, -35.0, 48.6864, -25.0, -25.0, 7.13530435, 20.0], 'Speicher nettoFlow doesnt match expected value', ) self.assert_almost_equal_numeric( - comps['Speicher'].model.results()['Investment']['SegmentedShares']['costs_segmented'], + comps['Speicher'].model.all_variables['Speicher__SegmentedShares__costs'].solution.values, 454.74666666666667, 'Speicher investCosts_segmented_costs doesnt match expected value', ) From 1734255b67c3d82a71cb27bad7574c3bf35bf7e3 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 16 Feb 2025 17:20:03 +0100 Subject: [PATCH 124/507] Update example and change default Name of OnOffModel --- examples/02_Complex/complex_example.py | 4 ++-- flixOpt/features.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/02_Complex/complex_example.py b/examples/02_Complex/complex_example.py index 4f2b7bc67..f77c2c464 100644 --- a/examples/02_Complex/complex_example.py +++ b/examples/02_Complex/complex_example.py @@ -190,8 +190,8 @@ # You can analyze results directly. But it's better to save them to a file and start from there, # letting you continue at any time # See complex_example_evaluation.py - used_time_series = time_series[time_indices] if time_indices else time_series + used_time_series = timesteps[time_indices] if time_indices else timesteps # Analyze results directly fig = fx.plotting.with_plotly( - data=pd.DataFrame(Gaskessel.Q_th.model.flow_rate.result, index=used_time_series), mode='bar', show=True + data=pd.DataFrame(Gaskessel.Q_th.model.flow_rate.solution, index=used_time_series), mode='bar', show=True ) diff --git a/flixOpt/features.py b/flixOpt/features.py index 38a06a7c3..c35589d27 100644 --- a/flixOpt/features.py +++ b/flixOpt/features.py @@ -206,7 +206,7 @@ def __init__( defining_variables: List[linopy.Variable], defining_bounds: List[Tuple[Numeric, Numeric]], previous_values: List[Optional[Numeric]], - label: str = 'OnOffModel', + label: str = 'OnOff', ): """ Constructor for OnOffModel From 98e60911163dd3b5c64cd28719ef3fa50a55021b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 16 Feb 2025 17:20:19 +0100 Subject: [PATCH 125/507] Save Timesteps with last timestep! --- flixOpt/flow_system.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixOpt/flow_system.py b/flixOpt/flow_system.py index 5fe437ab8..0b0d1ceb7 100644 --- a/flixOpt/flow_system.py +++ b/flixOpt/flow_system.py @@ -173,7 +173,7 @@ def results(self): effect.label: effect.model.solution_structured(use_numpy=True) for effect in sorted(self.effects.values(), key=lambda effect: effect.label.upper()) }, - 'Time': self.timesteps.tolist(), + 'Time': self.timesteps_extra.tolist(), 'Time intervals in hours': self.hours_per_step, } From 8f80ce75f23647fe320db8c4a13b490600759eb1 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 16 Feb 2025 18:30:53 +0100 Subject: [PATCH 126/507] Improve label handling in Models --- flixOpt/components.py | 2 +- flixOpt/effects.py | 12 ++++---- flixOpt/elements.py | 24 +++++++++------- flixOpt/features.py | 66 ++++++++++++++++++------------------------- flixOpt/structure.py | 52 +++++++++------------------------- 5 files changed, 63 insertions(+), 93 deletions(-) diff --git a/flixOpt/components.py b/flixOpt/components.py index 83951934e..2843753f1 100644 --- a/flixOpt/components.py +++ b/flixOpt/components.py @@ -475,7 +475,7 @@ def do_modeling(self, system_model): if isinstance(self.element.capacity_in_flow_hours, InvestParameters): self._investment = InvestmentModel( model=self._model, - label_of_parent=self.element.label_full, + label_of_element=self.label_of_element, parameters=self.element.capacity_in_flow_hours, defining_variable=self.charge_state, relative_bounds_of_defining_variable=self.relative_charge_state_bounds, diff --git a/flixOpt/effects.py b/flixOpt/effects.py index 711ad5e47..0aa2a987f 100644 --- a/flixOpt/effects.py +++ b/flixOpt/effects.py @@ -166,7 +166,7 @@ def __init__(self, model: SystemModel, element: Effect): ShareAllocationModel( self._model, False, - self.element.label_full, + self.label_of_element, 'invest', total_max=self.element.maximum_invest, total_min=self.element.minimum_invest @@ -177,7 +177,7 @@ def __init__(self, model: SystemModel, element: Effect): ShareAllocationModel( self._model, True, - self.element.label_full, + self.label_of_element, 'operation', total_max=self.element.maximum_operation, total_min=self.element.minimum_operation, @@ -199,7 +199,7 @@ def do_modeling(self, system_model: SystemModel): 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=None, - name=f'{self.element.label_full}__total' + name=f'{self.label_of_element}__total' ), 'total' ) @@ -207,7 +207,7 @@ def do_modeling(self, system_model: SystemModel): self.add( system_model.add_constraints( self.total == self.operation.total.sum() + self.invest.total.sum(), - name=f'{self.element.label_full}__total' + name=f'{self.label_of_element}__total' ), 'total' ) @@ -277,7 +277,7 @@ class EffectCollection(Model): """ def __init__(self, model: SystemModel, effects: List[Effect]): - super().__init__(model, label_full='Effects') + super().__init__(model, label_of_element='Effects') self._effects = {} self._standard_effect: Optional[Effect] = None self._objective_effect: Optional[Effect] = None @@ -309,7 +309,7 @@ def do_modeling(self, system_model: SystemModel): self._model = system_model for effect in self.effects.values(): effect.create_model(self._model) - self.penalty = self.add(ShareAllocationModel(self._model,shares_are_time_series=False, label_full='penalty')) + self.penalty = self.add(ShareAllocationModel(self._model, shares_are_time_series=False, label_of_element='Penalty')) for model in [effect.model for effect in self.effects.values()] + [self.penalty]: model.do_modeling(system_model) diff --git a/flixOpt/elements.py b/flixOpt/elements.py index 699de4e67..b795cb44e 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -326,8 +326,12 @@ def do_modeling(self, system_model: SystemModel): if self.element.on_off_parameters is not None: self.on_off = self.add( OnOffModel( - self._model, self.element.on_off_parameters, self.label_full, [self.flow_rate], [self.absolute_flow_rate_bounds], - [self.element.previous_flow_rate] + model=self._model, + label_of_element=self.label_of_element, + on_off_parameters=self.element.on_off_parameters, + defining_variables=[self.flow_rate], + defining_bounds=[self.absolute_flow_rate_bounds], + previous_values=[self.element.previous_flow_rate], ), 'on_off' ) @@ -338,7 +342,7 @@ def do_modeling(self, system_model: SystemModel): self._investment = self.add( InvestmentModel( model=self._model, - label_of_parent=self.element.label_full, + label_of_element=self.label_of_element, parameters=self.element.size, defining_variable=self.flow_rate, relative_bounds_of_defining_variable=self.relative_flow_rate_bounds, @@ -354,7 +358,7 @@ def do_modeling(self, system_model: SystemModel): lower=self.element.flow_hours_total_min if self.element.flow_hours_total_min is not None else -np.inf, upper=self.element.flow_hours_total_max if self.element.flow_hours_total_max is not None else np.inf, coords=None, - name=f'{self.element.label_full}__total_flow_hours' + name=f'{self.label_of_element}__total_flow_hours' ), 'total_flow_hours' ) @@ -362,7 +366,7 @@ def do_modeling(self, system_model: SystemModel): self.add( self._model.add_constraints( self.total_flow_hours == (self.flow_rate * self._model.hours_per_step).sum(), - name=f'{self.element.label_full}__total_flow_hours' + name=f'{self.label_of_element}__total_flow_hours' ), 'total_flow_hours' ) @@ -399,7 +403,7 @@ def _create_bounds_for_load_factor(self, system_model: SystemModel): self.add( self._model.add_constraints( self.total_flow_hours <= size * flow_hours_per_size_max, - name=f'{self.element.label_full}__{name_short}', + name=f'{self.label_full}__{name_short}', ), name_short ) @@ -414,7 +418,7 @@ def _create_bounds_for_load_factor(self, system_model: SystemModel): self.add( self._model.add_constraints( self.total_flow_hours >= size * flow_hours_per_size_min, - name=f'{self.element.label_full}__{name_short}', + name=f'{self.label_full}__{name_short}', ), name_short ) @@ -488,10 +492,10 @@ def do_modeling(self, system_model: SystemModel) -> None: eq_bus_balance.lhs -= -self.excess_input + self.excess_output self._model.effects.add_share_to_penalty( - self._model, self.element.label_full, (self.excess_input * excess_penalty).sum() + self._model, self.label_of_element, (self.excess_input * excess_penalty).sum() ) self._model.effects.add_share_to_penalty( - self._model, self.element.label_full, (self.excess_output * excess_penalty).sum() + self._model, self.label_of_element, (self.excess_output * excess_penalty).sum() ) @@ -524,7 +528,7 @@ def do_modeling(self, system_model: SystemModel): self.on_off = self.add(OnOffModel( self._model, self.element.on_off_parameters, - self.element.label_full, + self.label_of_element, defining_variables=[flow.model.flow_rate for flow in all_flows], defining_bounds=[flow.model.absolute_flow_rate_bounds for flow in all_flows], previous_values=[flow.previous_flow_rate for flow in all_flows])) diff --git a/flixOpt/features.py b/flixOpt/features.py index c35589d27..db21a22f5 100644 --- a/flixOpt/features.py +++ b/flixOpt/features.py @@ -13,16 +13,7 @@ from .core import Numeric, Skalar, TimeSeries from .interface import InvestParameters, OnOffParameters from .math_modeling import Equation, Variable, VariableTS -from .structure import ( - Element, - ElementModel, - InterfaceModel, - Interface, - Model, - SystemModel, - create_equation, - create_variable, -) +from .structure import Model, SystemModel if TYPE_CHECKING: # for type checking and preventing circular imports from .components import Storage @@ -39,18 +30,18 @@ class InvestmentModel(Model): def __init__( self, model: SystemModel, - label_of_parent: str, + label_of_element: str, parameters: InvestParameters, defining_variable: [linopy.Variable], relative_bounds_of_defining_variable: Tuple[Numeric, Numeric], fixed_relative_profile: Optional[Numeric] = None, - label: str = 'Investment', + label: Optional[str] = 'Investment', on_variable: Optional[linopy.Variable] = None, ): """ If fixed relative profile is used, the relative bounds are ignored """ - super().__init__(model, label_of_parent, label) + super().__init__(model, label_of_element, label) self.size: Optional[Union[Skalar, Variable]] = None self.is_invested: Optional[Variable] = None @@ -97,7 +88,7 @@ def _create_shares(self, system_model: SystemModel): if fix_effects != {}: self._model.effects.add_share_to_effects( system_model=self._model, - name=self._label_of_parent, + name=self.label_of_element, expressions={effect: self.is_invested * factor if self.is_invested is not None else factor for effect, factor in fix_effects.items()}, target='invest', @@ -107,7 +98,7 @@ def _create_shares(self, system_model: SystemModel): # share: divest_effects - isInvested * divest_effects self._model.effects.add_share_to_effects( system_model=self._model, - name=self._label_of_parent, + name=self.label_of_element, expressions={effect: -self.is_invested * factor + factor for effect, factor in fix_effects.items()}, target='invest', ) @@ -115,7 +106,7 @@ def _create_shares(self, system_model: SystemModel): if self.parameters.specific_effects != {}: self._model.effects.add_share_to_effects( system_model=self._model, - name=self._label_of_parent, + name=self.label_of_element, expressions={effect: self.size * factor for effect, factor in self.parameters.specific_effects.items()}, target='invest', ) @@ -124,7 +115,7 @@ def _create_shares(self, system_model: SystemModel): self._segments = self.add( SegmentedSharesModel( model=self._model, - label_of_parent=self._label_of_parent, + label_of_element=self.label_of_element, variable_segments=(self.size, self.parameters.effects_in_segments[0]), share_segments=self.parameters.effects_in_segments[1], can_be_outside_segments=self.is_invested), @@ -202,11 +193,11 @@ def __init__( self, model: SystemModel, on_off_parameters: OnOffParameters, - label_of_parent: str, + label_of_element: str, defining_variables: List[linopy.Variable], defining_bounds: List[Tuple[Numeric, Numeric]], previous_values: List[Optional[Numeric]], - label: str = 'OnOff', + label: Optional[str] = None, ): """ Constructor for OnOffModel @@ -217,7 +208,7 @@ def __init__( Reference to the SystemModel on_off_parameters: OnOffParameters Parameters for the OnOffModel - label_of_parent: + label_of_element: Label of the Parent defining_variables: List of Variables that are used to define the OnOffModel @@ -228,7 +219,7 @@ def __init__( label: Label of the OnOffModel """ - super().__init__(model, label_of_parent, label) + super().__init__(model, label_of_element, label) assert len(defining_variables) == len(defining_bounds), 'Every defining Variable needs bounds to Model OnOff' self.parameters = on_off_parameters self._defining_variables = defining_variables @@ -585,7 +576,7 @@ def _create_shares(self, system_model: SystemModel): if effects_per_switch_on != {}: self._model.effects.add_share_to_effects( system_model=self._model, - name=self._label_of_parent, + name=self.label_of_element, expressions={effect: self.switch_on * factor for effect, factor in effects_per_switch_on.items()}, target='operation', ) @@ -595,7 +586,7 @@ def _create_shares(self, system_model: SystemModel): if effects_per_running_hour != {}: self._model.effects.add_share_to_effects( system_model=self._model, - name=self._label_of_parent, + name=self.label_of_element, expressions={effect: self.on * factor * self._model.hours_per_step for effect, factor in effects_per_running_hour.items()}, target='operation', @@ -707,12 +698,12 @@ class SegmentModel(Model): def __init__( self, model: SystemModel, - label_of_parent: str, + label_of_element: str, segment_index: Union[int, str], sample_points: Dict[str, Tuple[Union[Numeric, TimeSeries], Union[Numeric, TimeSeries]]], as_time_series: bool = True, ): - super().__init__(model, label_of_parent, f'Segment{segment_index}') + super().__init__(model, label_of_element, f'Segment{segment_index}') self.in_segment: Optional[VariableTS] = None self.lambda0: Optional[VariableTS] = None self.lambda1: Optional[VariableTS] = None @@ -756,7 +747,7 @@ class MultipleSegmentsModel(Model): def __init__( self, model: SystemModel, - label_of_parent: str, + label_of_element: str, sample_points: Dict[str, List[Tuple[Numeric, Numeric]]], can_be_outside_segments: Optional[Union[bool, Variable]], as_time_series: bool = True, @@ -767,7 +758,7 @@ def __init__( ---------- model : linopy.Model Model to which the segmented variable belongs. - label_of_parent : str + label_of_element : str Name of the parent variable. sample_points : dict[str, list[tuple[float, float]]] Dictionary mapping variables (names) to their sample points for each segment. @@ -777,7 +768,7 @@ def __init__( If False or None, no variable is created. If a Variable is passed, it is used. as_time_series : bool, optional """ - super().__init__(model, label_of_parent, label) + super().__init__(model, label_of_element, label) self.outside_segments: Optional[linopy.Variable] = None self._as_time_series = as_time_series @@ -794,7 +785,7 @@ def do_modeling(self, system_model: SystemModel): self.add( SegmentModel( self._model, - label_of_parent=self._label_of_parent, + label_of_element=self.label_of_element, segment_index=i, sample_points=sample_points, as_time_series=self._as_time_series), @@ -849,15 +840,14 @@ def __init__( self, model: SystemModel, shares_are_time_series: bool, - label_of_parent: Optional[str] = None, + label_of_element: Optional[str] = None, label: Optional[str] = None, - label_full: Optional[str] = None, total_max: Optional[Skalar] = None, total_min: Optional[Skalar] = None, max_per_hour: Optional[Numeric] = None, min_per_hour: Optional[Numeric] = None, ): - super().__init__(model, label_of_parent=label_of_parent, label=label, label_full=label_full) + super().__init__(model, label_of_element=label_of_element, label=label) if not shares_are_time_series: # If the condition is True assert max_per_hour is None and min_per_hour is None, ( 'Both max_per_hour and min_per_hour cannot be used when shares_are_time_series is False' @@ -972,13 +962,13 @@ class SegmentedSharesModel(Model): def __init__( self, model: SystemModel, - label_of_parent: str, + label_of_element: str, variable_segments: Tuple[Variable, List[Tuple[Skalar, Skalar]]], share_segments: Dict['Effect', List[Tuple[Skalar, Skalar]]], can_be_outside_segments: Optional[Union[bool, Variable]], label: str = 'SegmentedShares', ): - super().__init__(model, label_of_parent, label) + super().__init__(model, label_of_element, label) assert len(variable_segments[1]) == len(list(share_segments.values())[0]), ( 'Segment length of variable_segments and share_segments must be equal' ) @@ -1007,7 +997,7 @@ def do_modeling(self, system_model: SystemModel): self._segments_model = self.add( MultipleSegmentsModel( model=self._model, - label_of_parent=self._label_of_parent, + label_of_element=self.label_of_element, sample_points=segments, can_be_outside_segments=self._can_be_outside_segments, as_time_series=self._as_tme_series), @@ -1018,7 +1008,7 @@ def do_modeling(self, system_model: SystemModel): # Shares self._model.effects.add_share_to_effects( system_model=self._model, - name=self._label_of_parent, + name=self.label_of_element, expressions={effect: variable*1 for effect, variable in self._shares.items()}, target='operation' if self._as_tme_series else 'invest', ) @@ -1042,8 +1032,8 @@ class PreventSimultaneousUsageModel(Model): # --> könnte man auch umsetzen (statt force_on_variable() für die Flows, aber sollte aufs selbe wie "new" kommen) """ - def __init__(self, model: SystemModel, variables: List[linopy.Variable], label_of_parent: str, label: str = 'PreventSimultaneousUsage'): - super().__init__(model, label_of_parent, label) + def __init__(self, model: SystemModel, variables: List[linopy.Variable], label_of_element: str, label: str = 'PreventSimultaneousUsage'): + super().__init__(model, label_of_element, label) self._simultanious_use_variables = variables assert len(self._simultanious_use_variables) >= 2, f'Model {self.__class__.__name__} must get at least two variables' for variable in self._simultanious_use_variables: # classic diff --git a/flixOpt/structure.py b/flixOpt/structure.py index b35da7661..c1e670a34 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -70,13 +70,13 @@ def main_results(self) -> Dict[str, Union[Skalar, Dict]]: }, "Invest-Decisions": { "Invested": { - model._label_of_parent: float(model.size.solution) + model.label_of_element: float(model.size.solution) for component in self.flow_system.components.values() for model in component.model.all_sub_models if isinstance(model, InvestmentModel) and float(model.size.solution) >= CONFIG.modeling.EPSILON }, "Not invested": { - model._label_of_parent: float(model.size.solution) + model.label_of_element: float(model.size.solution) for component in self.flow_system.components.values() for model in component.model.all_sub_models if isinstance(model, InvestmentModel) and float(model.size.solution) < CONFIG.modeling.EPSILON @@ -295,23 +295,20 @@ def _create_time_series( class Model: """Stores Variables and Constraints""" - def __init__(self, model: SystemModel, label_of_parent: Optional[str] = None, label: Optional[str] = None, label_full: Optional[str] = None): + def __init__(self, model: SystemModel, label_of_element: str, label: Optional[str] = None, label_full: Optional[str] = None): """ Parameters ---------- - label_of_parent : str + label_of_element : str The label of the parent (Element). Used to construct the full label of the model. label : str The label of the model. Used to construct the full label of the model. label_full : str - The full label of the model. Can overwrite the full label constructed from label_of_parent and label. + The full label of the model. Can overwrite the full label constructed from the other labels. """ - if not label_full and not (label_of_parent and label): - raise ValueError('Either label_full or label_of_parent and label must be set. ' - 'Got {label_full=}, {label_of_parent=}, {label=}') self._model = model + self.label_of_element = label_of_element self._label = label - self._label_of_parent = label_of_parent self._label_full = label_full self._variables: List[str] = [] @@ -389,15 +386,16 @@ def solution_structured( @property def label(self) -> str: - return self._label if self._label is not None else self.label_full + return self._label if self._label is not None else self.label_of_element @property def label_full(self) -> str: - return self._label_full or f'{self._label_of_parent}__{self.label}' - - @property - def label_of_parent(self) -> str: - return self._label_of_parent or self.label_full + """ Used to construct the names of variables and constraints """ + if self._label_full is not None: + return self._label_full + elif self._label is not None: + return f'{self.label_of_element}__{self.label}' + return self.label_of_element @property def variables(self) -> linopy.Variables: @@ -442,28 +440,6 @@ def all_sub_models(self) -> List['Model']: return [model for sub_model in self.sub_models for model in [sub_model] + sub_model.all_sub_models] -class InterfaceModel(Model): - """Stores the mathematical Variables and Constraints related to an Interface""" - - def __init__(self, model: SystemModel, interface: Optional[Interface] = None, label_of_parent: Optional[str] = None, label: Optional[str] = None): - """ - Parameters - ---------- - interface : Interface - The interface this model is created for. - label_of_parent : str - The label of the parent. Used to construct the full label of the model. - label : str - Used to construct the label of the model. If None, the interface label is used. - """ - if label_of_parent is None and label is None: - raise ValueError('Either label_of_parent or label must be set') - super().__init__(model, label, f'{label_of_parent}__{label}' if label_of_parent else None) - - self.interface = interface - logger.debug(f'Created {self.__class__.__name__} "{self.label_full}"') - - class ElementModel(Model): """Interface to create the mathematical Variables and Constraints for Elements""" @@ -474,7 +450,7 @@ def __init__(self, model: SystemModel, element: Element): element : Element The element this model is created for. """ - super().__init__(model, label_full=element.label_full, label=element.label) + super().__init__(model, label_of_element=element.label_full, label=element.label, label_full=element.label_full) self.element = element From 668770c86a64b9de1f016827cb7a7f132a02923b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 16 Feb 2025 18:58:54 +0100 Subject: [PATCH 127/507] Improve how lables are constructed everywhere --- flixOpt/components.py | 20 ++++----- flixOpt/effects.py | 4 +- flixOpt/elements.py | 20 ++++----- flixOpt/features.py | 98 +++++++++++++++++++++---------------------- flixOpt/structure.py | 28 +++++++++---- flixOpt/utils.py | 7 ---- 6 files changed, 91 insertions(+), 86 deletions(-) diff --git a/flixOpt/components.py b/flixOpt/components.py index 2843753f1..b9e1bb4fd 100644 --- a/flixOpt/components.py +++ b/flixOpt/components.py @@ -359,7 +359,7 @@ def do_modeling(self, system_model: SystemModel): # eq: in1.size = in2.size self.add(self._model.add_constraints( self.element.in1.model._investment.size == self.element.in2.model._investment.size, - name=f'{self.label_full}__same_size'), + name=f'{self.label_full}|same_size'), 'same_size' ) @@ -368,7 +368,7 @@ def create_transmission_equation(self, name: str, in_flow: Flow, out_flow: Flow) # eq: out(t) + on(t)*loss_abs(t) = in(t)*(1 - loss_rel(t)) con_transmission = self.add(self._model.add_constraints( out_flow.model.flow_rate == -in_flow.model.flow_rate * (self.element.relative_losses.active_data - 1), - name=f'{self.label_full}__{name}'), + name=f'{self.label_full}|{name}'), name ) @@ -403,7 +403,7 @@ def do_modeling(self, system_model: SystemModel): sum([flow.model.flow_rate * conv_fact[flow].active_data for flow in used_inputs]) == sum([flow.model.flow_rate * conv_fact[flow].active_data for flow in used_outputs]), - name=f'{self.label_full}__conversion_{i}' + name=f'{self.label_full}|conversion_{i}' ) ) @@ -439,18 +439,18 @@ def do_modeling(self, system_model): lb, ub = self.absolute_charge_state_bounds self.charge_state = self.add(self._model.add_variables( lower=lb, upper=ub, coords=self._model.coords_extra, - name=f'{self.label_full}__charge_state'), + name=f'{self.label_full}|charge_state'), 'charge_state' ) self.netto_discharge = self.add(self._model.add_variables( - coords=self._model.coords, name=f'{self.label_full}__netto_discharge'), + coords=self._model.coords, name=f'{self.label_full}|netto_discharge'), 'netto_discharge' ) # netto_discharge: # eq: nettoFlow(t) - discharging(t) + charging(t) = 0 self.add(self._model.add_constraints( self.netto_discharge == self.element.discharging.model.flow_rate - self.element.charging.model.flow_rate, - name=f'{self.label_full}__netto_discharge'), + name=f'{self.label_full}|netto_discharge'), 'netto_discharge' ) @@ -468,7 +468,7 @@ def do_modeling(self, system_model): charge_state.isel(time=slice(None, -1)) * (1 - rel_loss) * hours_per_step + charge_rate * eff_charge * hours_per_step - discharge_rate * eff_discharge * hours_per_step, - name=f'{self.label_full}__charge_state'), + name=f'{self.label_full}|charge_state'), 'charge_state' ) @@ -489,7 +489,7 @@ def do_modeling(self, system_model): def _initial_and_final_charge_state(self, system_model): if self.element.initial_charge_state is not None: name_short = f'initial_charge_state' - name = f'{self.label_full}__{name_short}' + name = f'{self.label_full}|{name_short}' if utils.is_number(self.element.initial_charge_state): self.add(self._model.add_constraints( @@ -509,14 +509,14 @@ def _initial_and_final_charge_state(self, system_model): if self.element.maximal_final_charge_state is not None: self.add(self._model.add_constraints( self.charge_state.isel(time=-1) <= self.element.maximal_final_charge_state, - name=f'{self.label_full}__final_charge_max'), + name=f'{self.label_full}|final_charge_max'), 'final_charge_max' ) if self.element.minimal_final_charge_state is not None: self.add(self._model.add_constraints( self.charge_state.isel(time=-1) >= self.element.minimal_final_charge_state, - name=f'{self.label_full}__final_charge_min'), + name=f'{self.label_full}|final_charge_min'), 'final_charge_min' ) diff --git a/flixOpt/effects.py b/flixOpt/effects.py index 0aa2a987f..acbbabf9b 100644 --- a/flixOpt/effects.py +++ b/flixOpt/effects.py @@ -199,7 +199,7 @@ def do_modeling(self, system_model: SystemModel): 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=None, - name=f'{self.label_of_element}__total' + name=f'{self.label_full}|total' ), 'total' ) @@ -207,7 +207,7 @@ def do_modeling(self, system_model: SystemModel): self.add( system_model.add_constraints( self.total == self.operation.total.sum() + self.invest.total.sum(), - name=f'{self.label_of_element}__total' + name=f'{self.label_full}|total' ), 'total' ) diff --git a/flixOpt/elements.py b/flixOpt/elements.py index b795cb44e..15de0dcc9 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -275,7 +275,7 @@ def _plausibility_checks(self) -> None: def label_full(self) -> str: # Wenn im Erstellungsprozess comp noch nicht bekannt: comp_label = 'unknownComp' if self.comp is None else self.comp.label - return f'{self.label} ({comp_label})' + return f'{comp_label} ({self.label})' @property # Richtung def is_input_in_comp(self) -> bool: @@ -309,7 +309,7 @@ def do_modeling(self, system_model: SystemModel): lower=self.absolute_flow_rate_bounds[0] if self.element.on_off_parameters is None else 0, upper=self.absolute_flow_rate_bounds[1] if self.element.on_off_parameters is None else np.inf, coords=self._model.coords, - name=f'{self.label_full}__flow_rate' + name=f'{self.label_full}|flow_rate' ), 'flow_rate' ) @@ -317,7 +317,7 @@ def do_modeling(self, system_model: SystemModel): self.add( self._model.add_constraints( self.flow_rate == self.element.fixed_relative_profile.active_data, - name=f'{self.label_full}_fix_flow_rate' + name=f'{self.label_full}|fix_flow_rate' ), 'flow_rate (fix)' ) @@ -358,7 +358,7 @@ def do_modeling(self, system_model: SystemModel): lower=self.element.flow_hours_total_min if self.element.flow_hours_total_min is not None else -np.inf, upper=self.element.flow_hours_total_max if self.element.flow_hours_total_max is not None else np.inf, coords=None, - name=f'{self.label_of_element}__total_flow_hours' + name=f'{self.label_full}|total_flow_hours' ), 'total_flow_hours' ) @@ -366,7 +366,7 @@ def do_modeling(self, system_model: SystemModel): self.add( self._model.add_constraints( self.total_flow_hours == (self.flow_rate * self._model.hours_per_step).sum(), - name=f'{self.label_of_element}__total_flow_hours' + name=f'{self.label_full}|total_flow_hours' ), 'total_flow_hours' ) @@ -403,7 +403,7 @@ def _create_bounds_for_load_factor(self, system_model: SystemModel): self.add( self._model.add_constraints( self.total_flow_hours <= size * flow_hours_per_size_max, - name=f'{self.label_full}__{name_short}', + name=f'{self.label_full}|{name_short}', ), name_short ) @@ -418,7 +418,7 @@ def _create_bounds_for_load_factor(self, system_model: SystemModel): self.add( self._model.add_constraints( self.total_flow_hours >= size * flow_hours_per_size_min, - name=f'{self.label_full}__{name_short}', + name=f'{self.label_full}|{name_short}', ), name_short ) @@ -473,7 +473,7 @@ def do_modeling(self, system_model: SystemModel) -> None: outputs = sum([flow.model.flow_rate for flow in self.element.outputs]) eq_bus_balance = self.add(self._model.add_constraints( inputs == outputs, - name=f'{self.label_full}__balance' + name=f'{self.label_full}|balance' )) # Fehlerplus/-minus: @@ -482,11 +482,11 @@ def do_modeling(self, system_model: SystemModel) -> None: self._model.hours_per_step, self.element.excess_penalty_per_flow_hour.active_data ) self.excess_input = self.add(self._model.add_variables( - lower=0, coords=self._model.coords, name=f'{self.label_full}__excess_input'), + lower=0, coords=self._model.coords, name=f'{self.label_full}|excess_input'), 'excess_input' ) self.excess_output = self.add(self._model.add_variables( - lower=0, coords=self._model.coords, name=f'{self.label_full}__excess_output'), + lower=0, coords=self._model.coords, name=f'{self.label_full}|excess_output'), 'excess_output' ) eq_bus_balance.lhs -= -self.excess_input + self.excess_output diff --git a/flixOpt/features.py b/flixOpt/features.py index db21a22f5..084926dd7 100644 --- a/flixOpt/features.py +++ b/flixOpt/features.py @@ -58,20 +58,20 @@ def do_modeling(self, system_model: SystemModel): self.size = self.add(self._model.add_variables( lower=self.parameters.fixed_size, upper=self.parameters.fixed_size, - name=f'{self.label_full}__size'), + name=f'{self.label_full}|size'), 'size') else: self.size = self.add(self._model.add_variables( lower=0 if self.parameters.optional else self.parameters.minimum_size, upper=self.parameters.maximum_size, - name=f'{self.label_full}__size'), + name=f'{self.label_full}|size'), 'size') # Optional if self.parameters.optional: self.is_invested = self.add(self._model.add_variables( binary=True, - name=f'{self.label_full}__is_invested'), + name=f'{self.label_full}|is_invested'), 'is_invested') self._create_bounds_for_optional_investment() @@ -128,20 +128,20 @@ def _create_bounds_for_optional_investment(self): # eq: investment_size = isInvested * fixed_size self.add(self._model.add_constraints( self.size == self.is_invested * self.parameters.fixed_size, - name=f'{self.label_full}__is_invested'), + name=f'{self.label_full}|is_invested'), 'is_invested') else: # eq1: P_invest <= isInvested * investSize_max self.add(self._model.add_constraints( self.size <= self.is_invested * self.parameters.maximum_size, - name=f'{self.label_full}__is_invested_ub'), + name=f'{self.label_full}|is_invested_ub'), 'is_invested_ub') # eq2: P_invest >= isInvested * max(epsilon, investSize_min) self.add(self._model.add_constraints( self.size >= self.is_invested * np.maximum(CONFIG.modeling.EPSILON,self.parameters.minimum_size), - name=f'{self.label_full}__is_invested_lb'), + name=f'{self.label_full}|is_invested_lb'), 'is_invested_lb') def _create_bounds_for_defining_variable(self): @@ -151,7 +151,7 @@ def _create_bounds_for_defining_variable(self): # TODO: Allow Off? Currently not.. self.add(self._model.add_constraints( variable == self.size * self._fixed_relative_profile, - name=f'{self.label_full}__fixed_{variable.name}'), + name=f'{self.label_full}|fixed_{variable.name}'), f'fixed_{variable.name}') else: @@ -159,14 +159,14 @@ def _create_bounds_for_defining_variable(self): # eq: defining_variable(t) <= size * upper_bound(t) self.add(self._model.add_constraints( variable <= self.size * ub_relative, - name=f'{self.label_full}__ub_{variable.name}'), + name=f'{self.label_full}|ub_{variable.name}'), f'ub_{variable.name}') if self._on_variable is None: # eq: defining_variable(t) >= investment_size * relative_minimum(t) self.add(self._model.add_constraints( variable >= self.size * lb_relative, - name=f'{self.label_full}__lb_{variable.name}'), + name=f'{self.label_full}|lb_{variable.name}'), f'lb_{variable.name}') else: ## 2. Gleichung: Minimum durch Investmentgröße und On @@ -178,7 +178,7 @@ def _create_bounds_for_defining_variable(self): on = self._on_variable self.add(self._model.add_constraints( variable >= mega * (on - 1) + self.size * lb_relative, - name=f'{self.label_full}__lb_{variable.name}'), + name=f'{self.label_full}|lb_{variable.name}'), f'lb_{variable.name}') # anmerkung: Glg bei Spezialfall relative_minimum = 0 redundant zu OnOff ?? @@ -241,7 +241,7 @@ def __init__( def do_modeling(self, system_model: SystemModel): self.on = self.add( self._model.add_variables( - name=f'{self.label_full}__on', + name=f'{self.label_full}|on', binary=True, coords=system_model.coords, ), @@ -252,7 +252,7 @@ def do_modeling(self, system_model: SystemModel): self._model.add_variables( lower=self.parameters.on_hours_total_min if self.parameters.on_hours_total_min is not None else 0, upper=self.parameters.on_hours_total_max if self.parameters.on_hours_total_max is not None else np.inf, - name=f'{self.label_full}__on_hours_total' + name=f'{self.label_full}|on_hours_total' ), 'on_hours_total' ) @@ -260,7 +260,7 @@ def do_modeling(self, system_model: SystemModel): self.add( self._model.add_constraints( self.total_on_hours == (self.on * self._model.hours_per_step).sum(), - name=f'{self.label_full}__on_hours_total' + name=f'{self.label_full}|on_hours_total' ), 'on_hours_total' ) @@ -270,7 +270,7 @@ def do_modeling(self, system_model: SystemModel): if self.parameters.use_off: self.off = self.add( self._model.add_variables( - name=f'{self.label_full}__off', + name=f'{self.label_full}|off', binary=True, coords=system_model.coords, ), @@ -278,7 +278,7 @@ def do_modeling(self, system_model: SystemModel): ) # eq: var_on(t) + var_off(t) = 1 - self.add(self._model.add_constraints(self.on + self.off == 1, name=f'{self.label_full}__off'), 'off') + self.add(self._model.add_constraints(self.on + self.off == 1, name=f'{self.label_full}|off'), 'off') if self.parameters.use_consecutive_on_hours: self.consecutive_on_hours = self._get_duration_in_hours( @@ -300,14 +300,14 @@ def do_modeling(self, system_model: SystemModel): if self.parameters.use_switch_on: self.switch_on = self.add(self._model.add_variables( - binary=True, name=f'{self.label_full}__switch_on', coords=system_model.coords),'switch_on') + binary=True, name=f'{self.label_full}|switch_on', coords=system_model.coords),'switch_on') self.switch_off = self.add(self._model.add_variables( - binary=True, name=f'{self.label_full}__switch_off', coords=system_model.coords), 'switch_off') + binary=True, name=f'{self.label_full}|switch_off', coords=system_model.coords), 'switch_off') self.switch_on_nr = self.add(self._model.add_variables( upper=self.parameters.switch_on_total_max if self.parameters.switch_on_total_max is not None else np.inf, - name=f'{self.label_full}__switch_on_nr'), + name=f'{self.label_full}|switch_on_nr'), 'switch_on_nr') self._add_switch_constraints(system_model) @@ -334,7 +334,7 @@ def _add_on_constraints(self): self.add( self._model.add_constraints( self.on * np.maximum(EPSILON, lb) <= def_var, - name=f'{self.label_full}__on_con1' + name=f'{self.label_full}|on_con1' ), 'on_con1' ) @@ -343,7 +343,7 @@ def _add_on_constraints(self): self.add( self._model.add_constraints( self.on * np.maximum(EPSILON, ub) >= def_var, - name=f'{self.label_full}__on_con2' + name=f'{self.label_full}|on_con2' ), 'on_con2' ) @@ -357,19 +357,19 @@ def _add_on_constraints(self): self.add( self._model.add_constraints( self.on * lb <= sum(self._defining_variables), - name=f'{self.label_full}__on_con1' + name=f'{self.label_full}|on_con1' ), 'on_con1' ) - ## sum(alle Leistung) >0 -> On = 1 | On=0 -> sum(Leistung)=0 + ## sum(alle Leistung) >0 -> On = 1|On=0 -> sum(Leistung)=0 # eq: sum( Leistung(t,i)) - sum(Leistung_max(i)) * On(t) <= 0 # --> damit Gleichungswerte nicht zu groß werden, noch durch nr_of_flows geteilt: # eq: sum( Leistung(t,i) / nr_of_flows ) - sum(Leistung_max(i)) / nr_of_flows * On(t) <= 0 self.add( self._model.add_constraints( self.on * ub >= sum([def_var / nr_of_def_vars for def_var in self._defining_variables]), - name=f'{self.label_full}__on_con2' + name=f'{self.label_full}|on_con2' ), 'on_con2' ) @@ -447,14 +447,14 @@ def _get_duration_in_hours( lower=0, upper=maximum_duration.active_data if maximum_duration is not None else mega, coords=self._model.coords, - name=f'{self.label_full}__{variable_name}'), + name=f'{self.label_full}|{variable_name}'), variable_name ) # 1) eq: duration(t) - On(t) * BIG <= 0 self.add(self._model.add_constraints( duration_in_hours <= binary_variable * mega, - name=f'{self.label_full}__{variable_name}_con1'), + name=f'{self.label_full}|{variable_name}_con1'), f'{variable_name}_con1' ) @@ -465,7 +465,7 @@ def _get_duration_in_hours( duration_in_hours.isel(time=slice(1, None)) <= duration_in_hours.isel(time=slice(None, -1)) + self._model.hours_per_step.isel(time=slice(None, -1)), - name=f'{self.label_full}__{variable_name}_con2a'), + name=f'{self.label_full}|{variable_name}_con2a'), f'{variable_name}_con2a' ) @@ -480,7 +480,7 @@ def _get_duration_in_hours( >= duration_in_hours.isel(time=slice(None, -1)) + self._model.hours_per_step.isel(time=slice(None, -1)) + (binary_variable.isel(time=slice(1, None)) - 1) * mega, - name=f'{self.label_full}__{variable_name}_con2b'), + name=f'{self.label_full}|{variable_name}_con2b'), f'{variable_name}_con2b' ) @@ -497,7 +497,7 @@ def _get_duration_in_hours( >= (binary_variable.isel(time=slice(None, -1)) - binary_variable.isel(time=slice(1, None))) * minimum_duration.isel(time=slice(None, -1)), - name=f'{self.label_full}__{variable_name}_minimum_duration'), + name=f'{self.label_full}|{variable_name}_minimum_duration'), f'{variable_name}_minimum_duration' ) @@ -507,7 +507,7 @@ def _get_duration_in_hours( # eq: On(t=0) = 1 self.add(self._model.add_constraints( binary_variable.isel(time=0) == 1, - name=f'{self.label_full}__{variable_name}_minimum_inital'), + name=f'{self.label_full}|{variable_name}_minimum_inital'), f'{variable_name}_minimum_inital' ) @@ -515,7 +515,7 @@ def _get_duration_in_hours( # eq: duration(t=0)= dt(0) * On(0) self.add(self._model.add_constraints( duration_in_hours.isel(time=0) == self._model.hours_per_step.isel(time=0) * binary_variable.isel(time=0), - name=f'{self.label_full}__{variable_name}_initial'), + name=f'{self.label_full}|{variable_name}_initial'), f'{variable_name}_initial' ) @@ -535,7 +535,7 @@ def _add_switch_constraints(self, system_model: SystemModel): self.switch_on.isel(time=slice(1, None)) - self.switch_off.isel(time=slice(1, None)) == self.on.isel(time=slice(1,None)) - self.on.isel(time=slice(None,-1)), - name=f'{self.label_full}__switch_con' + name=f'{self.label_full}|switch_con' ), 'switch_con' ) @@ -546,7 +546,7 @@ def _add_switch_constraints(self, system_model: SystemModel): self.switch_on.isel(time=0) - self.switch_off.isel(time=0) == self.on.isel(time=0) - self.previous_on_values[-1], - name=f'{self.label_full}__initial_switch_con' + name=f'{self.label_full}|initial_switch_con' ), 'initial_switch_con' ) @@ -555,7 +555,7 @@ def _add_switch_constraints(self, system_model: SystemModel): self.add( self._model.add_constraints( self.switch_on + self.switch_off <= 1.1, - name=f'{self.label_full}__switch_on_or_off' + name=f'{self.label_full}|switch_on_or_off' ), 'switch_on_or_off' ) @@ -565,7 +565,7 @@ def _add_switch_constraints(self, system_model: SystemModel): self.add( self._model.add_constraints( self.switch_on_nr == self.switch_on.sum(), - name=f'{self.label_full}__switch_on_nr' + name=f'{self.label_full}|switch_on_nr' ), 'switch_on_nr' ) @@ -715,21 +715,21 @@ def __init__( def do_modeling(self, system_model: SystemModel): self.in_segment = self.add(self._model.add_variables( binary=True, - name=f'{self.label_full}__in_segment', + name=f'{self.label_full}|in_segment', coords=system_model.coords if self._as_time_series else None), 'in_segment' ) self.lambda0 = self.add(self._model.add_variables( lower=0, upper=1, - name=f'{self.label_full}__lambda0', + name=f'{self.label_full}|lambda0', coords=system_model.coords if self._as_time_series else None), 'lambda0' ) self.lambda1 = self.add(self._model.add_variables( lower=0, upper=1, - name=f'{self.label_full}__lambda1', + name=f'{self.label_full}|lambda1', coords=system_model.coords if self._as_time_series else None), 'lambda1' ) @@ -737,7 +737,7 @@ def do_modeling(self, system_model: SystemModel): # eq: lambda0(t) + lambda1(t) = in_segment(t) self.add(self._model.add_constraints( self.in_segment == self.lambda0 + self.lambda1, - name=f'{self.label_full}__in_segment'), + name=f'{self.label_full}|in_segment'), 'in_segment' ) @@ -804,7 +804,7 @@ def do_modeling(self, system_model: SystemModel): variable == sum([segment.lambda0 * segment.sample_points[var_name][0] + segment.lambda1 * segment.sample_points[var_name][1] for segment in self._segment_models]), - name=f'{self.label_full}__{var_name}_lambda'), + name=f'{self.label_full}|{var_name}_lambda'), f'{var_name}_lambda' ) @@ -817,7 +817,7 @@ def do_modeling(self, system_model: SystemModel): self.outside_segments = self.add(self._model.add_variables( coords=self._model.coords, binary=True, - name=f'{self.label_full}__outside_segments'), + name=f'{self.label_full}|outside_segments'), 'outside_segments' ) rhs = self.outside_segments @@ -826,7 +826,7 @@ def do_modeling(self, system_model: SystemModel): self.add(self._model.add_constraints( sum([segment.in_segment for segment in self._segment_models]) <= rhs, - name=f'{self.label_full}__{variable.name}_single_segment'), + name=f'{self.label_full}|{variable.name}_single_segment'), f'single_segment' ) @@ -870,12 +870,12 @@ def __init__( def do_modeling(self, system_model: SystemModel): self.total = self.add( system_model.add_variables( - lower=self._total_min, upper=self._total_max, coords=None, name=f'{self.label_full}__total' + lower=self._total_min, upper=self._total_max, coords=None, name=f'{self.label_full}|total' ), 'total' ) # eq: sum = sum(share_i) # skalar - self._eq_total = self.add(system_model.add_constraints(self.total == 0, name=f'{self.label_full}__total'), 'total') + self._eq_total = self.add(system_model.add_constraints(self.total == 0, name=f'{self.label_full}|total'), 'total') if self._shares_are_time_series: self.total_per_timestep = self.add( @@ -883,13 +883,13 @@ def do_modeling(self, system_model: SystemModel): lower=-np.inf if (self._min_per_hour is None) else np.multiply(self._min_per_hour, system_model.hours_per_step), upper=np.inf if (self._max_per_hour is None) else np.multiply(self._max_per_hour, system_model.hours_per_step), coords=system_model.coords, - name=f'{self.label_full}_total_per_timestep' + name=f'{self.label_full}|total_per_timestep' ), 'total_per_timestep' ) self._eq_total_per_timestep = self.add( - system_model.add_constraints(self.total_per_timestep == 0, name=f'{self.label_full}__total_per_timestep'), + system_model.add_constraints(self.total_per_timestep == 0, name=f'{self.label_full}|total_per_timestep'), 'total_per_timestep' ) @@ -923,13 +923,13 @@ def add_share( self.shares[name] = self.add( system_model.add_variables( coords=None if isinstance(expression, linopy.LinearExpression) and expression.ndim == 0 or not isinstance(expression, linopy.LinearExpression) else system_model.coords, - name=f'{name}__{self.label_full}' + name=f'{name}->{self.label_full}' ), name ) self.share_constraints[name] = self.add( system_model.add_constraints( - self.shares[name] == expression, name=f'{name}__{self.label_full}' + self.shares[name] == expression, name=f'{name}->{self.label_full}' ), name ) @@ -983,7 +983,7 @@ def do_modeling(self, system_model: SystemModel): self._shares = { effect: self.add(self._model.add_variables( coords=self._model.coords if self._as_tme_series else None, - name=f'{self.label_full}__{effect.label}'), + name=f'{self.label_full}|{effect.label}'), f'{effect.label}' ) for effect in self._share_segments } @@ -1042,5 +1042,5 @@ def __init__(self, model: SystemModel, variables: List[linopy.Variable], label_o def do_modeling(self, system_model: SystemModel): # eq: sum(flow_i.on(t)) <= 1.1 (1 wird etwas größer gewählt wg. Binärvariablengenauigkeit) self.add(self._model.add_constraints(sum(self._simultanious_use_variables) <= 1.1, - name=f'{self.label_full}__prevent_simultaneous_use'), + name=f'{self.label_full}|prevent_simultaneous_use'), 'prevent_simultaneous_use') diff --git a/flixOpt/structure.py b/flixOpt/structure.py index c1e670a34..557c8a183 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -207,7 +207,7 @@ def _create_time_series( return data time_series = TimeSeries.from_datasource( - name=f'{element.label_full}__{name}', + name=f'{element.label_full}|{name}', data=data.data if isinstance(data, TimeSeriesData) else data, timesteps=timesteps, periods=periods, @@ -231,12 +231,7 @@ def __init__(self, label: str, meta_data: Dict = None): meta_data : Optional[Dict] used to store more information about the element. Is not used internally, but saved in the results """ - if not utils.label_is_valid(label): - logger.critical( - f"'{label}' cannot be used as a label. Leading or Trailing '_' and '__' are reserved. " - f'Use any other symbol instead' - ) - self.label = label + self.label = Element._valid_label(label) self.meta_data = meta_data if meta_data is not None else {} self.used_time_series: List[TimeSeries] = [] # Used for better access self.model: Optional[ElementModel] = None @@ -291,6 +286,23 @@ def _create_time_series( ) -> Optional[TimeSeries]: return super()._create_time_series(self, name, data, timesteps, periods) + @staticmethod + def _valid_label(label: str) -> str: + """ + Checks if the label is valid. If not, it is replaced by the default label + + Raises + ------ + ValueError + If the label is not valid + """ + not_allowed = ['(', ')', '|', '->'] + if any([sign in label for sign in not_allowed]): + raise ValueError( + f'Label "{label}" is not valid. Labels cannot contain the following characters: {not_allowed}' + f'Use any other symbol instead' + ) + return label class Model: """Stores Variables and Constraints""" @@ -394,7 +406,7 @@ def label_full(self) -> str: if self._label_full is not None: return self._label_full elif self._label is not None: - return f'{self.label_of_element}__{self.label}' + return f'{self.label_of_element}|{self.label}' return self.label_of_element @property diff --git a/flixOpt/utils.py b/flixOpt/utils.py index 721bbaa1d..fb784c7c6 100644 --- a/flixOpt/utils.py +++ b/flixOpt/utils.py @@ -92,13 +92,6 @@ def apply_formating( return '\n'.join(lines) -def label_is_valid(label: str) -> bool: - """Function to make sure '__' is reserved for internal splitting of labels""" - if label.startswith('_') or label.endswith('_') or '__' in label: - return False - return True - - def convert_numeric_lists_to_arrays( d: Union[Dict[str, Any], List[Any], tuple], ) -> Union[Dict[str, Any], List[Any], tuple]: From cd35bb262158b664a1946f55c0f45b43cd91818f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 16 Feb 2025 19:06:46 +0100 Subject: [PATCH 128/507] Adjusted Naming in tests --- tests/test_functional.py | 2 +- tests/test_integration.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_functional.py b/tests/test_functional.py index 1c0c97644..5bfe7be83 100644 --- a/tests/test_functional.py +++ b/tests/test_functional.py @@ -141,7 +141,7 @@ def test_minimal_model(solver_fixture, time_steps_fixture): ) assert_allclose( - results.effect_results['costs'].all_results['operation']['Shares']['Gas (Gastarif)'], + results.effect_results['costs'].all_results['operation']['Shares']['Gastarif (Gas)'], [-0.0, 20.0, 40.0, -0.0, 20.0], rtol=1e-5, atol=1e-10, diff --git a/tests/test_integration.py b/tests/test_integration.py index ab47f23f9..77ba9bcec 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -347,7 +347,7 @@ def test_basic(self): 'costs doesnt match expected value', ) self.assert_almost_equal_numeric( - sum(effects['costs'].model.operation.shares['Q_th (Kessel)'].solution.values), + sum(effects['costs'].model.operation.shares['Kessel (Q_th)'].solution.values), 0.01, 'costs doesnt match expected value', ) @@ -357,7 +357,7 @@ def test_basic(self): 'costs doesnt match expected value', ) self.assert_almost_equal_numeric( - sum(effects['costs'].model.operation.shares['Q_Gas (Gastarif)'].solution.values), + sum(effects['costs'].model.operation.shares['Gastarif (Q_Gas)'].solution.values), 39.09153113079115, 'costs doesnt match expected value', ) @@ -373,7 +373,7 @@ def test_basic(self): ) self.assert_almost_equal_numeric( - effects['costs'].model.invest.shares['Q_th (Kessel)'].solution.values, + effects['costs'].model.invest.shares['Kessel (Q_th)'].solution.values, 1000 + 500, 'costs doesnt match expected value', ) From 9c923727563d7e263ed427677925976130a4ec5a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 16 Feb 2025 19:51:22 +0100 Subject: [PATCH 129/507] Adjust labeling in Results dicts --- flixOpt/structure.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/flixOpt/structure.py b/flixOpt/structure.py index 557c8a183..bd910028f 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -391,10 +391,20 @@ def solution_structured( self._variables_short[var_name]: var.values for var_name, var in self.variables.solution.data_vars.items() } - return { - **results, - **{sub_model.label: sub_model.solution_structured(use_numpy) for sub_model in self.sub_models} - } + + for sub_model in self.sub_models: + sub_solution = sub_model.solution_structured(use_numpy) + + if sub_model.label is None: + # Ensure no key conflicts when merging + for key, value in sub_solution.items(): + if key in results: + raise ValueError(f"Key conflict: '{key}' already exists in the results of {self.label_full}.") + results[key] = value + else: + results[sub_model.label] = sub_solution # Keep under its label + + return results @property def label(self) -> str: From 677f57db5898e0c15b1054705054f6cd06c42404 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 16 Feb 2025 20:00:12 +0100 Subject: [PATCH 130/507] Made Variable names more compact by making Model Labels optional --- flixOpt/features.py | 2 +- flixOpt/structure.py | 12 +++++------- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/flixOpt/features.py b/flixOpt/features.py index 084926dd7..e5a09ba95 100644 --- a/flixOpt/features.py +++ b/flixOpt/features.py @@ -35,7 +35,7 @@ def __init__( defining_variable: [linopy.Variable], relative_bounds_of_defining_variable: Tuple[Numeric, Numeric], fixed_relative_profile: Optional[Numeric] = None, - label: Optional[str] = 'Investment', + label: Optional[str] = None, on_variable: Optional[linopy.Variable] = None, ): """ diff --git a/flixOpt/structure.py b/flixOpt/structure.py index bd910028f..4e64a6c5b 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -394,15 +394,13 @@ def solution_structured( for sub_model in self.sub_models: sub_solution = sub_model.solution_structured(use_numpy) - if sub_model.label is None: - # Ensure no key conflicts when merging - for key, value in sub_solution.items(): - if key in results: - raise ValueError(f"Key conflict: '{key}' already exists in the results of {self.label_full}.") - results[key] = value + if any(key in results for key in sub_solution): + conflict_keys = [key for key in sub_solution if key in results] + raise ValueError(f"Key conflict in {self.label_full}: {conflict_keys}") + results.update(sub_solution) else: - results[sub_model.label] = sub_solution # Keep under its label + results[sub_model.label] = sub_solution return results From 0e82f796bc30c65bd3e5b573c47e51ad94251b19 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 16 Feb 2025 20:06:22 +0100 Subject: [PATCH 131/507] Reorganized variables and constraints --- flixOpt/aggregation.py | 4 ++-- flixOpt/features.py | 4 ++-- flixOpt/structure.py | 48 +++++++++++++++++++++--------------------- 3 files changed, 28 insertions(+), 28 deletions(-) diff --git a/flixOpt/aggregation.py b/flixOpt/aggregation.py index eeb3e68bb..318ea0e7f 100644 --- a/flixOpt/aggregation.py +++ b/flixOpt/aggregation.py @@ -361,7 +361,7 @@ def do_modeling(self, system_model: SystemModel): if isinstance(component, Storage) and not self.aggregation_parameters.fix_storage_flows: continue # Fix Nothing in The Storage - all_variables_of_component = component.model.all_variables + all_variables_of_component = component.model.variables if self.aggregation_parameters.aggregate_data_and_fix_non_binary_vars: all_relevant_variables = [v for v in all_variables_of_component.values() if isinstance(v, VariableTS)] else: @@ -373,7 +373,7 @@ def do_modeling(self, system_model: SystemModel): penalty = self.aggregation_parameters.penalty_of_period_freedom if (self.aggregation_parameters.percentage_of_period_freedom > 0) and penalty != 0: - for label, variable in self.variables.items(): + for label, variable in self.variables_direct.items(): system_model.effect_collection_model.add_share_to_penalty( f'Aggregation_penalty__{label}', variable, penalty ) diff --git a/flixOpt/features.py b/flixOpt/features.py index e5a09ba95..d76bc305a 100644 --- a/flixOpt/features.py +++ b/flixOpt/features.py @@ -945,11 +945,11 @@ def solution_structured( shares_var_names = [var.name for var in self.shares.values()] results = { self._variables_short[var_name]: var.values - for var_name, var in self.variables.solution.data_vars.items() if var_name not in shares_var_names + for var_name, var in self.variables_direct.solution.data_vars.items() if var_name not in shares_var_names } results['Shares'] = { self._variables_short[var_name]: var.values - for var_name, var in self.variables.solution.data_vars.items() if var_name in shares_var_names + for var_name, var in self.variables_direct.solution.data_vars.items() if var_name in shares_var_names } return { **results, diff --git a/flixOpt/structure.py b/flixOpt/structure.py index 4e64a6c5b..e7ede65df 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -257,7 +257,7 @@ def solution_numeric( decimals int: Number of decimal places to round the solution to. Defaults to None. """ - vars = self.model.all_variables if all_variables else self.model.variables + vars = self.model.variables if all_variables else self.model.variables_direct if decimals is not None: results = {var: vars.solution[var].round(decimals).values for var in vars.solution.data_vars} else: @@ -323,8 +323,8 @@ def __init__(self, model: SystemModel, label_of_element: str, label: Optional[st self._label = label self._label_full = label_full - self._variables: List[str] = [] - self._constraints: List[str] = [] + self._variables_direct: List[str] = [] + self._constraints_direct: List[str] = [] self.sub_models: List[Model] = [] self._variables_short: Dict[str, str] = {} @@ -349,10 +349,10 @@ def add( """ # TODO: Check uniquenes of short names if isinstance(item, linopy.Variable): - self._variables.append(item.name) + self._variables_direct.append(item.name) self._variables_short[item.name] = short_name or item.name elif isinstance(item, linopy.Constraint): - self._constraints.append(item.name) + self._constraints_direct.append(item.name) self._constraints_short[item.name] = short_name or item.name elif isinstance(item, Model): self.sub_models.append(item) @@ -366,13 +366,13 @@ def filter_variables(self, filter_by: Optional[Literal['binary', 'continuous', 'integer']] = None, length: Literal['scalar', 'time'] = None): if filter_by is None: - all_variables = self.all_variables + all_variables = self.variables elif filter_by == 'binary': - all_variables = self.all_variables.binaries + all_variables = self.variables.binaries elif filter_by == 'integer': - all_variables = self.all_variables.integers + all_variables = self.variables.integers elif filter_by == 'continuous': - all_variables = self.all_variables.continuous + all_variables = self.variables.continuous else: raise ValueError(f'Invalid filter_by "{filter_by}", must be one of "binary", "continous", "integer"') if length is None: @@ -389,7 +389,7 @@ def solution_structured( ) -> Dict[str, Union[np.ndarray, Dict]]: results = { self._variables_short[var_name]: var.values - for var_name, var in self.variables.solution.data_vars.items() + for var_name, var in self.variables_direct.solution.data_vars.items() } for sub_model in self.sub_models: @@ -418,18 +418,18 @@ def label_full(self) -> str: return self.label_of_element @property - def variables(self) -> linopy.Variables: - return self._model.variables[self._variables] + def variables_direct(self) -> linopy.Variables: + return self._model.variables[self._variables_direct] @property - def constraints(self) -> linopy.Constraints: - return self._model.constraints[self._constraints] + def constraints_direct(self) -> linopy.Constraints: + return self._model.constraints[self._constraints_direct] @property - def _all_variables(self) -> List[str]: - all_variables = self._variables.copy() + def _variables(self) -> List[str]: + all_variables = self._variables_direct.copy() for sub_model in self.sub_models: - for variable in sub_model._all_variables: + for variable in sub_model._variables: if variable in all_variables: raise KeyError( f"Duplicate key found: '{variable}' in both {self.label_full} and {sub_model.label_full}!" @@ -438,22 +438,22 @@ def _all_variables(self) -> List[str]: return all_variables @property - def _all_constraints(self) -> List[str]: - all_constraints = self._constraints.copy() + def _constraints(self) -> List[str]: + all_constraints = self._constraints_direct.copy() for sub_model in self.sub_models: - for constraint in sub_model._all_constraints: + for constraint in sub_model._constraints: if constraint in all_constraints: raise KeyError(f"Duplicate key found: '{constraint}' in both main model and submodel!") all_constraints.append(constraint) return all_constraints @property - def all_variables(self) -> linopy.Variables: - return self._model.variables[self._all_variables] + def variables(self) -> linopy.Variables: + return self._model.variables[self._variables] @property - def all_constraints(self) -> linopy.Constraints: - return self._model.constraints[self._all_constraints] + def constraints(self) -> linopy.Constraints: + return self._model.constraints[self._constraints] @property def all_sub_models(self) -> List['Model']: From 3def3c399f522194748cffd24354b0ca547c51b5 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 16 Feb 2025 20:15:30 +0100 Subject: [PATCH 132/507] Move solution_numeric to Model --- flixOpt/structure.py | 61 ++++++++++++++++++++++---------------------- 1 file changed, 31 insertions(+), 30 deletions(-) diff --git a/flixOpt/structure.py b/flixOpt/structure.py index e7ede65df..4cd4fc7b6 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -236,36 +236,6 @@ def __init__(self, label: str, meta_data: Dict = None): self.used_time_series: List[TimeSeries] = [] # Used for better access self.model: Optional[ElementModel] = None - def solution_numeric( - self, - use_numpy: bool = True, - all_variables: bool = True, - decimals: Optional[int] = None - ) -> Union[Dict[str, np.ndarray], Dict[str, Union[List, int, float]]]: - """ - Returns the solution of the element as a dictionary of numeric values. - - Parameters: - ----------- - use_numpy bool: - Whether to return the solution as a numpy array. Defaults to True. - If True, numeric numpy arrays (`np.ndarray`) are preserved as-is. - If False, they are converted to lists. - all_variables bool: - Whether to return the solution for all variables (including sub-models) or only the variables of the element. - Defaults to True. - decimals int: - Number of decimal places to round the solution to. Defaults to None. - """ - vars = self.model.variables if all_variables else self.model.variables_direct - if decimals is not None: - results = {var: vars.solution[var].round(decimals).values for var in vars.solution.data_vars} - else: - results = {var: vars.solution[var].values for var in vars.solution.data_vars} - if use_numpy: - return {k: v.item() if v.ndim == 0 else v for k, v in results.items()} - return {k: v.tolist() for k, v in results.items()} - def _plausibility_checks(self) -> None: """This function is used to do some basic plausibility checks for each Element during initialization""" raise NotImplementedError('Every Element needs a _plausibility_checks() method') @@ -304,6 +274,7 @@ def _valid_label(label: str) -> str: ) return label + class Model: """Stores Variables and Constraints""" @@ -404,6 +375,36 @@ def solution_structured( return results + def solution_numeric( + self, + use_numpy: bool = True, + all_variables: bool = True, + decimals: Optional[int] = None + ) -> Union[Dict[str, np.ndarray], Dict[str, Union[List, int, float]]]: + """ + Returns the solution of the element as a dictionary of numeric values. + + Parameters: + ----------- + use_numpy bool: + Whether to return the solution as a numpy array. Defaults to True. + If True, numeric numpy arrays (`np.ndarray`) are preserved as-is. + If False, they are converted to lists. + all_variables bool: + Whether to return the solution for all variables (including sub-models) or only the variables of the element. + Defaults to True. + decimals int: + Number of decimal places to round the solution to. Defaults to None. + """ + vars = self.model.variables if all_variables else self.model.variables_direct + if decimals is not None: + results = {var: vars.solution[var].round(decimals).values for var in vars.solution.data_vars} + else: + results = {var: vars.solution[var].values for var in vars.solution.data_vars} + if use_numpy: + return {k: v.item() if v.ndim == 0 else v for k, v in results.items()} + return {k: v.tolist() for k, v in results.items()} + @property def label(self) -> str: return self._label if self._label is not None else self.label_of_element From 01c12b3773909900f4a7ab42e781e05e99399baa Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 17 Feb 2025 11:25:45 +0100 Subject: [PATCH 133/507] Add function to save all results in a structured way to file and reload them --- flixOpt/features.py | 7 +++--- flixOpt/structure.py | 56 ++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/flixOpt/features.py b/flixOpt/features.py index d76bc305a..1e9f4f608 100644 --- a/flixOpt/features.py +++ b/flixOpt/features.py @@ -941,19 +941,20 @@ def add_share( def solution_structured( self, use_numpy: bool = True, + only_structure: bool = False ) -> Dict[str, Union[np.ndarray, Dict]]: shares_var_names = [var.name for var in self.shares.values()] results = { - self._variables_short[var_name]: var.values + self._variables_short[var_name]: var.values if not only_structure else f':::{var_name}' for var_name, var in self.variables_direct.solution.data_vars.items() if var_name not in shares_var_names } results['Shares'] = { - self._variables_short[var_name]: var.values + self._variables_short[var_name]: var.values if not only_structure else f':::{var_name}' for var_name, var in self.variables_direct.solution.data_vars.items() if var_name in shares_var_names } return { **results, - **{sub_model.label: sub_model.solution_structured(use_numpy) for sub_model in self.sub_models} + **{sub_model.label: sub_model.solution_structured(use_numpy, only_structure) for sub_model in self.sub_models} } diff --git a/flixOpt/structure.py b/flixOpt/structure.py index 4cd4fc7b6..6f31c35b2 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -53,6 +53,57 @@ def do_modeling(self): self.effects.objective_effect.model.total + self.effects.penalty.total ) + def solution_structured(self, use_numpy: bool = True, only_structure: bool = False): + return { + 'Buses': { + bus.label_full: bus.model.solution_structured(use_numpy=use_numpy, only_structure=only_structure) + for bus in sorted(self.flow_system.buses.values(), key=lambda bus: bus.label_full.upper()) + }, + 'Components': { + comp.label_full: comp.model.solution_structured(use_numpy=use_numpy, only_structure=only_structure) + for comp in sorted(self.flow_system.components.values(), key=lambda component: component.label_full.upper()) + }, + 'Effects': { + effect.label_full: effect.model.solution_structured(use_numpy=use_numpy, only_structure=only_structure) + for effect in sorted(self.flow_system.effects.values(), key=lambda effect: effect.label_full.upper()) + }, + **self.effects.solution_structured(use_numpy=use_numpy, only_structure=only_structure), + 'Objective': self.objective.value, + } + + def save_to_netcdf(self, path: Union[str, pathlib.Path] = 'flow_system.nc'): + """ + Save the flow system to a netcdf file. + """ + ds = self.solution + ds.attrs["structure"] = json.dumps(self.solution_structured(only_structure=True)) # Convert dict to JSON string + ds.to_netcdf(path) + + @staticmethod + def load_from_netcdf(path: Union[str, pathlib.Path] = 'flow_system.nc') -> Dict[str, Union[str, Dict, xr.DataArray]]: + results = xr.open_dataset(path) + return { + **SystemModel._insert_dataarrays(results, json.loads(results.attrs['structure'])), + 'Solution': results + } + + @staticmethod + def _insert_dataarrays(dataset: xr.Dataset, structure: Dict[str, Union[str, Dict]]): + result = {} + + for key, value in structure.items(): + if isinstance(value, dict): # If the value is another nested dictionary + result[key] = SystemModel._insert_dataarrays(dataset, value) # Recursively handle it + elif isinstance(value, str) and value.startswith(':::'): + value = value.removeprefix(':::') + result[key] = dataset[value] + elif isinstance(value, (int, float)): + result[key] = value + else: + raise ValueError(f'Loading the Dataset failed. Not able to handle {value}') + + return result + @property def main_results(self) -> Dict[str, Union[Skalar, Dict]]: from flixOpt.features import InvestmentModel @@ -357,14 +408,15 @@ def filter_variables(self, def solution_structured( self, use_numpy: bool = True, + only_structure: bool = False ) -> Dict[str, Union[np.ndarray, Dict]]: results = { - self._variables_short[var_name]: var.values + self._variables_short[var_name]: var.values if not only_structure else f':::{var_name}' for var_name, var in self.variables_direct.solution.data_vars.items() } for sub_model in self.sub_models: - sub_solution = sub_model.solution_structured(use_numpy) + sub_solution = sub_model.solution_structured(use_numpy, only_structure) if sub_model.label is None: if any(key in results for key in sub_solution): conflict_keys = [key for key in sub_solution if key in results] From bc7153b79c4692c5b5f6e5f0455336ba0d6b2bff Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 17 Feb 2025 11:27:29 +0100 Subject: [PATCH 134/507] Remove wrong type hint --- flixOpt/effects.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixOpt/effects.py b/flixOpt/effects.py index acbbabf9b..f4e527c73 100644 --- a/flixOpt/effects.py +++ b/flixOpt/effects.py @@ -282,7 +282,7 @@ def __init__(self, model: SystemModel, effects: List[Effect]): self._standard_effect: Optional[Effect] = None self._objective_effect: Optional[Effect] = None - self.effects: Dict[str, Effect] = effects # Performs some validation + self.effects = effects # Performs some validation self.penalty: Optional[ShareAllocationModel] = None def add_share_to_effects( From 951cd06b6fc1274c152504eff0f4206ac2332e39 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 17 Feb 2025 13:07:37 +0100 Subject: [PATCH 135/507] Improve structure and add inputs and outputs infos to buses and Components --- flixOpt/elements.py | 43 ++++++++++++++++++++++++++++++++++++++++++- flixOpt/structure.py | 36 ++++++++++++++++++++++++++---------- 2 files changed, 68 insertions(+), 11 deletions(-) diff --git a/flixOpt/elements.py b/flixOpt/elements.py index 15de0dcc9..4ff3ebe12 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -423,7 +423,6 @@ def _create_bounds_for_load_factor(self, system_model: SystemModel): name_short ) - @property def with_investment(self) -> bool: """Checks if the element's size is investment-driven.""" @@ -498,6 +497,27 @@ def do_modeling(self, system_model: SystemModel) -> None: self._model, self.label_of_element, (self.excess_output * excess_penalty).sum() ) + def solution_structured( + self, + use_numpy: bool = True, + only_structure: bool = False + ) -> Dict[str, Union[np.ndarray, Dict]]: + """ + Return the structure of the SystemModel solution. + + Parameters + ---------- + use_numpy : bool, optional + Whether to return the solution as a dictionary of numpy arrays or dictionaries, by default True + """ + # TODO: The main functionality is to return the structure. The numeric solutions are used for the old json export + + results = super().solution_structured(use_numpy, only_structure) + results['inputs'] = [flow.label for flow in self.element.inputs] + results['outputs'] = [flow.label for flow in self.element.inputs] + + return results + class ComponentModel(ElementModel): def __init__(self, model: SystemModel, element: Component): @@ -540,3 +560,24 @@ def do_modeling(self, system_model: SystemModel): on_variables = [flow.model.on_off.on for flow in self.element.prevent_simultaneous_flows] simultaneous_use = self.add(PreventSimultaneousUsageModel(self._model, on_variables, self.label_full)) simultaneous_use.do_modeling(self._model) + + def solution_structured( + self, + use_numpy: bool = True, + only_structure: bool = False + ) -> Dict[str, Union[np.ndarray, Dict]]: + """ + Return the structure of the SystemModel solution. + + Parameters + ---------- + use_numpy : bool, optional + Whether to return the solution as a dictionary of numpy arrays or dictionaries, by default True + """ + # TODO: The main functionality is to return the structure. The numeric solutions are used for the old json export + + results = super().solution_structured(use_numpy, only_structure) + results['inputs'] = [flow.label for flow in self.element.inputs] + results['outputs'] = [flow.label for flow in self.element.inputs] + + return results diff --git a/flixOpt/structure.py b/flixOpt/structure.py index 6f31c35b2..352e579b8 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -91,16 +91,22 @@ def load_from_netcdf(path: Union[str, pathlib.Path] = 'flow_system.nc') -> Dict[ def _insert_dataarrays(dataset: xr.Dataset, structure: Dict[str, Union[str, Dict]]): result = {} - for key, value in structure.items(): - if isinstance(value, dict): # If the value is another nested dictionary - result[key] = SystemModel._insert_dataarrays(dataset, value) # Recursively handle it - elif isinstance(value, str) and value.startswith(':::'): - value = value.removeprefix(':::') - result[key] = dataset[value] - elif isinstance(value, (int, float)): - result[key] = value + def insert_data(value_part): + if isinstance(value_part, dict): # If the value is another nested dictionary + return SystemModel._insert_dataarrays(dataset, value_part) # Recursively handle it + elif isinstance(value_part, list): + return [insert_data(v) for v in value_part] + elif isinstance(value_part, str) and value_part.startswith(':::'): + return dataset[value_part.removeprefix(':::')] + elif isinstance(value_part, str): + return value_part + elif isinstance(value_part, (int, float)): + return value_part else: - raise ValueError(f'Loading the Dataset failed. Not able to handle {value}') + raise ValueError(f'Loading the Dataset failed. Not able to handle {value_part}') + + for key, value in structure.items(): + result[key] = insert_data(value) return result @@ -410,6 +416,16 @@ def solution_structured( use_numpy: bool = True, only_structure: bool = False ) -> Dict[str, Union[np.ndarray, Dict]]: + """ + Return the structure of the SystemModel solution. + + Parameters + ---------- + use_numpy : bool, optional + Whether to return the solution as a dictionary of numpy arrays or dictionaries, by default True + """ + # TODO: The main functionality is to return the structure. The numeric solutions are used for the old json export + results = { self._variables_short[var_name]: var.values if not only_structure else f':::{var_name}' for var_name, var in self.variables_direct.solution.data_vars.items() @@ -417,7 +433,7 @@ def solution_structured( for sub_model in self.sub_models: sub_solution = sub_model.solution_structured(use_numpy, only_structure) - if sub_model.label is None: + if sub_model.label is None or sub_model.label == self.label: if any(key in results for key in sub_solution): conflict_keys = [key for key in sub_solution if key in results] raise ValueError(f"Key conflict in {self.label_full}: {conflict_keys}") From 2be9fd984805a496cbd6aa87ad3624d16b829be7 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 17 Feb 2025 13:32:08 +0100 Subject: [PATCH 136/507] Improve structure and add inputs and outputs infos to buses and Components --- flixOpt/structure.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/flixOpt/structure.py b/flixOpt/structure.py index 352e579b8..82f3b5e99 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -433,7 +433,9 @@ def solution_structured( for sub_model in self.sub_models: sub_solution = sub_model.solution_structured(use_numpy, only_structure) - if sub_model.label is None or sub_model.label == self.label: + if sub_solution == {}: # If the submodel has no variables, skip it + continue + if sub_model.label_full == self.label_full: if any(key in results for key in sub_solution): conflict_keys = [key for key in sub_solution if key in results] raise ValueError(f"Key conflict in {self.label_full}: {conflict_keys}") From 66a250ed68c06221a9b7248babd41909af05d36f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 17 Feb 2025 15:48:46 +0100 Subject: [PATCH 137/507] Add symbol to forbidden symbols --- flixOpt/structure.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flixOpt/structure.py b/flixOpt/structure.py index 82f3b5e99..75d5469c7 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -323,10 +323,10 @@ def _valid_label(label: str) -> str: ValueError If the label is not valid """ - not_allowed = ['(', ')', '|', '->'] + not_allowed = ['(', ')', '|', '->', '/'] if any([sign in label for sign in not_allowed]): raise ValueError( - f'Label "{label}" is not valid. Labels cannot contain the following characters: {not_allowed}' + f'Label "{label}" is not valid. Labels cannot contain the following characters: {not_allowed}. ' f'Use any other symbol instead' ) return label From f30f751a1c4ebe7ad155c39ab5ce93bd32cdd58f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 17 Feb 2025 16:09:17 +0100 Subject: [PATCH 138/507] Add forbidden symbols to _valid_label() --- flixOpt/structure.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/flixOpt/structure.py b/flixOpt/structure.py index 75d5469c7..ad145fe59 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -323,12 +323,15 @@ def _valid_label(label: str) -> str: ValueError If the label is not valid """ - not_allowed = ['(', ')', '|', '->', '/'] + not_allowed = ['(', ')', '|', '->', '/', '\\'] # \\ is needed to check for \ if any([sign in label for sign in not_allowed]): raise ValueError( f'Label "{label}" is not valid. Labels cannot contain the following characters: {not_allowed}. ' f'Use any other symbol instead' ) + if label.endswith(' '): + logger.warning(f'Label "{label}" ends with a space. This will be removed.') + return label.rstrip() return label From cf31075d1af8cf66a0fba6d524b8e187c08edfd1 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 18 Feb 2025 08:29:38 +0100 Subject: [PATCH 139/507] Allow / as label by replacing it on save --- flixOpt/structure.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/flixOpt/structure.py b/flixOpt/structure.py index ad145fe59..17b9c7289 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -76,6 +76,7 @@ def save_to_netcdf(self, path: Union[str, pathlib.Path] = 'flow_system.nc'): Save the flow system to a netcdf file. """ ds = self.solution + ds = ds.rename_vars({var: var.replace('/', '-slash-') for var in ds.data_vars}) ds.attrs["structure"] = json.dumps(self.solution_structured(only_structure=True)) # Convert dict to JSON string ds.to_netcdf(path) @@ -91,15 +92,19 @@ def load_from_netcdf(path: Union[str, pathlib.Path] = 'flow_system.nc') -> Dict[ def _insert_dataarrays(dataset: xr.Dataset, structure: Dict[str, Union[str, Dict]]): result = {} + def convert_string(text): + text = text.replace('-slash-', '/').removeprefix(':::') + return text + def insert_data(value_part): if isinstance(value_part, dict): # If the value is another nested dictionary return SystemModel._insert_dataarrays(dataset, value_part) # Recursively handle it elif isinstance(value_part, list): return [insert_data(v) for v in value_part] elif isinstance(value_part, str) and value_part.startswith(':::'): - return dataset[value_part.removeprefix(':::')] + return dataset[convert_string(value_part)] elif isinstance(value_part, str): - return value_part + return convert_string(value_part) elif isinstance(value_part, (int, float)): return value_part else: @@ -323,7 +328,7 @@ def _valid_label(label: str) -> str: ValueError If the label is not valid """ - not_allowed = ['(', ')', '|', '->', '/', '\\'] # \\ is needed to check for \ + not_allowed = ['(', ')', '|', '->', '\\', '-slash-'] # \\ is needed to check for \ if any([sign in label for sign in not_allowed]): raise ValueError( f'Label "{label}" is not valid. Labels cannot contain the following characters: {not_allowed}. ' From 5c2233b064da4b55aa8d780f1172b9e13b7629fc Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 18 Feb 2025 10:42:00 +0100 Subject: [PATCH 140/507] Allow / as label by replacing it on save --- flixOpt/structure.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/flixOpt/structure.py b/flixOpt/structure.py index 17b9c7289..baa495e0e 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -90,21 +90,18 @@ def load_from_netcdf(path: Union[str, pathlib.Path] = 'flow_system.nc') -> Dict[ @staticmethod def _insert_dataarrays(dataset: xr.Dataset, structure: Dict[str, Union[str, Dict]]): + dataset = dataset.rename_vars({var: var.replace('-slash-', '/') for var in dataset.data_vars}) result = {} - def convert_string(text): - text = text.replace('-slash-', '/').removeprefix(':::') - return text - def insert_data(value_part): if isinstance(value_part, dict): # If the value is another nested dictionary return SystemModel._insert_dataarrays(dataset, value_part) # Recursively handle it elif isinstance(value_part, list): return [insert_data(v) for v in value_part] elif isinstance(value_part, str) and value_part.startswith(':::'): - return dataset[convert_string(value_part)] + return dataset[value_part.removeprefix(':::')] elif isinstance(value_part, str): - return convert_string(value_part) + return value_part elif isinstance(value_part, (int, float)): return value_part else: From 3ed08c0c032ddda0fe61238e6ca5c10c33dc2a56 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 18 Feb 2025 10:42:49 +0100 Subject: [PATCH 141/507] Bugfix in Models outputs inputs saving --- flixOpt/elements.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flixOpt/elements.py b/flixOpt/elements.py index 4ff3ebe12..63440f244 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -514,7 +514,7 @@ def solution_structured( results = super().solution_structured(use_numpy, only_structure) results['inputs'] = [flow.label for flow in self.element.inputs] - results['outputs'] = [flow.label for flow in self.element.inputs] + results['outputs'] = [flow.label for flow in self.element.outputs] return results @@ -578,6 +578,6 @@ def solution_structured( results = super().solution_structured(use_numpy, only_structure) results['inputs'] = [flow.label for flow in self.element.inputs] - results['outputs'] = [flow.label for flow in self.element.inputs] + results['outputs'] = [flow.label for flow in self.element.outputs] return results From 80a0232d97498b112a6273f915afd4c4cbb691ae Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 18 Feb 2025 11:52:10 +0100 Subject: [PATCH 142/507] Add a function to make solution_numeric() more flexible --- flixOpt/elements.py | 36 +++++++++++++++++++----------------- flixOpt/features.py | 24 ++++++++++++++++++------ flixOpt/flow_system.py | 6 +++--- flixOpt/structure.py | 32 +++++++++++++++++--------------- flixOpt/utils.py | 29 +++++++++++++++++++++++++++++ 5 files changed, 86 insertions(+), 41 deletions(-) diff --git a/flixOpt/elements.py b/flixOpt/elements.py index 63440f244..3d641fc0a 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -3,7 +3,7 @@ """ import logging -from typing import Dict, List, Optional, Tuple, Union, TYPE_CHECKING +from typing import Dict, List, Optional, Tuple, Union, TYPE_CHECKING, Literal import numpy as np import linopy @@ -498,21 +498,22 @@ def do_modeling(self, system_model: SystemModel) -> None: ) def solution_structured( - self, - use_numpy: bool = True, - only_structure: bool = False + self, + mode: Literal['py', 'numpy', 'xarray', 'structure'] = 'py', ) -> Dict[str, Union[np.ndarray, Dict]]: """ Return the structure of the SystemModel solution. Parameters ---------- - use_numpy : bool, optional - Whether to return the solution as a dictionary of numpy arrays or dictionaries, by default True + mode : Literal['py', 'numpy', 'xarray', 'structure'] + Whether to return the solution as a dictionary of + - python native types (for json) + - numpy arrays + - xarray.DataArrays + - strings (for structure, storing variable names) """ - # TODO: The main functionality is to return the structure. The numeric solutions are used for the old json export - - results = super().solution_structured(use_numpy, only_structure) + results = super().solution_structured(mode) results['inputs'] = [flow.label for flow in self.element.inputs] results['outputs'] = [flow.label for flow in self.element.outputs] @@ -562,21 +563,22 @@ def do_modeling(self, system_model: SystemModel): simultaneous_use.do_modeling(self._model) def solution_structured( - self, - use_numpy: bool = True, - only_structure: bool = False + self, + mode: Literal['py', 'numpy', 'xarray', 'structure'] = 'py', ) -> Dict[str, Union[np.ndarray, Dict]]: """ Return the structure of the SystemModel solution. Parameters ---------- - use_numpy : bool, optional - Whether to return the solution as a dictionary of numpy arrays or dictionaries, by default True + mode : Literal['py', 'numpy', 'xarray', 'structure'] + Whether to return the solution as a dictionary of + - python native types (for json) + - numpy arrays + - xarray.DataArrays + - strings (for structure, storing variable names) """ - # TODO: The main functionality is to return the structure. The numeric solutions are used for the old json export - - results = super().solution_structured(use_numpy, only_structure) + results = super().solution_structured(mode) results['inputs'] = [flow.label for flow in self.element.inputs] results['outputs'] = [flow.label for flow in self.element.outputs] diff --git a/flixOpt/features.py b/flixOpt/features.py index 1e9f4f608..2006c2af0 100644 --- a/flixOpt/features.py +++ b/flixOpt/features.py @@ -4,7 +4,7 @@ """ import logging -from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union +from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union, Literal import linopy import numpy as np @@ -14,6 +14,7 @@ from .interface import InvestParameters, OnOffParameters from .math_modeling import Equation, Variable, VariableTS from .structure import Model, SystemModel +from . import utils if TYPE_CHECKING: # for type checking and preventing circular imports from .components import Storage @@ -940,21 +941,32 @@ def add_share( def solution_structured( self, - use_numpy: bool = True, - only_structure: bool = False + mode: Literal['py', 'numpy', 'xarray', 'structure'] = 'py', ) -> Dict[str, Union[np.ndarray, Dict]]: + """ + Return the structure of the SystemModel solution. + + Parameters + ---------- + mode : Literal['py', 'numpy', 'xarray', 'structure'] + Whether to return the solution as a dictionary of + - python native types (for json) + - numpy arrays + - xarray.DataArrays + - strings (for structure, storing variable names) + """ shares_var_names = [var.name for var in self.shares.values()] results = { - self._variables_short[var_name]: var.values if not only_structure else f':::{var_name}' + self._variables_short[var_name]: utils.convert_dataarray(var, mode) for var_name, var in self.variables_direct.solution.data_vars.items() if var_name not in shares_var_names } results['Shares'] = { - self._variables_short[var_name]: var.values if not only_structure else f':::{var_name}' + self._variables_short[var_name]: utils.convert_dataarray(var, mode) for var_name, var in self.variables_direct.solution.data_vars.items() if var_name in shares_var_names } return { **results, - **{sub_model.label: sub_model.solution_structured(use_numpy, only_structure) for sub_model in self.sub_models} + **{sub_model.label: sub_model.solution_structured(mode) for sub_model in self.sub_models} } diff --git a/flixOpt/flow_system.py b/flixOpt/flow_system.py index 0b0d1ceb7..b5f0cc3a0 100644 --- a/flixOpt/flow_system.py +++ b/flixOpt/flow_system.py @@ -162,15 +162,15 @@ def to_json(self, path: Union[str, pathlib.Path]): def results(self): return { 'Components': { - comp.label: comp.model.solution_structured(use_numpy=True) + comp.label: comp.model.solution_structured(mode='numpy') for comp in sorted(self.components.values(), key=lambda component: component.label.upper()) }, 'Buses': { - bus.label: bus.model.solution_structured(use_numpy=True) + bus.label: bus.model.solution_structured(mode='numpy') for bus in sorted(self.buses.values(), key=lambda bus: bus.label.upper()) }, 'Effects': { - effect.label: effect.model.solution_structured(use_numpy=True) + effect.label: effect.model.solution_structured(mode='numpy') for effect in sorted(self.effects.values(), key=lambda effect: effect.label.upper()) }, 'Time': self.timesteps_extra.tolist(), diff --git a/flixOpt/structure.py b/flixOpt/structure.py index baa495e0e..2b5e041e9 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -53,35 +53,35 @@ def do_modeling(self): self.effects.objective_effect.model.total + self.effects.penalty.total ) - def solution_structured(self, use_numpy: bool = True, only_structure: bool = False): + def solution_structured(self, mode: Literal['py', 'numpy', 'xarray', 'structure'] = 'numpy'): return { 'Buses': { - bus.label_full: bus.model.solution_structured(use_numpy=use_numpy, only_structure=only_structure) + bus.label_full: bus.model.solution_structured(mode=mode) for bus in sorted(self.flow_system.buses.values(), key=lambda bus: bus.label_full.upper()) }, 'Components': { - comp.label_full: comp.model.solution_structured(use_numpy=use_numpy, only_structure=only_structure) + comp.label_full: comp.model.solution_structured(mode=mode) for comp in sorted(self.flow_system.components.values(), key=lambda component: component.label_full.upper()) }, 'Effects': { - effect.label_full: effect.model.solution_structured(use_numpy=use_numpy, only_structure=only_structure) + effect.label_full: effect.model.solution_structured(mode=mode) for effect in sorted(self.flow_system.effects.values(), key=lambda effect: effect.label_full.upper()) }, - **self.effects.solution_structured(use_numpy=use_numpy, only_structure=only_structure), + **self.effects.solution_structured(mode=mode), 'Objective': self.objective.value, } - def save_to_netcdf(self, path: Union[str, pathlib.Path] = 'flow_system.nc'): + def to_netcdf(self, path: Union[str, pathlib.Path] = 'flow_system.nc'): """ Save the flow system to a netcdf file. """ ds = self.solution ds = ds.rename_vars({var: var.replace('/', '-slash-') for var in ds.data_vars}) - ds.attrs["structure"] = json.dumps(self.solution_structured(only_structure=True)) # Convert dict to JSON string + ds.attrs["structure"] = json.dumps(self.solution_structured(mode='structure')) # Convert dict to JSON string ds.to_netcdf(path) @staticmethod - def load_from_netcdf(path: Union[str, pathlib.Path] = 'flow_system.nc') -> Dict[str, Union[str, Dict, xr.DataArray]]: + def from_netcdf(path: Union[str, pathlib.Path] = 'flow_system.nc') -> Dict[str, Union[str, Dict, xr.DataArray]]: results = xr.open_dataset(path) return { **SystemModel._insert_dataarrays(results, json.loads(results.attrs['structure'])), @@ -418,26 +418,28 @@ def filter_variables(self, def solution_structured( self, - use_numpy: bool = True, - only_structure: bool = False + mode: Literal['py', 'numpy', 'xarray', 'structure'] = 'numpy', ) -> Dict[str, Union[np.ndarray, Dict]]: """ Return the structure of the SystemModel solution. Parameters ---------- - use_numpy : bool, optional - Whether to return the solution as a dictionary of numpy arrays or dictionaries, by default True + mode : Literal['py', 'numpy', 'xarray', 'structure'] + Whether to return the solution as a dictionary of + - python native types (for json) + - numpy arrays + - xarray.DataArrays + - strings (for structure, storing variable names) """ - # TODO: The main functionality is to return the structure. The numeric solutions are used for the old json export results = { - self._variables_short[var_name]: var.values if not only_structure else f':::{var_name}' + self._variables_short[var_name]: utils.convert_dataarray(var, mode) for var_name, var in self.variables_direct.solution.data_vars.items() } for sub_model in self.sub_models: - sub_solution = sub_model.solution_structured(use_numpy, only_structure) + sub_solution = sub_model.solution_structured(mode) if sub_solution == {}: # If the submodel has no variables, skip it continue if sub_model.label_full == self.label_full: diff --git a/flixOpt/utils.py b/flixOpt/utils.py index fb784c7c6..1c26dd69e 100644 --- a/flixOpt/utils.py +++ b/flixOpt/utils.py @@ -6,6 +6,7 @@ from typing import Any, Dict, List, Literal, Optional, Union import numpy as np +import xarray as xr logger = logging.getLogger('flixOpt') @@ -134,3 +135,31 @@ def convert_list_to_array_if_numeric(sequence: Union[List[Any], tuple]) -> Union return convert_list_to_array_if_numeric(d) else: return d + + +def convert_dataarray(data: xr.DataArray, mode: Literal['py', 'numpy', 'xarray', 'structure']) -> Union[List, np.ndarray, xr.DataArray, str]: + """ + Convert a DataArray to a different format. + + Parameters + ---------- + data : xr.DataArray + The data to convert. + mode : Literal['py', 'numpy', 'xarray', 'structure'] + Whether to return the dataaray to + - python native types (for json) + - numpy array + - xarray.DataArray + - strings (for structure, storing variable names) + """ + if mode == 'numpy': + return data.values + elif mode == 'py': + return data.values.tolist() + elif mode == 'xarray': + return data + elif mode == 'structure': + return f':::{data.name}' + else: + raise ValueError(f'Unknown mode {mode}') + From e1cac663e07c2592afc6fb99fccc28a740045e6b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 19 Feb 2025 17:11:23 +0100 Subject: [PATCH 143/507] Small Bugfix --- flixOpt/components.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixOpt/components.py b/flixOpt/components.py index b9e1bb4fd..6bbd78cd1 100644 --- a/flixOpt/components.py +++ b/flixOpt/components.py @@ -417,7 +417,7 @@ def do_modeling(self, system_model: SystemModel): for flow in self.element.inputs + self.element.outputs } linear_segments = MultipleSegmentsModel( - self._model, self.label_of_parent, segments, self.on_off.on if self.on_off is not None else None + self._model, self.label_of_element, segments, self.on_off.on if self.on_off is not None else None ) # TODO: Add Outside_segments Variable (On) linear_segments.do_modeling(system_model) self.sub_models.append(linear_segments) From e23d060b0e2233d6cb8509a11fd08846e5f6fb41 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 19 Feb 2025 22:32:48 +0100 Subject: [PATCH 144/507] Re-add Solver classes --- flixOpt/calculation.py | 12 +- flixOpt/math_modeling.py | 323 +++++---------------------------------- flixOpt/solvers.py | 10 +- flixOpt/structure.py | 2 +- 4 files changed, 47 insertions(+), 300 deletions(-) diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py index f3bf79758..92a4408a2 100644 --- a/flixOpt/calculation.py +++ b/flixOpt/calculation.py @@ -27,7 +27,7 @@ from .elements import Component from .features import InvestmentModel from .flow_system import FlowSystem -from .solvers import Solver +from .solvers import _Solver from .structure import SystemModel, copy_and_convert_datatypes, get_compact_representation logger = logging.getLogger('flixOpt') @@ -159,10 +159,12 @@ def do_modeling(self) -> SystemModel: self.durations['modeling'] = round(timeit.default_timer() - t_start, 2) return self.flow_system.model - def solve(self, solver_name: str, save_results: Union[bool, str, pathlib.Path] = False, solver_options: dict = None): + def solve(self, solver: _Solver, save_results: Union[bool, str, pathlib.Path] = False, solver_options: Optional[dict] = None): self._define_path_names(save_results) t_start = timeit.default_timer() - self.flow_system.model.solve(log_fn=self._paths['log'], solver_name=solver_name, solver_options=solver_options) + self.flow_system.model.solve(log_fn=self._paths['log'], + solver_name=solver.name, + **solver.options) self.durations['solving'] = round(timeit.default_timer() - t_start, 2) # Log the formatted output @@ -294,7 +296,7 @@ def do_modeling(self) -> SystemModel: self.durations['modeling'] = round(timeit.default_timer() - t_start, 2) return self.system_model - def solve(self, solver: Solver, save_results: Union[bool, str, pathlib.Path] = False): + def solve(self, solver: _Solver, save_results: Union[bool, str, pathlib.Path] = False): self._define_path_names(save_results) t_start = timeit.default_timer() solver.logfile_name = self._paths['log'] @@ -366,7 +368,7 @@ def __init__( } self._transfered_start_values: Dict[str, Dict[str, Any]] = {} - def do_modeling_and_solve(self, solver: Solver, save_results: Union[bool, str, pathlib.Path] = True): + def do_modeling_and_solve(self, solver: _Solver, save_results: Union[bool, str, pathlib.Path] = True): logger.info(f'{"":#^80}') logger.info(f'{" Segmented Solving ":#^80}') self._define_path_names(save_results) diff --git a/flixOpt/math_modeling.py b/flixOpt/math_modeling.py index 2d4b141e7..5f1543c1e 100644 --- a/flixOpt/math_modeling.py +++ b/flixOpt/math_modeling.py @@ -5,12 +5,12 @@ and translate it into a ModelingLanguage like Pyomo, and the solve it through a solver. Multiple solvers are supported. """ - +from dataclasses import dataclass, field import logging import re import timeit from abc import ABC, abstractmethod -from typing import Any, Dict, List, Literal, Optional, Union +from typing import Any, Dict, List, Literal, Optional, Union, ClassVar import numpy as np from numpy import inf @@ -693,302 +693,53 @@ def parse_infos(self): raise Exception('SolverLog.parse_infos() is not defined for solver ' + self.solver_name) -class Solver(ABC): +@dataclass +class _Solver: """ Abstract base class for solvers. Attributes: mip_gap (float): Solver's mip gap setting. The MIP gap describes the accepted (MILP) objective, and the lower bound, which is the theoretically optimal solution (LP) - solver_output_to_console (bool): Whether to display solver output. logfile_name (str): Filename for saving the solver log. - objective (Optional[float]): Objective value from the solution. - best_bound (Optional[float]): Best bound from the solver. - termination_message (Optional[str]): Solver's termination message. """ + name: ClassVar[str] + mip_gap: float + time_limit_seconds: int + extra_options: Dict[str, Any] = field(default_factory=dict) - def __init__( - self, - mip_gap: float, - solver_output_to_console: bool, - logfile_name: str, - ): - self.mip_gap = mip_gap - self.solver_output_to_console = solver_output_to_console - self.logfile_name = logfile_name - - self.objective: Optional[float] = None - self.best_bound: Optional[float] = None - self.termination_message: Optional[str] = None - self.log: Optional[str, SolverLog] = None - - self._solver = None - self._results: Optional[float, str] = None - - @abstractmethod - def solve(self, modeling_language: 'ModelingLanguage'): - raise NotImplementedError(' Solving is not possible with this Abstract class') - - def __repr__(self): - return ( - f'{self.__class__.__name__}(' - f'mip_gap={self.mip_gap}, ' - f'solver_output_to_console={self.solver_output_to_console}, ' - f"logfile_name='{self.logfile_name}', " - f'objective={self.objective!r}, ' - f'best_bound={self.best_bound!r}, ' - f'termination_message={self.termination_message!r})' - ) - - -class GurobiSolver(Solver): - """ - Solver implementation for Gurobi. - Also Look in class Solver for more details - - Attributes: - time_limit_seconds (int): Time limit for the solver. After this time, the solver takes the currently - best solution, ignoring the mip_gap. - """ - - def __init__( - self, - mip_gap: float = 0.01, - time_limit_seconds: int = 300, - logfile_name: str = 'gurobi.log', - solver_output_to_console: bool = True, - ): - super().__init__(mip_gap, solver_output_to_console, logfile_name) - self.time_limit_seconds = time_limit_seconds - - def solve(self, modeling_language: 'ModelingLanguage'): - if isinstance(modeling_language, PyomoModel): - self._solver = pyo.SolverFactory('gurobi') - self._results = self._solver.solve( - modeling_language.model, - tee=self.solver_output_to_console, - keepfiles=True, - logfile=self.logfile_name, - options={'mipgap': self.mip_gap, 'TimeLimit': self.time_limit_seconds}, - ) - - self.objective = modeling_language.model.objective.expr() - self.termination_message = self._results.solver.termination_message - self.best_bound = self._results.problem.lower_bound - - from pyomo.opt import SolverStatus, TerminationCondition - - if not ( - self._results.solver.status == SolverStatus.ok - and self._results.solver.termination_condition == TerminationCondition.optimal - ): - logger.warning( - f'Solver ended with status {self._results.solver.status} and ' - f'termination condition {self._results.solver.termination_condition}' - ) - try: - self.log = SolverLog('gurobi', self.logfile_name) - except Exception as e: - self.log = None - logger.warning(f'SolverLog could not be loaded. {e}') - - try: - import gurobi_logtools - - self.log = gurobi_logtools.get_dataframe([str(self.logfile_name)]).T.to_dict()[0] - except ImportError: - logger.info( - 'Evaluationg the gurobi log after the solve was not possible, due to a missing dependency ' - '"gurobi_logtools". For further details of the solving process, ' - 'install the dependency via "pip install gurobi_logtools".' - ) - elif isinstance(modeling_language, LinopyModel): - status = modeling_language.model.solve( - log_fn=self.logfile_name, - solver_name='gurobi', - **{'mipgap': self.mip_gap, 'TimeLimit': self.time_limit_seconds} - ) - - self.objective = modeling_language.model.objective.value - self.termination_message = status[1] - self.best_bound = modeling_language.model.solver_model.ObjBound - else: - raise NotImplementedError('Only Pyomo and Linopy are implemented for GUROBI solver.') - - -class CplexSolver(Solver): - """ - Solver implementation for CPLEX. - Also Look in class Solver for more details - - Attributes: - time_limit_seconds (int): Time limit for the solver. After this time, the solver takes the currently - best solution, ignoring the mip_gap. - """ - - def __init__( - self, - mip_gap: float = 0.01, - time_limit_seconds: int = 300, - logfile_name: str = 'cplex.log', - solver_output_to_console: bool = True, - ): - super().__init__(mip_gap, solver_output_to_console, logfile_name) - self.time_limit_seconds = time_limit_seconds - - def solve(self, modeling_language: 'ModelingLanguage'): - if isinstance(modeling_language, PyomoModel): - self._solver = pyo.SolverFactory('cplex') - self._results = self._solver.solve( - modeling_language.model, - tee=self.solver_output_to_console, - keepfiles=True, - logfile=self.logfile_name, - options={'mipgap': self.mip_gap, 'timelimit': self.time_limit_seconds}, - ) - - self.objective = modeling_language.model.objective.expr() - self.termination_message: Optional[str] = f'Not Implemented for {self.__class__.__name__} yet' - self.best_bound = self._results['Problem'][0]['Lower bound'] - self.log = f'Not Implemented for {self.__class__.__name__} yet' - else: - raise NotImplementedError('Only Pyomo is implemented for CPLEX solver.') - - -class HighsSolver(Solver): - """ - Solver implementation for HIGHS. - Also Look in class Solver for more details - - Attributes: - time_limit_seconds (int): Time limit for the solver. After this time, the solver takes the currently - best solution, ignoring the mip_gap. - threads (int): Number of threads to use for the solver. - """ - - def __init__( - self, - mip_gap: float = 0.01, - time_limit_seconds: int = 300, - logfile_name: str = 'highs.log', - solver_output_to_console: bool = True, - threads: int = 4, - ): - super().__init__(mip_gap, solver_output_to_console, logfile_name) - self.time_limit_seconds = time_limit_seconds - self.threads = threads - - def solve(self, modeling_language: 'ModelingLanguage'): - if isinstance(modeling_language, PyomoModel): - from pyomo.contrib import appsi - - self._solver = appsi.solvers.Highs() - self._solver.highs_options = { - 'mip_rel_gap': self.mip_gap, - 'time_limit': self.time_limit_seconds, - 'log_file': str(self.logfile_name), - # "log_to_console": self.solver_output_to_console, - 'threads': self.threads, - 'parallel': 'on', - 'presolve': 'on', - 'output_flag': True, - } - self._solver.config.stream_solver = True - - self._results = self._solver.solve( - modeling_language.model - ) # HiGHS writes logs to stdout/stderr, so we capture them here - - self.objective = modeling_language.model.objective.expr() - self.termination_message: Optional[str] = self._results.termination_condition.name - if not self.termination_message == 'optimal': - logger.warning(f'Solution is not optimal. Termination Message: "{self.termination_message}"') - self.best_bound = self._results.best_objective_bound - self.log = f'Not Implemented for {self.__class__.__name__} yet' - elif isinstance(modeling_language, LinopyModel): - status = modeling_language.model.solve( - log_fn=self.logfile_name, - solver_name='highs', - **{'mip_rel_gap': self.mip_gap, 'time_limit': self.time_limit_seconds} - ) - - self.objective = modeling_language.model.objective.value - self.termination_message = status[1] - self.best_bound = None - else: - raise NotImplementedError('Only Pyomo and linopy are implemented for HIGHS solver.') - - -class CbcSolver(Solver): - """ - Solver implementation for CBC. - Also Look in class Solver for more details + @property + def options(self) -> Dict[str, Any]: + """Return a dictionary of solver options.""" + return {key: value for key, value in {**self._options, **self.extra_options}.items() if value is not None} - Attributes: - time_limit_seconds (int): Time limit for the solver. After this time, the solver takes the currently - best solution, ignoring the mip_gap. - """ + @property + def _options(self) -> Dict[str, Any]: + """Return a dictionary of solver options, translated to the solver's API.""" + raise NotImplementedError - def __init__( - self, - mip_gap: float = 0.01, - time_limit_seconds: int = 300, - logfile_name: str = 'cbc.log', - solver_output_to_console: bool = True, - ): - super().__init__(mip_gap, solver_output_to_console, logfile_name) - self.time_limit_seconds = time_limit_seconds - - def solve(self, modeling_language: 'ModelingLanguage'): - if isinstance(modeling_language, PyomoModel): - self._solver = pyo.SolverFactory('cbc') - self._results = self._solver.solve( - modeling_language.model, - tee=self.solver_output_to_console, - keepfiles=True, - logfile=self.logfile_name, - options={'ratio': self.mip_gap, 'sec': self.time_limit_seconds}, - ) - self.objective = modeling_language.model.objective.expr() - self.termination_message: Optional[str] = f'Not Implemented for {self.__class__.__name__} yet' - self.best_bound = self._results['Problem'][0]['Lower bound'] - self.log = f'Not Implemented for {self.__class__.__name__} yet' - else: - raise NotImplementedError('Only Pyomo is implemented for Cbc solver.') +class GurobiSolver(_Solver): + name: ClassVar[str] = 'gurobi' -class GlpkSolver(Solver): - """Solver implementation for Glpk. Also Look in class Solver for more details""" + @property + def _options(self) -> Dict[str, Any]: + return { + 'MIPGap': self.mip_gap, + 'TimeLimit': self.time_limit_seconds, + } - def __init__( - self, - mip_gap: float = 0.01, - logfile_name: str = 'glpk.log', - solver_output_to_console: bool = True, - ): - super().__init__(mip_gap, solver_output_to_console, logfile_name) - - def solve(self, modeling_language: 'ModelingLanguage'): - if isinstance(modeling_language, PyomoModel): - self._solver = pyo.SolverFactory('glpk') - self._results = self._solver.solve( - modeling_language.model, - tee=self.solver_output_to_console, - keepfiles=True, - logfile=self.logfile_name, - options={'mipgap': self.mip_gap}, - ) +class HighsSolver(_Solver): + threads: Optional[int] = None + name: ClassVar[str] = 'highs' - self.objective = modeling_language.model.objective.expr() - self.termination_message = self._results['Solver'][0]['Status'] - self.best_bound = self._results['Problem'][0]['Lower bound'] - try: - self.log = SolverLog('glpk', self.logfile_name) - except Exception as e: - self.log = None - logger.warning(f'SolverLog could not be loaded. {e}') - else: - raise NotImplementedError('Only Pyomo is implemented for Cbc solver.') + @property + def _options(self) -> Dict[str, Any]: + return { + 'mip_gap': self.mip_gap, + 'time_limit': self.time_limit_seconds, + 'threads': self.threads, + } class ModelingLanguage(ABC): @@ -1003,7 +754,7 @@ class ModelingLanguage(ABC): def translate_model(self, model: MathModel): raise NotImplementedError - def solve(self, math_model: MathModel, solver: Solver): + def solve(self, math_model: MathModel, solver: _Solver): raise NotImplementedError @@ -1029,7 +780,7 @@ def __init__(self): self.mapping: Dict[Union[Variable, Equation], Any] = {} # Mapping to Pyomo Units self._counter = 0 - def solve(self, math_model: MathModel, solver: Solver): + def solve(self, math_model: MathModel, solver: _Solver): if self._counter == 0: raise Exception(' First, call .translate_model(). Else PyomoModel cant solve()') solver.solve(self) @@ -1192,7 +943,7 @@ def __init__(self): self.model = linopy.Model() self.mapping: Dict[Variable, linopy.Variable] = {} - def solve(self, math_model: MathModel, solver: Solver): + def solve(self, math_model: MathModel, solver: _Solver): solver.solve(self) # write results diff --git a/flixOpt/solvers.py b/flixOpt/solvers.py index 5ca4b5945..62ba51c88 100644 --- a/flixOpt/solvers.py +++ b/flixOpt/solvers.py @@ -3,19 +3,13 @@ """ from .math_modeling import ( - CbcSolver, - CplexSolver, - GlpkSolver, GurobiSolver, HighsSolver, - Solver, + _Solver, ) __all__ = [ - 'Solver', + '_Solver', 'HighsSolver', 'GurobiSolver', - 'CbcSolver', - 'CplexSolver', - 'GlpkSolver', ] diff --git a/flixOpt/structure.py b/flixOpt/structure.py index 2b5e041e9..92447830c 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -21,7 +21,7 @@ from . import utils from .config import CONFIG from .core import Numeric, Numeric_TS, Skalar, TimeSeries, TimeSeriesData -from .math_modeling import Equation, Inequation, MathModel, Solver, Variable, VariableTS +from .math_modeling import Equation, Inequation, MathModel, _Solver, Variable, VariableTS if TYPE_CHECKING: # for type checking and preventing circular imports from .elements import BusModel, ComponentModel From 1b46f3ddd4570ccda3e32da0963fe22f81fa292d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 21 Feb 2025 09:16:07 +0100 Subject: [PATCH 145/507] Fixes from renaming variables --- examples/01_Simple/simple_example.py | 2 +- .../03_Calculation_types/example_calculation_types.py | 2 +- flixOpt/calculation.py | 4 ++-- tests/test_integration.py | 8 ++++---- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/examples/01_Simple/simple_example.py b/examples/01_Simple/simple_example.py index a8b841b70..d67f98284 100644 --- a/examples/01_Simple/simple_example.py +++ b/examples/01_Simple/simple_example.py @@ -104,7 +104,7 @@ calculation.do_modeling() # Translate the model to a solvable form, creating equations and Variables # --- Solve the Calculation and Save Results --- - calculation.solve('highs', save_results=True) + calculation.solve(fx.solvers.HighsSolver(mip_gap=0, time_limit_seconds=30), save_results=True) # --- Load and Analyze Results --- # Load the results and plot the operation of the District Heating Bus diff --git a/examples/03_Calculation_types/example_calculation_types.py b/examples/03_Calculation_types/example_calculation_types.py index 0cffbfcaa..f96aaba6d 100644 --- a/examples/03_Calculation_types/example_calculation_types.py +++ b/examples/03_Calculation_types/example_calculation_types.py @@ -36,7 +36,7 @@ # filtered_data = data_import[0:500] # Alternatively filter by index filtered_data.index = pd.to_datetime(filtered_data.index) - datetime_series = np.array(filtered_data.index).astype('datetime64') + datetime_series = filtered_data.index # Access specific columns and convert to 1D-numpy array electricity_demand = filtered_data['P_Netz/MW'].to_numpy() diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py index 92a4408a2..63c3c5670 100644 --- a/flixOpt/calculation.py +++ b/flixOpt/calculation.py @@ -42,7 +42,7 @@ def __init__( self, name: str, flow_system: FlowSystem, - active_timesteps: Optional[Union[List[int], pd.DatetimeIndex]] = None, + active_timesteps: Optional[Union[List[int], pd.DatetimeIndex]] = None, ): """ Parameters @@ -149,7 +149,7 @@ def do_modeling(self) -> SystemModel: self.flow_system.transform_data() for time_series in self.flow_system.all_time_series: - pass # TODO: This must work for timeseriews that are always one step longer + pass # TODO: This must work for timeseries that are always one step longer # time_series.active_periods = self.flow_system.periods #time_series.active_timesteps = self.flow_system.timesteps diff --git a/tests/test_integration.py b/tests/test_integration.py index 77ba9bcec..eba228cc9 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -17,7 +17,7 @@ def setUp(self): fx.change_logging_level('DEBUG') def get_solver(self): - return 'highs' + return fx.solvers.HighsSolver(mip_gap=0, time_limit_seconds=300) def assert_almost_equal_numeric( self, actual, desired, err_msg, relative_error_range_in_percent=0.011, absolute_tolerance=1e-9 @@ -362,7 +362,7 @@ def test_basic(self): 'costs doesnt match expected value', ) self.assert_almost_equal_numeric( - sum(effects['costs'].model.operation.shares['P_el (Einspeisung)'].solution.values), + sum(effects['costs'].model.operation.shares['Einspeisung (P_el)'].solution.values), -14196.61245231646, 'costs doesnt match expected value', ) @@ -439,7 +439,7 @@ def test_basic(self): ) self.assert_almost_equal_numeric( - comps['Speicher'].model.all_variables['Speicher__SegmentedShares__costs'].solution.values, + comps['Speicher'].model.variables['Speicher|SegmentedShares|costs'].solution.values, 800, 'Speicher investCosts_segmented_costs doesnt match expected value', ) @@ -480,7 +480,7 @@ def test_segments_of_flows(self): ) self.assert_almost_equal_numeric( - comps['Speicher'].model.all_variables['Speicher__SegmentedShares__costs'].solution.values, + comps['Speicher'].model.variables['Speicher|SegmentedShares|costs'].solution.values, 454.74666666666667, 'Speicher investCosts_segmented_costs doesnt match expected value', ) From 6a3681df689169bb20143b1e0bd9a558a0a1d630 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 21 Feb 2025 13:17:04 +0100 Subject: [PATCH 146/507] Bugfix in StorageModel --- flixOpt/components.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixOpt/components.py b/flixOpt/components.py index 6bbd78cd1..b1ecb928e 100644 --- a/flixOpt/components.py +++ b/flixOpt/components.py @@ -465,7 +465,7 @@ def do_modeling(self, system_model): self.add(self._model.add_constraints( charge_state.isel(time=slice(1, None)) == - charge_state.isel(time=slice(None, -1)) * (1 - rel_loss) * hours_per_step + charge_state.isel(time=slice(None, -1)) * (1 - rel_loss * hours_per_step) + charge_rate * eff_charge * hours_per_step - discharge_rate * eff_discharge * hours_per_step, name=f'{self.label_full}|charge_state'), From 8404360ec1a7fa1192e7b5cff6433947c4a3612c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 21 Feb 2025 09:17:07 +0100 Subject: [PATCH 147/507] Add aggregation_group to TimeSeries --- flixOpt/aggregation.py | 80 ++++++++++++++++++++++-------------------- flixOpt/calculation.py | 20 +++++------ flixOpt/core.py | 12 +++++-- 3 files changed, 58 insertions(+), 54 deletions(-) diff --git a/flixOpt/aggregation.py b/flixOpt/aggregation.py index 318ea0e7f..0017fcd9a 100644 --- a/flixOpt/aggregation.py +++ b/flixOpt/aggregation.py @@ -15,7 +15,7 @@ import tsam.timeseriesaggregation as tsam from .components import Storage -from .core import Skalar, TimeSeries, TimeSeriesData +from .core import Skalar, TimeSeries, TimeSeriesData, DataConverter from .elements import Component from .flow_system import FlowSystem from .math_modeling import Equation, Variable, VariableTS @@ -210,58 +210,60 @@ def get_equation_indices(self, skip_first_index_of_period: bool = True) -> Tuple class TimeSeriesCollection: - def __init__(self, time_series_list: List[TimeSeries]): - self.time_series_list = time_series_list - self.group_weights: Dict[str, float] = {} - self._unique_labels() - self._calculate_aggregation_weigths() - self.weights: Dict[str, float] = { - time_series.label: time_series.aggregation_weight for time_series in self.time_series_list - } - self.data: Dict[str, np.ndarray] = { - time_series.label: time_series.active_data for time_series in self.time_series_list - } + def __init__(self, *time_series): + self.time_serieses: List[TimeSeries] = list(time_series) + self._check_unique_labels() + self.group_weights = self._calculate_group_weights() + self.weights = self._calculate_aggregation_weights() if np.all(np.isclose(list(self.weights.values()), 1, atol=1e-6)): logger.info('All Aggregation weights were set to 1') - def _calculate_aggregation_weigths(self): - """Calculates the aggergation weights of all TimeSeries. Necessary to use groups""" + def insert_data(self, data: pd.DataFrame): + for time_series in self.time_serieses: + if time_series.name in data.columns: + time_series.stored_data = DataConverter.as_dataarray( + data[time_series.name], + time_series.active_timesteps, + time_series.active_periods + ) + logger.debug(f'Inserted data for {time_series.name}') + + def description(self) -> str: + # TODO: + result = f'{len(self.time_serieses)} TimeSeries used for aggregation:\n' + for time_series in self.time_serieses: + result += f' -> {time_series.name} (weight: {self.weights[time_series.name]:.4f}; group: "{time_series.aggregation_group}")\n' + if self.group_weights: + result += f'Aggregation_Groups: {list(self.group_weights.keys())}\n' + else: + result += 'Warning!: no agg_types defined, i.e. all TS have weight 1 (or explicitly given weight)!\n' + return result + + def _calculate_group_weights(self) -> Dict[str, float]: + """Calculates the aggregation weights of each group""" groups = [ time_series.aggregation_group - for time_series in self.time_series_list + for time_series in self.time_serieses if time_series.aggregation_group is not None ] group_size = dict(Counter(groups)) - self.group_weights = {group: 1 / size for group, size in group_size.items()} - for time_series in self.time_series_list: - time_series.aggregation_weight = self.group_weights.get( - time_series.aggregation_group, time_series.aggregation_weight or 1 - ) + group_weights = {group: 1 / size for group, size in group_size.items()} + return group_weights + + def _calculate_aggregation_weights(self) -> Dict[str, float]: + """Calculates the aggregation weight for each TimeSeries. Default is 1""" + return { + time_series.name: self.group_weights.get(time_series.aggregation_group, time_series.aggregation_weight or 1) + for time_series in self.time_serieses + } - def _unique_labels(self): + def _check_unique_labels(self): """Makes sure every label of the TimeSeries in time_series_list is unique""" - label_counts = Counter([time_series.label for time_series in self.time_series_list]) + label_counts = Counter([time_series.name for time_series in self.time_serieses]) duplicates = [label for label, count in label_counts.items() if count > 1] assert duplicates == [], 'Duplicate TimeSeries labels found: {}.'.format(', '.join(duplicates)) - def insert_data(self, data: Dict[str, np.ndarray]): - for time_series in self.time_series_list: - if time_series.label in data: - time_series.aggregated_data = data[time_series.label] - logger.debug(f'Inserted data for {time_series.label}') - - def description(self) -> str: - # TODO: - result = f'{len(self.time_series_list)} TimeSeries used for aggregation:\n' - for time_series in self.time_series_list: - result += f' -> {time_series.label} (weight: {time_series.aggregation_weight:.4f}; group: "{time_series.aggregation_group}")\n' - if self.group_weights: - result += f'Aggregation_Groups: {list(self.group_weights.keys())}\n' - else: - result += 'Warning!: no agg_types defined, i.e. all TS have weight 1 (or explicitly given weight)!\n' - return result - class AggregationParameters: def __init__( diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py index 63c3c5670..e80250013 100644 --- a/flixOpt/calculation.py +++ b/flixOpt/calculation.py @@ -184,12 +184,11 @@ class for defined way of solving a flow_system optimization def __init__( self, - name, + name: str, flow_system: FlowSystem, aggregation_parameters: AggregationParameters, components_to_clusterize: Optional[List[Component]] = None, - modeling_language: Literal['pyomo', 'linopy'] = 'pyomo', - time_indices: Optional[Union[range, List[int]]] = None, + active_timesteps: Optional[Union[List[int], pd.DatetimeIndex]] = None, ): """ Class for Optimizing the FLowSystem including: @@ -212,7 +211,7 @@ def __init__( time_indices : List[int] or None list with indices, which should be used for calculation. If None, then all timesteps are used. """ - super().__init__(name, flow_system, modeling_language, time_indices) + super().__init__(name, flow_system, active_timesteps) self.aggregation_parameters = aggregation_parameters self.components_to_clusterize = components_to_clusterize self.time_series_for_aggregation = None @@ -222,28 +221,25 @@ def __init__( def do_modeling(self) -> SystemModel: self.flow_system.transform_data() for time_series in self.flow_system.all_time_series: - time_series.activate_indices(self.time_indices) + pass #TODO: This must work for timeseries that are always one step longer + #time_series.activate_indices(self.time_indices) from .aggregation import Aggregation - (chosen_time_series, chosen_time_series_with_end, dt_in_hours, dt_in_hours_total) = ( - self.flow_system.get_time_data_from_indices(self.time_indices) - ) - t_start_agg = timeit.default_timer() # Validation - dt_min, dt_max = np.min(dt_in_hours), np.max(dt_in_hours) + dt_min, dt_max = np.min(self.flow_system.hours_per_step), np.max(self.flow_system.hours_per_step) 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 / dt_in_hours[0] + steps_per_period = self.aggregation_parameters.hours_per_period / self.flow_system.hours_per_step.max() if not steps_per_period.is_integer(): raise Exception( f'The selected {self.aggregation_parameters.hours_per_period=} does not match the time ' - f'step size of {dt_in_hours[0]} hours). It must be a multiple of {dt_in_hours[0]} hours.' + f'step size of {dt_min} hours). It must be a multiple of {dt_min} hours.' ) logger.info(f'{"":#^80}') diff --git a/flixOpt/core.py b/flixOpt/core.py index 42e2da7c6..ffc4cb4a0 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -166,7 +166,9 @@ def from_datasource(cls, name: str, timesteps: pd.DatetimeIndex = None, periods: Optional[pd.Index] = None, - aggregation_weight: Optional[float] = None) -> 'TimeSeries': + aggregation_weight: Optional[float] = None, + aggregation_group: Optional[str] = None + ) -> 'TimeSeries': """ Initialize the TimeSeries from multiple datasources. @@ -174,20 +176,23 @@ def from_datasource(cls, - data (pd.Series): A Series with a DatetimeIndex and possibly a MultiIndex. - dims (Tuple[pd.Index, ...]): The dimensions of the TimeSeries. - aggregation_weight (float, optional): The weight of the data in the aggregation. Defaults to None. + - aggregation_group (str, optional): The group this TimeSeries is a part of. agg_weight is split between members of a group. Default is None. """ - data = cls(DataConverter.as_dataarray(data, timesteps, periods), name, aggregation_weight) + data = cls(DataConverter.as_dataarray(data, timesteps, periods), name, aggregation_weight, aggregation_group) return data def __init__(self, data: xr.DataArray, name: str, - aggregation_weight: Optional[float] = None): + aggregation_weight: Optional[float] = None, + aggregation_group: Optional[str] = None): """ Initialize a TimeSeries with a DataArray. Parameters: - data (xr.DataArray): A Series with a DatetimeIndex and possibly a MultiIndex. - aggregation_weight (float, optional): The weight of the data in the aggregation. Defaults to None. + - aggregation_group (str, optional): The group this TimeSeries is a part of. agg_weight is split between members of a group. Default is None. """ if 'time' not in data.indexes: raise ValueError(f'DataArray must have a "time" index. Got {data.indexes}') @@ -196,6 +201,7 @@ def __init__(self, self.name = name self.aggregation_weight = aggregation_weight + self.aggregation_group = aggregation_group self._stored_data = data.copy() self._backup: xr.DataArray = self.stored_data # Single backup instance. Enables to temporarily overwrite the data. From 1d09fd60415d260750d4459da67190ec63316ee1 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 21 Feb 2025 10:15:56 +0100 Subject: [PATCH 148/507] First commit changing aggregation.py --- flixOpt/aggregation.py | 84 ++++++++++++++++++++++++------------------ 1 file changed, 49 insertions(+), 35 deletions(-) diff --git a/flixOpt/aggregation.py b/flixOpt/aggregation.py index 0017fcd9a..7536b9dab 100644 --- a/flixOpt/aggregation.py +++ b/flixOpt/aggregation.py @@ -10,6 +10,7 @@ from collections import Counter from typing import TYPE_CHECKING, Dict, List, Optional, Tuple +import linopy import numpy as np import pandas as pd import tsam.timeseriesaggregation as tsam @@ -21,7 +22,7 @@ from .math_modeling import Equation, Variable, VariableTS from .structure import ( Element, - ElementModel, + Model, SystemModel, create_equation, create_variable, @@ -330,13 +331,14 @@ def use_low_peaks(self): return self.time_series_for_low_peaks is not None -class AggregationModel(ElementModel): +class AggregationModel(Model): """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""" def __init__( self, + model: SystemModel, aggregation_parameters: AggregationParameters, flow_system: FlowSystem, aggregation_data: Aggregation, @@ -345,7 +347,7 @@ def __init__( """ Modeling-Element for "index-equating"-equations """ - super().__init__(Element('Aggregation'), 'Model') + super().__init__(model, label_of_element='No Element', label_full='Aggregation') self.flow_system = flow_system self.aggregation_parameters = aggregation_parameters self.aggregation_data = aggregation_data @@ -359,66 +361,78 @@ def do_modeling(self, system_model: SystemModel): indices = self.aggregation_data.get_equation_indices(skip_first_index_of_period=True) + time_variables: List[str] = [k for k, v in self._model.variables.data.items() if 'time' in v.indexes] + binary_time_variables: List[str] = [ + k for k, v in self._model.variables.data.items() if 'time' in v.indexes and k in self._model.binaries + ] + for component in components: if isinstance(component, Storage) and not self.aggregation_parameters.fix_storage_flows: continue # Fix Nothing in The Storage all_variables_of_component = component.model.variables if self.aggregation_parameters.aggregate_data_and_fix_non_binary_vars: - all_relevant_variables = [v for v in all_variables_of_component.values() if isinstance(v, VariableTS)] + + relevant_variables = all_variables_of_component[v for v in all_variables_of_component if v in self._model.] else: - all_relevant_variables = [ + relevant_variables = [ v for v in all_variables_of_component.values() if isinstance(v, VariableTS) and v.is_binary ] - for variable in all_relevant_variables: - self.equate_indices(variable, indices, system_model) + for variable in relevant_variables: + self.equate_indices(variable, indices) penalty = self.aggregation_parameters.penalty_of_period_freedom if (self.aggregation_parameters.percentage_of_period_freedom > 0) and penalty != 0: for label, variable in self.variables_direct.items(): - system_model.effect_collection_model.add_share_to_penalty( - f'Aggregation_penalty__{label}', variable, penalty + system_model.effects.add_share_to_penalty( + system_model, + f'Aggregation_penalty__{label}', + variable * penalty ) - def equate_indices( - self, variable: Variable, indices: Tuple[np.ndarray, np.ndarray], system_model: SystemModel - ) -> Equation: - # Gleichung: - # eq1: x(p1,t) - x(p3,t) = 0 # wobei p1 und p3 im gleichen Cluster sind und t = 0..N_p - length = len(indices[0]) + def equate_indices(self, variable: linopy.Variable, indices: Tuple[np.ndarray, np.ndarray]) -> None: assert len(indices[0]) == len(indices[1]), 'The length of the indices must match!!' + length = len(indices[0]) - eq = create_equation(f'Equate_indices_of_{variable.label}', self) - eq.add_summand(variable, 1, indices_of_variable=indices[0]) - eq.add_summand(variable, -1, indices_of_variable=indices[1]) + # Gleichung: + # eq1: x(p1,t) - x(p3,t) = 0 # wobei p1 und p3 im gleichen Cluster sind und t = 0..N_p + con = self.add(self._model.add_constraints( + variable.isel(time=indices[0]) - variable.isel(time=indices[1]) == 0, + name=f'Equate_indices_of_{variable.name}'), + variable.name) # Korrektur: (bisher nur für Binärvariablen:) - if variable.is_binary and self.aggregation_parameters.percentage_of_period_freedom > 0: - # correction-vars (so viele wie Indexe in eq:) - var_k1 = create_variable(f'Korr1_{variable.label}', self, length, is_binary=True) - var_k0 = create_variable(f'Korr0_{variable.label}', self, length, is_binary=True) + if variable in self._model.binaries and self.aggregation_parameters.percentage_of_period_freedom > 0: + var_k1 = self.add(self._model.add_variables( + binary=True, + coords={'time': variable.isel(time=indices[0]).indexes['time']}, + name=f'{self.label_full}|Korr1|{variable.name}'), f'Korr1|{variable.name}') + + var_k0 = self.add(self._model.add_variables( + binary=True, + coords={'time': variable.isel(time=indices[0]).indexes['time']}, + name=f'{self.label_full}|Korr0|{variable.name}'), f'Korr0|{variable.name}') + # equation extends ... # --> On(p3) can be 0/1 independent of On(p1,t)! # eq1: On(p1,t) - On(p3,t) + K1(p3,t) - K0(p3,t) = 0 # --> correction On(p3) can be: # On(p1,t) = 1 -> On(p3) can be 0 -> K0=1 (,K1=0) # On(p1,t) = 0 -> On(p3) can be 1 -> K1=1 (,K0=1) - eq.add_summand(var_k1, +1) - eq.add_summand(var_k0, -1) + con.lhs += 1 * var_k1 - 1 * var_k0 # interlock var_k1 and var_K2: # eq: var_k0(t)+var_k1(t) <= 1.1 - eq_lock = create_equation(f'lock_K0andK1_{variable.label}', self, eq_type='ineq') - eq_lock.add_summand(var_k0, 1) - eq_lock.add_summand(var_k1, 1) - eq_lock.add_constant(1.1) + self.add(self._model.add_constraints( + var_k0 + var_k1 <= 1.1, + name=f'{self.label_full}|lock_K0andK1|{variable.name}'), + f'lock_K0andK1|{variable.name}' + ) # Begrenzung der Korrektur-Anzahl: # eq: sum(K) <= n_Corr_max - eq_max = create_equation(f'Nr_of_Corrections_{variable.label}', self, eq_type='ineq') - eq_max.add_summand(var_k1, 1, as_sum=True) - eq_max.add_summand(var_k0, 1, as_sum=True) - eq_max.add_constant( - round(self.aggregation_parameters.percentage_of_period_freedom / 100 * var_k1.length) - ) # Maximum - return eq + self.add(self._model.add_constraints( + sum(var_k0) + sum(var_k1) <= round(self.aggregation_parameters.percentage_of_period_freedom / 100 * length), + name=f'{self.label_full}|Nr_of_Corrections|{variable.name}'), + f'Nr_of_Corrections|{variable.name}' + ) From d5885fe0d33a6956bd5ae7a839f68cd2820910e4 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 21 Feb 2025 12:29:52 +0100 Subject: [PATCH 149/507] BUGFIX: Ensure unique names --- flixOpt/components.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flixOpt/components.py b/flixOpt/components.py index b1ecb928e..9c38cde26 100644 --- a/flixOpt/components.py +++ b/flixOpt/components.py @@ -115,10 +115,10 @@ def transform_data(self, flow_system: 'FlowSystem'): def _transform_conversion_factors(self, flow_system: 'FlowSystem') -> List[Dict[Flow, TimeSeries]]: """macht alle Faktoren, die nicht TimeSeries sind, zu TimeSeries""" list_of_conversion_factors = [] - for conversion_factor in self.conversion_factors: + for idx, conversion_factor in enumerate(self.conversion_factors): transformed_dict = {} for flow, values in conversion_factor.items(): - transformed_dict[flow] = self._create_time_series(f'{flow.label}_factor', values, flow_system.timesteps, flow_system.periods) + transformed_dict[flow] = self._create_time_series(f'{flow.label}_factor{idx}', values, flow_system.timesteps, flow_system.periods) list_of_conversion_factors.append(transformed_dict) return list_of_conversion_factors From be6fbc5d7308e66294cc233ead47e011c83d2a2d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 21 Feb 2025 12:30:07 +0100 Subject: [PATCH 150/507] Add check for all equal in TImeSeries --- flixOpt/core.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/flixOpt/core.py b/flixOpt/core.py index ffc4cb4a0..ffcec8f6f 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -224,6 +224,11 @@ def _update_active_data(self): else: self._active_data = self._stored_data.sel(time=self.active_timesteps) + @property + def all_equal(self) -> bool: + """ Checks for all values in the being equal""" + return np.unique(self.active_data.values).size == 1 + @property def active_timesteps(self) -> pd.DatetimeIndex: """Return the current active index.""" From 7305b8ff06cba8b642722c230047168da63c6c43 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 21 Feb 2025 12:30:24 +0100 Subject: [PATCH 151/507] Improve Aggregated calculation --- flixOpt/aggregation.py | 52 +++++++++++++++++++++++------------------- flixOpt/calculation.py | 52 +++++++++++++++++++----------------------- 2 files changed, 52 insertions(+), 52 deletions(-) diff --git a/flixOpt/aggregation.py b/flixOpt/aggregation.py index 7536b9dab..78e78288b 100644 --- a/flixOpt/aggregation.py +++ b/flixOpt/aggregation.py @@ -8,7 +8,7 @@ import timeit import warnings from collections import Counter -from typing import TYPE_CHECKING, Dict, List, Optional, Tuple +from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Set import linopy import numpy as np @@ -52,11 +52,6 @@ def __init__( ): """ Write a docstring please - - Parameters - ---------- - timeseries: pd.DataFrame - timeseries of the data with a datetime index """ self.original_data = copy.deepcopy(original_data) self.hours_per_time_step = hours_per_time_step @@ -86,7 +81,7 @@ def cluster(self) -> None: extremePeriodMethod='new_cluster_center' if self.use_extreme_periods else 'None', # Wenn Extremperioden eingebunden werden sollen, nutze die Methode 'new_cluster_center' aus tsam - weightDict=self.weights, + weightDict={name: weight for name, weight in self.weights.items() if name in self.original_data.columns}, addPeakMax=self.time_series_for_high_peaks, addPeakMin=self.time_series_for_low_peaks, ) @@ -230,6 +225,20 @@ def insert_data(self, data: pd.DataFrame): ) logger.debug(f'Inserted data for {time_series.name}') + def to_dataframe(self, with_constant_data: bool = False): + if with_constant_data: + return pd.concat([time_series.active_data.to_dataframe(time_series.name) + for time_series in self.time_serieses], + axis=1) + + return pd.concat([time_series.active_data.to_dataframe(time_series.name) + for time_series in self.time_serieses_non_constant], + axis=1) + + @property + def time_serieses_non_constant(self) -> List[TimeSeries]: + return [time_series for time_series in self.time_serieses if not time_series.all_equal] + def description(self) -> str: # TODO: result = f'{len(self.time_serieses)} TimeSeries used for aggregation:\n' @@ -353,7 +362,7 @@ def __init__( self.aggregation_data = aggregation_data self.components_to_clusterize = components_to_clusterize - def do_modeling(self, system_model: SystemModel): + def do_modeling(self): if not self.components_to_clusterize: components = self.flow_system.components.values() else: @@ -361,36 +370,33 @@ def do_modeling(self, system_model: SystemModel): indices = self.aggregation_data.get_equation_indices(skip_first_index_of_period=True) - time_variables: List[str] = [k for k, v in self._model.variables.data.items() if 'time' in v.indexes] - binary_time_variables: List[str] = [ - k for k, v in self._model.variables.data.items() if 'time' in v.indexes and k in self._model.binaries - ] + time_variables: Set[str] = {k for k, v in self._model.variables.data.items() if 'time' in v.indexes} + binary_variables: Set[str] = {k for k, v in self._model.variables.data.items() if k in self._model.binaries} + binary_time_variables: Set[str] = time_variables & binary_variables for component in components: if isinstance(component, Storage) and not self.aggregation_parameters.fix_storage_flows: continue # Fix Nothing in The Storage - all_variables_of_component = component.model.variables - if self.aggregation_parameters.aggregate_data_and_fix_non_binary_vars: + all_variables_of_component = set(component.model.variables) - relevant_variables = all_variables_of_component[v for v in all_variables_of_component if v in self._model.] + if self.aggregation_parameters.aggregate_data_and_fix_non_binary_vars: + relevant_variables = component.model.variables[all_variables_of_component & time_variables] else: - relevant_variables = [ - v for v in all_variables_of_component.values() if isinstance(v, VariableTS) and v.is_binary - ] + relevant_variables = component.model.variables[all_variables_of_component & binary_time_variables] for variable in relevant_variables: - self.equate_indices(variable, indices) + self._equate_indices(component.model.variables[variable], indices) penalty = self.aggregation_parameters.penalty_of_period_freedom if (self.aggregation_parameters.percentage_of_period_freedom > 0) and penalty != 0: for label, variable in self.variables_direct.items(): - system_model.effects.add_share_to_penalty( - system_model, + self._model.effects.add_share_to_penalty( + self._model, f'Aggregation_penalty__{label}', variable * penalty ) - def equate_indices(self, variable: linopy.Variable, indices: Tuple[np.ndarray, np.ndarray]) -> None: + def _equate_indices(self, variable: linopy.Variable, indices: Tuple[np.ndarray, np.ndarray]) -> None: assert len(indices[0]) == len(indices[1]), 'The length of the indices must match!!' length = len(indices[0]) @@ -402,7 +408,7 @@ def equate_indices(self, variable: linopy.Variable, indices: Tuple[np.ndarray, n variable.name) # Korrektur: (bisher nur für Binärvariablen:) - if variable in self._model.binaries and self.aggregation_parameters.percentage_of_period_freedom > 0: + if variable.name in self._model.variables.binaries and self.aggregation_parameters.percentage_of_period_freedom > 0: var_k1 = self.add(self._model.add_variables( binary=True, coords={'time': variable.isel(time=indices[0]).indexes['time']}, diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py index e80250013..87ad66286 100644 --- a/flixOpt/calculation.py +++ b/flixOpt/calculation.py @@ -159,7 +159,7 @@ def do_modeling(self) -> SystemModel: self.durations['modeling'] = round(timeit.default_timer() - t_start, 2) return self.flow_system.model - def solve(self, solver: _Solver, save_results: Union[bool, str, pathlib.Path] = False, solver_options: Optional[dict] = None): + def solve(self, solver: _Solver, save_results: Union[bool, str, pathlib.Path] = False): self._define_path_names(save_results) t_start = timeit.default_timer() self.flow_system.model.solve(log_fn=self._paths['log'], @@ -236,27 +236,23 @@ def do_modeling(self) -> SystemModel: 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_step.max() - if not steps_per_period.is_integer(): + is_integer = (self.aggregation_parameters.hours_per_period % self.flow_system.hours_per_step.max()).item() == 0 + if not (steps_per_period.size == 1 and is_integer): raise Exception( 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.' ) + logger.info(f'{"":#^80}') logger.info(f'{" Aggregating TimeSeries Data ":#^80}') - self.time_series_collection = TimeSeriesCollection( - [ts for ts in self.flow_system.all_time_series if ts.is_array] - ) - - import pandas as pd - - original_data = pd.DataFrame(self.time_series_collection.data, index=chosen_time_series) + self.time_series_collection = TimeSeriesCollection(*self.flow_system.all_time_series) # Aggregation - creation of aggregated timeseries: self.aggregation = Aggregation( - original_data=original_data, - hours_per_time_step=dt_min, + original_data=self.time_series_collection.to_dataframe(), + hours_per_time_step=float(dt_min), hours_per_period=self.aggregation_parameters.hours_per_period, nr_of_periods=self.aggregation_parameters.nr_of_periods, weights=self.time_series_collection.weights, @@ -267,38 +263,36 @@ def do_modeling(self) -> SystemModel: self.aggregation.cluster() self.aggregation.plot() if self.aggregation_parameters.aggregate_data_and_fix_non_binary_vars: - self.time_series_collection.insert_data( # Converting it into a dict with labels as keys - { - col: np.array(values) - for col, values in self.aggregation.aggregated_data.to_dict(orient='list').items() - } - ) + self.time_series_collection.insert_data(self.aggregation.aggregated_data) self.durations['aggregation'] = round(timeit.default_timer() - t_start_agg, 2) # Model the System t_start = timeit.default_timer() - self.system_model = SystemModel(self.name, self.modeling_language, self.flow_system, self.time_indices) - self.system_model.do_modeling() + self.flow_system.create_model() + self.flow_system.model.do_modeling() # Add Aggregation Model after modeling the rest - aggregation_model = AggregationModel( - self.aggregation_parameters, self.flow_system, self.aggregation, self.components_to_clusterize + self.aggregation = AggregationModel( + self.flow_system.model, self.aggregation_parameters, self.flow_system, self.aggregation, self.components_to_clusterize ) - self.system_model.other_models.append(aggregation_model) - aggregation_model.do_modeling(self.system_model) - - self.system_model.translate_to_modeling_language() - + self.aggregation.do_modeling() self.durations['modeling'] = round(timeit.default_timer() - t_start, 2) - return self.system_model + return self.flow_system.model def solve(self, solver: _Solver, save_results: Union[bool, str, pathlib.Path] = False): self._define_path_names(save_results) t_start = timeit.default_timer() - solver.logfile_name = self._paths['log'] - self.system_model.solve(solver) + self.flow_system.model.solve(log_fn=self._paths['log'], + solver_name=solver.name, + **solver.options) self.durations['solving'] = round(timeit.default_timer() - t_start, 2) + # Log the formatted output + logger.info(f'{" Main Results ":#^80}') + logger.info("\n" + yaml.dump( + utils.round_floats(self.flow_system.model.infos), + default_flow_style=False, sort_keys=False, allow_unicode=True, indent=4)) + if save_results: self._save_solve_infos() From 44112c7b0d0a952f8f5d40723348c27d3d9a2ec1 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 21 Feb 2025 12:32:34 +0100 Subject: [PATCH 152/507] Update Calculation Mode examples --- .../03_Calculation_types/example_calculation_types.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/examples/03_Calculation_types/example_calculation_types.py b/examples/03_Calculation_types/example_calculation_types.py index f96aaba6d..8992398b8 100644 --- a/examples/03_Calculation_types/example_calculation_types.py +++ b/examples/03_Calculation_types/example_calculation_types.py @@ -14,7 +14,7 @@ if __name__ == '__main__': # Calculation Types - full, segmented, aggregated = True, True, True + full, segmented, aggregated = True, False, False # Segmented Properties segment_length, overlap_length = 96, 1 @@ -157,15 +157,15 @@ results: dict = {key: None for key in kinds} if full: - calculation = fx.FullCalculation('fullModel', flow_system, 'pyomo') + calculation = fx.FullCalculation('fullModel', flow_system) calculation.do_modeling() - calculation.solve(fx.solvers.HighsSolver()) + calculation.solve(fx.solvers.HighsSolver(0, 60)) calculations['Full'] = calculation results['Full'] = calculations['Full'].results() if segmented: calculation = fx.SegmentedCalculation('segModel', flow_system, segment_length, overlap_length) - calculation.do_modeling_and_solve(fx.solvers.HighsSolver()) + calculation.do_modeling_and_solve(fx.solvers.HighsSolver(0, 60)) calculations['Segmented'] = calculation results['Segmented'] = calculations['Segmented'].results(combined_arrays=True) @@ -175,7 +175,7 @@ aggregation_parameters.time_series_for_low_peaks = [TS_electricity_demand, TS_heat_demand] calculation = fx.AggregatedCalculation('aggModel', flow_system, aggregation_parameters) calculation.do_modeling() - calculation.solve(fx.solvers.HighsSolver()) + calculation.solve(fx.solvers.HighsSolver(0, 60)) calculations['Aggregated'] = calculation results['Aggregated'] = calculations['Aggregated'].results() pprint(results) From 519dd9b79cd7b321abb8a3773ceb45cdec184747 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 21 Feb 2025 12:33:14 +0100 Subject: [PATCH 153/507] Temp Commit --- examples/linopy_native_experiments.py | 2 +- tests/run_all_tests.py | 2 +- tests/test_integration.py | 6 ++---- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/examples/linopy_native_experiments.py b/examples/linopy_native_experiments.py index e1fc42e59..58736615c 100644 --- a/examples/linopy_native_experiments.py +++ b/examples/linopy_native_experiments.py @@ -127,7 +127,7 @@ def index_shape(self) -> Tuple[int, int]: name="con_storage_start" ) # Start = End for every period -start_is_end = True +start_is_end = False if start_is_end: con_storage_start_end = m.add_constraints( charge_state.isel(time=0) == charge_state.isel(time=-1), diff --git a/tests/run_all_tests.py b/tests/run_all_tests.py index c625e9f9b..83b6dfacf 100644 --- a/tests/run_all_tests.py +++ b/tests/run_all_tests.py @@ -7,4 +7,4 @@ import pytest if __name__ == '__main__': - pytest.main(['-v', '--disable-warnings']) + pytest.main(['test_integration.py', '--disable-warnings']) diff --git a/tests/test_integration.py b/tests/test_integration.py index eba228cc9..f3fc11691 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -822,13 +822,11 @@ def calculate(self, modeling_type: Literal['full', 'segmented', 'aggregated']): es.visualize_network() if doFullCalc: - calc = fx.FullCalculation('fullModel', es, 'pyomo') + calc = fx.FullCalculation('fullModel', es) calc.do_modeling() calc.solve(self.get_solver(), save_results=True) elif doSegmentedCalc: - calc = fx.SegmentedCalculation( - 'segModel', es, segment_length=96, overlap_length=1, modeling_language='pyomo' - ) + calc = fx.SegmentedCalculation('segModel', es, segment_length=96, overlap_length=1) calc.do_modeling_and_solve(self.get_solver(), save_results=True) elif doAggregatedCalc: calc = fx.AggregatedCalculation( From 25bece41caa69eda200a083312de931ada13923a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 21 Feb 2025 13:07:29 +0100 Subject: [PATCH 154/507] Improvement: Always upper Bound in FlowModel --- flixOpt/elements.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixOpt/elements.py b/flixOpt/elements.py index 3d641fc0a..d5829759a 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -307,7 +307,7 @@ def do_modeling(self, system_model: SystemModel): self.flow_rate = self.add( self._model.add_variables( lower=self.absolute_flow_rate_bounds[0] if self.element.on_off_parameters is None else 0, - upper=self.absolute_flow_rate_bounds[1] if self.element.on_off_parameters is None else np.inf, + upper=self.absolute_flow_rate_bounds[1], coords=self._model.coords, name=f'{self.label_full}|flow_rate' ), From eac2c8218a02a9417607f7e6c833461ec759f60b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 21 Feb 2025 13:38:37 +0100 Subject: [PATCH 155/507] Fix index in test_integration.py::TestModelingTypes --- tests/test_integration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_integration.py b/tests/test_integration.py index f3fc11691..68d806c54 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -738,7 +738,7 @@ def calculate(self, modeling_type: Literal['full', 'segmented', 'aggregated']): data['Strompr.€/MWh'].values, data['Gaspr.€/MWh'].values, ) - timesteps = pd.date_range('2020-01-01', periods=len(P_el_Last), freq='h', name='time') + timesteps = pd.DatetimeIndex(data.index) Strom, Fernwaerme, Gas, Kohle = fx.Bus('Strom'), fx.Bus('Fernwärme'), fx.Bus('Gas'), fx.Bus('Kohle') costs, CO2, PE = ( From 76313bb240bc2eec87d1cf6b9e2a3d1e1a0f7232 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 21 Feb 2025 13:48:28 +0100 Subject: [PATCH 156/507] Update Solver interfaces and a test --- examples/00_Minmal/minimal_example.py | 2 +- examples/02_Complex/complex_example.py | 2 +- tests/test_functional.py | 7 +++++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/examples/00_Minmal/minimal_example.py b/examples/00_Minmal/minimal_example.py index b722c4aca..4555aa65c 100644 --- a/examples/00_Minmal/minimal_example.py +++ b/examples/00_Minmal/minimal_example.py @@ -55,7 +55,7 @@ calculation.do_modeling() # --- Solve the Calculation and Save Results --- - calculation.solve('highs', save_results=True) + calculation.solve(fx.solvers.HighsSolver(0.01, 60), save_results=True) # --- Load and Analyze Results --- # Load results and plot the operation of the District Heating Bus diff --git a/examples/02_Complex/complex_example.py b/examples/02_Complex/complex_example.py index f77c2c464..d69f64c31 100644 --- a/examples/02_Complex/complex_example.py +++ b/examples/02_Complex/complex_example.py @@ -182,7 +182,7 @@ calculation.do_modeling() calculation.solve( - 'highs', + fx.solvers.HighsSolver(0.01, 60), save_results='results', # If and where to save results ) diff --git a/tests/test_functional.py b/tests/test_functional.py index 5bfe7be83..2862c6bbb 100644 --- a/tests/test_functional.py +++ b/tests/test_functional.py @@ -104,9 +104,12 @@ def solve_and_load( return results -@pytest.fixture(params=['highs'])#, 'gurobi']) +@pytest.fixture(params=['highs', 'gurobi']) def solver_fixture(request): - return request.param + return { + 'highs': fx.solvers.HighsSolver(0.01, 60), + 'gurobi': fx.solvers.GurobiSolver(0.01, 60), + }[request.param] @pytest.fixture From 6ef3236bb5aaae2c8a7ae979c7450a9c73a95b66 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 21 Feb 2025 14:41:35 +0100 Subject: [PATCH 157/507] Improve names in aggregation.py --- flixOpt/aggregation.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/flixOpt/aggregation.py b/flixOpt/aggregation.py index 78e78288b..f1d83098c 100644 --- a/flixOpt/aggregation.py +++ b/flixOpt/aggregation.py @@ -392,7 +392,7 @@ def do_modeling(self): for label, variable in self.variables_direct.items(): self._model.effects.add_share_to_penalty( self._model, - f'Aggregation_penalty__{label}', + 'Aggregation', variable * penalty ) @@ -404,20 +404,20 @@ def _equate_indices(self, variable: linopy.Variable, indices: Tuple[np.ndarray, # eq1: x(p1,t) - x(p3,t) = 0 # wobei p1 und p3 im gleichen Cluster sind und t = 0..N_p con = self.add(self._model.add_constraints( variable.isel(time=indices[0]) - variable.isel(time=indices[1]) == 0, - name=f'Equate_indices_of_{variable.name}'), - variable.name) + name=f'{self.label_full}|equate_indices|{variable.name}'), + f'equate_indices|{variable.name}') # Korrektur: (bisher nur für Binärvariablen:) if variable.name in self._model.variables.binaries and self.aggregation_parameters.percentage_of_period_freedom > 0: var_k1 = self.add(self._model.add_variables( binary=True, coords={'time': variable.isel(time=indices[0]).indexes['time']}, - name=f'{self.label_full}|Korr1|{variable.name}'), f'Korr1|{variable.name}') + name=f'{self.label_full}|correction1|{variable.name}'), f'correction1|{variable.name}') var_k0 = self.add(self._model.add_variables( binary=True, coords={'time': variable.isel(time=indices[0]).indexes['time']}, - name=f'{self.label_full}|Korr0|{variable.name}'), f'Korr0|{variable.name}') + name=f'{self.label_full}|correction0|{variable.name}'), f'correction0|{variable.name}') # equation extends ... # --> On(p3) can be 0/1 independent of On(p1,t)! @@ -431,14 +431,14 @@ def _equate_indices(self, variable: linopy.Variable, indices: Tuple[np.ndarray, # eq: var_k0(t)+var_k1(t) <= 1.1 self.add(self._model.add_constraints( var_k0 + var_k1 <= 1.1, - name=f'{self.label_full}|lock_K0andK1|{variable.name}'), - f'lock_K0andK1|{variable.name}' + name=f'{self.label_full}|lock_k0_and_k1|{variable.name}'), + f'lock_k0_and_k1|{variable.name}' ) # Begrenzung der Korrektur-Anzahl: # eq: sum(K) <= n_Corr_max self.add(self._model.add_constraints( sum(var_k0) + sum(var_k1) <= round(self.aggregation_parameters.percentage_of_period_freedom / 100 * length), - name=f'{self.label_full}|Nr_of_Corrections|{variable.name}'), - f'Nr_of_Corrections|{variable.name}' + name=f'{self.label_full}|limit_corrections|{variable.name}'), + f'limit_corrections|{variable.name}' ) From 32e35c555dae0c433076e0a48becd4fe7dc9aa62 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 21 Feb 2025 14:52:43 +0100 Subject: [PATCH 158/507] Update the .stored_data setter and improve type hint --- flixOpt/core.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/flixOpt/core.py b/flixOpt/core.py index ffcec8f6f..edb7eda72 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -247,7 +247,7 @@ def active_timesteps(self, timesteps: Optional[pd.DatetimeIndex]): self._update_active_data() # Refresh view @property - def active_periods(self) -> pd.Index: + def active_periods(self) -> Optional[pd.Index]: """Return the current active index.""" return self._active_periods @@ -279,10 +279,17 @@ def stored_data(self) -> xr.DataArray: return self._stored_data.copy() @stored_data.setter - def stored_data(self, value: xr.DataArray): - """Set stored_data and refresh active_index and active_data.""" + def stored_data(self, value: Union[pd.Series, pd.DataFrame, xr.DataArray]): + """ + Update stored_data and refresh active_index and active_data. + + Parameters + ---------- + value: Union[int, float, np.ndarray, pd.Series, pd.DataFrame, xr.DataArray] + Data to update stored_data with. + """ self._backup = self._stored_data - self._stored_data = value + self._stored_data = DataConverter.as_dataarray(value, time=self.active_timesteps, period=self.active_periods) self.active_timesteps = None self.active_periods = None From 84d8abb7984b230924c607864273a6597bf6c45c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 21 Feb 2025 14:55:41 +0100 Subject: [PATCH 159/507] Improve TimeSeries --- flixOpt/core.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/flixOpt/core.py b/flixOpt/core.py index edb7eda72..067461495 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -288,8 +288,11 @@ def stored_data(self, value: Union[pd.Series, pd.DataFrame, xr.DataArray]): value: Union[int, float, np.ndarray, pd.Series, pd.DataFrame, xr.DataArray] Data to update stored_data with. """ + new_data = DataConverter.as_dataarray(value, time=self.active_timesteps, period=self.active_periods) + if new_data.equals(self._stored_data): + return # No change in stored_data. Do nothing. This prevents pushing out the backup self._backup = self._stored_data - self._stored_data = DataConverter.as_dataarray(value, time=self.active_timesteps, period=self.active_periods) + self._stored_data = new_data self.active_timesteps = None self.active_periods = None @@ -301,8 +304,8 @@ def sel(self): def isel(self): return self.active_data.isel - # Enable arithmetic operations using active_data def _apply_operation(self, other, op): + # Enable arithmetic operations using active_data if isinstance(other, TimeSeries): other = other.active_data return op(self.active_data, other) From 2edfa70210b7f5c008dc6c38160cb6564a5f42be Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 21 Feb 2025 15:00:54 +0100 Subject: [PATCH 160/507] Use setting mechanism of TimeSeries to store new aggregated values --- flixOpt/aggregation.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/flixOpt/aggregation.py b/flixOpt/aggregation.py index f1d83098c..caa01d584 100644 --- a/flixOpt/aggregation.py +++ b/flixOpt/aggregation.py @@ -218,11 +218,7 @@ def __init__(self, *time_series): def insert_data(self, data: pd.DataFrame): for time_series in self.time_serieses: if time_series.name in data.columns: - time_series.stored_data = DataConverter.as_dataarray( - data[time_series.name], - time_series.active_timesteps, - time_series.active_periods - ) + time_series.stored_data = data[time_series.name] logger.debug(f'Inserted data for {time_series.name}') def to_dataframe(self, with_constant_data: bool = False): From 69c8cdae877b24260935fc030f6cc87398bd279b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 21 Feb 2025 17:22:58 +0100 Subject: [PATCH 161/507] Rename the ELement of AGGREGATION --- flixOpt/aggregation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixOpt/aggregation.py b/flixOpt/aggregation.py index caa01d584..50f95619c 100644 --- a/flixOpt/aggregation.py +++ b/flixOpt/aggregation.py @@ -352,7 +352,7 @@ def __init__( """ Modeling-Element for "index-equating"-equations """ - super().__init__(model, label_of_element='No Element', label_full='Aggregation') + super().__init__(model, label_of_element='Aggregation', label_full='Aggregation') self.flow_system = flow_system self.aggregation_parameters = aggregation_parameters self.aggregation_data = aggregation_data From 54bec70a5edf86be66a1d9ce8514fd3fbdd85267 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 21 Feb 2025 17:29:25 +0100 Subject: [PATCH 162/507] Move TimeSeriesCollection to core.py --- flixOpt/aggregation.py | 68 +------------- flixOpt/calculation.py | 25 ++--- flixOpt/core.py | 208 ++++++++++++++++++++++++++++++++++++++++- flixOpt/flow_system.py | 113 +++++----------------- 4 files changed, 242 insertions(+), 172 deletions(-) diff --git a/flixOpt/aggregation.py b/flixOpt/aggregation.py index 50f95619c..04dcff605 100644 --- a/flixOpt/aggregation.py +++ b/flixOpt/aggregation.py @@ -16,7 +16,7 @@ import tsam.timeseriesaggregation as tsam from .components import Storage -from .core import Skalar, TimeSeries, TimeSeriesData, DataConverter +from .core import Skalar, TimeSeriesData from .elements import Component from .flow_system import FlowSystem from .math_modeling import Equation, Variable, VariableTS @@ -205,72 +205,6 @@ def get_equation_indices(self, skip_first_index_of_period: bool = True) -> Tuple return np.array(idx_var1), np.array(idx_var2) -class TimeSeriesCollection: - def __init__(self, *time_series): - self.time_serieses: List[TimeSeries] = list(time_series) - self._check_unique_labels() - self.group_weights = self._calculate_group_weights() - self.weights = self._calculate_aggregation_weights() - - if np.all(np.isclose(list(self.weights.values()), 1, atol=1e-6)): - logger.info('All Aggregation weights were set to 1') - - def insert_data(self, data: pd.DataFrame): - for time_series in self.time_serieses: - if time_series.name in data.columns: - time_series.stored_data = data[time_series.name] - logger.debug(f'Inserted data for {time_series.name}') - - def to_dataframe(self, with_constant_data: bool = False): - if with_constant_data: - return pd.concat([time_series.active_data.to_dataframe(time_series.name) - for time_series in self.time_serieses], - axis=1) - - return pd.concat([time_series.active_data.to_dataframe(time_series.name) - for time_series in self.time_serieses_non_constant], - axis=1) - - @property - def time_serieses_non_constant(self) -> List[TimeSeries]: - return [time_series for time_series in self.time_serieses if not time_series.all_equal] - - def description(self) -> str: - # TODO: - result = f'{len(self.time_serieses)} TimeSeries used for aggregation:\n' - for time_series in self.time_serieses: - result += f' -> {time_series.name} (weight: {self.weights[time_series.name]:.4f}; group: "{time_series.aggregation_group}")\n' - if self.group_weights: - result += f'Aggregation_Groups: {list(self.group_weights.keys())}\n' - else: - result += 'Warning!: no agg_types defined, i.e. all TS have weight 1 (or explicitly given weight)!\n' - return result - - def _calculate_group_weights(self) -> Dict[str, float]: - """Calculates the aggregation weights of each group""" - groups = [ - time_series.aggregation_group - for time_series in self.time_serieses - if time_series.aggregation_group is not None - ] - group_size = dict(Counter(groups)) - group_weights = {group: 1 / size for group, size in group_size.items()} - return group_weights - - def _calculate_aggregation_weights(self) -> Dict[str, float]: - """Calculates the aggregation weight for each TimeSeries. Default is 1""" - return { - time_series.name: self.group_weights.get(time_series.aggregation_group, time_series.aggregation_weight or 1) - for time_series in self.time_serieses - } - - def _check_unique_labels(self): - """Makes sure every label of the TimeSeries in time_series_list is unique""" - label_counts = Counter([time_series.name for time_series in self.time_serieses]) - duplicates = [label for label, count in label_counts.items() if count > 1] - assert duplicates == [], 'Duplicate TimeSeries labels found: {}.'.format(', '.join(duplicates)) - - class AggregationParameters: def __init__( self, diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py index 87ad66286..595d30fb1 100644 --- a/flixOpt/calculation.py +++ b/flixOpt/calculation.py @@ -21,7 +21,7 @@ import yaml from . import utils as utils -from .aggregation import AggregationModel, AggregationParameters, TimeSeriesCollection +from .aggregation import AggregationModel, AggregationParameters from .components import Storage from .core import Numeric, Skalar from .elements import Component @@ -212,11 +212,12 @@ def __init__( list with indices, which should be used for calculation. If None, then all timesteps are used. """ super().__init__(name, flow_system, active_timesteps) + if flow_system.periods is not None: + raise NotImplementedError(f'Multiple Periods are currently not supported in AggregatedCalculation') self.aggregation_parameters = aggregation_parameters self.components_to_clusterize = components_to_clusterize self.time_series_for_aggregation = None self.aggregation = None - self.time_series_collection: Optional[TimeSeriesCollection] = None def do_modeling(self) -> SystemModel: self.flow_system.transform_data() @@ -247,15 +248,13 @@ def do_modeling(self) -> SystemModel: logger.info(f'{"":#^80}') logger.info(f'{" Aggregating TimeSeries Data ":#^80}') - self.time_series_collection = TimeSeriesCollection(*self.flow_system.all_time_series) - # Aggregation - creation of aggregated timeseries: self.aggregation = Aggregation( - original_data=self.time_series_collection.to_dataframe(), + original_data=self.flow_system.time_series_collection.to_dataframe(), hours_per_time_step=float(dt_min), hours_per_period=self.aggregation_parameters.hours_per_period, nr_of_periods=self.aggregation_parameters.nr_of_periods, - weights=self.time_series_collection.weights, + weights=self.flow_system.time_series_collection.calculate_aggregation_weights(), time_series_for_high_peaks=self.aggregation_parameters.labels_for_high_peaks, time_series_for_low_peaks=self.aggregation_parameters.labels_for_low_peaks, ) @@ -304,8 +303,7 @@ def __init__( flow_system: FlowSystem, segment_length: int, overlap_length: int, - modeling_language: Literal['pyomo', 'linopy'] = 'pyomo', - time_indices: Optional[Union[range, list[int]]] = None, + active_timesteps: Optional[Union[List[int], pd.DatetimeIndex]] = None, ): """ Dividing and Modeling the problem in (overlapping) segments. @@ -329,16 +327,13 @@ def __init__( overlap_length : int The number of time_steps that are added to each individual model. Used for better results of storages) - modeling_language : 'pyomo', 'linopy' (not implemeted yet) - choose optimization modeling language - time_indices : List[int] or None - list with indices, which should be used for calculation. If None, then all timesteps are used. - """ - super().__init__(name, flow_system, modeling_language, time_indices) + super().__init__(name, flow_system, active_timesteps) + if flow_system.periods is not None: + raise NotImplementedError(f'Multiple Periods are currently not supported in SegmentedCalculation') self.segment_length = segment_length self.overlap_length = overlap_length - self._total_length = len(self.time_indices) if self.time_indices is not None else len(flow_system.time_series) + self._total_length = len(self.flow_system.timesteps) if self.time_indices is not None else len(flow_system.time_series) self.number_of_segments = math.ceil(self._total_length / self.segment_length) self.sub_calculations: List[FullCalculation] = [] diff --git a/flixOpt/core.py b/flixOpt/core.py index 067461495..7efd6c882 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -5,7 +5,8 @@ import inspect import logging -from typing import Any, Dict, List, Optional, Union, Tuple +from typing import Any, Dict, List, Optional, Union, Tuple, Literal +from collections import Counter import numpy as np import xarray as xr @@ -349,3 +350,208 @@ def __array_ufunc__(self, ufunc, method, *inputs, **kwargs): """Ensures NumPy functions like np.add(TimeSeries, xarray) work correctly.""" inputs = [x.active_data if isinstance(x, TimeSeries) else x for x in inputs] return getattr(ufunc, method)(*inputs, **kwargs) + + +class TimeSeriesCollection: + def __init__(self, + timesteps: pd.DatetimeIndex, + hours_of_last_timestep: Optional[float], + hours_of_previous_timesteps: Optional[Union[int, float, np.ndarray]], + periods: Optional[List[int]], *timeseries: TimeSeries): + ( + self.timesteps, + self.timesteps_extra, + self.hours_per_timestep, + self.hours_of_previous_timesteps, + self.periods) = TimeSeriesCollection.allign_dimensions( + timesteps, periods, hours_of_last_timestep, hours_of_previous_timesteps + ) + + self.group_weights: Dict[str, float] = {} + self.weights: Dict[str, float] = {} + self.time_serieses: List[TimeSeries] = [] + self._timeserieses_longer: List[TimeSeries] = [] + + self.add_time_series(*timeseries) + + def add_time_series(self, *time_series: TimeSeries): + for time_series in list(time_series): + if len(time_series.active_timesteps) - len(self.timesteps) == 1: + self._timeserieses_longer.append(time_series) + self.time_serieses.extend(time_series) + self._check_unique_labels() + + def create_time_series( + self, + data: Union[int, float, np.ndarray, pd.Series, pd.DataFrame, xr.DataArray], + name: str, + additional_step:bool=False + ) -> TimeSeries: + """ + Creates a TimeSeries from the given data and adds it to the list of time_serieses of an Element. + If the data already is a TimeSeries, nothing happens. + + Parameters + ---------- + data: Union[int, float, np.ndarray, pd.Series, pd.DataFrame, xr.DataArray] + The data to create the TimeSeries from. + name: str + The name of the TimeSeries. + additional_step: bool, optional + Whether to create an additional timestep at the end of the timesteps. + + Returns + ------- + TimeSeries + The created TimeSeries. + + """ + if isinstance(data, TimeSeries): + if data not in self.time_serieses: + self.add_time_series(data) + return data + else: + time_series = TimeSeries.from_datasource( + name=f'{name}', + data=data, + timesteps=self.timesteps if not additional_step else self.timesteps_extra, + periods=self.periods) + self.add_time_series(time_series) + return time_series + + def calculate_aggregation_weights(self) -> Dict[str, float]: + self.group_weights = self._calculate_group_weights() + self.weights = self._calculate_aggregation_weights() + + if np.all(np.isclose(list(self.weights.values()), 1, atol=1e-6)): + logger.info('All Aggregation weights were set to 1') + + return self.weights + + def insert_data(self, data: pd.DataFrame): + for time_series in self.time_serieses: + if time_series.name in data.columns: + time_series.stored_data = data[time_series.name] + logger.debug(f'Inserted data for {time_series.name}') + + def to_dataframe(self, filtered: Literal['all', 'constant', 'non_constant'] = 'non_constant'): + if filtered == 'all': + return pd.concat([time_series.active_data.to_dataframe(time_series.name) + for time_series in self.time_serieses], + axis=1) + elif filtered == 'constant': + return pd.concat([time_series.active_data.to_dataframe(time_series.name) + for time_series in self.constants], + axis=1) + elif filtered == 'non_constant': + return pd.concat([time_series.active_data.to_dataframe(time_series.name) + for time_series in self.non_constants], + axis=1) + else: + raise ValueError('Not supported argument for "filtered".') + + @staticmethod + def allign_dimensions( + timesteps: pd.DatetimeIndex, + periods: Optional[pd.Index] = None, + hours_of_last_timestep: Optional[float] = None, + hours_of_previous_timesteps: Optional[Union[int, float, np.ndarray]] = None + ) -> Tuple[pd.DatetimeIndex, pd.DatetimeIndex, xr.DataArray, Union[int, float, np.ndarray], Optional[pd.Index]]: + """ Converts the given timesteps, periods and hours_of_last_timestep to the right format + + Parameters + ---------- + timesteps : pd.DatetimeIndex + The timesteps of the model. + hours_of_last_timestep : Optional[float], optional + The duration of the last time step. Uses the last time interval if not specified + hours_of_previous_timesteps: Un + periods : Optional[pd.Index], optional + The periods of the model. Every period has the same timesteps. + Usually years are used as periods. + + Returns + ------- + Tuple[pd.DatetimeIndex, pd.DatetimeIndex, xr.DataArray, Optional[pd.Index]] + The timesteps, timesteps_extra, hours_per_timestep and periods + + - timesteps: pd.DatetimeIndex + The timesteps of the model. + - timesteps_extra: pd.DatetimeIndex + The timesteps of the model, including an extra timestep at the end. + - hours_per_timestep: xr.DataArray + The duration of each timestep in hours. + - hours_of_previous_timesteps: Optional[Union[int, float, np.ndarray]] + The duration of the previous timesteps in hours. + - periods: Optional[pd.Index] + The periods of the model. Every period has the same timesteps. + Usually years are used as periods. + """ + timesteps = pd.DatetimeIndex(timesteps, name='time') + periods = pd.Index(periods, name='period') if periods is not None else None + + if hours_of_last_timestep: + last_date = pd.DatetimeIndex( + [timesteps[-1] + pd.to_timedelta(hours_of_last_timestep, 'h')]) + else: + last_date = pd.DatetimeIndex([timesteps[-1] + (timesteps[-1] - timesteps[-2])]) + + timesteps_extra = pd.DatetimeIndex(timesteps.append(last_date), name='time') + + hours_of_previous_timesteps: Union[int, float, np.ndarray] = ( + ((timesteps[1] - timesteps[0]) / np.timedelta64(1, 'h')) + if hours_of_previous_timesteps is None + else hours_of_previous_timesteps + ) + + hours_per_step = timesteps_extra.to_series().diff()[1:].values / pd.to_timedelta(1, 'h') + hours_per_step = xr.DataArray( + data=np.tile(hours_per_step, (len(periods), 1)) if periods is not None else hours_per_step, + coords=(periods, timesteps) if periods is not None else (timesteps,), + dims=('period', 'time') if periods is not None else ('time',), + name='hours_per_step' + ) + return timesteps, timesteps_extra , hours_per_step, hours_of_previous_timesteps, periods + + @property + def non_constants(self) -> List[TimeSeries]: + return [time_series for time_series in self.time_serieses if not time_series.all_equal] + + @property + def constants(self) -> List[TimeSeries]: + return [time_series for time_series in self.time_serieses if time_series.all_equal] + + def description(self) -> str: + # TODO: + result = f'{len(self.time_serieses)} TimeSeries used for aggregation:\n' + for time_series in self.time_serieses: + result += f' -> {time_series.name} (weight: {self.weights[time_series.name]:.4f}; group: "{time_series.aggregation_group}")\n' + if self.group_weights: + result += f'Aggregation_Groups: {list(self.group_weights.keys())}\n' + else: + result += 'Warning!: no agg_types defined, i.e. all TS have weight 1 (or explicitly given weight)!\n' + return result + + def _calculate_group_weights(self) -> Dict[str, float]: + """Calculates the aggregation weights of each group""" + groups = [ + time_series.aggregation_group + for time_series in self.time_serieses + if time_series.aggregation_group is not None + ] + group_size = dict(Counter(groups)) + group_weights = {group: 1 / size for group, size in group_size.items()} + return group_weights + + def _calculate_aggregation_weights(self) -> Dict[str, float]: + """Calculates the aggregation weight for each TimeSeries. Default is 1""" + return { + time_series.name: self.group_weights.get(time_series.aggregation_group, time_series.aggregation_weight or 1) + for time_series in self.time_serieses + } + + def _check_unique_labels(self): + """Makes sure every label of the TimeSeries in time_series_list is unique""" + label_counts = Counter([time_series.name for time_series in self.time_serieses]) + duplicates = [label for label, count in label_counts.items() if count > 1] + assert duplicates == [], 'Duplicate TimeSeries labels found: {}.'.format(', '.join(duplicates)) diff --git a/flixOpt/flow_system.py b/flixOpt/flow_system.py index b5f0cc3a0..15015b723 100644 --- a/flixOpt/flow_system.py +++ b/flixOpt/flow_system.py @@ -12,7 +12,7 @@ import xarray as xr from . import utils -from .core import TimeSeries +from .core import TimeSeries, TimeSeriesCollection from .effects import Effect from .elements import Bus, Component, Flow from .structure import Element, SystemModel, get_compact_representation, get_str_representation @@ -51,15 +51,11 @@ def __init__( The periods of the model. Every period has the same timesteps. Usually years are used as periods. """ - self.timesteps = timesteps - self.hours_of_last_step = hours_of_last_timestep - self.periods = periods - self._order_dimensions() - - self.hours_of_previous_timesteps: Union[int, float, np.ndarray] = ( - ((self.timesteps[1] - self.timesteps[0]) / np.timedelta64(1, 'h')) - if hours_of_previous_timesteps is None - else hours_of_previous_timesteps + self.time_series_collection = TimeSeriesCollection( + timesteps=timesteps, + hours_of_last_timestep=hours_of_last_timestep, + hours_of_previous_timesteps=hours_of_previous_timesteps, + periods=periods ) # defaults: @@ -247,69 +243,6 @@ def _check_if_element_is_unique(self, element: Element) -> None: if element.label_full in self.all_elements: raise Exception(f'Label of Element {element.label} already used in another element!') - def _order_dimensions(self): - self.timesteps = self.timesteps - self.timesteps.name = 'time' - - self.periods = pd.Index(self.periods, name='period') if self.periods is not None else None - - if self.hours_of_last_step: - last_date = pd.DatetimeIndex( - [self.timesteps[-1] + pd.to_timedelta(self.hours_of_last_step, 'h')]) - else: - last_date = pd.DatetimeIndex([self.timesteps[-1] + (self.timesteps[-1] - self.timesteps[-2])]) - self.timesteps_extra = self.timesteps.append(last_date) - self.timesteps_extra.name = 'time' - hours_per_step = self.timesteps_extra.to_series().diff()[1:].values / pd.to_timedelta(1, 'h') - self.hours_per_step = xr.DataArray( - data=np.tile(hours_per_step, (len(self.periods), 1)) if self.periods is not None else hours_per_step, - coords=self.coords, - name='hours_per_step' - ) - - def get_time_data_from_indices( - self, time_indices: Optional[Union[List[int], range]] = None - ) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.float64]: - """ - Computes time series data based on the provided time indices. - - Args: - time_indices: A list of indices or a range object indicating which time steps to extract. - If None, the entire time series is used. - - Returns: - A tuple containing: - - Extracted time series - - Time series with the "end time" appended - - Differences between consecutive timestamps in hours - - Total time in hours - """ - # If time_indices is None, use the full time series range - if time_indices is None: - time_indices = range(len(self.time_series)) - - # Extract the time series for the provided indices - time_series = self.time_series[time_indices] - - # Ensure the next timestamp for end time is within bounds - last_index = time_indices[-1] - if last_index + 1 < len(self.time_series_with_end): - end_time = self.time_series_with_end[last_index + 1] - else: - raise IndexError(f"Index {last_index + 1} out of bounds for 'self.time_series_with_end'.") - - # Append end time to the time series - time_series_with_end = np.append(time_series, end_time) - - # Calculate time differences (time deltas) in hours - time_deltas = time_series_with_end[1:] - time_series_with_end[:-1] - dt_in_hours = time_deltas / np.timedelta64(1, 'h') - - # Calculate the total time in hours - dt_in_hours_total = np.sum(dt_in_hours) - - return time_series, time_series_with_end, dt_in_hours, dt_in_hours_total - def __repr__(self): return f'<{self.__class__.__name__} with {len(self.components)} components and {len(self.effects)} effects>' @@ -334,30 +267,32 @@ def all_time_series(self) -> List[TimeSeries]: return [ts for element in self.all_elements.values() for ts in element.used_time_series] @property - def snapshots(self): - return xr.Dataset( - coords={'period': list(self.periods), 'time': list(self.timesteps)} if self.periods is not None else { - 'time': list(self.timesteps)}, - ) + def hours_of_previous_timesteps(self): + return self.time_series_collection.hours_of_previous_timesteps @property - def snapshots_extra(self): - return xr.Dataset( - coords={'period': list(self.periods), 'time': list(self.timesteps_extra)} if self.periods is not None else { - 'time': list(self.timesteps_extra)}, - ) + def timesteps(self): + return self.time_series_collection.timesteps @property - def coords(self): - return self.snapshots.coords + def timesteps_extra(self): + return self.time_series_collection.timesteps_extra @property - def coords_extra(self): - return self.snapshots_extra.coords + def periods(self): + return self.time_series_collection.periods @property - def index_shape(self) -> Tuple[int, int]: - return len(self.periods) if self.periods is not None else 1, len(self.timesteps) + def hours_per_step(self): #TODO: Rename to hours_per_timestep + return self.time_series_collection.hours_per_timestep + + @property + def coords(self): + return [self.periods, self.timesteps] if self.periods is not None else [self.timesteps] + + @property + def coords_extra(self): + return [self.periods, self.timesteps_extra] if self.periods is not None else [self.timesteps_extra] def create_datetime_array( From 184cbd0e4f0349c2ec10d135317fe732931f07ab Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 21 Feb 2025 17:43:55 +0100 Subject: [PATCH 163/507] Improve Interface._create_time_series() --- flixOpt/core.py | 27 ++++++++++++++++++--------- flixOpt/structure.py | 43 +++++++++++++++---------------------------- 2 files changed, 33 insertions(+), 37 deletions(-) diff --git a/flixOpt/core.py b/flixOpt/core.py index 7efd6c882..109bdbe7f 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -18,6 +18,8 @@ Skalar = Union[int, float] # Datatype Numeric = Union[int, float, np.ndarray] # Datatype +NumericData = Union[int, float, np.ndarray, pd.Series, pd.DataFrame, xr.DataArray] + class DataConverter: """ @@ -383,9 +385,9 @@ def add_time_series(self, *time_series: TimeSeries): def create_time_series( self, - data: Union[int, float, np.ndarray, pd.Series, pd.DataFrame, xr.DataArray], + data: Union[NumericData, TimeSeriesData], name: str, - additional_step:bool=False + extra_timestep: bool=False ) -> TimeSeries: """ Creates a TimeSeries from the given data and adds it to the list of time_serieses of an Element. @@ -397,7 +399,7 @@ def create_time_series( The data to create the TimeSeries from. name: str The name of the TimeSeries. - additional_step: bool, optional + extra_timestep: bool, optional Whether to create an additional timestep at the end of the timesteps. Returns @@ -410,12 +412,19 @@ def create_time_series( if data not in self.time_serieses: self.add_time_series(data) return data - else: - time_series = TimeSeries.from_datasource( - name=f'{name}', - data=data, - timesteps=self.timesteps if not additional_step else self.timesteps_extra, - periods=self.periods) + + time_series = TimeSeries.from_datasource( + name=name, + data=data if not isinstance(data, TimeSeriesData) else data.data, + timesteps=self.timesteps if not extra_timestep else self.timesteps_extra, + periods=self.periods, + aggregation_weight=data.agg_weight if isinstance(data, TimeSeriesData) else None, + aggregation_group=data.agg_group if isinstance(data, TimeSeriesData) else None + ) + + if isinstance(data, TimeSeriesData): + data.label = time_series.name # Connecting User_time_series to TimeSeries + self.add_time_series(time_series) return time_series diff --git a/flixOpt/structure.py b/flixOpt/structure.py index 92447830c..ac861f849 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -20,7 +20,7 @@ from . import utils from .config import CONFIG -from .core import Numeric, Numeric_TS, Skalar, TimeSeries, TimeSeriesData +from .core import Numeric, Numeric_TS, Skalar, TimeSeries, TimeSeriesData, TimeSeriesCollection, NumericData from .math_modeling import Equation, Inequation, MathModel, _Solver, Variable, VariableTS if TYPE_CHECKING: # for type checking and preventing circular imports @@ -181,7 +181,7 @@ class Interface: This class is used to collect arguments about a Model. """ - def transform_data(self, flow_system: 'FlowSystem'): + def transform_data(self, time_series_collection: TimeSeriesCollection): """ Transforms the data of the interface to match the FlowSystem's dimensions""" raise NotImplementedError('Every Interface needs a transform_data() method') @@ -250,32 +250,28 @@ def __str__(self): @staticmethod def _create_time_series( - element: 'Element', name: str, - data: Optional[Union[Numeric_TS, TimeSeries]], - timesteps: pd.DatetimeIndex, - periods: Optional[pd.Index], + data: Optional[Union[NumericData, TimeSeriesData, TimeSeries]], + time_series_collection: TimeSeriesCollection, + extra_timestep: bool = True, ) -> Optional[TimeSeries]: - """Creates a TimeSeries from Numeric Data and adds it to the list of time_series of an Element. - If the data already is a TimeSeries, nothing happens and the TimeSeries gets reset and returned""" + """ + Tries to create a TimeSeries from Numeric Data and adds it to the time_series_collection + If the data already is a TimeSeries, nothing happens and the TimeSeries gets reset and returned + If the data is a TimeSeriesData, it is converted to a TimeSeries, and the aggregation weights are applied. + If the data is None, nothing happens. + """ if data is None: return None elif isinstance(data, TimeSeries): data.restore_data() return data - - time_series = TimeSeries.from_datasource( - name=f'{element.label_full}|{name}', - data=data.data if isinstance(data, TimeSeriesData) else data, - timesteps=timesteps, - periods=periods, - aggregation_weight=data.agg_weight if isinstance(data, TimeSeriesData) else None, + return time_series_collection.create_time_series( + data=data, + name=name, + extra_timestep=extra_timestep, ) - element.used_time_series.append(time_series) - if isinstance(data, TimeSeriesData): - data.label = time_series.name # Connecting User_time_series to TimeSeries - return time_series class Element(Interface): @@ -306,15 +302,6 @@ def create_model(self, model: SystemModel) -> 'ElementModel': def label_full(self) -> str: return self.label - def _create_time_series( - self, - name: str, - data: Optional[Union[Numeric_TS, TimeSeries]], - timesteps: pd.DatetimeIndex, - periods: Optional[pd.Index], - ) -> Optional[TimeSeries]: - return super()._create_time_series(self, name, data, timesteps, periods) - @staticmethod def _valid_label(label: str) -> str: """ From 9799b46b500abfa0906bb4865b5e36c84a51014a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 21 Feb 2025 18:48:22 +0100 Subject: [PATCH 164/507] Update overall TimeSeries Management --- flixOpt/calculation.py | 3 +-- flixOpt/components.py | 45 ++++++++++++++++++++++-------------------- flixOpt/core.py | 33 ++++++++++++++++++++++--------- flixOpt/effects.py | 19 ++++++++---------- flixOpt/elements.py | 24 +++++++++++----------- flixOpt/flow_system.py | 2 +- flixOpt/interface.py | 17 ++++++++-------- flixOpt/structure.py | 17 +++++++++++++++- 8 files changed, 95 insertions(+), 65 deletions(-) diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py index 595d30fb1..653ce91fe 100644 --- a/flixOpt/calculation.py +++ b/flixOpt/calculation.py @@ -244,7 +244,6 @@ def do_modeling(self) -> SystemModel: f'step size of {dt_min} hours). It must be a multiple of {dt_min} hours.' ) - logger.info(f'{"":#^80}') logger.info(f'{" Aggregating TimeSeries Data ":#^80}') @@ -262,7 +261,7 @@ def do_modeling(self) -> SystemModel: self.aggregation.cluster() self.aggregation.plot() if self.aggregation_parameters.aggregate_data_and_fix_non_binary_vars: - self.time_series_collection.insert_data(self.aggregation.aggregated_data) + self.flow_system.time_series_collection.insert_data(self.aggregation.aggregated_data) self.durations['aggregation'] = round(timeit.default_timer() - t_start_agg, 2) # Model the System diff --git a/flixOpt/components.py b/flixOpt/components.py index 9c38cde26..6874b50e6 100644 --- a/flixOpt/components.py +++ b/flixOpt/components.py @@ -10,7 +10,7 @@ import linopy from . import utils -from .core import Numeric, Numeric_TS, Skalar, TimeSeries +from .core import Numeric, Numeric_TS, Skalar, TimeSeries, TimeSeriesCollection from .elements import Component, ComponentModel, Flow from .features import InvestmentModel, MultipleSegmentsModel, OnOffModel from .interface import InvestParameters, OnOffParameters @@ -96,29 +96,30 @@ def _plausibility_checks(self) -> None: f'(in flow {flow.label_full}) do not make sense together!' ) - def transform_data(self, flow_system: 'FlowSystem'): - super().transform_data(flow_system) + def transform_data(self, time_series_collection: TimeSeriesCollection): + super().transform_data(time_series_collection) if self.conversion_factors: - self.conversion_factors = self._transform_conversion_factors(flow_system) + self.conversion_factors = self._transform_conversion_factors(time_series_collection) else: segmented_conversion_factors = {} for flow, segments in self.segmented_conversion_factors.items(): segmented_conversion_factors[flow] = [ ( - self._create_time_series('Stützstelle', segment[0], flow_system.timesteps, flow_system.periods), - self._create_time_series('Stützstelle', segment[1], flow_system.timesteps, flow_system.periods), + self._create_time_series(f'{flow.label}|Stützstelle|{idx}a', segment[0], time_series_collection), + self._create_time_series(f'{flow.label}|Stützstelle|{idx}b', segment[1], time_series_collection), ) - for segment in segments + for idx, segment in enumerate(segments) ] self.segmented_conversion_factors = segmented_conversion_factors - def _transform_conversion_factors(self, flow_system: 'FlowSystem') -> List[Dict[Flow, TimeSeries]]: + def _transform_conversion_factors(self, time_series_collection: TimeSeriesCollection) -> List[Dict[Flow, TimeSeries]]: """macht alle Faktoren, die nicht TimeSeries sind, zu TimeSeries""" list_of_conversion_factors = [] for idx, conversion_factor in enumerate(self.conversion_factors): transformed_dict = {} for flow, values in conversion_factor.items(): - transformed_dict[flow] = self._create_time_series(f'{flow.label}_factor{idx}', values, flow_system.timesteps, flow_system.periods) + # TODO: Might be better to use the label of the component instead of the flow + transformed_dict[flow] = flow._create_time_series(f'conversion_factor{idx}', values, time_series_collection) list_of_conversion_factors.append(transformed_dict) return list_of_conversion_factors @@ -219,19 +220,21 @@ def create_model(self, model: SystemModel) -> 'StorageModel': self.model = StorageModel(model, self) return self.model - def transform_data(self, flow_system: 'FlowSystem') -> None: - super().transform_data(flow_system) + def transform_data(self, time_series_collection: TimeSeriesCollection) -> None: + super().transform_data(time_series_collection) self.relative_minimum_charge_state = self._create_time_series( - 'relative_minimum_charge_state', self.relative_minimum_charge_state, flow_system.timesteps_extra, flow_system.periods + 'relative_minimum_charge_state', self.relative_minimum_charge_state, time_series_collection, + extra_timestep=True ) self.relative_maximum_charge_state = self._create_time_series( - 'relative_maximum_charge_state', self.relative_maximum_charge_state, flow_system.timesteps_extra, flow_system.periods + 'relative_maximum_charge_state', self.relative_maximum_charge_state, time_series_collection, + extra_timestep=True ) - self.eta_charge = self._create_time_series('eta_charge', self.eta_charge, flow_system.timesteps, flow_system.periods) - self.eta_discharge = self._create_time_series('eta_discharge', self.eta_discharge, flow_system.timesteps, flow_system.periods) - self.relative_loss_per_hour = self._create_time_series('relative_loss_per_hour', self.relative_loss_per_hour, flow_system.timesteps, flow_system.periods) + self.eta_charge = self._create_time_series('eta_charge', self.eta_charge, time_series_collection) + self.eta_discharge = self._create_time_series('eta_discharge', self.eta_discharge, time_series_collection) + self.relative_loss_per_hour = self._create_time_series('relative_loss_per_hour', self.relative_loss_per_hour, time_series_collection) if isinstance(self.capacity_in_flow_hours, InvestParameters): - self.capacity_in_flow_hours.transform_data(flow_system) + self.capacity_in_flow_hours.transform_data(time_series_collection) class Transmission(Component): @@ -318,10 +321,10 @@ def create_model(self, model) -> 'TransmissionModel': self.model = TransmissionModel(model, self) return self.model - def transform_data(self, flow_system: 'FlowSystem') -> None: - super().transform_data(flow_system) - self.relative_losses = self._create_time_series('relative_losses', self.relative_losses, flow_system.timesteps, flow_system.periods) - self.absolute_losses = self._create_time_series('absolute_losses', self.absolute_losses, flow_system.timesteps, flow_system.periods) + def transform_data(self, time_series_collection: TimeSeriesCollection) -> None: + super().transform_data(time_series_collection) + self.relative_losses = self._create_time_series('relative_losses', self.relative_losses, time_series_collection) + self.absolute_losses = self._create_time_series('absolute_losses', self.absolute_losses, time_series_collection) class TransmissionModel(ComponentModel): diff --git a/flixOpt/core.py b/flixOpt/core.py index 109bdbe7f..f42b3111e 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -41,7 +41,7 @@ class DataConverter: - ValueError if data dimensions do not match expected time and period indexes. """ @staticmethod - def as_dataarray(data: Union[Numeric, pd.Series, pd.DataFrame, np.ndarray], time: pd.DatetimeIndex, + def as_dataarray(data: NumericData, time: pd.DatetimeIndex, period: Optional[pd.Index] = None) -> xr.DataArray: """ Converts the given data to an xarray.DataArray with the specified time and period indexes. @@ -55,14 +55,18 @@ def as_dataarray(data: Union[Numeric, pd.Series, pd.DataFrame, np.ndarray], time if isinstance(data, (int, float)): return DataConverter._handle_scalar(data, coords, dims) - if isinstance(data, pd.DataFrame): + elif isinstance(data, pd.DataFrame): return DataConverter._handle_dataframe(data, coords, dims) - if isinstance(data, pd.Series): + elif isinstance(data, pd.Series): return DataConverter._handle_series(data, coords, dims) - if isinstance(data, np.ndarray): + elif isinstance(data, np.ndarray): return DataConverter._handle_array(data, coords, dims) + elif isinstance(data, xr.DataArray): + return data + - raise TypeError("Unsupported data type. Must be scalar, np.ndarray, pd.Series, or pd.DataFrame.") + raise TypeError(f"Unsupported data type. Must be scalar, np.ndarray, pd.Series, or pd.DataFrame." + f"Got {type(data)=}") @staticmethod def _handle_scalar(data: Numeric, coords: list, dims: list) -> xr.DataArray: @@ -106,6 +110,17 @@ def _handle_array(data: np.ndarray, coords: list, dims: list) -> xr.DataArray: return xr.DataArray(data, coords=coords, dims=dims) + @staticmethod + def _handle_xr_dataarray(data: xr.DataArray, coords: list, dims: list) -> xr.DataArray: + """Handles xr.DataArray input.""" + if data.ndim != len(coords): + raise ValueError(f"DataArray must have {len(coords)} dimensions, got {data.ndim}") + if data.dims != dims: + raise ValueError(f"DataArray dimensions {data.dims} do not match expected dimensions {dims}") + if data.shape != tuple(coord.size for coord in coords): + raise ValueError(f"DataArray shape {data.shape} does not match expected shape {tuple(coord.size for coord in coords)}") + # TODO: This is not really thought through or tested + return data class TimeSeriesData: # TODO: Move to Interface.py @@ -377,10 +392,10 @@ def __init__(self, self.add_time_series(*timeseries) def add_time_series(self, *time_series: TimeSeries): - for time_series in list(time_series): - if len(time_series.active_timesteps) - len(self.timesteps) == 1: - self._timeserieses_longer.append(time_series) - self.time_serieses.extend(time_series) + for single_time_series in time_series: + if len(single_time_series.active_timesteps) - len(self.timesteps) == 1: + self._timeserieses_longer.append(single_time_series) + self.time_serieses.extend(list(time_series)) self._check_unique_labels() def create_time_series( diff --git a/flixOpt/effects.py b/flixOpt/effects.py index f4e527c73..2c53e3ce3 100644 --- a/flixOpt/effects.py +++ b/flixOpt/effects.py @@ -12,7 +12,7 @@ import pandas as pd import linopy -from .core import Numeric, Numeric_TS, Skalar, TimeSeries +from .core import Numeric, Numeric_TS, Skalar, TimeSeries, TimeSeriesCollection from .features import ShareAllocationModel from .math_modeling import Equation, Variable from .structure import Element, ElementModel, SystemModel, Model @@ -136,20 +136,19 @@ def error_str(effect_label: str, share_ffect_label: str): f'Error: circular invest-shares \n{error_str(target_effect.label, target_effect.label)}' ) - def transform_data(self, flow_system: 'FlowSystem'): + def transform_data(self, time_series_collection: TimeSeriesCollection): self.minimum_operation_per_hour = self._create_time_series( - 'minimum_operation_per_hour', self.minimum_operation_per_hour, flow_system.timesteps, flow_system.periods + 'minimum_operation_per_hour', self.minimum_operation_per_hour, time_series_collection ) self.maximum_operation_per_hour = self._create_time_series( - 'maximum_operation_per_hour', self.maximum_operation_per_hour, flow_system.timesteps, flow_system.periods + 'maximum_operation_per_hour', self.maximum_operation_per_hour, time_series_collection ) self.specific_share_to_other_effects_operation = effect_values_to_time_series( 'operation_to', self.specific_share_to_other_effects_operation, self, - flow_system.timesteps, - flow_system.periods + time_series_collection ) def create_model(self, model: SystemModel) -> 'EffectModel': @@ -225,8 +224,7 @@ def do_modeling(self, system_model: SystemModel): def effect_values_to_time_series(label_suffix: str, effect_values: EffectValuesUser, parent_element: Element, - timesteps: pd.DatetimeIndex, - periods: Optional[pd.Index]) -> Optional[EffectValuesTS]: + time_series_collection: TimeSeriesCollection) -> Optional[EffectValuesTS]: """ Transform EffectValues to EffectValuesTS. Creates a TimeSeries for each key in the nested_values dictionary, using the value as the data. @@ -241,10 +239,9 @@ def effect_values_to_time_series(label_suffix: str, effect_values_ts: EffectValuesTS = { effect: parent_element._create_time_series( - f'{effect.label if effect is not None else "Standard_Effect"}_{label_suffix}', + f'{effect.label_full if effect is not None else "Standard_Effect"}|{label_suffix}', value, - timesteps, - periods + time_series_collection ) for effect, value in effect_values.items() } diff --git a/flixOpt/elements.py b/flixOpt/elements.py index d5829759a..004d53533 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -10,7 +10,7 @@ import pandas as pd from .config import CONFIG -from .core import Numeric, Numeric_TS, Skalar +from .core import Numeric, Numeric_TS, Skalar, TimeSeriesCollection from .effects import EffectValuesUser, effect_values_to_time_series from .features import InvestmentModel, OnOffModel, PreventSimultaneousUsageModel from .interface import InvestParameters, OnOffParameters @@ -69,9 +69,9 @@ def create_model(self, model: SystemModel) -> 'ComponentModel': self.model = ComponentModel(model, self) return self.model - def transform_data(self, flow_system: 'FlowSystem') -> None: + def transform_data(self, time_series_collection: TimeSeriesCollection) -> None: if self.on_off_parameters is not None: - self.on_off_parameters.transform_data(flow_system, self) + self.on_off_parameters.transform_data(time_series_collection, self) def register_component_in_flows(self) -> None: for flow in self.inputs + self.outputs: @@ -120,9 +120,9 @@ def create_model(self, model: SystemModel) -> 'BusModel': self.model = BusModel(model, self) return self.model - def transform_data(self, flow_system: 'FlowSystem'): + def transform_data(self, time_series_collection: TimeSeriesCollection): self.excess_penalty_per_flow_hour = self._create_time_series( - 'excess_penalty_per_flow_hour', self.excess_penalty_per_flow_hour, flow_system.timesteps, flow_system.periods + 'excess_penalty_per_flow_hour', self.excess_penalty_per_flow_hour, time_series_collection ) def add_input(self, flow) -> None: @@ -242,15 +242,15 @@ def create_model(self, model: SystemModel) -> 'FlowModel': self.model = FlowModel(model, self) return self.model - def transform_data(self, flow_system: 'FlowSystem'): - self.relative_minimum = self._create_time_series('relative_minimum', self.relative_minimum, flow_system.timesteps, flow_system.periods) - self.relative_maximum = self._create_time_series('relative_maximum', self.relative_maximum, flow_system.timesteps, flow_system.periods) - self.fixed_relative_profile = self._create_time_series('fixed_relative_profile', self.fixed_relative_profile, flow_system.timesteps, flow_system.periods) - self.effects_per_flow_hour = effect_values_to_time_series('per_flow_hour', self.effects_per_flow_hour, self, flow_system.timesteps, flow_system.periods) + def transform_data(self, time_series_collection: TimeSeriesCollection): + self.relative_minimum = self._create_time_series('relative_minimum', self.relative_minimum, time_series_collection) + self.relative_maximum = self._create_time_series('relative_maximum', self.relative_maximum, time_series_collection) + self.fixed_relative_profile = self._create_time_series('fixed_relative_profile', self.fixed_relative_profile, time_series_collection) + self.effects_per_flow_hour = effect_values_to_time_series('per_flow_hour', self.effects_per_flow_hour, self, time_series_collection) if self.on_off_parameters is not None: - self.on_off_parameters.transform_data(flow_system, self) + self.on_off_parameters.transform_data(time_series_collection, self) if isinstance(self.size, InvestParameters): - self.size.transform_data(flow_system) + self.size.transform_data(time_series_collection) def infos(self, use_numpy=True, use_element_label=False) -> Dict: infos = super().infos(use_numpy, use_element_label) diff --git a/flixOpt/flow_system.py b/flixOpt/flow_system.py index 15015b723..37f729d0a 100644 --- a/flixOpt/flow_system.py +++ b/flixOpt/flow_system.py @@ -100,7 +100,7 @@ def add_elements(self, *args: Element) -> None: def transform_data(self): for element in self.all_elements.values(): - element.transform_data(self) + element.transform_data(self.time_series_collection) def network_infos(self) -> Tuple[Dict[str, Dict[str, str]], Dict[str, Dict[str, str]]]: nodes = { diff --git a/flixOpt/interface.py b/flixOpt/interface.py index 8c4b0c84f..6411a6ab0 100644 --- a/flixOpt/interface.py +++ b/flixOpt/interface.py @@ -8,6 +8,7 @@ import pandas as pd +from flixOpt.core import TimeSeriesCollection from .config import CONFIG from .core import Numeric, Numeric_TS, Skalar from .structure import Element, Interface @@ -81,7 +82,7 @@ def __init__( self._minimum_size = minimum_size self._maximum_size = maximum_size or CONFIG.modeling.BIG # default maximum - def transform_data(self, flow_system: 'FlowSystem'): + def transform_data(self, time_series_collection: TimeSeriesCollection): from .effects import effect_values_to_dict self.fix_effects = effect_values_to_dict(self.fix_effects) @@ -153,26 +154,26 @@ def __init__( self.switch_on_total_max: Skalar = switch_on_total_max self.force_switch_on: bool = force_switch_on - def transform_data(self, flow_system: 'FlowSystem', owner: 'Element'): + def transform_data(self, time_series_collection: TimeSeriesCollection, name_prefix: str): from .effects import effect_values_to_time_series self.effects_per_switch_on = effect_values_to_time_series( - 'per_switch_on', self.effects_per_switch_on, owner, flow_system.timesteps, flow_system.periods + 'per_switch_on', self.effects_per_switch_on, name_prefix, time_series_collection ) self.effects_per_running_hour = effect_values_to_time_series( - 'per_running_hour', self.effects_per_running_hour, owner, flow_system.timesteps, flow_system.periods + 'per_running_hour', self.effects_per_running_hour, name_prefix, time_series_collection ) self.consecutive_on_hours_min = self._create_time_series( - owner, 'consecutive_on_hours_min', self.consecutive_on_hours_min, flow_system.timesteps, flow_system.periods + 'consecutive_on_hours_min', self.consecutive_on_hours_min, time_series_collection ) self.consecutive_on_hours_max = self._create_time_series( - owner, 'consecutive_on_hours_max', self.consecutive_on_hours_max, flow_system.timesteps, flow_system.periods + 'consecutive_on_hours_max', self.consecutive_on_hours_max, time_series_collection ) self.consecutive_off_hours_min = self._create_time_series( - owner, 'consecutive_off_hours_min', self.consecutive_off_hours_min, flow_system.timesteps, flow_system.periods + 'consecutive_off_hours_min', self.consecutive_off_hours_min, time_series_collection ) self.consecutive_off_hours_max = self._create_time_series( - owner, 'consecutive_off_hours_max', self.consecutive_off_hours_max, flow_system.timesteps, flow_system.periods + 'consecutive_off_hours_max', self.consecutive_off_hours_max, time_series_collection ) @property diff --git a/flixOpt/structure.py b/flixOpt/structure.py index ac861f849..cbab25056 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -253,7 +253,7 @@ def _create_time_series( name: str, data: Optional[Union[NumericData, TimeSeriesData, TimeSeries]], time_series_collection: TimeSeriesCollection, - extra_timestep: bool = True, + extra_timestep: bool = False, ) -> Optional[TimeSeries]: """ Tries to create a TimeSeries from Numeric Data and adds it to the time_series_collection @@ -302,6 +302,21 @@ def create_model(self, model: SystemModel) -> 'ElementModel': def label_full(self) -> str: return self.label + def _create_time_series( + self, + name: str, + data: Optional[Union[NumericData, TimeSeriesData, TimeSeries]], + time_series_collection: TimeSeriesCollection, + extra_timestep: bool = False, + ) -> Optional[TimeSeries]: + """ + Tries to create a TimeSeries from Numeric Data and adds it to the time_series_collection + If the data already is a TimeSeries, nothing happens and the TimeSeries gets reset and returned + If the data is a TimeSeriesData, it is converted to a TimeSeries, and the aggregation weights are applied. + If the data is None, nothing happens. + """ + return super()._create_time_series(f'{self.label_full}|{name}', data, time_series_collection, extra_timestep) + @staticmethod def _valid_label(label: str) -> str: """ From 8c46b8995953d6965306e3459704740e1a78b9f4 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 21 Feb 2025 18:48:44 +0100 Subject: [PATCH 165/507] Make example run again --- examples/02_Complex/complex_example_results.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/02_Complex/complex_example_results.py b/examples/02_Complex/complex_example_results.py index 06548cdbd..03b3e31bc 100644 --- a/examples/02_Complex/complex_example_results.py +++ b/examples/02_Complex/complex_example_results.py @@ -35,8 +35,8 @@ # --- Plotting internal variables manually --- on_data = pd.DataFrame( { - 'BHKW2 On': results.component_results['BHKW2'].variables['Q_th']['OnOff']['on'], - 'Kessel On': results.component_results['Kessel'].variables['Q_th']['OnOff']['on'], + 'BHKW2 On': results.component_results['BHKW2'].variables['Q_th']['on'], + 'Kessel On': results.component_results['Kessel'].variables['Q_th']['on'], }, index=results.time, ) From d19c8615fed51406de2d2f035238124040dd5b0d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 21 Feb 2025 18:51:15 +0100 Subject: [PATCH 166/507] Change test runner script back --- tests/run_all_tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/run_all_tests.py b/tests/run_all_tests.py index 83b6dfacf..6e5774214 100644 --- a/tests/run_all_tests.py +++ b/tests/run_all_tests.py @@ -7,4 +7,4 @@ import pytest if __name__ == '__main__': - pytest.main(['test_integration.py', '--disable-warnings']) + pytest.main(['--disable-warnings']) From de36682c770efa9b1aaafe0d9a33136510cbcf6a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 21 Feb 2025 18:52:12 +0100 Subject: [PATCH 167/507] Indents --- flixOpt/core.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/flixOpt/core.py b/flixOpt/core.py index f42b3111e..27acec92d 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -41,8 +41,7 @@ class DataConverter: - ValueError if data dimensions do not match expected time and period indexes. """ @staticmethod - def as_dataarray(data: NumericData, time: pd.DatetimeIndex, - period: Optional[pd.Index] = None) -> xr.DataArray: + def as_dataarray(data: NumericData, time: pd.DatetimeIndex, period: Optional[pd.Index] = None) -> xr.DataArray: """ Converts the given data to an xarray.DataArray with the specified time and period indexes. """ @@ -64,7 +63,6 @@ def as_dataarray(data: NumericData, time: pd.DatetimeIndex, elif isinstance(data, xr.DataArray): return data - raise TypeError(f"Unsupported data type. Must be scalar, np.ndarray, pd.Series, or pd.DataFrame." f"Got {type(data)=}") @@ -122,6 +120,7 @@ def _handle_xr_dataarray(data: xr.DataArray, coords: list, dims: list) -> xr.Dat # TODO: This is not really thought through or tested return data + class TimeSeriesData: # TODO: Move to Interface.py def __init__(self, data: Numeric, agg_group: Optional[str] = None, agg_weight: Optional[float] = None): From d01301e7d9f9b8b7ed83def2c4b18dc5eb45910f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 22 Feb 2025 11:13:08 +0100 Subject: [PATCH 168/507] Ruff check improvements --- flixOpt/aggregation.py | 4 ++-- flixOpt/calculation.py | 4 ++-- flixOpt/components.py | 6 +++--- flixOpt/core.py | 5 ++--- flixOpt/effects.py | 19 ++++++++----------- flixOpt/elements.py | 4 ++-- flixOpt/features.py | 13 ++++++------- flixOpt/interface.py | 1 + flixOpt/math_modeling.py | 4 ++-- flixOpt/structure.py | 14 +++++++------- tests/test_dataconverter.py | 3 ++- tests/test_timeseries.py | 5 +++-- 12 files changed, 40 insertions(+), 42 deletions(-) diff --git a/flixOpt/aggregation.py b/flixOpt/aggregation.py index 04dcff605..b6a912078 100644 --- a/flixOpt/aggregation.py +++ b/flixOpt/aggregation.py @@ -8,7 +8,7 @@ import timeit import warnings from collections import Counter -from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Set +from typing import TYPE_CHECKING, Dict, List, Optional, Set, Tuple import linopy import numpy as np @@ -319,7 +319,7 @@ def do_modeling(self): penalty = self.aggregation_parameters.penalty_of_period_freedom if (self.aggregation_parameters.percentage_of_period_freedom > 0) and penalty != 0: - for label, variable in self.variables_direct.items(): + for variable in self.variables_direct.values(): self._model.effects.add_share_to_penalty( self._model, 'Aggregation', diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py index 653ce91fe..5b385214f 100644 --- a/flixOpt/calculation.py +++ b/flixOpt/calculation.py @@ -213,7 +213,7 @@ def __init__( """ super().__init__(name, flow_system, active_timesteps) if flow_system.periods is not None: - raise NotImplementedError(f'Multiple Periods are currently not supported in AggregatedCalculation') + raise NotImplementedError('Multiple Periods are currently not supported in AggregatedCalculation') self.aggregation_parameters = aggregation_parameters self.components_to_clusterize = components_to_clusterize self.time_series_for_aggregation = None @@ -329,7 +329,7 @@ def __init__( """ super().__init__(name, flow_system, active_timesteps) if flow_system.periods is not None: - raise NotImplementedError(f'Multiple Periods are currently not supported in SegmentedCalculation') + raise NotImplementedError('Multiple Periods are currently not supported in SegmentedCalculation') self.segment_length = segment_length self.overlap_length = overlap_length self._total_length = len(self.flow_system.timesteps) if self.time_indices is not None else len(flow_system.time_series) diff --git a/flixOpt/components.py b/flixOpt/components.py index 6874b50e6..f97abd0f5 100644 --- a/flixOpt/components.py +++ b/flixOpt/components.py @@ -3,11 +3,11 @@ """ import logging -from typing import Dict, List, Literal, Optional, Set, Tuple, Union, TYPE_CHECKING +from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Set, Tuple, Union +import linopy import numpy as np import pandas as pd -import linopy from . import utils from .core import Numeric, Numeric_TS, Skalar, TimeSeries, TimeSeriesCollection @@ -491,7 +491,7 @@ def do_modeling(self, system_model): def _initial_and_final_charge_state(self, system_model): if self.element.initial_charge_state is not None: - name_short = f'initial_charge_state' + name_short = 'initial_charge_state' name = f'{self.label_full}|{name_short}' if utils.is_number(self.element.initial_charge_state): diff --git a/flixOpt/core.py b/flixOpt/core.py index 27acec92d..c8a30829c 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -5,13 +5,12 @@ import inspect import logging -from typing import Any, Dict, List, Optional, Union, Tuple, Literal from collections import Counter +from typing import Any, Dict, List, Literal, Optional, Tuple, Union import numpy as np -import xarray as xr import pandas as pd - +import xarray as xr logger = logging.getLogger('flixOpt') diff --git a/flixOpt/effects.py b/flixOpt/effects.py index 2c53e3ce3..cd96858ee 100644 --- a/flixOpt/effects.py +++ b/flixOpt/effects.py @@ -6,16 +6,16 @@ """ import logging -from typing import Dict, Literal, Optional, Union, List, TYPE_CHECKING +from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Union +import linopy import numpy as np import pandas as pd -import linopy from .core import Numeric, Numeric_TS, Skalar, TimeSeries, TimeSeriesCollection from .features import ShareAllocationModel from .math_modeling import Equation, Variable -from .structure import Element, ElementModel, SystemModel, Model +from .structure import Element, ElementModel, Model, SystemModel if TYPE_CHECKING: from .flow_system import FlowSystem @@ -338,10 +338,7 @@ def __getitem__(self, effect: Union[str, Effect]) -> 'Effect': KeyError: If no standard effect is specified. """ if effect is None: - try: - return self.standard_effect - except: - raise KeyError(f'No Standard-effect specified!') + return self.standard_effect if isinstance(effect, Effect): if effect in self: return effect @@ -349,8 +346,8 @@ def __getitem__(self, effect: Union[str, Effect]) -> 'Effect': raise KeyError(f'Effect {effect} not found!') try: return self.effects[effect] - except: - raise KeyError(f'No effect with label {effect} found!') + except KeyError as e: + raise KeyError(f'No effect with label {effect} found!') from e def __contains__(self, item: Union[str, 'Effect']) -> bool: """Check if the effect exists. Checks for label or object""" @@ -378,7 +375,7 @@ def effects(self, value: List[Effect]): @property def standard_effect(self) -> Effect: if self._standard_effect is None: - raise KeyError(f'No standard-effect specified!') + raise KeyError('No standard-effect specified!') return self._standard_effect @standard_effect.setter @@ -390,7 +387,7 @@ def standard_effect(self, value: Effect) -> None: @property def objective_effect(self) -> Effect: if self._objective_effect is None: - raise KeyError(f'No objective-effect specified!') + raise KeyError('No objective-effect specified!') return self._objective_effect @objective_effect.setter diff --git a/flixOpt/elements.py b/flixOpt/elements.py index 004d53533..f2c81d91a 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -3,10 +3,10 @@ """ import logging -from typing import Dict, List, Optional, Tuple, Union, TYPE_CHECKING, Literal +from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Tuple, Union -import numpy as np import linopy +import numpy as np import pandas as pd from .config import CONFIG diff --git a/flixOpt/features.py b/flixOpt/features.py index 2006c2af0..c394626b9 100644 --- a/flixOpt/features.py +++ b/flixOpt/features.py @@ -4,17 +4,17 @@ """ import logging -from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union, Literal +from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Tuple, Union import linopy import numpy as np +from . import utils from .config import CONFIG from .core import Numeric, Skalar, TimeSeries from .interface import InvestParameters, OnOffParameters from .math_modeling import Equation, Variable, VariableTS from .structure import Model, SystemModel -from . import utils if TYPE_CHECKING: # for type checking and preventing circular imports from .components import Storage @@ -325,7 +325,6 @@ def _add_on_constraints(self): nr_of_def_vars = len(self._defining_variables) assert nr_of_def_vars > 0, 'Achtung: mindestens 1 Flow notwendig' - EPSILON = CONFIG.modeling.EPSILON if nr_of_def_vars == 1: def_var = self._defining_variables[0] @@ -334,7 +333,7 @@ def _add_on_constraints(self): # eq: On(t) * max(epsilon, lower_bound) <= Q_th(t) self.add( self._model.add_constraints( - self.on * np.maximum(EPSILON, lb) <= def_var, + self.on * np.maximum(CONFIG.modeling.EPSILON, lb) <= def_var, name=f'{self.label_full}|on_con1' ), 'on_con1' @@ -343,7 +342,7 @@ def _add_on_constraints(self): # eq: Q_th(t) <= Q_th_max * On(t) self.add( self._model.add_constraints( - self.on * np.maximum(EPSILON, ub) >= def_var, + self.on * np.maximum(CONFIG.modeling.EPSILON, ub) >= def_var, name=f'{self.label_full}|on_con2' ), 'on_con2' @@ -351,7 +350,7 @@ def _add_on_constraints(self): else: # Bei mehreren Leistungsvariablen: ub = sum(bound[1] for bound in self._defining_bounds) - lb = EPSILON + lb = CONFIG.modeling.EPSILON # When all defining variables are 0, On is 0 # eq: On(t) * Epsilon <= sum(alle Leistungen(t)) @@ -828,7 +827,7 @@ def do_modeling(self, system_model: SystemModel): self.add(self._model.add_constraints( sum([segment.in_segment for segment in self._segment_models]) <= rhs, name=f'{self.label_full}|{variable.name}_single_segment'), - f'single_segment' + 'single_segment' ) @property diff --git a/flixOpt/interface.py b/flixOpt/interface.py index 6411a6ab0..88d90cce3 100644 --- a/flixOpt/interface.py +++ b/flixOpt/interface.py @@ -9,6 +9,7 @@ import pandas as pd from flixOpt.core import TimeSeriesCollection + from .config import CONFIG from .core import Numeric, Numeric_TS, Skalar from .structure import Element, Interface diff --git a/flixOpt/math_modeling.py b/flixOpt/math_modeling.py index 5f1543c1e..175fe3513 100644 --- a/flixOpt/math_modeling.py +++ b/flixOpt/math_modeling.py @@ -5,12 +5,12 @@ and translate it into a ModelingLanguage like Pyomo, and the solve it through a solver. Multiple solvers are supported. """ -from dataclasses import dataclass, field import logging import re import timeit from abc import ABC, abstractmethod -from typing import Any, Dict, List, Literal, Optional, Union, ClassVar +from dataclasses import dataclass, field +from typing import Any, ClassVar, Dict, List, Literal, Optional, Union import numpy as np from numpy import inf diff --git a/flixOpt/structure.py b/flixOpt/structure.py index cbab25056..d0b07389c 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -9,24 +9,24 @@ import pathlib from datetime import datetime from io import StringIO -from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Union, Tuple +from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, Union +import linopy import numpy as np +import pandas as pd +import xarray as xr from rich.console import Console from rich.pretty import Pretty -import xarray as xr -import linopy -import pandas as pd from . import utils from .config import CONFIG -from .core import Numeric, Numeric_TS, Skalar, TimeSeries, TimeSeriesData, TimeSeriesCollection, NumericData -from .math_modeling import Equation, Inequation, MathModel, _Solver, Variable, VariableTS +from .core import Numeric, Numeric_TS, NumericData, Skalar, TimeSeries, TimeSeriesCollection, TimeSeriesData +from .math_modeling import Equation, Inequation, MathModel, Variable, VariableTS, _Solver if TYPE_CHECKING: # for type checking and preventing circular imports + from .effects import EffectCollection from .elements import BusModel, ComponentModel from .flow_system import FlowSystem - from .effects import EffectCollection logger = logging.getLogger('flixOpt') diff --git a/tests/test_dataconverter.py b/tests/test_dataconverter.py index 2634f7730..b2edf2668 100644 --- a/tests/test_dataconverter.py +++ b/tests/test_dataconverter.py @@ -1,7 +1,8 @@ -import pytest import numpy as np import pandas as pd +import pytest import xarray as xr + from flixOpt.core import DataConverter # Adjust this import to match your project structure diff --git a/tests/test_timeseries.py b/tests/test_timeseries.py index 7c9cec649..36b9e98ab 100644 --- a/tests/test_timeseries.py +++ b/tests/test_timeseries.py @@ -1,10 +1,11 @@ -import pytest +import linopy import pandas as pd +import pytest import xarray as xr -import linopy from flixOpt.core import TimeSeries # Adjust import based on your module structure + # Helper function to create a test TimeSeries object def create_test_timeseries(): data = xr.DataArray([10, 20, 30], coords={'time': pd.date_range('2023-01-01', periods=3)}) From cfad041a08facab242a8279c57d1fb4ed62d9c81 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 22 Feb 2025 11:14:19 +0100 Subject: [PATCH 169/507] Improve TImeSeriesCollcection to handle TimeSerieses and indexing better --- flixOpt/core.py | 172 +++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 142 insertions(+), 30 deletions(-) diff --git a/flixOpt/core.py b/flixOpt/core.py index c8a30829c..70e31ac25 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -227,11 +227,15 @@ def __init__(self, self._active_periods = self.stored_data.indexes['period'] if 'period' in self.stored_data.indexes else None self._update_active_data() + def reset(self): + """Reset active timesteps and periods.""" + self.active_timesteps = None + self.active_periods = None + def restore_data(self): """Restore stored_data from the backup.""" self._stored_data = self._backup.copy() - self.active_timesteps = None - self.active_periods = None + self.reset() def _update_active_data(self): """Update the active data.""" @@ -368,32 +372,39 @@ def __array_ufunc__(self, ufunc, method, *inputs, **kwargs): class TimeSeriesCollection: - def __init__(self, - timesteps: pd.DatetimeIndex, - hours_of_last_timestep: Optional[float], - hours_of_previous_timesteps: Optional[Union[int, float, np.ndarray]], - periods: Optional[List[int]], *timeseries: TimeSeries): + def __init__( + self, + timesteps: pd.DatetimeIndex, + hours_of_last_timestep: Optional[float], + hours_of_previous_timesteps: Optional[Union[int, float, np.ndarray]], + periods: Optional[List[int]] + ): + self.hours_of_last_timestep = hours_of_last_timestep ( - self.timesteps, - self.timesteps_extra, - self.hours_per_timestep, - self.hours_of_previous_timesteps, - self.periods) = TimeSeriesCollection.allign_dimensions( - timesteps, periods, hours_of_last_timestep, hours_of_previous_timesteps - ) + self._timesteps, + self._timesteps_extra, + self._hours_per_timestep, + self._hours_of_previous_timesteps, + self._periods + ) = TimeSeriesCollection.allign_dimensions(timesteps, + periods, + hours_of_last_timestep, + hours_of_previous_timesteps) + + self._active_timesteps = None + self._active_timesteps_extra = None + self._active_periods = None + self._active_hours_per_timestep = None self.group_weights: Dict[str, float] = {} self.weights: Dict[str, float] = {} self.time_serieses: List[TimeSeries] = [] - self._timeserieses_longer: List[TimeSeries] = [] - - self.add_time_series(*timeseries) - - def add_time_series(self, *time_series: TimeSeries): - for single_time_series in time_series: - if len(single_time_series.active_timesteps) - len(self.timesteps) == 1: - self._timeserieses_longer.append(single_time_series) - self.time_serieses.extend(list(time_series)) + self._timeserieses_longer: List[TimeSeries] = [] # All part of self.time_serieses, but with extra timestep + + def _add_time_series(self, time_series: TimeSeries, extra_timestep: bool): + self.time_serieses.append(time_series) + if extra_timestep: + self._timeserieses_longer.append(time_series) self._check_unique_labels() def create_time_series( @@ -423,7 +434,7 @@ def create_time_series( """ if isinstance(data, TimeSeries): if data not in self.time_serieses: - self.add_time_series(data) + self._add_time_series(data, extra_timestep) return data time_series = TimeSeries.from_datasource( @@ -438,24 +449,92 @@ def create_time_series( if isinstance(data, TimeSeriesData): data.label = time_series.name # Connecting User_time_series to TimeSeries - self.add_time_series(time_series) + self._add_time_series(time_series, extra_timestep) return time_series - + def calculate_aggregation_weights(self) -> Dict[str, float]: self.group_weights = self._calculate_group_weights() self.weights = self._calculate_aggregation_weights() - + if np.all(np.isclose(list(self.weights.values()), 1, atol=1e-6)): logger.info('All Aggregation weights were set to 1') return self.weights - def insert_data(self, data: pd.DataFrame): + def update_data(self, + active_timesteps: Optional[pd.DatetimeIndex] = None, + active_periods: Optional[pd.Index] = None): + """ + Update active timesteps, periods, and data of the TimeSeriesCollection. + + Parameters + ---------- + active_timesteps : Optional[pd.DatetimeIndex] + The active timesteps of the model. + If None, the all timesteps of the TimeSeriesCollection are taken. + active_periods : Optional[pd.Index] + The active periods of the model. + If None, all periods from the TimeSeriesCollection are taken. + """ + + if active_timesteps is None and active_periods is None: + raise ValueError('Either active_timesteps or active_periods must be provided.' + 'Else use .reset() to reset the active timesteps and periods.') + + active_timesteps = active_timesteps if active_timesteps is not None else self._timesteps + active_periods = active_periods if active_periods is not None else self._periods + + if not active_timesteps.isin(self._timesteps): + raise ValueError('active_timesteps must be a subset of the timesteps of the TimeSeriesCollection') + if not active_periods.isin(self._periods): + raise ValueError('active_periods must be a subset of the periods of the TimeSeriesCollection') + + + ( + self._active_timesteps, + self._active_timesteps_extra, + self._active_hours_per_timestep, + _, + self._active_periods + ) = TimeSeriesCollection.allign_dimensions( + active_timesteps, active_periods, self.hours_of_last_timestep, self._hours_of_previous_timesteps + ) + + self._active_timeserieses() + + def reset(self): + """Reset active timesteps and periods of all TimeSeries.""" + self._active_timesteps = None + self._active_timesteps_extra = None + self._active_hours_per_timestep = None + self._active_periods = None + for time_series in self.time_serieses: + time_series.reset() + + def insert_new_data(self, data: pd.DataFrame): + """Insert new data into the TimeSeriesCollection. + + Parameters + ---------- + data : pd.DataFrame + The new data to insert. + Must have the same columns as the TimeSeries in the TimeSeriesCollection. + Must have the same index as the timesteps of the TimeSeriesCollection. + """ + #TODO: Sanitize the values for timeseries that are one step longer! for time_series in self.time_serieses: if time_series.name in data.columns: time_series.stored_data = data[time_series.name] logger.debug(f'Inserted data for {time_series.name}') + def _active_timeserieses(self): + for time_series in self.time_serieses: + time_series.active_periods = self.periods + if time_series in self._timeserieses_longer: + time_series.active_timesteps = self.timesteps_extra + else: + time_series.active_timesteps = self.timesteps + def to_dataframe(self, filtered: Literal['all', 'constant', 'non_constant'] = 'non_constant'): if filtered == 'all': return pd.concat([time_series.active_data.to_dataframe(time_series.name) @@ -509,8 +588,17 @@ def allign_dimensions( The periods of the model. Every period has the same timesteps. Usually years are used as periods. """ - timesteps = pd.DatetimeIndex(timesteps, name='time') - periods = pd.Index(periods, name='period') if periods is not None else None + if not isinstance(timesteps, pd.DatetimeIndex): + raise TypeError('timesteps must be a pandas DatetimeIndex') + if not timesteps.name == 'time': + logger.warning('timesteps must be a pandas DatetimeIndex with name "time". Renamed it to "time".') + timesteps.name = 'time' + + if periods is not None and not isinstance(periods, pd.Index): + raise TypeError('periods must be a pandas Index or None') + if periods is not None and periods.name != 'period': + logger.warning('periods must be a pandas Index with name "period". Renamed it.') + periods.name = 'period' if hours_of_last_timestep: last_date = pd.DatetimeIndex( @@ -543,6 +631,30 @@ def non_constants(self) -> List[TimeSeries]: def constants(self) -> List[TimeSeries]: return [time_series for time_series in self.time_serieses if time_series.all_equal] + @property + def coords(self): + return [self.periods, self.timesteps] if self.periods is not None else [self.timesteps] + + @property + def coords_extra(self): + return [self.periods, self.timesteps_extra] if self.periods is not None else [self.timesteps_extra] + + @property + def timesteps(self): + return self._timesteps if self._active_timesteps is None else self._active_timesteps + + @property + def timesteps_extra(self): + return self._timesteps_extra if self._active_timesteps_extra is None else self._active_timesteps_extra + + @property + def periods(self): + return self._periods if self._active_periods is None else self._active_periods + + @property + def hours_per_timestep(self): + return self._hours_per_timestep if self._active_hours_per_timestep is None else self._active_hours_per_timestep + def description(self) -> str: # TODO: result = f'{len(self.time_serieses)} TimeSeries used for aggregation:\n' From 368ed09d7823db8edc91d218bfb908ae7e60db89 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 22 Feb 2025 11:28:00 +0100 Subject: [PATCH 170/507] Reorder some functions and fix some if statements --- flixOpt/core.py | 84 ++++++++++++++++++++++++++----------------------- 1 file changed, 44 insertions(+), 40 deletions(-) diff --git a/flixOpt/core.py b/flixOpt/core.py index 70e31ac25..295d406b7 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -401,12 +401,6 @@ def __init__( self.time_serieses: List[TimeSeries] = [] self._timeserieses_longer: List[TimeSeries] = [] # All part of self.time_serieses, but with extra timestep - def _add_time_series(self, time_series: TimeSeries, extra_timestep: bool): - self.time_serieses.append(time_series) - if extra_timestep: - self._timeserieses_longer.append(time_series) - self._check_unique_labels() - def create_time_series( self, data: Union[NumericData, TimeSeriesData], @@ -461,11 +455,12 @@ def calculate_aggregation_weights(self) -> Dict[str, float]: return self.weights - def update_data(self, - active_timesteps: Optional[pd.DatetimeIndex] = None, - active_periods: Optional[pd.Index] = None): + def activate_indices(self, + active_timesteps: Optional[pd.DatetimeIndex] = None, + active_periods: Optional[pd.Index] = None): """ Update active timesteps, periods, and data of the TimeSeriesCollection. + If no arguments are provided, the active timesteps and periods are reset. Parameters ---------- @@ -478,18 +473,16 @@ def update_data(self, """ if active_timesteps is None and active_periods is None: - raise ValueError('Either active_timesteps or active_periods must be provided.' - 'Else use .reset() to reset the active timesteps and periods.') + return self.reset() active_timesteps = active_timesteps if active_timesteps is not None else self._timesteps active_periods = active_periods if active_periods is not None else self._periods - if not active_timesteps.isin(self._timesteps): + if not np.all(active_timesteps.isin(self._timesteps)): raise ValueError('active_timesteps must be a subset of the timesteps of the TimeSeriesCollection') - if not active_periods.isin(self._periods): + if active_periods is not None and not np.all(active_periods.isin(self._periods)): raise ValueError('active_periods must be a subset of the periods of the TimeSeriesCollection') - ( self._active_timesteps, self._active_timesteps_extra, @@ -500,7 +493,7 @@ def update_data(self, active_timesteps, active_periods, self.hours_of_last_timestep, self._hours_of_previous_timesteps ) - self._active_timeserieses() + self._activate_timeserieses() def reset(self): """Reset active timesteps and periods of all TimeSeries.""" @@ -511,6 +504,11 @@ def reset(self): for time_series in self.time_serieses: time_series.reset() + def restore_data(self): + """Restore stored_data from the backup.""" + for time_series in self.time_serieses: + time_series.restore_data() + def insert_new_data(self, data: pd.DataFrame): """Insert new data into the TimeSeriesCollection. @@ -527,7 +525,7 @@ def insert_new_data(self, data: pd.DataFrame): time_series.stored_data = data[time_series.name] logger.debug(f'Inserted data for {time_series.name}') - def _active_timeserieses(self): + def _activate_timeserieses(self): for time_series in self.time_serieses: time_series.active_periods = self.periods if time_series in self._timeserieses_longer: @@ -623,6 +621,36 @@ def allign_dimensions( ) return timesteps, timesteps_extra , hours_per_step, hours_of_previous_timesteps, periods + def _add_time_series(self, time_series: TimeSeries, extra_timestep: bool): + self.time_serieses.append(time_series) + if extra_timestep: + self._timeserieses_longer.append(time_series) + self._check_unique_labels() + + def _calculate_group_weights(self) -> Dict[str, float]: + """Calculates the aggregation weights of each group""" + groups = [ + time_series.aggregation_group + for time_series in self.time_serieses + if time_series.aggregation_group is not None + ] + group_size = dict(Counter(groups)) + group_weights = {group: 1 / size for group, size in group_size.items()} + return group_weights + + def _calculate_aggregation_weights(self) -> Dict[str, float]: + """Calculates the aggregation weight for each TimeSeries. Default is 1""" + return { + time_series.name: self.group_weights.get(time_series.aggregation_group, time_series.aggregation_weight or 1) + for time_series in self.time_serieses + } + + def _check_unique_labels(self): + """Makes sure every label of the TimeSeries in time_series_list is unique""" + label_counts = Counter([time_series.name for time_series in self.time_serieses]) + duplicates = [label for label, count in label_counts.items() if count > 1] + assert duplicates == [], 'Duplicate TimeSeries labels found: {}.'.format(', '.join(duplicates)) + @property def non_constants(self) -> List[TimeSeries]: return [time_series for time_series in self.time_serieses if not time_series.all_equal] @@ -665,27 +693,3 @@ def description(self) -> str: else: result += 'Warning!: no agg_types defined, i.e. all TS have weight 1 (or explicitly given weight)!\n' return result - - def _calculate_group_weights(self) -> Dict[str, float]: - """Calculates the aggregation weights of each group""" - groups = [ - time_series.aggregation_group - for time_series in self.time_serieses - if time_series.aggregation_group is not None - ] - group_size = dict(Counter(groups)) - group_weights = {group: 1 / size for group, size in group_size.items()} - return group_weights - - def _calculate_aggregation_weights(self) -> Dict[str, float]: - """Calculates the aggregation weight for each TimeSeries. Default is 1""" - return { - time_series.name: self.group_weights.get(time_series.aggregation_group, time_series.aggregation_weight or 1) - for time_series in self.time_serieses - } - - def _check_unique_labels(self): - """Makes sure every label of the TimeSeries in time_series_list is unique""" - label_counts = Counter([time_series.name for time_series in self.time_serieses]) - duplicates = [label for label, count in label_counts.items() if count > 1] - assert duplicates == [], 'Duplicate TimeSeries labels found: {}.'.format(', '.join(duplicates)) From a58b9e40a7e961ff4630a9153132f3e9e68b9022 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 22 Feb 2025 11:28:17 +0100 Subject: [PATCH 171/507] Improve docstring --- flixOpt/flow_system.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flixOpt/flow_system.py b/flixOpt/flow_system.py index 37f729d0a..f3084382d 100644 --- a/flixOpt/flow_system.py +++ b/flixOpt/flow_system.py @@ -40,9 +40,9 @@ def __init__( ---------- timesteps : pd.DatetimeIndex The timesteps of the model. - hours_of_last_step : Optional[float], optional + hours_of_last_timestep : Optional[float], optional The duration of the last time step. Uses the last time interval if not specified - previous_dt_in_hours : Union[int, float, np.ndarray] + hours_of_previous_timesteps : Union[int, float, np.ndarray] 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). From 687ba4264cba017c51f040b5d6f97a92007859ec Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 22 Feb 2025 11:29:18 +0100 Subject: [PATCH 172/507] Make Aggregated Calculation inherit from FullCalculation. Re-add indexing in do_modeling --- flixOpt/calculation.py | 37 +++++++++---------------------------- 1 file changed, 9 insertions(+), 28 deletions(-) diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py index 5b385214f..a5f2ed8cc 100644 --- a/flixOpt/calculation.py +++ b/flixOpt/calculation.py @@ -42,7 +42,8 @@ def __init__( self, name: str, flow_system: FlowSystem, - active_timesteps: Optional[Union[List[int], pd.DatetimeIndex]] = None, + active_timesteps: Optional[pd.DatetimeIndex] = None, + active_periods: Optional[pd.Index] = None, ): """ Parameters @@ -57,6 +58,7 @@ def __init__( self.name = name self.flow_system = flow_system self.active_timesteps = active_timesteps + self.active_periods = active_periods self.durations = {'modeling': 0.0, 'solving': 0.0, 'saving': 0.0} @@ -148,10 +150,9 @@ def do_modeling(self) -> SystemModel: t_start = timeit.default_timer() self.flow_system.transform_data() - for time_series in self.flow_system.all_time_series: - pass # TODO: This must work for timeseries that are always one step longer - # time_series.active_periods = self.flow_system.periods - #time_series.active_timesteps = self.flow_system.timesteps + self.flow_system.time_series_collection.activate_indices( + active_timesteps=self.active_timesteps, active_periods=self.active_periods + ) self.flow_system.create_model() self.flow_system.model.do_modeling() @@ -177,7 +178,7 @@ def solve(self, solver: _Solver, save_results: Union[bool, str, pathlib.Path] = self._save_solve_infos() -class AggregatedCalculation(Calculation): +class AggregatedCalculation(FullCalculation): """ class for defined way of solving a flow_system optimization """ @@ -211,19 +212,16 @@ def __init__( time_indices : List[int] or None list with indices, which should be used for calculation. If None, then all timesteps are used. """ - super().__init__(name, flow_system, active_timesteps) if flow_system.periods is not None: raise NotImplementedError('Multiple Periods are currently not supported in AggregatedCalculation') + super().__init__(name, flow_system, active_timesteps) self.aggregation_parameters = aggregation_parameters self.components_to_clusterize = components_to_clusterize self.time_series_for_aggregation = None self.aggregation = None def do_modeling(self) -> SystemModel: - self.flow_system.transform_data() - for time_series in self.flow_system.all_time_series: - pass #TODO: This must work for timeseries that are always one step longer - #time_series.activate_indices(self.time_indices) + super().do_modeling() from .aggregation import Aggregation @@ -277,23 +275,6 @@ def do_modeling(self) -> SystemModel: self.durations['modeling'] = round(timeit.default_timer() - t_start, 2) return self.flow_system.model - def solve(self, solver: _Solver, save_results: Union[bool, str, pathlib.Path] = False): - self._define_path_names(save_results) - t_start = timeit.default_timer() - self.flow_system.model.solve(log_fn=self._paths['log'], - solver_name=solver.name, - **solver.options) - self.durations['solving'] = round(timeit.default_timer() - t_start, 2) - - # Log the formatted output - logger.info(f'{" Main Results ":#^80}') - logger.info("\n" + yaml.dump( - utils.round_floats(self.flow_system.model.infos), - default_flow_style=False, sort_keys=False, allow_unicode=True, indent=4)) - - if save_results: - self._save_solve_infos() - class SegmentedCalculation(Calculation): def __init__( From ac761422833828239aa81235e34ce92af28572ec Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 22 Feb 2025 11:49:04 +0100 Subject: [PATCH 173/507] Some renaming in TimeSeriesCollection and added repr and str --- flixOpt/core.py | 62 ++++++++++++++++++++++++++++--------------------- 1 file changed, 35 insertions(+), 27 deletions(-) diff --git a/flixOpt/core.py b/flixOpt/core.py index 295d406b7..207fe4750 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -398,8 +398,8 @@ def __init__( self.group_weights: Dict[str, float] = {} self.weights: Dict[str, float] = {} - self.time_serieses: List[TimeSeries] = [] - self._timeserieses_longer: List[TimeSeries] = [] # All part of self.time_serieses, but with extra timestep + self.time_series_data: List[TimeSeries] = [] + self._time_series_data_with_extra_step: List[TimeSeries] = [] # All part of self.time_series_data, but with extra timestep def create_time_series( self, @@ -408,7 +408,7 @@ def create_time_series( extra_timestep: bool=False ) -> TimeSeries: """ - Creates a TimeSeries from the given data and adds it to the list of time_serieses of an Element. + Creates a TimeSeries from the given data and adds it to the time_series_data. If the data already is a TimeSeries, nothing happens. Parameters @@ -427,7 +427,7 @@ def create_time_series( """ if isinstance(data, TimeSeries): - if data not in self.time_serieses: + if data not in self.time_series_data: self._add_time_series(data, extra_timestep) return data @@ -501,12 +501,12 @@ def reset(self): self._active_timesteps_extra = None self._active_hours_per_timestep = None self._active_periods = None - for time_series in self.time_serieses: + for time_series in self.time_series_data: time_series.reset() def restore_data(self): """Restore stored_data from the backup.""" - for time_series in self.time_serieses: + for time_series in self.time_series_data: time_series.restore_data() def insert_new_data(self, data: pd.DataFrame): @@ -520,15 +520,15 @@ def insert_new_data(self, data: pd.DataFrame): Must have the same index as the timesteps of the TimeSeriesCollection. """ #TODO: Sanitize the values for timeseries that are one step longer! - for time_series in self.time_serieses: + for time_series in self.time_series_data: if time_series.name in data.columns: time_series.stored_data = data[time_series.name] logger.debug(f'Inserted data for {time_series.name}') def _activate_timeserieses(self): - for time_series in self.time_serieses: + for time_series in self.time_series_data: time_series.active_periods = self.periods - if time_series in self._timeserieses_longer: + if time_series in self._time_series_data_with_extra_step: time_series.active_timesteps = self.timesteps_extra else: time_series.active_timesteps = self.timesteps @@ -536,7 +536,7 @@ def _activate_timeserieses(self): def to_dataframe(self, filtered: Literal['all', 'constant', 'non_constant'] = 'non_constant'): if filtered == 'all': return pd.concat([time_series.active_data.to_dataframe(time_series.name) - for time_series in self.time_serieses], + for time_series in self.time_series_data], axis=1) elif filtered == 'constant': return pd.concat([time_series.active_data.to_dataframe(time_series.name) @@ -622,16 +622,16 @@ def allign_dimensions( return timesteps, timesteps_extra , hours_per_step, hours_of_previous_timesteps, periods def _add_time_series(self, time_series: TimeSeries, extra_timestep: bool): - self.time_serieses.append(time_series) + self.time_series_data.append(time_series) if extra_timestep: - self._timeserieses_longer.append(time_series) + self._time_series_data_with_extra_step.append(time_series) self._check_unique_labels() def _calculate_group_weights(self) -> Dict[str, float]: """Calculates the aggregation weights of each group""" groups = [ time_series.aggregation_group - for time_series in self.time_serieses + for time_series in self.time_series_data if time_series.aggregation_group is not None ] group_size = dict(Counter(groups)) @@ -642,22 +642,22 @@ def _calculate_aggregation_weights(self) -> Dict[str, float]: """Calculates the aggregation weight for each TimeSeries. Default is 1""" return { time_series.name: self.group_weights.get(time_series.aggregation_group, time_series.aggregation_weight or 1) - for time_series in self.time_serieses + for time_series in self.time_series_data } def _check_unique_labels(self): """Makes sure every label of the TimeSeries in time_series_list is unique""" - label_counts = Counter([time_series.name for time_series in self.time_serieses]) + label_counts = Counter([time_series.name for time_series in self.time_series_data]) duplicates = [label for label, count in label_counts.items() if count > 1] assert duplicates == [], 'Duplicate TimeSeries labels found: {}.'.format(', '.join(duplicates)) @property def non_constants(self) -> List[TimeSeries]: - return [time_series for time_series in self.time_serieses if not time_series.all_equal] + return [time_series for time_series in self.time_series_data if not time_series.all_equal] @property def constants(self) -> List[TimeSeries]: - return [time_series for time_series in self.time_serieses if time_series.all_equal] + return [time_series for time_series in self.time_series_data if time_series.all_equal] @property def coords(self): @@ -683,13 +683,21 @@ def periods(self): def hours_per_timestep(self): return self._hours_per_timestep if self._active_hours_per_timestep is None else self._active_hours_per_timestep - def description(self) -> str: - # TODO: - result = f'{len(self.time_serieses)} TimeSeries used for aggregation:\n' - for time_series in self.time_serieses: - result += f' -> {time_series.name} (weight: {self.weights[time_series.name]:.4f}; group: "{time_series.aggregation_group}")\n' - if self.group_weights: - result += f'Aggregation_Groups: {list(self.group_weights.keys())}\n' - else: - result += 'Warning!: no agg_types defined, i.e. all TS have weight 1 (or explicitly given weight)!\n' - return result + def __repr__(self): + return ( + f"TimeSeriesCollection(timesteps={len(self._timesteps)}, " + f"periods={len(self._periods) if self._periods is not None else None}, " + f"time_series_data={len(self.time_series_data)} ({len(self._time_series_data_with_extra_step)} with an extra step))" + ) + + def __str__(self): + details = ( + f"TimeSeriesCollection with {len(self._timesteps)} timesteps " + f"and {len(self._periods) if self._periods is not None else 0} periods.\n" + f"- {len(self.time_series_data)} time series stored." + f"- {len(self._time_series_data_with_extra_step)} of which with an extra timestep).\n" + f"- Aggregation parameters:\n" + f" - Group weights: {self.group_weights if self.group_weights else 'None'}\n" + f" - Individual weights: {self.weights if self.weights else 'None'}\n" + ) + return details From 471592f066c65d06f0747b6140eb7c369f148199 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 22 Feb 2025 11:52:31 +0100 Subject: [PATCH 174/507] Trying to improve the repr and str --- flixOpt/core.py | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/flixOpt/core.py b/flixOpt/core.py index 207fe4750..036310c79 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -685,19 +685,17 @@ def hours_per_timestep(self): def __repr__(self): return ( - f"TimeSeriesCollection(timesteps={len(self._timesteps)}, " - f"periods={len(self._periods) if self._periods is not None else None}, " - f"time_series_data={len(self.time_series_data)} ({len(self._time_series_data_with_extra_step)} with an extra step))" + f"TimeSeriesCollection(" + f"timesteps={len(self._timesteps)}, " + f"periods={len(self._periods) if self._periods is not None else 'None'}, " + f"time_series_count={len(self.time_series_data)}, " + f"time_series_with_extra_step_count={len(self._time_series_data_with_extra_step)}, " + f")" ) def __str__(self): - details = ( - f"TimeSeriesCollection with {len(self._timesteps)} timesteps " - f"and {len(self._periods) if self._periods is not None else 0} periods.\n" - f"- {len(self.time_series_data)} time series stored." - f"- {len(self._time_series_data_with_extra_step)} of which with an extra timestep).\n" - f"- Aggregation parameters:\n" - f" - Group weights: {self.group_weights if self.group_weights else 'None'}\n" - f" - Individual weights: {self.weights if self.weights else 'None'}\n" + return ( + f"TimeSeriesCollection with {len(self.time_series_data)} time series, " + f"{len(self._timesteps)} timesteps, " + f"{len(self._periods) if self._periods is not None else 'no'} periods." ) - return details From 016499b1324057882bb801cbc0242e55bb9ec910 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 22 Feb 2025 13:03:54 +0100 Subject: [PATCH 175/507] Added functionto_dataset() and nice repr and str to TimeSeriesCollection --- flixOpt/core.py | 72 +++++++++++++++++++++++++++++++++++-------------- 1 file changed, 52 insertions(+), 20 deletions(-) diff --git a/flixOpt/core.py b/flixOpt/core.py index 036310c79..37361337b 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -534,21 +534,20 @@ def _activate_timeserieses(self): time_series.active_timesteps = self.timesteps def to_dataframe(self, filtered: Literal['all', 'constant', 'non_constant'] = 'non_constant'): - if filtered == 'all': - return pd.concat([time_series.active_data.to_dataframe(time_series.name) - for time_series in self.time_series_data], - axis=1) - elif filtered == 'constant': - return pd.concat([time_series.active_data.to_dataframe(time_series.name) - for time_series in self.constants], - axis=1) - elif filtered == 'non_constant': - return pd.concat([time_series.active_data.to_dataframe(time_series.name) - for time_series in self.non_constants], - axis=1) + df = self.to_dataset().to_dataframe() + if filtered == 'all': # Return all time series + return df + elif filtered == 'constant': # Return only constant time series + return df.loc[:, df.nunique() ==1] + elif filtered == 'non_constant': # Return only non-constant time series + return df.loc[:, df.nunique() > 1] else: raise ValueError('Not supported argument for "filtered".') + def to_dataset(self) -> xr.Dataset: + """Combine all stored DataArrays into a single Dataset.""" + return xr.Dataset({time_series.name: time_series.active_data for time_series in self.time_series_data}) + @staticmethod def allign_dimensions( timesteps: pd.DatetimeIndex, @@ -684,18 +683,51 @@ def hours_per_timestep(self): return self._hours_per_timestep if self._active_hours_per_timestep is None else self._active_hours_per_timestep def __repr__(self): + timestep_range = f"{self._timesteps[0]} to {self._timesteps[-1]}" if len(self.timesteps) > 1 else str( + self.timesteps[0]) + periods_str = f"Periods: {len(self.periods)}" if self.periods is not None else "No periods" + time_series_count = len(self.time_series_data) + return ( - f"TimeSeriesCollection(" - f"timesteps={len(self._timesteps)}, " - f"periods={len(self._periods) if self._periods is not None else 'None'}, " - f"time_series_count={len(self.time_series_data)}, " - f"time_series_with_extra_step_count={len(self._time_series_data_with_extra_step)}, " + f"TimeSeriesCollection(\n" + f" nr_of_timesteps={len(self.timesteps)},\n" + f" timesteps={timestep_range},\n" + f" active_timesteps={np.array(self._active_timesteps) if self._active_timesteps is not None else 'None'}\n" + f" hours_of_last_timestep={self.hours_of_last_timestep},\n" + f" hours_per_timestep={get_numeric_stats(self._hours_per_timestep)},\n" + f" nr_of_periods={len(self.periods) if self.periods is not None else 'None'},\n" + f" periods={periods_str},\n" + f" active_periods={self._active_periods if self._active_periods is not None else 'None'}\n" + f" time_series_count={time_series_count},\n" f")" ) def __str__(self): + longest_name = max([time_series.name for time_series in self.time_series_data], key=len) + + stats_summary = "\n".join( + [f" - {time_series.name:<{len(longest_name)}}: {get_numeric_stats(time_series.active_data)}" + for time_series in self.time_series_data] + ) + return ( - f"TimeSeriesCollection with {len(self.time_series_data)} time series, " - f"{len(self._timesteps)} timesteps, " - f"{len(self._periods) if self._periods is not None else 'no'} periods." + f"TimeSeriesCollection with {len(self.time_series_data)} series\n" + f" Time Range: {self.timesteps[0]} -> {self.timesteps[-1]}\n" + f" No. of timesteps: {len(self.timesteps)}\n" + f" Hours per timestep: {get_numeric_stats(self.hours_per_timestep)}" + f" Periods: {list(self.periods) if self.periods is not None else 'None'}\n" + f" TimeSeriesData:\n" + f"{stats_summary}" ) + +def get_numeric_stats(data: xr.DataArray, decimals: int = 2, padd: int = 10) -> str: + """Calculates the mean, median, min, max, and standard deviation of a numeric DataArray.""" + format_spec = f">{padd}.{decimals}f" if padd else f".{decimals}f" + if np.unique(data).size == 1: + return f"{data.max().item():{format_spec}} (constant)" + mean = data.mean().item() + median = data.median().item() + min_val = data.min().item() + max_val = data.max().item() + std = data.std().item() + return f"{mean:{format_spec}} (mean), {median:{format_spec}} (median), {min_val:{format_spec}} (min), {max_val:{format_spec}} (max), {std:{format_spec}} (std)" From a12f2a71b46a2724677099291ff4022a4be5abeb Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 22 Feb 2025 13:10:01 +0100 Subject: [PATCH 176/507] Bugfix in AGgregtaedCalculation --- flixOpt/calculation.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py index a5f2ed8cc..318d0664d 100644 --- a/flixOpt/calculation.py +++ b/flixOpt/calculation.py @@ -135,7 +135,7 @@ def results(self): def infos(self): return { 'Name': self.name, - 'Number of indices': len(self.active_timesteps) if self.active_timesteps else 'all', + 'Number of indices': len(self.active_timesteps) if self.active_timesteps is not None else 'all', 'Calculation Type': self.__class__.__name__, 'Durations': self.durations, } @@ -160,7 +160,7 @@ def do_modeling(self) -> SystemModel: self.durations['modeling'] = round(timeit.default_timer() - t_start, 2) return self.flow_system.model - def solve(self, solver: _Solver, save_results: Union[bool, str, pathlib.Path] = False): + def solve(self, solver: _Solver, save_results: Union[bool, str, pathlib.Path] = True): self._define_path_names(save_results) t_start = timeit.default_timer() self.flow_system.model.solve(log_fn=self._paths['log'], @@ -247,7 +247,7 @@ def do_modeling(self) -> SystemModel: # Aggregation - creation of aggregated timeseries: self.aggregation = Aggregation( - original_data=self.flow_system.time_series_collection.to_dataframe(), + original_data=self.flow_system.time_series_collection.to_dataframe().iloc[:-1,:], # Exclude last row (NaN) hours_per_time_step=float(dt_min), hours_per_period=self.aggregation_parameters.hours_per_period, nr_of_periods=self.aggregation_parameters.nr_of_periods, @@ -259,7 +259,7 @@ def do_modeling(self) -> SystemModel: self.aggregation.cluster() self.aggregation.plot() if self.aggregation_parameters.aggregate_data_and_fix_non_binary_vars: - self.flow_system.time_series_collection.insert_data(self.aggregation.aggregated_data) + self.flow_system.time_series_collection.insert_new_data(self.aggregation.aggregated_data) self.durations['aggregation'] = round(timeit.default_timer() - t_start_agg, 2) # Model the System From 3927cec77b46ffd580f40fc926696ae0906921d4 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 22 Feb 2025 13:33:38 +0100 Subject: [PATCH 177/507] Improve TIme Series handling when setting new data --- flixOpt/core.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/flixOpt/core.py b/flixOpt/core.py index 37361337b..73539121b 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -522,7 +522,13 @@ def insert_new_data(self, data: pd.DataFrame): #TODO: Sanitize the values for timeseries that are one step longer! for time_series in self.time_series_data: if time_series.name in data.columns: - time_series.stored_data = data[time_series.name] + if time_series in self._time_series_data_with_extra_step: + extra_step_value = data[time_series.name].iloc[-1] + time_series.stored_data = pd.concat( + [data[time_series.name], pd.Series( + extra_step_value, index=[data.index[-1] + pd.Timedelta(hours=self.hours_of_last_timestep)]) + ] + ) logger.debug(f'Inserted data for {time_series.name}') def _activate_timeserieses(self): From 8e552f1fae578a5db6532c2e31c912330c89549e Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 22 Feb 2025 13:41:50 +0100 Subject: [PATCH 178/507] Add type hints --- flixOpt/core.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flixOpt/core.py b/flixOpt/core.py index 73539121b..3ce12350b 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -677,15 +677,15 @@ def timesteps(self): return self._timesteps if self._active_timesteps is None else self._active_timesteps @property - def timesteps_extra(self): + def timesteps_extra(self) -> pd.DatetimeIndex: return self._timesteps_extra if self._active_timesteps_extra is None else self._active_timesteps_extra @property - def periods(self): + def periods(self) -> pd.Index: return self._periods if self._active_periods is None else self._active_periods @property - def hours_per_timestep(self): + def hours_per_timestep(self) -> xr.DataArray: return self._hours_per_timestep if self._active_hours_per_timestep is None else self._active_hours_per_timestep def __repr__(self): From 72baab2fefe4f4622a619b42c3bb9b5be27430ab Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 22 Feb 2025 13:47:02 +0100 Subject: [PATCH 179/507] Added typehints and made hours_of_last_timestep a property --- flixOpt/core.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/flixOpt/core.py b/flixOpt/core.py index 3ce12350b..9d84b110d 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -379,7 +379,6 @@ def __init__( hours_of_previous_timesteps: Optional[Union[int, float, np.ndarray]], periods: Optional[List[int]] ): - self.hours_of_last_timestep = hours_of_last_timestep ( self._timesteps, self._timesteps_extra, @@ -665,15 +664,15 @@ def constants(self) -> List[TimeSeries]: return [time_series for time_series in self.time_series_data if time_series.all_equal] @property - def coords(self): - return [self.periods, self.timesteps] if self.periods is not None else [self.timesteps] + def coords(self) -> Union[Tuple[pd.Index, pd.DatetimeIndex], Tuple[pd.DatetimeIndex]]: + return (self.periods, self.timesteps) if self.periods is not None else (self.timesteps,) @property - def coords_extra(self): - return [self.periods, self.timesteps_extra] if self.periods is not None else [self.timesteps_extra] + def coords_extra(self) -> Union[Tuple[pd.Index, pd.DatetimeIndex], Tuple[pd.DatetimeIndex]]: + return (self.periods, self.timesteps_extra) if self.periods is not None else (self.timesteps_extra,) @property - def timesteps(self): + def timesteps(self) -> pd.DatetimeIndex: return self._timesteps if self._active_timesteps is None else self._active_timesteps @property @@ -688,6 +687,10 @@ def periods(self) -> pd.Index: def hours_per_timestep(self) -> xr.DataArray: return self._hours_per_timestep if self._active_hours_per_timestep is None else self._active_hours_per_timestep + @property + def hours_of_last_timestep(self) -> float: + return self.hours_per_timestep[-1].item() + def __repr__(self): timestep_range = f"{self._timesteps[0]} to {self._timesteps[-1]}" if len(self.timesteps) > 1 else str( self.timesteps[0]) From de9c5a0a53d68a7d1d03ca488d0276ca7e4c9cbc Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 22 Feb 2025 14:01:06 +0100 Subject: [PATCH 180/507] Bugfix in insert_new_data() --- flixOpt/core.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/flixOpt/core.py b/flixOpt/core.py index 9d84b110d..0a89f2782 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -528,6 +528,8 @@ def insert_new_data(self, data: pd.DataFrame): extra_step_value, index=[data.index[-1] + pd.Timedelta(hours=self.hours_of_last_timestep)]) ] ) + else: + time_series.stored_data = data[time_series.name] logger.debug(f'Inserted data for {time_series.name}') def _activate_timeserieses(self): From 547564ac469c83f2e98493d4a90e3df64998c24a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 22 Feb 2025 14:01:31 +0100 Subject: [PATCH 181/507] Rename some functions and split long align_dimensions() into parts --- flixOpt/core.py | 112 +++++++++++++++++++++++++++++------------------- 1 file changed, 69 insertions(+), 43 deletions(-) diff --git a/flixOpt/core.py b/flixOpt/core.py index 0a89f2782..d4835b100 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -385,7 +385,7 @@ def __init__( self._hours_per_timestep, self._hours_of_previous_timesteps, self._periods - ) = TimeSeriesCollection.allign_dimensions(timesteps, + ) = TimeSeriesCollection.align_dimensions(timesteps, periods, hours_of_last_timestep, hours_of_previous_timesteps) @@ -488,7 +488,7 @@ def activate_indices(self, self._active_hours_per_timestep, _, self._active_periods - ) = TimeSeriesCollection.allign_dimensions( + ) = TimeSeriesCollection.align_dimensions( active_timesteps, active_periods, self.hours_of_last_timestep, self._hours_of_previous_timesteps ) @@ -518,7 +518,9 @@ def insert_new_data(self, data: pd.DataFrame): Must have the same columns as the TimeSeries in the TimeSeriesCollection. Must have the same index as the timesteps of the TimeSeriesCollection. """ - #TODO: Sanitize the values for timeseries that are one step longer! + if not isinstance(data, pd.DataFrame): + raise TypeError(f"data must be a pandas DataFrame. Got {type(data)=}") + for time_series in self.time_series_data: if time_series.name in data.columns: if time_series in self._time_series_data_with_extra_step: @@ -532,37 +534,14 @@ def insert_new_data(self, data: pd.DataFrame): time_series.stored_data = data[time_series.name] logger.debug(f'Inserted data for {time_series.name}') - def _activate_timeserieses(self): - for time_series in self.time_series_data: - time_series.active_periods = self.periods - if time_series in self._time_series_data_with_extra_step: - time_series.active_timesteps = self.timesteps_extra - else: - time_series.active_timesteps = self.timesteps - - def to_dataframe(self, filtered: Literal['all', 'constant', 'non_constant'] = 'non_constant'): - df = self.to_dataset().to_dataframe() - if filtered == 'all': # Return all time series - return df - elif filtered == 'constant': # Return only constant time series - return df.loc[:, df.nunique() ==1] - elif filtered == 'non_constant': # Return only non-constant time series - return df.loc[:, df.nunique() > 1] - else: - raise ValueError('Not supported argument for "filtered".') - - def to_dataset(self) -> xr.Dataset: - """Combine all stored DataArrays into a single Dataset.""" - return xr.Dataset({time_series.name: time_series.active_data for time_series in self.time_series_data}) - @staticmethod - def allign_dimensions( - timesteps: pd.DatetimeIndex, - periods: Optional[pd.Index] = None, - hours_of_last_timestep: Optional[float] = None, - hours_of_previous_timesteps: Optional[Union[int, float, np.ndarray]] = None + def align_dimensions( + timesteps: pd.DatetimeIndex, + periods: Optional[pd.Index] = None, + hours_of_last_timestep: Optional[float] = None, + hours_of_previous_timesteps: Optional[Union[int, float, np.ndarray]] = None ) -> Tuple[pd.DatetimeIndex, pd.DatetimeIndex, xr.DataArray, Union[int, float, np.ndarray], Optional[pd.Index]]: - """ Converts the given timesteps, periods and hours_of_last_timestep to the right format + """Converts the given timesteps, periods and hours_of_last_timestep to the right format Parameters ---------- @@ -592,6 +571,7 @@ def allign_dimensions( The periods of the model. Every period has the same timesteps. Usually years are used as periods. """ + if not isinstance(timesteps, pd.DatetimeIndex): raise TypeError('timesteps must be a pandas DatetimeIndex') if not timesteps.name == 'time': @@ -604,34 +584,80 @@ def allign_dimensions( logger.warning('periods must be a pandas Index with name "period". Renamed it.') periods.name = 'period' + timesteps_extra = TimeSeriesCollection._create_extra_timestep(timesteps, hours_of_last_timestep) + hours_of_previous_timesteps = TimeSeriesCollection._calculate_hours_of_previous_timesteps( + timesteps, hours_of_previous_timesteps + ) + hours_per_step = TimeSeriesCollection._create_hours_per_timestep(timesteps_extra, periods) + + return timesteps, timesteps_extra, hours_per_step, hours_of_previous_timesteps, periods + + def _add_time_series(self, time_series: TimeSeries, extra_timestep: bool): + self.time_series_data.append(time_series) + if extra_timestep: + self._time_series_data_with_extra_step.append(time_series) + self._check_unique_labels() + + def _activate_timeserieses(self): + for time_series in self.time_series_data: + time_series.active_periods = self.periods + if time_series in self._time_series_data_with_extra_step: + time_series.active_timesteps = self.timesteps_extra + else: + time_series.active_timesteps = self.timesteps + + def to_dataframe(self, filtered: Literal['all', 'constant', 'non_constant'] = 'non_constant'): + df = self.to_dataset().to_dataframe() + if filtered == 'all': # Return all time series + return df + elif filtered == 'constant': # Return only constant time series + return df.loc[:, df.nunique() ==1] + elif filtered == 'non_constant': # Return only non-constant time series + return df.loc[:, df.nunique() > 1] + else: + raise ValueError('Not supported argument for "filtered".') + + def to_dataset(self) -> xr.Dataset: + """Combine all stored DataArrays into a single Dataset.""" + return xr.Dataset({time_series.name: time_series.active_data for time_series in self.time_series_data}) + + @staticmethod + def _create_extra_timestep(timesteps: pd.DatetimeIndex, + hours_of_last_timestep: Optional[float]) -> pd.DatetimeIndex: + """Creates an extra timestep at the end of the timesteps.""" if hours_of_last_timestep: last_date = pd.DatetimeIndex( [timesteps[-1] + pd.to_timedelta(hours_of_last_timestep, 'h')]) else: last_date = pd.DatetimeIndex([timesteps[-1] + (timesteps[-1] - timesteps[-2])]) - timesteps_extra = pd.DatetimeIndex(timesteps.append(last_date), name='time') + return pd.DatetimeIndex(timesteps.append(last_date), name='time') - hours_of_previous_timesteps: Union[int, float, np.ndarray] = ( + @staticmethod + def _calculate_hours_of_previous_timesteps( + timesteps: pd.DatetimeIndex, + hours_of_previous_timesteps: Optional[Union[int, float, np.ndarray]] + ) -> Union[int, float, np.ndarray]: + """Calculates the duration of the previous timesteps in hours.""" + return ( ((timesteps[1] - timesteps[0]) / np.timedelta64(1, 'h')) if hours_of_previous_timesteps is None else hours_of_previous_timesteps ) + @staticmethod + def _create_hours_per_timestep( + timesteps_extra: pd.DatetimeIndex, + periods: Optional[pd.Index] + ) -> xr.DataArray: + """Creates a DataArray representing the duration of each timestep in hours.""" hours_per_step = timesteps_extra.to_series().diff()[1:].values / pd.to_timedelta(1, 'h') - hours_per_step = xr.DataArray( + return xr.DataArray( data=np.tile(hours_per_step, (len(periods), 1)) if periods is not None else hours_per_step, - coords=(periods, timesteps) if periods is not None else (timesteps,), + coords=(periods, timesteps_extra[:-1]) if periods is not None else (timesteps_extra[:-1],), dims=('period', 'time') if periods is not None else ('time',), name='hours_per_step' ) - return timesteps, timesteps_extra , hours_per_step, hours_of_previous_timesteps, periods - - def _add_time_series(self, time_series: TimeSeries, extra_timestep: bool): - self.time_series_data.append(time_series) - if extra_timestep: - self._time_series_data_with_extra_step.append(time_series) - self._check_unique_labels() def _calculate_group_weights(self) -> Dict[str, float]: """Calculates the aggregation weights of each group""" From 9e38d250259741650bec5dba2847d50929b9cf33 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 22 Feb 2025 14:04:38 +0100 Subject: [PATCH 182/507] Improve imports in minimal_example.py --- examples/00_Minmal/minimal_example.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/00_Minmal/minimal_example.py b/examples/00_Minmal/minimal_example.py index 4555aa65c..408553366 100644 --- a/examples/00_Minmal/minimal_example.py +++ b/examples/00_Minmal/minimal_example.py @@ -3,8 +3,8 @@ """ import numpy as np -from rich.pretty import pprint import pandas as pd +from rich.pretty import pprint import flixOpt as fx From 3a99977aa2ff9c1c5488542655970aa5dfbb0705 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 22 Feb 2025 14:25:33 +0100 Subject: [PATCH 183/507] Reorganize Calculation classes a bit and fix a Bug that models twice --- flixOpt/calculation.py | 54 +++++++++++++++++++++++------------------- 1 file changed, 29 insertions(+), 25 deletions(-) diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py index 318d0664d..99d04680f 100644 --- a/flixOpt/calculation.py +++ b/flixOpt/calculation.py @@ -57,6 +57,7 @@ def __init__( """ self.name = name self.flow_system = flow_system + self.model: Optional[SystemModel] = None self.active_timesteps = active_timesteps self.active_periods = active_periods @@ -148,24 +149,20 @@ class for defined way of solving a flow_system optimization def do_modeling(self) -> SystemModel: t_start = timeit.default_timer() + self._activate_time_series() - self.flow_system.transform_data() - self.flow_system.time_series_collection.activate_indices( - active_timesteps=self.active_timesteps, active_periods=self.active_periods - ) - - self.flow_system.create_model() - self.flow_system.model.do_modeling() + self.model = self.flow_system.create_model() + self.model.do_modeling() self.durations['modeling'] = round(timeit.default_timer() - t_start, 2) - return self.flow_system.model + return self.model def solve(self, solver: _Solver, save_results: Union[bool, str, pathlib.Path] = True): self._define_path_names(save_results) t_start = timeit.default_timer() - self.flow_system.model.solve(log_fn=self._paths['log'], - solver_name=solver.name, - **solver.options) + self.model.solve(log_fn=self._paths['log'], + solver_name=solver.name, + **solver.options) self.durations['solving'] = round(timeit.default_timer() - t_start, 2) # Log the formatted output @@ -177,6 +174,12 @@ def solve(self, solver: _Solver, save_results: Union[bool, str, pathlib.Path] = if save_results: self._save_solve_infos() + def _activate_time_series(self): + self.flow_system.transform_data() + self.flow_system.time_series_collection.activate_indices( + active_timesteps=self.active_timesteps, active_periods=self.active_periods + ) + class AggregatedCalculation(FullCalculation): """ @@ -221,8 +224,22 @@ def __init__( self.aggregation = None def do_modeling(self) -> SystemModel: - super().do_modeling() + t_start = timeit.default_timer() + self._activate_time_series() + self._perform_aggregation() + # Model the System + self.model = self.flow_system.create_model() + self.model.do_modeling() + # Add Aggregation Model after modeling the rest + self.aggregation = AggregationModel( + self.model, self.aggregation_parameters, self.flow_system, self.aggregation, self.components_to_clusterize + ) + self.aggregation.do_modeling() + self.durations['modeling'] = round(timeit.default_timer() - t_start, 2) + return self.model + + def _perform_aggregation(self): from .aggregation import Aggregation t_start_agg = timeit.default_timer() @@ -262,19 +279,6 @@ def do_modeling(self) -> SystemModel: self.flow_system.time_series_collection.insert_new_data(self.aggregation.aggregated_data) self.durations['aggregation'] = round(timeit.default_timer() - t_start_agg, 2) - # Model the System - t_start = timeit.default_timer() - - self.flow_system.create_model() - self.flow_system.model.do_modeling() - # Add Aggregation Model after modeling the rest - self.aggregation = AggregationModel( - self.flow_system.model, self.aggregation_parameters, self.flow_system, self.aggregation, self.components_to_clusterize - ) - self.aggregation.do_modeling() - self.durations['modeling'] = round(timeit.default_timer() - t_start, 2) - return self.flow_system.model - class SegmentedCalculation(Calculation): def __init__( From 31b51fdb82782aea8e19f249838152330de89c3d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 22 Feb 2025 14:59:59 +0100 Subject: [PATCH 184/507] Get SegmentedCalculation working --- flixOpt/calculation.py | 64 ++++++++++++++++++++++-------------------- 1 file changed, 34 insertions(+), 30 deletions(-) diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py index 99d04680f..8c6cc1987 100644 --- a/flixOpt/calculation.py +++ b/flixOpt/calculation.py @@ -285,9 +285,10 @@ def __init__( self, name, flow_system: FlowSystem, - segment_length: int, - overlap_length: int, - active_timesteps: Optional[Union[List[int], pd.DatetimeIndex]] = None, + timesteps_per_segment: int, + overlap_timesteps: int, + active_timesteps: Optional[pd.DatetimeIndex] = None, + nr_of_previous_values: int = 1, ): """ Dividing and Modeling the problem in (overlapping) segments. @@ -306,22 +307,24 @@ def __init__( name of calculation flow_system : FlowSystem flow_system which should be calculated - segment_length : int + timesteps_per_segment : int The number of time_steps per individual segment (without the overlap) - overlap_length : int + overlap_timesteps : int The number of time_steps that are added to each individual model. Used for better results of storages) """ super().__init__(name, flow_system, active_timesteps) if flow_system.periods is not None: raise NotImplementedError('Multiple Periods are currently not supported in SegmentedCalculation') - self.segment_length = segment_length - self.overlap_length = overlap_length - self._total_length = len(self.flow_system.timesteps) if self.time_indices is not None else len(flow_system.time_series) - self.number_of_segments = math.ceil(self._total_length / self.segment_length) + self.timesteps_per_segment = timesteps_per_segment + self.overlap_timesteps = overlap_timesteps + self.all_timesteps = self.flow_system.timesteps if self.active_timesteps is None else self.active_timesteps + self._total_length = len(self.all_timesteps) + self.number_of_segments = math.ceil(self._total_length / self.timesteps_per_segment) + self.nr_of_previous_values = nr_of_previous_values self.sub_calculations: List[FullCalculation] = [] - assert segment_length > 2, 'The Segment length must be greater 2, due to unwanted internal side effects' + assert timesteps_per_segment > 2, 'The Segment length must be greater 2, due to unwanted internal side effects' assert self.segment_length_with_overlap <= self._total_length, ( f'{self.segment_length_with_overlap=} cant be greater than the total length {self._total_length}' ) @@ -346,21 +349,21 @@ def do_modeling_and_solve(self, solver: _Solver, save_results: Union[bool, str, name_of_segment = f'Segment_{i + 1}' if self.sub_calculations: self._transfer_start_values(name_of_segment) - time_indices = self._get_indices(i) - logger.info(f'{name_of_segment}. (flow_system indices {time_indices.start}...{time_indices.stop - 1}):') - calculation = FullCalculation(name_of_segment, self.flow_system, self.modeling_language, time_indices) - # TODO: Add Before Values if available + timesteps_of_segment = self._get_timesteps_of_segment(i) + logger.info(f'{name_of_segment} ({timesteps_of_segment[0]} -> {timesteps_of_segment[-1]}):') + calculation = FullCalculation(name_of_segment, self.flow_system, active_timesteps=timesteps_of_segment) self.sub_calculations.append(calculation) calculation.do_modeling() invest_elements = [ - model.element.label_full - for model in calculation.system_model.sub_models + model.label_full + for component in self.flow_system.components.values() + for model in component.model.all_sub_models if isinstance(model, InvestmentModel) ] if invest_elements: logger.critical( f'Investments are not supported in Segmented Calculation! ' - f'Following elements Contain Investments: {invest_elements}' + f'Following InvestmentModels were found: {invest_elements}' ) calculation.solve(solver, save_results=False) @@ -394,11 +397,11 @@ def results( """ options_chosen = combined_arrays + combined_scalars + individual_results assert options_chosen == 1, ( - 'Exactly one of the three options to retrieve the results needs to be chosen! You chose {options_chosen}!' + f'Exactly one of the three options to retrieve the results needs to be chosen! You chose {options_chosen}!' ) all_results = {f'Segment_{i + 1}': calculation.results() for i, calculation in enumerate(self.sub_calculations)} if combined_arrays: - return _combine_nested_arrays(*list(all_results.values()), length_per_array=self.segment_length) + return _combine_nested_arrays(*list(all_results.values()), length_per_array=self.timesteps_per_segment) elif combined_scalars: return _combine_nested_scalars(*list(all_results.values())) else: @@ -406,7 +409,7 @@ def results( def _save_solve_infos(self): t_start = timeit.default_timer() - indent = 4 if len(self.flow_system.time_series) < 50 else None + indent = 4 if len(self.flow_system.timesteps) < 50 else None with open(self._paths['results'], 'w', encoding='utf-8') as f: results = copy_and_convert_datatypes( self.results(combined_arrays=True), use_numpy=False, use_element_label=False @@ -455,18 +458,18 @@ def _save_solve_infos(self): def _transfer_start_values(self, segment_name: str): """ This function gets the last values of the previous solved segment and - inserts them as start values for the nest segment + inserts them as start values for the next segment """ - final_index_of_prior_segment = -(1 + self.overlap_length) + final_index_of_prior_segment = -(1 + self.overlap_timesteps) + first_index_of_previous_values = final_index_of_prior_segment - self.nr_of_previous_values + logger.debug(f'Using The following indices for the start values: {first_index_of_previous_values=}') start_values_of_this_segment = {} for flow in self.flow_system.flows.values(): - flow.previous_flow_rate = flow.model.flow_rate.result[ - final_index_of_prior_segment - ] # TODO: maybe more values? + flow.previous_flow_rate = flow.model.flow_rate.solution.values[first_index_of_previous_values:final_index_of_prior_segment] start_values_of_this_segment[flow.label_full] = flow.previous_flow_rate for comp in self.flow_system.components.values(): if isinstance(comp, Storage): - comp.initial_charge_state = comp.model.charge_state.result[final_index_of_prior_segment] + comp.initial_charge_state = comp.model.charge_state.solution.isel(time=final_index_of_prior_segment).item() start_values_of_this_segment[comp.label_full] = comp.initial_charge_state self._transfered_start_values[segment_name] = start_values_of_this_segment @@ -479,13 +482,14 @@ def _reset_start_values(self): if isinstance(comp, Storage): comp.initial_charge_state = self._original_start_values[comp] - def _get_indices(self, segment_index: int) -> range: - start = segment_index * self.segment_length - return range(start, min(start + self.segment_length + self.overlap_length, self._total_length)) + def _get_timesteps_of_segment(self, segment_index: int) -> pd.DatetimeIndex: + start = segment_index * self.timesteps_per_segment + end = min(start + self.timesteps_per_segment + self.overlap_timesteps, self._total_length) + return self.all_timesteps[start:end] @property def segment_length_with_overlap(self): - return self.segment_length + self.overlap_length + return self.timesteps_per_segment + self.overlap_timesteps @property def start_values_of_segments(self) -> Dict[str, Dict[str, Any]]: From 91c118fb908163c35a2128c2ac7b4fff2dbe6925 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 22 Feb 2025 15:13:00 +0100 Subject: [PATCH 185/507] Improving SegmentedCalculation --- flixOpt/calculation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py index 8c6cc1987..806e49e4b 100644 --- a/flixOpt/calculation.py +++ b/flixOpt/calculation.py @@ -436,7 +436,7 @@ def _save_solve_infos(self): nodes_info, edges_info = self.flow_system.network_infos() infos = { 'Calculation': self.infos, - 'Model': self.sub_calculations[0].system_model.infos, + 'Model': self.sub_calculations[0].model.infos, 'FlowSystem': get_compact_representation(self.flow_system.infos(use_numpy=True, use_element_label=True)), 'Network': {'Nodes': nodes_info, 'Edges': edges_info}, } @@ -557,7 +557,7 @@ def combine_arrays_recursively( if all(isinstance(val, dict) for val in values): # If all values are dictionaries, recursively combine each key return {key: combine_arrays_recursively(*(val[key] for val in values)) for key in values[0]} - if all(isinstance(val, np.ndarray) for val in values): + if all(isinstance(val, np.ndarray) for val in values) and all(val.ndim != 0 for val in values): def limit(idx: int, arr: np.ndarray) -> np.ndarray: # Performs the trimming of the arrays. Doesn't trim the last array! From 75167a10146cd4c42546bb7130fc965b42716dbe Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 22 Feb 2025 18:31:44 +0100 Subject: [PATCH 186/507] Never overwrite the backup in TimeSeries --- flixOpt/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixOpt/core.py b/flixOpt/core.py index d4835b100..86014cba1 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -311,7 +311,6 @@ def stored_data(self, value: Union[pd.Series, pd.DataFrame, xr.DataArray]): new_data = DataConverter.as_dataarray(value, time=self.active_timesteps, period=self.active_periods) if new_data.equals(self._stored_data): return # No change in stored_data. Do nothing. This prevents pushing out the backup - self._backup = self._stored_data self._stored_data = new_data self.active_timesteps = None self.active_periods = None @@ -428,6 +427,7 @@ def create_time_series( if isinstance(data, TimeSeries): if data not in self.time_series_data: self._add_time_series(data, extra_timestep) + data.restore_data() return data time_series = TimeSeries.from_datasource( From ce6344cefa157b781f723a07c252d78102d1d4c9 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 22 Feb 2025 18:32:19 +0100 Subject: [PATCH 187/507] Improve SegmentedCalculation --- flixOpt/calculation.py | 65 ++++++++++++++++++++++++------------------ 1 file changed, 37 insertions(+), 28 deletions(-) diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py index 806e49e4b..275548de6 100644 --- a/flixOpt/calculation.py +++ b/flixOpt/calculation.py @@ -318,15 +318,17 @@ def __init__( raise NotImplementedError('Multiple Periods are currently not supported in SegmentedCalculation') self.timesteps_per_segment = timesteps_per_segment self.overlap_timesteps = overlap_timesteps - self.all_timesteps = self.flow_system.timesteps if self.active_timesteps is None else self.active_timesteps - self._total_length = len(self.all_timesteps) - self.number_of_segments = math.ceil(self._total_length / self.timesteps_per_segment) self.nr_of_previous_values = nr_of_previous_values self.sub_calculations: List[FullCalculation] = [] + self.all_timesteps = self.flow_system.timesteps if self.active_timesteps is None else self.active_timesteps + + self.segment_names = [f'Segment_{i + 1}' for i in range(math.ceil(len(self.all_timesteps) / self.timesteps_per_segment))] + self.active_timesteps_per_segment = self._calculate_timesteps_of_segment() + assert timesteps_per_segment > 2, 'The Segment length must be greater 2, due to unwanted internal side effects' - assert self.segment_length_with_overlap <= self._total_length, ( - f'{self.segment_length_with_overlap=} cant be greater than the total length {self._total_length}' + assert self.timesteps_per_segment_with_overlap <= len(self.all_timesteps), ( + f'{self.timesteps_per_segment_with_overlap=} cant be greater than the total length {len(self.all_timesteps)}' ) # Storing all original start values @@ -338,20 +340,20 @@ def __init__( if isinstance(comp, Storage) }, } - self._transfered_start_values: Dict[str, Dict[str, Any]] = {} + self._transfered_start_values: List[Dict[str, Any]] = [] def do_modeling_and_solve(self, solver: _Solver, save_results: Union[bool, str, pathlib.Path] = True): logger.info(f'{"":#^80}') logger.info(f'{" Segmented Solving ":#^80}') self._define_path_names(save_results) - for i in range(self.number_of_segments): - name_of_segment = f'Segment_{i + 1}' + for i, (segment_name, timesteps_of_segment) in enumerate(zip(self.segment_names, self.active_timesteps_per_segment)): if self.sub_calculations: - self._transfer_start_values(name_of_segment) - timesteps_of_segment = self._get_timesteps_of_segment(i) - logger.info(f'{name_of_segment} ({timesteps_of_segment[0]} -> {timesteps_of_segment[-1]}):') - calculation = FullCalculation(name_of_segment, self.flow_system, active_timesteps=timesteps_of_segment) + self._transfer_start_values(i) + + logger.info(f'{segment_name} ({timesteps_of_segment[0]} -> {timesteps_of_segment[-1]}):') + + calculation = FullCalculation(segment_name, self.flow_system, active_timesteps=timesteps_of_segment) self.sub_calculations.append(calculation) calculation.do_modeling() invest_elements = [ @@ -399,7 +401,7 @@ def results( assert options_chosen == 1, ( f'Exactly one of the three options to retrieve the results needs to be chosen! You chose {options_chosen}!' ) - all_results = {f'Segment_{i + 1}': calculation.results() for i, calculation in enumerate(self.sub_calculations)} + all_results = {calculation.name: calculation.results() for calculation in self.sub_calculations} if combined_arrays: return _combine_nested_arrays(*list(all_results.values()), length_per_array=self.timesteps_per_segment) elif combined_scalars: @@ -455,24 +457,28 @@ def _save_solve_infos(self): logger.info(f'Saving calculation to .json took {self.durations["saving"]:>8.2f} seconds') logger.info(f'Saving calculation to .yaml took {(timeit.default_timer() - t_start):>8.2f} seconds') - def _transfer_start_values(self, segment_name: str): + def _transfer_start_values(self, segment_index: int): """ This function gets the last values of the previous solved segment and inserts them as start values for the next segment """ - final_index_of_prior_segment = -(1 + self.overlap_timesteps) - first_index_of_previous_values = final_index_of_prior_segment - self.nr_of_previous_values - logger.debug(f'Using The following indices for the start values: {first_index_of_previous_values=}') + timesteps_of_prior_segment = self.active_timesteps_per_segment[segment_index-1] + + start = self.active_timesteps_per_segment[segment_index][0] + start_previous_values = timesteps_of_prior_segment[self.timesteps_per_segment - self.nr_of_previous_values] + end_previous_values = timesteps_of_prior_segment[self.timesteps_per_segment-1] + + logger.debug(f'start of next segment: {start}. indices of previous values: {start_previous_values}:{end_previous_values}') start_values_of_this_segment = {} for flow in self.flow_system.flows.values(): - flow.previous_flow_rate = flow.model.flow_rate.solution.values[first_index_of_previous_values:final_index_of_prior_segment] + flow.previous_flow_rate = flow.model.flow_rate.solution.sel(time=slice(start_previous_values, end_previous_values)).values start_values_of_this_segment[flow.label_full] = flow.previous_flow_rate for comp in self.flow_system.components.values(): if isinstance(comp, Storage): - comp.initial_charge_state = comp.model.charge_state.solution.isel(time=final_index_of_prior_segment).item() + comp.initial_charge_state = comp.model.charge_state.solution.sel(time=start).item() start_values_of_this_segment[comp.label_full] = comp.initial_charge_state - self._transfered_start_values[segment_name] = start_values_of_this_segment + self._transfered_start_values.append(start_values_of_this_segment) def _reset_start_values(self): """This resets the start values of all Elements to its original state""" @@ -482,23 +488,26 @@ def _reset_start_values(self): if isinstance(comp, Storage): comp.initial_charge_state = self._original_start_values[comp] - def _get_timesteps_of_segment(self, segment_index: int) -> pd.DatetimeIndex: - start = segment_index * self.timesteps_per_segment - end = min(start + self.timesteps_per_segment + self.overlap_timesteps, self._total_length) - return self.all_timesteps[start:end] + def _calculate_timesteps_of_segment(self) -> List[pd.DatetimeIndex]: + active_timesteps_per_segment = [] + for i, segment_name in enumerate(self.segment_names): + start = self.timesteps_per_segment * i + end = min(start + self.timesteps_per_segment_with_overlap, len(self.all_timesteps)) + active_timesteps_per_segment.append(self.all_timesteps[start:end]) + return active_timesteps_per_segment @property - def segment_length_with_overlap(self): + def timesteps_per_segment_with_overlap(self): return self.timesteps_per_segment + self.overlap_timesteps @property - def start_values_of_segments(self) -> Dict[str, Dict[str, Any]]: + def start_values_of_segments(self) -> Dict[int, Dict[str, Any]]: """Gives an overview of the start values of all Segments""" return { - self.sub_calculations[0].name: { + 0: { element.label_full: value for element, value in self._original_start_values.items() }, - **self._transfered_start_values, + **{i: start_values for i, start_values in enumerate(self._transfered_start_values, 1)}, } From bde05ed9939ffd7ce35436ec9143bd9b7cfc59c7 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 22 Feb 2025 18:52:47 +0100 Subject: [PATCH 188/507] Add new function to store the solution of a FLowSystem permanently, including its structure --- flixOpt/calculation.py | 12 +++++++----- flixOpt/structure.py | 12 ++++++++++-- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py index 275548de6..1e9303833 100644 --- a/flixOpt/calculation.py +++ b/flixOpt/calculation.py @@ -94,7 +94,7 @@ def _save_solve_infos(self): t_start = timeit.default_timer() indent = 4 if len(self.flow_system.timesteps) < 50 else None with open(self._paths['results'], 'w', encoding='utf-8') as f: - results = copy_and_convert_datatypes(self.results(), use_numpy=False, use_element_label=False) + results = copy_and_convert_datatypes(self.results, use_numpy=False, use_element_label=False) json.dump(results, f, indent=indent) with open(self._paths['data'], 'w', encoding='utf-8') as f: @@ -107,7 +107,7 @@ def _save_solve_infos(self): nodes_info, edges_info = self.flow_system.network_infos() infos = { 'Calculation': self.infos, - 'Model': self.flow_system.model.infos, + 'Model': self.model.infos, 'FlowSystem': get_compact_representation(self.flow_system.infos(use_numpy=True, use_element_label=True)), 'Network': {'Nodes': nodes_info, 'Edges': edges_info}, } @@ -127,9 +127,8 @@ def _save_solve_infos(self): logger.info(f'Saving calculation to .json took {self.durations["saving"]:>8.2f} seconds') logger.info(f'Saving calculation to .yaml took {(timeit.default_timer() - t_start):>8.2f} seconds') + @property def results(self): - if self._results is None: - self._results = self.flow_system.results() return self._results @property @@ -163,6 +162,8 @@ def solve(self, solver: _Solver, save_results: Union[bool, str, pathlib.Path] = self.model.solve(log_fn=self._paths['log'], solver_name=solver.name, **solver.options) + self.model.store_solution() + self._results = self.flow_system.results() self.durations['solving'] = round(timeit.default_timer() - t_start, 2) # Log the formatted output @@ -368,6 +369,7 @@ def do_modeling_and_solve(self, solver: _Solver, save_results: Union[bool, str, f'Following InvestmentModels were found: {invest_elements}' ) calculation.solve(solver, save_results=False) + calculation.model.store_solution() self._reset_start_values() @@ -401,7 +403,7 @@ def results( assert options_chosen == 1, ( f'Exactly one of the three options to retrieve the results needs to be chosen! You chose {options_chosen}!' ) - all_results = {calculation.name: calculation.results() for calculation in self.sub_calculations} + all_results = {calculation.name: calculation.results for calculation in self.sub_calculations} if combined_arrays: return _combine_nested_arrays(*list(all_results.values()), length_per_array=self.timesteps_per_segment) elif combined_scalars: diff --git a/flixOpt/structure.py b/flixOpt/structure.py index d0b07389c..8f39ece1a 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -38,6 +38,9 @@ def __init__(self, flow_system: 'FlowSystem'): self.flow_system = flow_system self.effects: Optional[EffectCollection] = None + self._solution_structure = None + self.solution_structured = None + def do_modeling(self): from .effects import EffectCollection self.effects = EffectCollection(self, list(self.flow_system.effects.values())) @@ -53,7 +56,12 @@ def do_modeling(self): self.effects.objective_effect.model.total + self.effects.penalty.total ) - def solution_structured(self, mode: Literal['py', 'numpy', 'xarray', 'structure'] = 'numpy'): + def store_solution(self): + self._solution_structure = self._get_solution_structured(mode='structure') + solution = self.variables.solution + self.solution_structured = SystemModel._insert_dataarrays(solution, self._solution_structure) + + def _get_solution_structured(self, mode: Literal['py', 'numpy', 'xarray', 'structure'] = 'numpy'): return { 'Buses': { bus.label_full: bus.model.solution_structured(mode=mode) @@ -77,7 +85,7 @@ def to_netcdf(self, path: Union[str, pathlib.Path] = 'flow_system.nc'): """ ds = self.solution ds = ds.rename_vars({var: var.replace('/', '-slash-') for var in ds.data_vars}) - ds.attrs["structure"] = json.dumps(self.solution_structured(mode='structure')) # Convert dict to JSON string + ds.attrs["structure"] = json.dumps(self._solution_structure) # Convert dict to JSON string ds.to_netcdf(path) @staticmethod From c759244dcebfa5f39461cecfb86894b323cb4745 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 22 Feb 2025 19:57:06 +0100 Subject: [PATCH 189/507] Add new function to store the solution of a FLowSystem permanently, including its structure --- examples/00_Minmal/minimal_example.py | 2 +- examples/03_Calculation_types/example_calculation_types.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/00_Minmal/minimal_example.py b/examples/00_Minmal/minimal_example.py index 408553366..3cb08095a 100644 --- a/examples/00_Minmal/minimal_example.py +++ b/examples/00_Minmal/minimal_example.py @@ -63,6 +63,6 @@ results.plot_operation('District Heating', 'area') # Print results to the console. Check Results in file or perform more plotting - pprint(calculation.results()) + pprint(calculation.results) pprint('Look into .yaml and .json file for results') pprint(calculation.flow_system.model.main_results) diff --git a/examples/03_Calculation_types/example_calculation_types.py b/examples/03_Calculation_types/example_calculation_types.py index 8992398b8..f50165299 100644 --- a/examples/03_Calculation_types/example_calculation_types.py +++ b/examples/03_Calculation_types/example_calculation_types.py @@ -14,7 +14,7 @@ if __name__ == '__main__': # Calculation Types - full, segmented, aggregated = True, False, False + full, segmented, aggregated = True, True, True # Segmented Properties segment_length, overlap_length = 96, 1 @@ -161,7 +161,7 @@ calculation.do_modeling() calculation.solve(fx.solvers.HighsSolver(0, 60)) calculations['Full'] = calculation - results['Full'] = calculations['Full'].results() + results['Full'] = calculations['Full'].results if segmented: calculation = fx.SegmentedCalculation('segModel', flow_system, segment_length, overlap_length) @@ -177,7 +177,7 @@ calculation.do_modeling() calculation.solve(fx.solvers.HighsSolver(0, 60)) calculations['Aggregated'] = calculation - results['Aggregated'] = calculations['Aggregated'].results() + results['Aggregated'] = calculations['Aggregated'].results pprint(results) def extract_result(results_data: dict[str, dict], keys: List[str]) -> Dict[str, Union[int, float, np.ndarray]]: From 128d3d797629970f1280e846de010ec4728f3415 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 22 Feb 2025 20:13:34 +0100 Subject: [PATCH 190/507] Add possibility to not show the main results of a calculations --- flixOpt/calculation.py | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py index 1e9303833..04029b290 100644 --- a/flixOpt/calculation.py +++ b/flixOpt/calculation.py @@ -156,7 +156,10 @@ def do_modeling(self) -> SystemModel: self.durations['modeling'] = round(timeit.default_timer() - t_start, 2) return self.model - def solve(self, solver: _Solver, save_results: Union[bool, str, pathlib.Path] = True): + def solve(self, + solver: _Solver, + save_results: Union[bool, str, pathlib.Path] = True, + log_main_results: bool = True): self._define_path_names(save_results) t_start = timeit.default_timer() self.model.solve(log_fn=self._paths['log'], @@ -167,10 +170,11 @@ def solve(self, solver: _Solver, save_results: Union[bool, str, pathlib.Path] = self.durations['solving'] = round(timeit.default_timer() - t_start, 2) # Log the formatted output - logger.info(f'{" Main Results ":#^80}') - logger.info("\n" + yaml.dump( - utils.round_floats(self.flow_system.model.infos), - default_flow_style=False, sort_keys=False, allow_unicode=True, indent=4)) + if log_main_results: + logger.info(f'{" Main Results ":#^80}') + logger.info("\n" + yaml.dump( + utils.round_floats(self.flow_system.model.infos), + default_flow_style=False, sort_keys=False, allow_unicode=True, indent=4)) if save_results: self._save_solve_infos() @@ -343,7 +347,11 @@ def __init__( } self._transfered_start_values: List[Dict[str, Any]] = [] - def do_modeling_and_solve(self, solver: _Solver, save_results: Union[bool, str, pathlib.Path] = True): + def do_modeling_and_solve( + self, + solver: _Solver, + save_results: Union[bool, str, pathlib.Path] = True, + log_main_results: bool = False): logger.info(f'{"":#^80}') logger.info(f'{" Segmented Solving ":#^80}') self._define_path_names(save_results) @@ -352,7 +360,8 @@ def do_modeling_and_solve(self, solver: _Solver, save_results: Union[bool, str, if self.sub_calculations: self._transfer_start_values(i) - logger.info(f'{segment_name} ({timesteps_of_segment[0]} -> {timesteps_of_segment[-1]}):') + logger.info(f'{segment_name} [{i+1:>2}/{len(self.segment_names):<2}] ' + f'({timesteps_of_segment[0]} -> {timesteps_of_segment[-1]}):') calculation = FullCalculation(segment_name, self.flow_system, active_timesteps=timesteps_of_segment) self.sub_calculations.append(calculation) @@ -368,8 +377,7 @@ def do_modeling_and_solve(self, solver: _Solver, save_results: Union[bool, str, f'Investments are not supported in Segmented Calculation! ' f'Following InvestmentModels were found: {invest_elements}' ) - calculation.solve(solver, save_results=False) - calculation.model.store_solution() + calculation.solve(solver, save_results=False, log_main_results=log_main_results) self._reset_start_values() From 29c7344f4ad9a53296abf6292088532a2e8a514b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 22 Feb 2025 20:19:27 +0100 Subject: [PATCH 191/507] Update example_calculation_types.py --- .../example_calculation_types.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/examples/03_Calculation_types/example_calculation_types.py b/examples/03_Calculation_types/example_calculation_types.py index f50165299..f389694d1 100644 --- a/examples/03_Calculation_types/example_calculation_types.py +++ b/examples/03_Calculation_types/example_calculation_types.py @@ -178,7 +178,6 @@ calculation.solve(fx.solvers.HighsSolver(0, 60)) calculations['Aggregated'] = calculation results['Aggregated'] = calculations['Aggregated'].results - pprint(results) def extract_result(results_data: dict[str, dict], keys: List[str]) -> Dict[str, Union[int, float, np.ndarray]]: """ @@ -198,11 +197,11 @@ def get_nested_value(d, ks): return {kind: get_nested_value(results_data.get(kind, {}), keys) for kind in results_data.keys()} if calculations['Full'] is not None: - time_series_used = calculations['Full'].system_model.time_series - time_series_used_w_end = calculations['Full'].system_model.time_series_with_end + time_series_used = calculations['Full'].flow_system.timesteps + time_series_used_w_end = calculations['Full'].flow_system.timesteps_extra else: - time_series_used = calculations['Aggregated'].system_model.time_series - time_series_used_w_end = calculations['Aggregated'].system_model.time_series_with_end + time_series_used = calculations['Aggregated'].flow_system.timesteps + time_series_used_w_end = calculations['Aggregated'].flow_system.timesteps_extra data = pd.DataFrame( extract_result(results, ['Components', 'Speicher', 'charge_state']), index=time_series_used_w_end @@ -217,15 +216,15 @@ def get_nested_value(d, ks): fig.write_html('results/BHKW2 Thermal Power.html') data = pd.DataFrame( - extract_result(results, ['Effects', 'costs', 'operation', 'operation_sum_TS']), - index=calculations['Full'].system_model.time_series, + extract_result(results, ['Effects', 'costs', 'operation', 'total_per_timestep']), + index=time_series_used, ) fig = fx.plotting.with_plotly(data, 'line') fig.update_layout(title='Cost Comparison', xaxis_title='Time', yaxis_title='Costs (€)') fig.write_html('results/Operation Costs.html') data = pd.DataFrame( - extract_result(results, ['Effects', 'costs', 'operation', 'operation_sum_TS']), index=time_series_used + extract_result(results, ['Effects', 'costs', 'operation', 'total_per_timestep']), index=time_series_used ) data = pd.DataFrame(data.sum()).T fig = fx.plotting.with_plotly(data, 'bar') From d4a91f0bcc77307dc88245e2340b6ecc263e25ce Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 22 Feb 2025 20:33:17 +0100 Subject: [PATCH 192/507] Fixing the saving of Segmented Results --- flixOpt/calculation.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py index 04029b290..eca3694a5 100644 --- a/flixOpt/calculation.py +++ b/flixOpt/calculation.py @@ -292,7 +292,6 @@ def __init__( flow_system: FlowSystem, timesteps_per_segment: int, overlap_timesteps: int, - active_timesteps: Optional[pd.DatetimeIndex] = None, nr_of_previous_values: int = 1, ): """ @@ -318,7 +317,7 @@ def __init__( The number of time_steps that are added to each individual model. Used for better results of storages) """ - super().__init__(name, flow_system, active_timesteps) + super().__init__(name, flow_system) if flow_system.periods is not None: raise NotImplementedError('Multiple Periods are currently not supported in SegmentedCalculation') self.timesteps_per_segment = timesteps_per_segment @@ -326,7 +325,7 @@ def __init__( self.nr_of_previous_values = nr_of_previous_values self.sub_calculations: List[FullCalculation] = [] - self.all_timesteps = self.flow_system.timesteps if self.active_timesteps is None else self.active_timesteps + self.all_timesteps = self.flow_system.timesteps self.segment_names = [f'Segment_{i + 1}' for i in range(math.ceil(len(self.all_timesteps) / self.timesteps_per_segment))] self.active_timesteps_per_segment = self._calculate_timesteps_of_segment() @@ -413,7 +412,11 @@ def results( ) all_results = {calculation.name: calculation.results for calculation in self.sub_calculations} if combined_arrays: - return _combine_nested_arrays(*list(all_results.values()), length_per_array=self.timesteps_per_segment) + return { + **_combine_nested_arrays(*list(all_results.values()), length_per_array=self.timesteps_per_segment), + 'Time': self.flow_system.time_series_collection._timesteps_extra.tolist(), + 'Time intervals in hours': self.flow_system.time_series_collection._hours_per_timestep, + } elif combined_scalars: return _combine_nested_scalars(*list(all_results.values())) else: From 23dc731df1b4ef01d18f320ed6efa5d1ca880cc6 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 22 Feb 2025 20:47:59 +0100 Subject: [PATCH 193/507] Update example_calculation_types.py --- .../example_calculation_types.py | 61 ++++++------------- 1 file changed, 20 insertions(+), 41 deletions(-) diff --git a/examples/03_Calculation_types/example_calculation_types.py b/examples/03_Calculation_types/example_calculation_types.py index f389694d1..837a67e0c 100644 --- a/examples/03_Calculation_types/example_calculation_types.py +++ b/examples/03_Calculation_types/example_calculation_types.py @@ -23,7 +23,7 @@ aggregation_parameters = fx.AggregationParameters( hours_per_period=6, nr_of_periods=4, - fix_storage_flows=True, + fix_storage_flows=False, aggregate_data_and_fix_non_binary_vars=True, percentage_of_period_freedom=0, penalty_of_period_freedom=0, @@ -157,77 +157,56 @@ results: dict = {key: None for key in kinds} if full: - calculation = fx.FullCalculation('fullModel', flow_system) + calculation = fx.FullCalculation('Full', flow_system) calculation.do_modeling() calculation.solve(fx.solvers.HighsSolver(0, 60)) calculations['Full'] = calculation - results['Full'] = calculations['Full'].results + results['Full'] = fx.results.CalculationResults('Full', folder='results') if segmented: - calculation = fx.SegmentedCalculation('segModel', flow_system, segment_length, overlap_length) + calculation = fx.SegmentedCalculation('Segmented', flow_system, segment_length, overlap_length) calculation.do_modeling_and_solve(fx.solvers.HighsSolver(0, 60)) calculations['Segmented'] = calculation - results['Segmented'] = calculations['Segmented'].results(combined_arrays=True) + results['Segmented'] = fx.results.CalculationResults('Segmented', folder='results') if aggregated: if keep_extreme_periods: aggregation_parameters.time_series_for_high_peaks = [TS_heat_demand] aggregation_parameters.time_series_for_low_peaks = [TS_electricity_demand, TS_heat_demand] - calculation = fx.AggregatedCalculation('aggModel', flow_system, aggregation_parameters) + calculation = fx.AggregatedCalculation('Aggregated', flow_system, aggregation_parameters) calculation.do_modeling() calculation.solve(fx.solvers.HighsSolver(0, 60)) calculations['Aggregated'] = calculation - results['Aggregated'] = calculations['Aggregated'].results - - def extract_result(results_data: dict[str, dict], keys: List[str]) -> Dict[str, Union[int, float, np.ndarray]]: - """ - Function to retrieve values from a nested dictionary. - Tries to get the wanted value for eachnkey in the first layer of the dict. - Returns a dict with one key value pair for each dict it found a value in. - """ - - def get_nested_value(d, ks): - for k in ks: - if isinstance(d, dict): - d = d.get(k, None) - else: - return None - return d - - return {kind: get_nested_value(results_data.get(kind, {}), keys) for kind in results_data.keys()} - - if calculations['Full'] is not None: - time_series_used = calculations['Full'].flow_system.timesteps - time_series_used_w_end = calculations['Full'].flow_system.timesteps_extra - else: - time_series_used = calculations['Aggregated'].flow_system.timesteps - time_series_used_w_end = calculations['Aggregated'].flow_system.timesteps_extra + results['Aggregated'] = fx.results.CalculationResults('Aggregated', folder='results') + + + time_series_used = flow_system.timesteps + time_series_used_w_end = flow_system.timesteps_extra data = pd.DataFrame( - extract_result(results, ['Components', 'Speicher', 'charge_state']), index=time_series_used_w_end + {mode: results[mode].component_results['Speicher'].all_results['charge_state'] for mode in results}, + index=time_series_used_w_end ) fig = fx.plotting.with_plotly(data, 'line') fig.update_layout(title='Charge State Comparison', xaxis_title='Time', yaxis_title='Charge state') fig.write_html('results/Charge State.html') - data = pd.DataFrame(extract_result(results, ['Components', 'BHKW2', 'Q_th', 'flow_rate']), index=time_series_used) + data = pd.DataFrame( + {mode: results[mode].component_results['BHKW2'].all_results['Q_th']['flow_rate'] for mode in results}, + index=time_series_used + ) fig = fx.plotting.with_plotly(data, 'line') fig.update_layout(title='BHKW2 Q_th Flow Rate Comparison', xaxis_title='Time', yaxis_title='Flow rate') fig.write_html('results/BHKW2 Thermal Power.html') data = pd.DataFrame( - extract_result(results, ['Effects', 'costs', 'operation', 'total_per_timestep']), - index=time_series_used, + {mode: results[mode].effect_results['costs'].all_results['operation']['total_per_timestep'] for mode in results}, + index=time_series_used ) fig = fx.plotting.with_plotly(data, 'line') fig.update_layout(title='Cost Comparison', xaxis_title='Time', yaxis_title='Costs (€)') fig.write_html('results/Operation Costs.html') - - data = pd.DataFrame( - extract_result(results, ['Effects', 'costs', 'operation', 'total_per_timestep']), index=time_series_used - ) - data = pd.DataFrame(data.sum()).T - fig = fx.plotting.with_plotly(data, 'bar') + fig = fx.plotting.with_plotly(pd.DataFrame(data.sum()).T, 'bar') fig.update_layout(title='Total Cost Comparison', yaxis_title='Costs (€)', barmode='group') fig.write_html('results/Total Costs.html') From a7fba0595b7beabe005a5b7e82a1d4ce6704dbe5 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 22 Feb 2025 21:07:39 +0100 Subject: [PATCH 194/507] update test --- tests/test_integration.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_integration.py b/tests/test_integration.py index 68d806c54..136722a17 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -218,7 +218,7 @@ def test_transmission_basic(self): calculation = fx.FullCalculation('Test_Sim', flow_system) calculation.do_modeling() calculation.solve(self.get_solver()) - print(calculation.results()) + print(calculation.results) self.assert_almost_equal_numeric( transmission.in1.model.on_off.on.solution.values, np.array([1, 1, 1, 1, 1, 1, 1, 1, 1, 1]), 'On does not work properly' ) @@ -826,7 +826,7 @@ def calculate(self, modeling_type: Literal['full', 'segmented', 'aggregated']): calc.do_modeling() calc.solve(self.get_solver(), save_results=True) elif doSegmentedCalc: - calc = fx.SegmentedCalculation('segModel', es, segment_length=96, overlap_length=1) + calc = fx.SegmentedCalculation('segModel', es, timesteps_per_segment=96, overlap_timesteps=1) calc.do_modeling_and_solve(self.get_solver(), save_results=True) elif doAggregatedCalc: calc = fx.AggregatedCalculation( From 85e28f83ed6a91411b8c346ebdae913eec2a2bec Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 23 Feb 2025 01:18:01 +0100 Subject: [PATCH 195/507] remove math_modeling.py --- flixOpt/aggregation.py | 3 - flixOpt/components.py | 3 +- flixOpt/effects.py | 1 - flixOpt/features.py | 21 +- flixOpt/math_modeling.py | 1072 -------------------------------------- flixOpt/solvers.py | 66 ++- flixOpt/structure.py | 38 -- 7 files changed, 66 insertions(+), 1138 deletions(-) delete mode 100644 flixOpt/math_modeling.py diff --git a/flixOpt/aggregation.py b/flixOpt/aggregation.py index b6a912078..e71c75af9 100644 --- a/flixOpt/aggregation.py +++ b/flixOpt/aggregation.py @@ -19,13 +19,10 @@ from .core import Skalar, TimeSeriesData from .elements import Component from .flow_system import FlowSystem -from .math_modeling import Equation, Variable, VariableTS from .structure import ( Element, Model, SystemModel, - create_equation, - create_variable, ) if TYPE_CHECKING: diff --git a/flixOpt/components.py b/flixOpt/components.py index f97abd0f5..ff29b332f 100644 --- a/flixOpt/components.py +++ b/flixOpt/components.py @@ -14,8 +14,7 @@ from .elements import Component, ComponentModel, Flow from .features import InvestmentModel, MultipleSegmentsModel, OnOffModel from .interface import InvestParameters, OnOffParameters -from .math_modeling import Equation, VariableTS -from .structure import SystemModel, create_equation, create_variable +from .structure import SystemModel if TYPE_CHECKING: from .flow_system import FlowSystem diff --git a/flixOpt/effects.py b/flixOpt/effects.py index cd96858ee..0afa051e1 100644 --- a/flixOpt/effects.py +++ b/flixOpt/effects.py @@ -14,7 +14,6 @@ from .core import Numeric, Numeric_TS, Skalar, TimeSeries, TimeSeriesCollection from .features import ShareAllocationModel -from .math_modeling import Equation, Variable from .structure import Element, ElementModel, Model, SystemModel if TYPE_CHECKING: diff --git a/flixOpt/features.py b/flixOpt/features.py index c394626b9..e1f3aee29 100644 --- a/flixOpt/features.py +++ b/flixOpt/features.py @@ -13,7 +13,6 @@ from .config import CONFIG from .core import Numeric, Skalar, TimeSeries from .interface import InvestParameters, OnOffParameters -from .math_modeling import Equation, Variable, VariableTS from .structure import Model, SystemModel if TYPE_CHECKING: # for type checking and preventing circular imports @@ -43,8 +42,8 @@ def __init__( If fixed relative profile is used, the relative bounds are ignored """ super().__init__(model, label_of_element, label) - self.size: Optional[Union[Skalar, Variable]] = None - self.is_invested: Optional[Variable] = None + self.size: Optional[Union[Skalar, linopy.Variable]] = None + self.is_invested: Optional[linopy.Variable] = None self._segments: Optional[SegmentedSharesModel] = None @@ -228,7 +227,7 @@ def __init__( self._previous_values = previous_values self.on: Optional[linopy.Variable] = None - self.total_on_hours: Optional[Variable] = None + self.total_on_hours: Optional[linopy.Variable] = None self.consecutive_on_hours: Optional[linopy.Variable] = None self.consecutive_off_hours: Optional[linopy.Variable] = None @@ -704,9 +703,9 @@ def __init__( as_time_series: bool = True, ): super().__init__(model, label_of_element, f'Segment{segment_index}') - self.in_segment: Optional[VariableTS] = None - self.lambda0: Optional[VariableTS] = None - self.lambda1: Optional[VariableTS] = None + self.in_segment: Optional[linopy.Variable] = None + self.lambda0: Optional[linopy.Variable] = None + self.lambda1: Optional[linopy.Variable] = None self._segment_index = segment_index self._as_time_series = as_time_series @@ -749,7 +748,7 @@ def __init__( model: SystemModel, label_of_element: str, sample_points: Dict[str, List[Tuple[Numeric, Numeric]]], - can_be_outside_segments: Optional[Union[bool, Variable]], + can_be_outside_segments: Optional[Union[bool, linopy.Variable]], as_time_series: bool = True, label: str = 'MultipleSegments', ): @@ -975,9 +974,9 @@ def __init__( self, model: SystemModel, label_of_element: str, - variable_segments: Tuple[Variable, List[Tuple[Skalar, Skalar]]], + variable_segments: Tuple[linopy.Variable, List[Tuple[Skalar, Skalar]]], share_segments: Dict['Effect', List[Tuple[Skalar, Skalar]]], - can_be_outside_segments: Optional[Union[bool, Variable]], + can_be_outside_segments: Optional[Union[bool, linopy.Variable]], label: str = 'SegmentedShares', ): super().__init__(model, label_of_element, label) @@ -989,7 +988,7 @@ def __init__( self._share_segments = share_segments self._shares: Dict['Effect', linopy.Variable] = {} self._segments_model: Optional[MultipleSegmentsModel] = None - self._as_tme_series: bool = isinstance(self._variable_segments[0], VariableTS) + self._as_tme_series: bool = 'time' in self._variable_segments[0].indexes def do_modeling(self, system_model: SystemModel): self._shares = { diff --git a/flixOpt/math_modeling.py b/flixOpt/math_modeling.py deleted file mode 100644 index 175fe3513..000000000 --- a/flixOpt/math_modeling.py +++ /dev/null @@ -1,1072 +0,0 @@ -""" -This module contains the mathematical core of the flixOpt framework. -THe module is designed to be used by other modules than flixOpt itself. -It holds all necessary classes and functions to create a mathematical model, consisting of Varaibles and constraints, -and translate it into a ModelingLanguage like Pyomo, and the solve it through a solver. -Multiple solvers are supported. -""" -import logging -import re -import timeit -from abc import ABC, abstractmethod -from dataclasses import dataclass, field -from typing import Any, ClassVar, Dict, List, Literal, Optional, Union - -import numpy as np -from numpy import inf - -from . import utils -from .core import Numeric - -logger = logging.getLogger('flixOpt') - - -class Variable: - """ - Variable class - """ - - def __init__( - self, - label: str, - length: int, - label_short: Optional[str] = None, - is_binary: bool = False, - fixed_value: Optional[Numeric] = None, - lower_bound: Optional[Numeric] = None, - upper_bound: Optional[Numeric] = None, - ): - """ - label: full label of the variable - label_short: short label of the variable - - # TODO: Allow for None values in fixed_value. If None, the index gets not fixed! - """ - self.label = label - self.label_short = label_short or label - self.length = length - self.is_binary = is_binary - self.fixed_value = fixed_value - self.lower_bound = lower_bound - self.upper_bound = upper_bound - - self.indices = range(self.length) - self.fixed = False - - self.result = None # Ergebnis-Speicher - - if self.fixed_value is not None: # Check if value is within bounds, element-wise - above = self.lower_bound is None or np.all(np.asarray(self.fixed_value) >= np.asarray(self.lower_bound)) - below = self.upper_bound is None or np.all(np.asarray(self.fixed_value) <= np.asarray(self.upper_bound)) - if not (above and below): - raise Exception( - f'Fixed value of Variable {self.label} not inside set bounds:' - f'\n{self.fixed_value=};\n{self.lower_bound=};\n{self.upper_bound=}' - ) - - # Mark as fixed - self.fixed = True - - logger.debug('Variable created: ' + self.label) - - def description(self, max_length_ts=60) -> str: - bin_type = 'bin' if self.is_binary else ' ' - - header = f'Var {bin_type} x {self.length:<6} "{self.label}"' - if self.fixed: - description = f'{header:<40}: fixed={str(self.fixed_value)[:max_length_ts]:<10}' - else: - description = ( - f'{header:<40}: min={str(self.lower_bound)[:max_length_ts]:<10}, ' - f'max={str(self.upper_bound)[:max_length_ts]:<10}' - ) - return description - - def reset_result(self): - self.result = None - - -class VariableTS(Variable): - """ - Timeseries-Variable, optionally with previous_values. class for Variables that are related by time - """ - - def __init__( - self, - label: str, - length: int, - label_short: Optional[str] = None, - is_binary: bool = False, - fixed_value: Optional[Numeric] = None, - lower_bound: Optional[Numeric] = None, - upper_bound: Optional[Numeric] = None, - previous_values: Optional[Numeric] = None, - ): - assert length > 1, 'length is one, that seems not right for VariableTS' - super().__init__( - label, - length, - label_short, - is_binary=is_binary, - fixed_value=fixed_value, - lower_bound=lower_bound, - upper_bound=upper_bound, - ) - self.previous_values = previous_values - - -class _Constraint: - """ - Abstract Class for Constraints. Use Child classes! - - """ - - def __init__(self, label: str, label_short: Optional[str] = None): - """ - Equation of the form: ∑() = type: 'eq' - Equation of the form: ∑() <= type: 'ineq' - Equation of the form: ∑() = type: 'objective' - - Parameters - ---------- - label: full label of the variable - label_short: short label of the variable. If None, the the full label is used - """ - self.label = label - self.label_short = label_short or label - self.summands: List[SumOfSummand] = [] - self.parts_of_constant: List[Numeric] = [] - self.constant: Numeric = 0 # Total of right side - - self.length = 1 # Anzahl der Gleichungen - - logger.debug(f'Equation created: {self.label}') - - def add_summand( - self, - variable: Variable, - factor: Numeric, - indices_of_variable: Optional[Union[int, np.ndarray, range, List[int]]] = None, - as_sum: bool = False, - ) -> None: - """ - Adds a summand to the left side of the equation. - - This method creates a summand from the given variable and factor, optionally summing over all given indices. - The summand is then added to the summands of the equation, which represent the left side. - - Parameters: - ----------- - variable : Variable - The variable to be used in the summand. - factor : Numeric - The factor by which the variable is multiplied. - indices_of_variable : Optional[Numeric], optional - Specific indices of the variable to be used. If not provided, all indices are used. - as_sum : bool, optional - If True, the summand is treated as a sum over all indices of the variable. - - Raises: - ------- - TypeError - If the provided variable is not an instance of the Variable class. - ValueError - If the variable is None and as_sum is True. - ValueError - If the length doesnt match the Equation's length. - """ - # TODO: Functionality to create A Sum of Summand over a specified range of indices? For Limiting stuff per one year...? - if not isinstance(variable, Variable): - raise TypeError(f'Error in Equation "{self.label}": no variable given (variable = "{variable}")') - if variable is None and as_sum: - raise ValueError(f'Error in Equation "{self.label}": Variable can not be None and be summed up!') - - if np.isscalar(indices_of_variable): # Wenn nur ein Wert, dann Liste mit einem Eintrag drausmachen: - indices_of_variable = [indices_of_variable] - - if as_sum: - summand = SumOfSummand(variable, factor, indices=indices_of_variable) - else: - summand = Summand(variable, factor, indices=indices_of_variable) - - try: - self._update_length(summand.length) # Check Variablen-Länge: - except ValueError as e: - raise ValueError( - f'Length of Summand with variable "{variable.label}" does not fit equation "{self.label}": {e}' - ) from e - self.summands.append(summand) - - def add_constant(self, value: Numeric) -> None: - """ - Adds a constant value to the rigth side of the equation - - Parameters - ---------- - value : float or array - constant-value of equation [A*x = constant] or [A*x <= constant] - - Returns - ------- - None. - - Raises: - ------- - ValueError - If the length doesnt match the Equation's length. - - """ - self.constant = np.add(self.constant, value) # Adding to current constant - self.parts_of_constant.append(value) # Adding to parts of constants - - length = 1 if np.isscalar(self.constant) else len(self.constant) - try: - self._update_length(length) - except ValueError as e: - raise ValueError(f'Length of Constant {value=} does not fit: {e}') from e - - def description(self, at_index: int = 0) -> str: - raise NotImplementedError('Not implemented for Abstract class <_Constraint>') - - def _update_length(self, new_length: int) -> None: - """ - Passes if the new_length is 1, the current length is 1 or new_length matches the existing length of the Equation - """ - if self.length == 1: # First Summand sets length - self.length = new_length - elif new_length == 1 or new_length == self.length: # Length 1 is always possible - pass - else: - raise ValueError( - f'The length of the new element {new_length=} doesnt match the existing ' - f'length of the Equation {self.length=}!' - ) - - @property - def constant_vector(self) -> Numeric: - return utils.as_vector(self.constant, self.length) - - -class Equation(_Constraint): - """ - Equation of the form: ∑() = - Can be the Objective of a MathModel. - - Parameters - ---------- - label : str - Full label of the variable. - label_short : str, optional - Short label of the variable. If None, the full label is used. - is_objective : bool, optional - Indicates if this equation is the objective of the model (default is False). - """ - - def __init__(self, label, label_short=None, is_objective=False): - super().__init__(label, label_short) - self.is_objective = is_objective - - def description(self, at_index: int = 0) -> str: - equation_nr = min(at_index, self.length - 1) - - # Name and index as str - if self.is_objective == 'objective': - name, index_str = 'OBJ', '' - else: - name, index_str = f'EQ {self.label}', f'[{equation_nr + 1}/{self.length}]' - - # Summands: - summand_strings = [summand.description(at_index) for summand in self.summands] - all_summands_string = ' + '.join(summand_strings) - - constant = self.constant_vector[equation_nr] - - # String formating - header_width = 30 - header = f'{name:<{header_width - len(index_str) - 1}} {index_str}' - return f'{header:<{header_width}}: {constant:>8} = {all_summands_string}' - - -class Inequation(_Constraint): - """ - Equation of the form: >= ∑() - - Parameters - ---------- - label: full label of the variable - label_short: short label of the variable. If None, the full label is used - """ - - def __init__(self, label, label_short=None): - super().__init__(label, label_short) - - def description(self, at_index: int = 0) -> str: - equation_nr = min(at_index, self.length - 1) - - # Name and index as str - name, index_str = f'INEQ {self.label}', f'[{equation_nr + 1}/{self.length}]' - - # Summands: - summand_strings = [summand.description(at_index) for summand in self.summands] - all_summands_string = ' + '.join(summand_strings) - - constant = self.constant_vector[equation_nr] - - # String formating - header_width = 30 - header = f'{name:<{header_width - len(index_str) - 1}} {index_str}' - return f'{header:<{header_width}}: {constant:>8} >= {all_summands_string}' - - -class Summand: - """ - Represents a part of a Constraint , consisting of a variable (or a time-series variable) and a factor. - - Parameters - ---------- - variable : Variable - The variable associated with this summand. - factor : Numeric - The factor by which the variable is multiplied in the equation. - indices : int, np.ndarray, range, List[int], optional - Specifies which indices of the variable to use. If None, all indices of the variable are used. - """ - - def __init__( - self, variable: Variable, factor: Numeric, indices: Optional[Union[int, np.ndarray, range, List[int]]] = None - ): # indices_of_variable default : alle - self.variable = variable - self.factor = factor - self.indices = indices if indices is not None else variable.indices # wenn nicht definiert, dann alle Indexe - - self.length = self._check_length() # Länge ermitteln: - - self.factor_vec = utils.as_vector(factor, self.length) # Faktor als Vektor: - - def description(self, at_index=0): - i = 0 if self.length == 1 else at_index - index = self.indices[i] - factor = self.factor_vec[i] - factor_str = f'{factor:.6}' if isinstance(factor, (float, np.floating)) else str(factor) - return f'{factor_str} * {self.variable.label}[{index}]' - - def _check_length(self): - """ - Determines and returns the length of the summand by comparing the lengths of the factor and the variable indices. - Sets the attribute .length to this value. - - Returns: - -------- - int - The length of the summand, which is the length of the indices if they match the length of the factor, - or the length of the longer one if one of them is a scalar. - - Raises: - ------- - Exception - If the lengths of the factor and the variable indices do not match and neither is a scalar. - """ - length_of_factor = 1 if np.isscalar(self.factor) else len(self.factor) - length_of_indices = len(self.indices) - if length_of_indices == length_of_factor: - return length_of_indices - elif length_of_factor == 1: - return length_of_indices - elif length_of_indices == 1: - return length_of_factor - else: - raise Exception( - f'Variable {self.variable.label} (length={length_of_indices}) und ' - f'Faktor (length={length_of_factor}) müssen gleiche Länge haben oder Skalar sein' - ) - - -class SumOfSummand(Summand): - """ - Represents a part of an Equation that sums all components of a regular Summand over specified indices. - - Parameters - ---------- - variable : Variable - The variable associated with this summand. - factor : Numeric - The factor by which the variable is multiplied. - indices : int, np.ndarray, range, List[int], optional - Specifies which indices of the variable to use for the sum. If None, all indices are summed. - """ - - def __init__( - self, variable: Variable, factor: Numeric, indices: Optional[Union[int, np.ndarray, range, List[int]]] = None - ): # indices_of_variable default : alle - super().__init__(variable, factor, indices) - self.length = 1 - - def description(self, at_index=0): - index = self.indices[at_index] - factor = self.factor_vec[0] - factor_str = str(factor) if isinstance(factor, int) else f'{factor:.6}' - single_summand_str = f'{factor_str} * {self.variable.label}[{index}]' - return f'∑({("..+" if index > 0 else "")}{single_summand_str}{("+.." if index < self.variable.length else "")})' - - -class MathModel: - """ - A mathematical model for defining equations and constraints of the form: - - a1 * x1 + a2 + x2 = y - and - a1 * x1 + a2 + x2 <= y - - where 'a1', 'a2' and y can be vectors or scalars, while 'x1' and 'x2' are variables with an appropriate length. - - - This class provides methods to add variables, equations, and inequality constraints to the model and supports - translation to a specified modeling language like pyomo. - - The expression 'a1 * x1' is referred to as a 'Summand'. Supported summand formats are: - - 'Variable[j] * Factor[i]' : Multiplication of vector variables and vector factors. - - 'Variable[j] * Factor' : Vector variable with scalar factor. - - 'Variable * Factor' : Scalar variable with scalar factor. - - 'Factor' : Scalar constant. - - - Parameters - ---------- - label : str - A descriptive label for the model. - modeling_language : {'pyomo', 'linopy'}, optional - Specifies the modeling language used for translation (default is 'pyomo'). - - Attributes - ---------- - label : str - The label assigned to the model. - modeling_language : str - The modeling language to which the model will be translated. - epsilon : float - Small tolerance value used in model calculations, defaulting to `1e-5`. - solver : Optional[Solver] - The solver instance assigned to solve the model. - model : Optional[ModelingLanguage] - The model instance in the specified modeling language. - _variables : List[Variable] - List of variables added to the model. - _constraints : List[Union[Equation, Inequation]] - List of equations and inequality constraints in the model. - _objective : Optional[Equation] - The objective function, if defined as an equation. - duration : dict - Dictionary tracking the time taken for translation and solving steps. - - Methods - ------- - add(*args) - Adds variables, equations, or inequations to the model. - describe_size() - Provides a summary of the number of equations, inequations, and variables. - translate_to_modeling_language() - Translates the model to the specified modeling language. - solve(solver) - Solves the model using the specified solver instance. - results() - Returns a dictionary of variable results after solving. - """ - - def __init__(self, label: str, modeling_language: Literal['pyomo', 'linopy'] = 'pyomo'): - self._infos = {} - self.label = label - self.modeling_language: str = modeling_language - - self.solver: Optional[Solver] = None - self.model: Optional[ModelingLanguage] = None - - self._variables: List[Variable] = [] - self._constraints: List[Union[Equation, Inequation]] = [] - self._objective: Optional[Equation] = None - self.result_of_objective: Optional[float] = None - - self.duration = {} - - def add(self, *args: Union[Variable, Equation, Inequation]) -> None: - if not isinstance(args, list): - args = list(args) - for arg in args: - if isinstance(arg, Variable): - self._variables.append(arg) - elif isinstance(arg, (Equation, Inequation)): - if isinstance(arg, Equation) and arg.is_objective: - self._objective = arg - else: - self._constraints.append(arg) - else: - raise Exception(f'{arg} cant be added this way!') - - def describe_size(self) -> str: - return ( - f'No. of Equations (single): {self.nr_of_equations} ({self.nr_of_single_equations})\n' - f'No. of Inequations (single): {self.nr_of_inequations} ({self.nr_of_single_inequations})\n' - f'No. of Variables (single): {self.nr_of_variables} ({self.nr_of_single_variables})' - ) - - def translate_to_modeling_language(self) -> None: - t_start = timeit.default_timer() - if self.modeling_language == 'pyomo': - self.model = PyomoModel() - self.model.translate_model(self) - elif self.modeling_language == 'linopy': - self.model = LinopyModel() - self.model.translate_model(self) - else: - raise NotImplementedError(f'Modeling Language {self.modeling_language} is not yet implemented') - self.duration['Translation'] = round(timeit.default_timer() - t_start, 2) - - def solve(self, solver: 'Solver') -> None: - self.solver = solver - t_start = timeit.default_timer() - for variable in self.variables: - variable.reset_result() # altes Ergebnis löschen (falls vorhanden) - self.model.solve(self, solver) - self.duration['Solving'] = round(timeit.default_timer() - t_start, 2) - - def results(self) -> Dict[str, Numeric]: - return {variable.label: variable.result for variable in self.variables} - - @property - def infos(self) -> Dict: - return { - 'Solver': repr(self.solver), - 'Model Size': { - 'No. of Eqs.': self.nr_of_equations, - 'No. of Eqs. (single)': self.nr_of_single_equations, - 'No. of Ineqs.': self.nr_of_inequations, - 'No. of Ineqs. (single)': self.nr_of_single_inequations, - 'No. of Vars.': self.nr_of_variables, - 'No. of Vars. (single)': self.nr_of_single_variables, - 'No. of Vars. (TS)': len(self.ts_variables), - }, - 'Solver Log': self.solver.log.infos if isinstance(self.solver.log, SolverLog) else self.solver.log, - } - - @property - def variables(self) -> List[Variable]: - return self._variables - - @property - def equations(self) -> List[Equation]: - return [eq for eq in self._constraints if isinstance(eq, Equation)] - - @property - def inequations(self): - return [eq for eq in self._constraints if isinstance(eq, Inequation)] - - @property - def objective(self) -> Equation: - return self._objective - - @property - def ts_variables(self) -> List[VariableTS]: - return [variable for variable in self.variables if isinstance(variable, VariableTS)] - - @property - def nr_of_variables(self) -> int: - return len(self.variables) - - @property - def nr_of_constraints(self) -> int: - return len(self._constraints) - - @property - def nr_of_equations(self) -> int: - return len(self.equations) - - @property - def nr_of_inequations(self) -> int: - return len(self.inequations) - - @property - def nr_of_single_variables(self) -> int: - return sum([var.length for var in self.variables]) - - @property - def nr_of_single_equations(self) -> int: - return sum([eq.length for eq in self.equations]) - - @property - def nr_of_single_inequations(self) -> int: - return sum([eq.length for eq in self.inequations]) - - -class SolverLog: - """ - Parses and holds solver log information for specific solvers. - - Attributes: - solver_name (str): Name of the solver (e.g., 'gurobi', 'cbc'). - log (str): Content of the log file. - presolved_rows (Optional[int]): Number of rows after presolving. - presolved_cols (Optional[int]): Number of columns after presolving. - presolved_nonzeros (Optional[int]): Number of nonzeros after presolving. - presolved_continuous (Optional[int]): Number of continuous variables after presolving. - presolved_integer (Optional[int]): Number of integer variables after presolving. - presolved_binary (Optional[int]): Number of binary variables after presolving. - """ - - def __init__(self, solver_name: str, filename: str): - with open(filename, 'r') as file: - self.log = file.read() - - self.solver_name = solver_name - - self.presolved_rows = None - self.presolved_cols = None - self.presolved_nonzeros = None - - self.presolved_continuous = None - self.presolved_integer = None - self.presolved_binary = None - self.parse_infos() - - @property - def infos(self) -> Dict[str, Dict[str, int]]: - return { - 'presolved': { - 'cols': self.presolved_cols, - 'continuous': self.presolved_continuous, - 'integer': self.presolved_integer, - 'binary': self.presolved_binary, - 'rows': self.presolved_rows, - 'nonzeros': self.presolved_nonzeros, - } - } - - # Suche infos aus log: - def parse_infos(self): - if self.solver_name == 'gurobi': - # string-Schnipsel 1: - """ - Optimize a model with 285 rows, 292 columns and 878 nonzeros - Model fingerprint: 0x1756ffd1 - Variable types: 202 continuous, 90 integer (90 binary) - """ - # string-Schnipsel 2: - """ - Presolve removed 154 rows and 172 columns - Presolve time: 0.00s - Presolved: 131 rows, 120 columns, 339 nonzeros - Variable types: 53 continuous, 67 integer (67 binary) - """ - # string: Presolved: 131 rows, 120 columns, 339 nonzeros\n - match = re.search( - r'Presolved: (\d+) rows, (\d+) columns, (\d+) nonzeros\n' - r'Variable types: (\d+) continuous, (\d+) integer \((\d+) binary\)', - self.log, - ) - if match: - # string: Presolved: 131 rows, 120 columns, 339 nonzeros\n - self.presolved_rows = int(match.group(1)) - self.presolved_cols = int(match.group(2)) - self.presolved_nonzeros = int(match.group(3)) - # string: Variable types: 53 continuous, 67 integer (67 binary) - self.presolved_continuous = int(match.group(4)) - self.presolved_integer = int(match.group(5)) - self.presolved_binary = int(match.group(6)) - - elif self.solver_name == 'cbc': - # string: Presolve 1623 (-1079) rows, 1430 (-1078) columns and 4296 (-3306) elements - match = re.search(r'Presolve (\d+) \((-?\d+)\) rows, (\d+) \((-?\d+)\) columns and (\d+)', self.log) - if match is not None: - self.presolved_rows = int(match.group(1)) - self.presolved_cols = int(match.group(3)) - self.presolved_nonzeros = int(match.group(5)) - - # string: Presolved problem has 862 integers (862 of which binary) - match = re.search(r'Presolved problem has (\d+) integers \((\d+) of which binary\)', self.log) - if match is not None: - self.presolved_integer = int(match.group(1)) - self.presolved_binary = int(match.group(2)) - self.presolved_continuous = self.presolved_cols - self.presolved_integer - - elif self.solver_name == 'glpk': - logger.warning(f'{"":#^80}\n') - logger.warning(f'{" No solver-log parsing implemented for glpk yet! ":#^80}\n') - else: - raise Exception('SolverLog.parse_infos() is not defined for solver ' + self.solver_name) - - -@dataclass -class _Solver: - """ - Abstract base class for solvers. - - Attributes: - mip_gap (float): Solver's mip gap setting. The MIP gap describes the accepted (MILP) objective, - and the lower bound, which is the theoretically optimal solution (LP) - logfile_name (str): Filename for saving the solver log. - """ - name: ClassVar[str] - mip_gap: float - time_limit_seconds: int - extra_options: Dict[str, Any] = field(default_factory=dict) - - @property - def options(self) -> Dict[str, Any]: - """Return a dictionary of solver options.""" - return {key: value for key, value in {**self._options, **self.extra_options}.items() if value is not None} - - @property - def _options(self) -> Dict[str, Any]: - """Return a dictionary of solver options, translated to the solver's API.""" - raise NotImplementedError - - -class GurobiSolver(_Solver): - name: ClassVar[str] = 'gurobi' - - @property - def _options(self) -> Dict[str, Any]: - return { - 'MIPGap': self.mip_gap, - 'TimeLimit': self.time_limit_seconds, - } - -class HighsSolver(_Solver): - threads: Optional[int] = None - name: ClassVar[str] = 'highs' - - @property - def _options(self) -> Dict[str, Any]: - return { - 'mip_gap': self.mip_gap, - 'time_limit': self.time_limit_seconds, - 'threads': self.threads, - } - - -class ModelingLanguage(ABC): - """ - Abstract base class for modeling languages. - - Methods: - translate_model(model): Translates a math model into a solveable form. - """ - - @abstractmethod - def translate_model(self, model: MathModel): - raise NotImplementedError - - def solve(self, math_model: MathModel, solver: _Solver): - raise NotImplementedError - - -class PyomoModel(ModelingLanguage): - """ - Pyomo-based modeling language for constructing and solving optimization models. - Translates a MathModel into a PyomoModel. - - Attributes: - model: Pyomo model instance. - mapping (dict): Maps variables and equations to Pyomo components. - _counter (int): Counter for naming Pyomo components. - """ - - def __init__(self): - global pyo - import pyomo.environ as pyo - - logger.debug('Loaded pyomo modules') - - self.model = pyo.ConcreteModel(name='(Minimalbeispiel)') - - self.mapping: Dict[Union[Variable, Equation], Any] = {} # Mapping to Pyomo Units - self._counter = 0 - - def solve(self, math_model: MathModel, solver: _Solver): - if self._counter == 0: - raise Exception(' First, call .translate_model(). Else PyomoModel cant solve()') - solver.solve(self) - - # write results - math_model.result_of_objective = self.model.objective.expr() - for variable in math_model.variables: - raw_results = self.mapping[variable].get_values().values() # .values() of dict, because {0:0.1, 1:0.3,...} - if variable.is_binary: - dtype = np.int8 # geht das vielleicht noch kleiner ??? - else: - dtype = float - # transform to np-array (fromiter() is 5-7x faster than np.array(list(...)) ) - result = np.fromiter(raw_results, dtype=dtype) - # Falls skalar: - if len(result) == 1: - variable.result = result[0] - else: - variable.result = result - - def translate_model(self, math_model: MathModel): - for variable in math_model.variables: # Variablen erstellen - logger.debug(f'VAR {variable.label} gets translated to Pyomo') - self.translate_variable(variable) - for eq in math_model.equations: # Gleichungen erstellen - logger.debug(f'EQ {eq.label} gets translated to Pyomo') - self.translate_equation(eq) - for ineq in math_model.inequations: # Ungleichungen erstellen: - logger.debug(f'INEQ {ineq.label} gets translated to Pyomo') - self.translate_inequation(ineq) - - obj = math_model.objective - logger.debug(f'{obj.label} gets translated to Pyomo') - self.translate_objective(obj) - - def translate_variable(self, variable: Variable): - assert isinstance(variable, Variable), 'Wrong type of variable' - - if variable.is_binary: - pyomo_comp = pyo.Var(variable.indices, domain=pyo.Binary) - else: - pyomo_comp = pyo.Var(variable.indices, within=pyo.Reals) - self.mapping[variable] = pyomo_comp - - # Register in pyomo-model: - self._register_pyomo_comp(pyomo_comp, variable) - - lower_bound_vector = utils.as_vector(variable.lower_bound, variable.length) - upper_bound_vector = utils.as_vector(variable.upper_bound, variable.length) - fixed_value_vector = utils.as_vector(variable.fixed_value, variable.length) - for i in variable.indices: - # Wenn Vorgabe-Wert vorhanden: - if variable.fixed and (fixed_value_vector[i] is not None): - # Fixieren: - pyomo_comp[i].value = fixed_value_vector[i] - pyomo_comp[i].fix() - else: - # Boundaries: - pyomo_comp[i].setlb(lower_bound_vector[i]) # min - pyomo_comp[i].setub(upper_bound_vector[i]) # max - - def translate_equation(self, equation: Equation): - if not isinstance(equation, Equation): - raise TypeError(f'Wrong Class: {equation.__class__.__name__}') - - # constant_vector hier erneut erstellen, da Anz. Glg. vorher noch nicht bekannt: - constant_vector = equation.constant_vector - - def linear_sum_pyomo_rule(model, i): - """This function is needed for pyomoy internal construction of Constraints.""" - lhs = 0 - summand: Summand - for summand in equation.summands: - lhs += self._summand_math_expression(summand, i) # i-te Gleichung (wenn Skalar, dann wird i ignoriert) - rhs = constant_vector[i] - return lhs == rhs - - pyomo_comp = pyo.Constraint(range(equation.length), rule=linear_sum_pyomo_rule) # Nebenbedingung erstellen - - self._register_pyomo_comp(pyomo_comp, equation) - - def translate_inequation(self, inequation: Inequation): - if not isinstance(inequation, Inequation): - raise TypeError(f'Wrong Class: {inequation.__class__.__name__}') - - # constant_vector hier erneut erstellen, da Anz. Glg. vorher noch nicht bekannt: - constant_vector = inequation.constant_vector - - def linear_sum_pyomo_rule(model, i): - """This function is needed for pyomoy internal construction of Constraints.""" - lhs = 0 - summand: Summand - for summand in inequation.summands: - lhs += self._summand_math_expression(summand, i) # i-te Gleichung (wenn Skalar, dann wird i ignoriert) - rhs = constant_vector[i] - - return lhs <= rhs - - pyomo_comp = pyo.Constraint(range(inequation.length), rule=linear_sum_pyomo_rule) # Nebenbedingung erstellen - - self._register_pyomo_comp(pyomo_comp, inequation) - - def translate_objective(self, objective: Equation): - if not isinstance(objective, Equation): - raise TypeError(f'Class {objective.__class__.__name__} Can not be the objective!') - if not objective.is_objective: - raise TypeError( - f'Objective Equation is not marked as objective, {objective.is_objective=}, ' - f'but was sent to translate to objective!' - ) - if objective.length != 1: - raise Exception('Length of Objective must be 0') - - def _rule_linear_sum_skalar(model): - skalar = 0 - for summand in objective.summands: - skalar += self._summand_math_expression(summand) - return skalar - - self.model.objective = pyo.Objective(rule=_rule_linear_sum_skalar, sense=pyo.minimize) - self.mapping[objective] = self.model.objective - - def _summand_math_expression(self, summand: Summand, at_index: int = 0) -> 'pyo.Expression': - pyomo_variable = self.mapping[summand.variable] - if isinstance(summand, SumOfSummand): - return sum(pyomo_variable[summand.indices[j]] * summand.factor_vec[j] for j in summand.indices) - - # Ausdruck für i-te Gleichung (falls Skalar, dann immer gleicher Ausdruck ausgegeben) - if summand.length == 1: - # ignore argument at_index, because Skalar is used for every single equation - return pyomo_variable[summand.indices[0]] * summand.factor_vec[0] - if len(summand.indices) == 1: - return pyomo_variable[summand.indices[0]] * summand.factor_vec[at_index] - return pyomo_variable[summand.indices[at_index]] * summand.factor_vec[at_index] - - def _register_pyomo_comp(self, pyomo_comp, part: Union[Variable, Equation, Inequation]) -> None: - self._counter += 1 # Counter to guarantee unique names - self.model.add_component(f'{part.label}__{self._counter}', pyomo_comp) - self.mapping[part] = pyomo_comp - - -class LinopyModel(ModelingLanguage): - """ - Pyomo-based modeling language for constructing and solving optimization models. - Translates a MathModel into a PyomoModel. - - Attributes: - model: Pyomo model instance. - mapping (dict): Maps variables and equations to Pyomo components. - _counter (int): Counter for naming Pyomo components. - """ - - def __init__(self): - global linopy - global pd - import linopy - import pandas as pd - - logger.debug('Imported linopy and pandas') - self.model = linopy.Model() - self.mapping: Dict[Variable, linopy.Variable] = {} - - def solve(self, math_model: MathModel, solver: _Solver): - solver.solve(self) - - # write results - math_model.result_of_objective = self.model.objective.value - for variable in math_model.variables: - raw_results = self.mapping[variable].solution - if variable.is_binary: - dtype = np.int8 # geht das vielleicht noch kleiner ??? - else: - dtype = float - - if raw_results.ndim == 0 and dtype is float: - variable.result = float(raw_results) - elif raw_results.ndim == 0 and dtype == np.int8: - variable.result = np.int8(raw_results) - else: # transform to np-array (fromiter() is 5-7x faster than np.array(list(...)) ) - variable.result = np.fromiter(raw_results, dtype=dtype) - - def translate_model(self, math_model: MathModel): - for variable in math_model.variables: # Variablen erstellen - logger.debug(f'VAR {variable.label} gets translated to linopy') - self.translate_variable(variable) - for eq in math_model.equations: # Gleichungen erstellen - logger.debug(f'EQ {eq.label} gets translated to linopy') - self.translate_equation(eq) - for ineq in math_model.inequations: # Ungleichungen erstellen: - logger.debug(f'INEQ {ineq.label} gets translated to linopy') - self.translate_equation(ineq) - - obj = math_model.objective - logger.debug(f'{obj.label} gets translated to Pyomo') - self.translate_objective(obj) - - def translate_variable(self, variable: Variable): - assert isinstance(variable, Variable), 'Wrong type of variable' - - if variable.is_binary: - var = self.model.add_variables( - binary=True, - coords=(pd.RangeIndex(variable.indices),) if len(variable.indices) > 1 else None, - name=variable.label, - ) - else: - lower = utils.as_vector(variable.lower_bound, variable.length) if variable.lower_bound is not None else -inf - upper = utils.as_vector(variable.upper_bound, variable.length) if variable.upper_bound is not None else inf - if isinstance(lower, np.ndarray) and variable.length == 1: - lower = lower[0] - if isinstance(upper, np.ndarray) and variable.length == 1: - upper = upper[0] - var = self.model.add_variables( - lower=lower, - upper=upper, - coords=(pd.RangeIndex(variable.indices),) if len(variable.indices) > 1 else None, - name=variable.label, - ) - - if variable.fixed: # Wenn Vorgabe-Wert vorhanden: - fixed_value = utils.as_vector(variable.fixed_value, variable.length) - if isinstance(fixed_value, np.ndarray) and variable.length == 1: - fixed_value = fixed_value[0] - self.model.add_constraints(var == fixed_value, name=f'fix_{variable.label}') - - self.mapping[variable] = var - - def translate_equation(self, constraint: _Constraint): - if not isinstance(constraint, _Constraint): - raise TypeError(f'Wrong Class: {constraint.__class__.__name__}') - - lhs = 0 - summands_sorted = sorted(constraint.summands, key=lambda summand: len(summand.factor_vec), reverse=True) - for ( - summand - ) in summands_sorted: # Sorting is necessary to not cretae a ScalarExpression if SumOfSummand is present - lhs += self._summand_math_expression(summand) # i-te Gleichung (wenn Skalar, dann wird i ignoriert) - rhs = constraint.constant_vector - if len(rhs) == 1: - rhs = rhs[0] - if isinstance(constraint, Equation): - self.model.add_constraints(lhs == rhs, name=constraint.label) - elif isinstance(constraint, Inequation): - self.model.add_constraints(lhs <= rhs, name=constraint.label) - else: - raise TypeError(f'Wrong Class: {constraint.__class__.__name__}') - - def translate_objective(self, objective: Equation): - if not isinstance(objective, Equation): - raise TypeError(f'Class {objective.__class__.__name__} Can not be the objective!') - if not objective.is_objective: - raise TypeError( - f'Objective Equation is not marked as objective, {objective.is_objective=}, ' - f'but was sent to translate to objective!' - ) - if objective.length != 1: - raise Exception('Length of Objective must be 0') - - lhs = 0 - for summand in objective.summands: - lhs += self._summand_math_expression(summand) # i-te Gleichung (wenn Skalar, dann wird i ignoriert) - self.model.add_objective(lhs) - - def _summand_math_expression(self, summand: Summand) -> 'linopy.LinearExpression': - linopy_variable = self.mapping[summand.variable] - - if summand.variable.length != 1: - linopy_variable = linopy_variable.loc[summand.indices] - - factor = summand.factor_vec - if len(summand.factor_vec) == 1: - factor = factor[0] - - if summand.variable.length == 1 and len(summand.factor_vec) != 1: - - def scalar_var_and_array_factor(m, i): - return linopy_variable.at[i] * factor[i] - - expr = self.model.linexpr(scalar_var_and_array_factor, (range(len(factor)),)) - if isinstance(summand, SumOfSummand): - return expr.sum() - else: - return expr - - if isinstance(summand, SumOfSummand): - return (factor * linopy_variable).sum() - else: - # Ausdruck für i-te Gleichung (falls Skalar, dann immer gleicher Ausdruck ausgegeben) - return linopy_variable * factor diff --git a/flixOpt/solvers.py b/flixOpt/solvers.py index 62ba51c88..f84609d10 100644 --- a/flixOpt/solvers.py +++ b/flixOpt/solvers.py @@ -1,15 +1,59 @@ """ This module contains the solvers of the flixOpt framework, making them available to the end user in a compact way. """ +import logging +from dataclasses import dataclass, field +from typing import Any, ClassVar, Dict, Optional + +logger = logging.getLogger('flixOpt') + + +@dataclass +class _Solver: + """ + Abstract base class for solvers. + + Attributes: + mip_gap (float): Solver's mip gap setting. The MIP gap describes the accepted (MILP) objective, + and the lower bound, which is the theoretically optimal solution (LP) + logfile_name (str): Filename for saving the solver log. + """ + name: ClassVar[str] + mip_gap: float + time_limit_seconds: int + extra_options: Dict[str, Any] = field(default_factory=dict) + + @property + def options(self) -> Dict[str, Any]: + """Return a dictionary of solver options.""" + return {key: value for key, value in {**self._options, **self.extra_options}.items() if value is not None} + + @property + def _options(self) -> Dict[str, Any]: + """Return a dictionary of solver options, translated to the solver's API.""" + raise NotImplementedError + + +class GurobiSolver(_Solver): + name: ClassVar[str] = 'gurobi' + + @property + def _options(self) -> Dict[str, Any]: + return { + 'MIPGap': self.mip_gap, + 'TimeLimit': self.time_limit_seconds, + } + + +class HighsSolver(_Solver): + threads: Optional[int] = None + name: ClassVar[str] = 'highs' + + @property + def _options(self) -> Dict[str, Any]: + return { + 'mip_gap': self.mip_gap, + 'time_limit': self.time_limit_seconds, + 'threads': self.threads, + } -from .math_modeling import ( - GurobiSolver, - HighsSolver, - _Solver, -) - -__all__ = [ - '_Solver', - 'HighsSolver', - 'GurobiSolver', -] diff --git a/flixOpt/structure.py b/flixOpt/structure.py index 8f39ece1a..02dac00af 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -21,7 +21,6 @@ from . import utils from .config import CONFIG from .core import Numeric, Numeric_TS, NumericData, Skalar, TimeSeries, TimeSeriesCollection, TimeSeriesData -from .math_modeling import Equation, Inequation, MathModel, Variable, VariableTS, _Solver if TYPE_CHECKING: # for type checking and preventing circular imports from .effects import EffectCollection @@ -562,43 +561,6 @@ def __init__(self, model: SystemModel, element: Element): self.element = element -def create_equation( - label: str, element_model: ElementModel, eq_type: Literal['eq', 'ineq'] = 'eq' -) -> Union[Equation, Inequation]: - """Creates an Equation and adds it to the model of the Element""" - if eq_type == 'eq': - constr = Equation(f'{element_model.label_full}_{label}', label) - elif eq_type == 'ineq': - constr = Inequation(f'{element_model.label_full}_{label}', label) - element_model.add_constraints(constr) - return constr - - -def create_variable( - label: str, - element_model: ElementModel, - length: int, - is_binary: bool = False, - fixed_value: Optional[Numeric] = None, - lower_bound: Optional[Numeric] = None, - upper_bound: Optional[Numeric] = None, - previous_values: Optional[Numeric] = None, - avoid_use_of_variable_ts: bool = False, -) -> VariableTS: - """Creates a VariableTS and adds it to the model of the Element""" - variable_label = f'{element_model.label_full}_{label}' - if length > 1 and not avoid_use_of_variable_ts: - var = VariableTS( - variable_label, length, label, is_binary, fixed_value, lower_bound, upper_bound, previous_values - ) - logger.debug(f'Created VariableTS "{variable_label}": [{length}]') - else: - var = Variable(variable_label, length, label, is_binary, fixed_value, lower_bound, upper_bound) - logger.debug(f'Created Variable "{variable_label}": [{length}]') - element_model.add_variables(var) - return var - - def copy_and_convert_datatypes(data: Any, use_numpy: bool = True, use_element_label: bool = False) -> Any: """ Converts values in a nested data structure into JSON-compatible types while preserving or transforming numpy arrays From a834b7cfdf370043a6285ba4b79366d6de4a508d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 23 Feb 2025 01:25:28 +0100 Subject: [PATCH 196/507] Improved Types --- flixOpt/aggregation.py | 6 +++--- flixOpt/calculation.py | 12 ++++++------ flixOpt/components.py | 12 ++++++------ flixOpt/core.py | 14 ++++---------- flixOpt/effects.py | 16 ++++++++-------- flixOpt/elements.py | 12 ++++++------ flixOpt/features.py | 24 ++++++++++++------------ flixOpt/interface.py | 10 +++++----- flixOpt/structure.py | 4 ++-- flixOpt/utils.py | 6 +++--- 10 files changed, 55 insertions(+), 61 deletions(-) diff --git a/flixOpt/aggregation.py b/flixOpt/aggregation.py index e71c75af9..427456368 100644 --- a/flixOpt/aggregation.py +++ b/flixOpt/aggregation.py @@ -16,7 +16,7 @@ import tsam.timeseriesaggregation as tsam from .components import Storage -from .core import Skalar, TimeSeriesData +from .core import Scalar, TimeSeriesData from .elements import Component from .flow_system import FlowSystem from .structure import ( @@ -40,8 +40,8 @@ class Aggregation: def __init__( self, original_data: pd.DataFrame, - hours_per_time_step: Skalar, - hours_per_period: Skalar, + hours_per_time_step: Scalar, + hours_per_period: Scalar, nr_of_periods: int = 8, weights: Dict[str, float] = None, time_series_for_high_peaks: List[str] = None, diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py index eca3694a5..0f05ef3e5 100644 --- a/flixOpt/calculation.py +++ b/flixOpt/calculation.py @@ -23,7 +23,7 @@ from . import utils as utils from .aggregation import AggregationModel, AggregationParameters from .components import Storage -from .core import Numeric, Skalar +from .core import Numeric, Scalar from .elements import Component from .features import InvestmentModel from .flow_system import FlowSystem @@ -355,7 +355,7 @@ def do_modeling_and_solve( logger.info(f'{" Segmented Solving ":#^80}') self._define_path_names(save_results) - for i, (segment_name, timesteps_of_segment) in enumerate(zip(self.segment_names, self.active_timesteps_per_segment)): + for i, (segment_name, timesteps_of_segment) in enumerate(zip(self.segment_names, self.active_timesteps_per_segment, strict=False)): if self.sub_calculations: self._transfer_start_values(i) @@ -440,7 +440,7 @@ def _save_solve_infos(self): 'Individual Results': copy_and_convert_datatypes( self.results(individual_results=True), use_numpy=False, use_element_label=False ), - 'Skalar Results': copy_and_convert_datatypes( + 'Scalar Results': copy_and_convert_datatypes( self.results(combined_scalars=True), use_numpy=False, use_element_label=False ), } @@ -503,7 +503,7 @@ def _reset_start_values(self): def _calculate_timesteps_of_segment(self) -> List[pd.DatetimeIndex]: active_timesteps_per_segment = [] - for i, segment_name in enumerate(self.segment_names): + for i, _ in enumerate(self.segment_names): start = self.timesteps_per_segment * i end = min(start + self.timesteps_per_segment_with_overlap, len(self.all_timesteps)) active_timesteps_per_segment.append(self.all_timesteps[start:end]) @@ -600,7 +600,7 @@ def limit(idx: int, arr: np.ndarray) -> np.ndarray: return _remove_empty_dicts(combined_arrays) -def _combine_nested_scalars(*dicts: Dict[str, Union[Numeric, dict]]) -> Dict[str, Union[List[Skalar], dict]]: +def _combine_nested_scalars(*dicts: Dict[str, Union[Numeric, dict]]) -> Dict[str, Union[List[Scalar], dict]]: """ Combines multiple dictionaries with identical structures by combining its skalar values to a list. Filters out all other values. @@ -613,7 +613,7 @@ def _combine_nested_scalars(*dicts: Dict[str, Union[Numeric, dict]]) -> Dict[str def combine_scalars_recursively( *values: Union[Numeric, Dict[str, Numeric], Any], - ) -> Optional[Union[List[Skalar], Dict[str, Union[List[Skalar], dict]]]]: + ) -> Optional[Union[List[Scalar], Dict[str, Union[List[Scalar], dict]]]]: # If all values are dictionaries, recursively combine each key if all(isinstance(val, dict) for val in values): return {key: combine_scalars_recursively(*(val[key] for val in values)) for key in values[0]} diff --git a/flixOpt/components.py b/flixOpt/components.py index ff29b332f..3fa9d8670 100644 --- a/flixOpt/components.py +++ b/flixOpt/components.py @@ -10,7 +10,7 @@ import pandas as pd from . import utils -from .core import Numeric, Numeric_TS, Skalar, TimeSeries, TimeSeriesCollection +from .core import Numeric, Numeric_TS, Scalar, TimeSeries, TimeSeriesCollection from .elements import Component, ComponentModel, Flow from .features import InvestmentModel, MultipleSegmentsModel, OnOffModel from .interface import InvestParameters, OnOffParameters @@ -143,12 +143,12 @@ def __init__( label: str, charging: Flow, discharging: Flow, - capacity_in_flow_hours: Union[Skalar, InvestParameters], + capacity_in_flow_hours: Union[Scalar, InvestParameters], relative_minimum_charge_state: Numeric = 0, relative_maximum_charge_state: Numeric = 1, - initial_charge_state: Optional[Union[Skalar, Literal['lastValueOfSim']]] = 0, - minimal_final_charge_state: Optional[Skalar] = None, - maximal_final_charge_state: Optional[Skalar] = None, + initial_charge_state: Optional[Union[Scalar, Literal['lastValueOfSim']]] = 0, + minimal_final_charge_state: Optional[Scalar] = None, + maximal_final_charge_state: Optional[Scalar] = None, eta_charge: Numeric = 1, eta_discharge: Numeric = 1, relative_loss_per_hour: Numeric = 0, @@ -168,7 +168,7 @@ def __init__( ingoing flow. discharging : Flow outgoing flow. - capacity_in_flow_hours : Skalar or InvestParameter + capacity_in_flow_hours : Scalar or InvestParameter nominal capacity of the storage relative_minimum_charge_state : float or TS, optional minimum relative charge state. The default is 0. diff --git a/flixOpt/core.py b/flixOpt/core.py index 86014cba1..54a6ca5b5 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -14,9 +14,7 @@ logger = logging.getLogger('flixOpt') -Skalar = Union[int, float] # Datatype -Numeric = Union[int, float, np.ndarray] # Datatype - +Scalar = Union[int, float] # Datatype NumericData = Union[int, float, np.ndarray, pd.Series, pd.DataFrame, xr.DataArray] @@ -66,7 +64,7 @@ def as_dataarray(data: NumericData, time: pd.DatetimeIndex, period: Optional[pd. f"Got {type(data)=}") @staticmethod - def _handle_scalar(data: Numeric, coords: list, dims: list) -> xr.DataArray: + def _handle_scalar(data: Scalar, coords: list, dims: list) -> xr.DataArray: """Handles scalar input by filling the array with the value.""" return xr.DataArray(data, coords=coords, dims=dims) @@ -122,7 +120,7 @@ def _handle_xr_dataarray(data: xr.DataArray, coords: list, dims: list) -> xr.Dat class TimeSeriesData: # TODO: Move to Interface.py - def __init__(self, data: Numeric, agg_group: Optional[str] = None, agg_weight: Optional[float] = None): + def __init__(self, data: NumericData, agg_group: Optional[str] = None, agg_weight: Optional[float] = None): """ timeseries class for transmit timeseries AND special characteristics of timeseries, i.g. to define weights needed in calculation_type 'aggregated' @@ -169,11 +167,6 @@ def __str__(self): return str(self.data) -Numeric_TS = Union[ - Skalar, np.ndarray, TimeSeriesData -] # TODO: This is not really correct throughozt the codebase. Sometimes its used for TimeSeries aswell? - - class TimeSeries: @classmethod @@ -757,6 +750,7 @@ def __str__(self): f"{stats_summary}" ) + def get_numeric_stats(data: xr.DataArray, decimals: int = 2, padd: int = 10) -> str: """Calculates the mean, median, min, max, and standard deviation of a numeric DataArray.""" format_spec = f">{padd}.{decimals}f" if padd else f".{decimals}f" diff --git a/flixOpt/effects.py b/flixOpt/effects.py index 0afa051e1..e19e96a50 100644 --- a/flixOpt/effects.py +++ b/flixOpt/effects.py @@ -12,7 +12,7 @@ import numpy as np import pandas as pd -from .core import Numeric, Numeric_TS, Skalar, TimeSeries, TimeSeriesCollection +from .core import Numeric, Numeric_TS, Scalar, TimeSeries, TimeSeriesCollection from .features import ShareAllocationModel from .structure import Element, ElementModel, Model, SystemModel @@ -38,14 +38,14 @@ def __init__( is_objective: bool = False, specific_share_to_other_effects_operation: Optional['EffectValuesUser'] = None, specific_share_to_other_effects_invest: Optional['EffectValuesUser'] = None, - minimum_operation: Optional[Skalar] = None, - maximum_operation: Optional[Skalar] = None, - minimum_invest: Optional[Skalar] = None, - maximum_invest: Optional[Skalar] = None, + minimum_operation: Optional[Scalar] = None, + maximum_operation: Optional[Scalar] = None, + minimum_invest: Optional[Scalar] = None, + maximum_invest: Optional[Scalar] = None, minimum_operation_per_hour: Optional[Numeric_TS] = None, maximum_operation_per_hour: Optional[Numeric_TS] = None, - minimum_total: Optional[Skalar] = None, - maximum_total: Optional[Skalar] = None, + minimum_total: Optional[Scalar] = None, + maximum_total: Optional[Scalar] = None, ): """ Parameters @@ -320,7 +320,7 @@ def _add_share_between_effects(self, system_model: SystemModel): origin_effect.label_full, origin_effect.model.operation.total_per_timestep * time_series.active_data, ) - # 2. invest: -> hier ist es Skalar (share) + # 2. invest: -> hier ist es Scalar (share) for target_effect, factor in origin_effect.specific_share_to_other_effects_invest.items(): target_effect.model.invest.add_share( system_model, diff --git a/flixOpt/elements.py b/flixOpt/elements.py index f2c81d91a..30e86da0d 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -10,7 +10,7 @@ import pandas as pd from .config import CONFIG -from .core import Numeric, Numeric_TS, Skalar, TimeSeriesCollection +from .core import Numeric, Numeric_TS, Scalar, TimeSeriesCollection from .effects import EffectValuesUser, effect_values_to_time_series from .features import InvestmentModel, OnOffModel, PreventSimultaneousUsageModel from .interface import InvestParameters, OnOffParameters @@ -160,16 +160,16 @@ def __init__( self, label: str, bus: Bus, - size: Union[Skalar, InvestParameters] = None, + size: Union[Scalar, InvestParameters] = None, fixed_relative_profile: Optional[Numeric_TS] = None, relative_minimum: Numeric_TS = 0, relative_maximum: Numeric_TS = 1, effects_per_flow_hour: EffectValuesUser = None, on_off_parameters: Optional[OnOffParameters] = None, - flow_hours_total_max: Optional[Skalar] = None, - flow_hours_total_min: Optional[Skalar] = None, - load_factor_min: Optional[Skalar] = None, - load_factor_max: Optional[Skalar] = None, + flow_hours_total_max: Optional[Scalar] = None, + flow_hours_total_min: Optional[Scalar] = None, + load_factor_min: Optional[Scalar] = None, + load_factor_max: Optional[Scalar] = None, previous_flow_rate: Optional[Numeric] = None, meta_data: Optional[Dict] = None, ): diff --git a/flixOpt/features.py b/flixOpt/features.py index e1f3aee29..2d0e0892b 100644 --- a/flixOpt/features.py +++ b/flixOpt/features.py @@ -11,7 +11,7 @@ from . import utils from .config import CONFIG -from .core import Numeric, Skalar, TimeSeries +from .core import Numeric, Scalar, TimeSeries from .interface import InvestParameters, OnOffParameters from .structure import Model, SystemModel @@ -42,7 +42,7 @@ def __init__( If fixed relative profile is used, the relative bounds are ignored """ super().__init__(model, label_of_element, label) - self.size: Optional[Union[Skalar, linopy.Variable]] = None + self.size: Optional[Union[Scalar, linopy.Variable]] = None self.is_invested: Optional[linopy.Variable] = None self._segments: Optional[SegmentedSharesModel] = None @@ -386,7 +386,7 @@ def _get_duration_in_hours( self, variable_name: str, binary_variable: linopy.Variable, - previous_duration: Skalar, + previous_duration: Scalar, minimum_duration: Optional[TimeSeries], maximum_duration: Optional[TimeSeries], ) -> linopy.Variable: @@ -432,7 +432,7 @@ def _get_duration_in_hours( mega = self._model.hours_per_step.sum() + previous_duration if maximum_duration is not None: - first_step_max: Skalar = maximum_duration.isel(time=0) + first_step_max: Scalar = maximum_duration.isel(time=0) if previous_duration + self._model.hours_per_step[0] > first_step_max: logger.warning( @@ -600,11 +600,11 @@ def previous_off_values(self) -> np.ndarray: return 1 - self.previous_on_values @property - def previous_consecutive_on_hours(self) -> Skalar: + def previous_consecutive_on_hours(self) -> Scalar: return self.compute_consecutive_duration(self.previous_on_values, self._model.hours_per_step) @property - def previous_consecutive_off_hours(self) -> Skalar: + def previous_consecutive_off_hours(self) -> Scalar: return self.compute_consecutive_duration(self.previous_off_values, self._model.hours_per_step) @staticmethod @@ -639,7 +639,7 @@ def compute_previous_on_states(previous_values: List[Optional[Numeric]], epsilon def compute_consecutive_duration( binary_values: Numeric, hours_per_timestep: Union[int, float, np.ndarray] - ) -> Skalar: + ) -> Scalar: """ Computes the final consecutive duration in State 'on' (=1) in hours, from a binary. @@ -841,8 +841,8 @@ def __init__( shares_are_time_series: bool, label_of_element: Optional[str] = None, label: Optional[str] = None, - total_max: Optional[Skalar] = None, - total_min: Optional[Skalar] = None, + total_max: Optional[Scalar] = None, + total_min: Optional[Scalar] = None, max_per_hour: Optional[Numeric] = None, min_per_hour: Optional[Numeric] = None, ): @@ -974,8 +974,8 @@ def __init__( self, model: SystemModel, label_of_element: str, - variable_segments: Tuple[linopy.Variable, List[Tuple[Skalar, Skalar]]], - share_segments: Dict['Effect', List[Tuple[Skalar, Skalar]]], + variable_segments: Tuple[linopy.Variable, List[Tuple[Scalar, Scalar]]], + share_segments: Dict['Effect', List[Tuple[Scalar, Scalar]]], can_be_outside_segments: Optional[Union[bool, linopy.Variable]], label: str = 'SegmentedShares', ): @@ -1000,7 +1000,7 @@ def do_modeling(self, system_model: SystemModel): } # Mapping variable names to segments - segments: Dict[str, List[Tuple[Skalar, Skalar]]] = { + segments: Dict[str, List[Tuple[Scalar, Scalar]]] = { **{self._shares[effect].name: segment for effect, segment in self._share_segments.items()}, **{self._variable_segments[0].name: self._variable_segments[1]}, } diff --git a/flixOpt/interface.py b/flixOpt/interface.py index 88d90cce3..7ba4e5104 100644 --- a/flixOpt/interface.py +++ b/flixOpt/interface.py @@ -11,7 +11,7 @@ from flixOpt.core import TimeSeriesCollection from .config import CONFIG -from .core import Numeric, Numeric_TS, Skalar +from .core import Numeric, Numeric_TS, Scalar from .structure import Element, Interface if TYPE_CHECKING: @@ -35,7 +35,7 @@ def __init__( fix_effects: Union[Dict, int, float] = None, specific_effects: Union[Dict, int, float] = None, # costs per Flow-Unit/Storage-Size/... effects_in_segments: Optional[ - Tuple[List[Tuple[Skalar, Skalar]], Dict['Effect', List[Tuple[Skalar, Skalar]]]] + Tuple[List[Tuple[Scalar, Scalar]], Dict['Effect', List[Tuple[Scalar, Scalar]]]] ] = None, divest_effects: Union[Dict, int, float] = None, ): @@ -146,13 +146,13 @@ def __init__( """ self.effects_per_switch_on: Union[EffectValues, EffectTimeSeries] = effects_per_switch_on or {} self.effects_per_running_hour: Union[EffectValues, EffectTimeSeries] = effects_per_running_hour or {} - self.on_hours_total_min: Skalar = on_hours_total_min - self.on_hours_total_max: Skalar = on_hours_total_max + self.on_hours_total_min: Scalar = on_hours_total_min + self.on_hours_total_max: Scalar = on_hours_total_max self.consecutive_on_hours_min: Numeric_TS = consecutive_on_hours_min self.consecutive_on_hours_max: Numeric_TS = consecutive_on_hours_max self.consecutive_off_hours_min: Numeric_TS = consecutive_off_hours_min self.consecutive_off_hours_max: Numeric_TS = consecutive_off_hours_max - self.switch_on_total_max: Skalar = switch_on_total_max + self.switch_on_total_max: Scalar = switch_on_total_max self.force_switch_on: bool = force_switch_on def transform_data(self, time_series_collection: TimeSeriesCollection, name_prefix: str): diff --git a/flixOpt/structure.py b/flixOpt/structure.py index 02dac00af..b9896c73d 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -20,7 +20,7 @@ from . import utils from .config import CONFIG -from .core import Numeric, Numeric_TS, NumericData, Skalar, TimeSeries, TimeSeriesCollection, TimeSeriesData +from .core import Numeric, Numeric_TS, NumericData, Scalar, TimeSeries, TimeSeriesCollection, TimeSeriesData if TYPE_CHECKING: # for type checking and preventing circular imports from .effects import EffectCollection @@ -120,7 +120,7 @@ def insert_data(value_part): return result @property - def main_results(self) -> Dict[str, Union[Skalar, Dict]]: + def main_results(self) -> Dict[str, Union[Scalar, Dict]]: from flixOpt.features import InvestmentModel return { diff --git a/flixOpt/utils.py b/flixOpt/utils.py index 1c26dd69e..b910fbf26 100644 --- a/flixOpt/utils.py +++ b/flixOpt/utils.py @@ -13,8 +13,8 @@ def as_vector(value: Union[int, float, np.ndarray, List], length: int) -> np.ndarray: """ - Macht aus Skalar einen Vektor. Vektor bleibt Vektor. - -> Idee dahinter: Aufruf aus abgespeichertem Vektor schneller, als für jede i-te Gleichung zu Checken ob Vektor oder Skalar) + Macht aus Scalar einen Vektor. Vektor bleibt Vektor. + -> Idee dahinter: Aufruf aus abgespeichertem Vektor schneller, als für jede i-te Gleichung zu Checken ob Vektor oder Scalar) Parameters ---------- @@ -25,7 +25,7 @@ def as_vector(value: Union[int, float, np.ndarray, List], length: int) -> np.nda # dtype = 'float64' # -> muss mit übergeben werden, sonst entstehen evtl. int32 Reihen (dort ist kein +/-inf möglich) # TODO: as_vector() -> int32 Vektoren möglich machen - # Wenn Skalar oder None, return directly as array + # Wenn Scalar oder None, return directly as array if value is None: return np.array([None] * length) if np.isscalar(value): From 6a14ec36744f27eb58dc06de17fa5e5379f8456d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 23 Feb 2025 01:29:34 +0100 Subject: [PATCH 197/507] Improved Types --- flixOpt/core.py | 2 +- flixOpt/effects.py | 12 +++++------- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/flixOpt/core.py b/flixOpt/core.py index 54a6ca5b5..1d1f0bbbb 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -16,7 +16,7 @@ Scalar = Union[int, float] # Datatype NumericData = Union[int, float, np.ndarray, pd.Series, pd.DataFrame, xr.DataArray] - +NumericDataTS = Union[NumericData, 'TimeSeriesData'] class DataConverter: """ diff --git a/flixOpt/effects.py b/flixOpt/effects.py index e19e96a50..fb911ceda 100644 --- a/flixOpt/effects.py +++ b/flixOpt/effects.py @@ -211,14 +211,12 @@ def do_modeling(self, system_model: SystemModel): ) -EffectValuesExpr = Dict[Optional[Union[str, Effect]], linopy.LinearExpression] # This is used to create Shares - -EffectValuesTS = Dict[Optional[Union[str, Effect]], TimeSeries] # This is used internally to index the values - -EffectValuesDict = Dict[Optional[Union[str, Effect]], Numeric_TS] # This is how The effect values are stored - -EffectValuesUser = Union[Numeric_TS, Dict[Optional[Union[str, Effect]], Numeric_TS]] # This is how the User can specify Shares to Effects +EffectKey = Optional[Union[str, Effect]] # Common key type for effect-related dicts +EffectValuesExpr = Dict[EffectKey, linopy.LinearExpression] # Used to create Shares +EffectValuesTS = Dict[EffectKey, TimeSeries] # Used internally to index values +EffectValuesDict = Dict[EffectKey, Numeric_TS] # How effect values are stored +EffectValuesUser = Union[Numeric_TS, Dict[EffectKey, Numeric_TS]] # User-specified Shares to Effects def effect_values_to_time_series(label_suffix: str, effect_values: EffectValuesUser, From a8d53afefa0213710a737a44bf796e3d38badcfb Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 23 Feb 2025 01:30:44 +0100 Subject: [PATCH 198/507] Rename type --- flixOpt/components.py | 24 ++++++++++++------------ flixOpt/effects.py | 14 +++++++------- flixOpt/elements.py | 10 +++++----- flixOpt/interface.py | 10 +++++----- flixOpt/linear_converters.py | 24 ++++++++++++------------ flixOpt/structure.py | 2 +- 6 files changed, 42 insertions(+), 42 deletions(-) diff --git a/flixOpt/components.py b/flixOpt/components.py index 3fa9d8670..77c838445 100644 --- a/flixOpt/components.py +++ b/flixOpt/components.py @@ -10,7 +10,7 @@ import pandas as pd from . import utils -from .core import Numeric, Numeric_TS, Scalar, TimeSeries, TimeSeriesCollection +from .core import Numeric, NumericDataTS, Scalar, TimeSeries, TimeSeriesCollection from .elements import Component, ComponentModel, Flow from .features import InvestmentModel, MultipleSegmentsModel, OnOffModel from .interface import InvestParameters, OnOffParameters @@ -33,8 +33,8 @@ def __init__( inputs: List[Flow], outputs: List[Flow], on_off_parameters: OnOffParameters = None, - conversion_factors: List[Dict[Flow, Numeric_TS]] = None, - segmented_conversion_factors: Dict[Flow, List[Tuple[Numeric_TS, Numeric_TS]]] = None, + conversion_factors: List[Dict[Flow, NumericDataTS]] = None, + segmented_conversion_factors: Dict[Flow, List[Tuple[NumericDataTS, NumericDataTS]]] = None, meta_data: Optional[Dict] = None, ): """ @@ -204,16 +204,16 @@ def __init__( self.charging = charging self.discharging = discharging self.capacity_in_flow_hours = capacity_in_flow_hours - self.relative_minimum_charge_state: Numeric_TS = relative_minimum_charge_state - self.relative_maximum_charge_state: Numeric_TS = relative_maximum_charge_state + self.relative_minimum_charge_state: NumericDataTS = relative_minimum_charge_state + self.relative_maximum_charge_state: NumericDataTS = relative_maximum_charge_state self.initial_charge_state = initial_charge_state self.minimal_final_charge_state = minimal_final_charge_state self.maximal_final_charge_state = maximal_final_charge_state - self.eta_charge: Numeric_TS = eta_charge - self.eta_discharge: Numeric_TS = eta_discharge - self.relative_loss_per_hour: Numeric_TS = relative_loss_per_hour + self.eta_charge: NumericDataTS = eta_charge + self.eta_discharge: NumericDataTS = eta_discharge + self.relative_loss_per_hour: NumericDataTS = relative_loss_per_hour def create_model(self, model: SystemModel) -> 'StorageModel': self.model = StorageModel(model, self) @@ -250,8 +250,8 @@ def __init__( out1: Flow, in2: Optional[Flow] = None, out2: Optional[Flow] = None, - relative_losses: Optional[Numeric_TS] = None, - absolute_losses: Optional[Numeric_TS] = None, + relative_losses: Optional[NumericDataTS] = None, + absolute_losses: Optional[NumericDataTS] = None, on_off_parameters: OnOffParameters = None, prevent_simultaneous_flows_in_both_directions: bool = True, ): @@ -272,9 +272,9 @@ def __init__( If in1 got Investmentparameters, the size of this Flow will be equal to in1 (with no extra effects!) out2 : Optional[Flow], optional The optional outflow at side A. - relative_losses : Optional[Numeric_TS], optional + relative_losses : Optional[NumericDataTS], optional The relative loss between inflow and outflow, e.g., 0.02 for 2% loss. - absolute_losses : Optional[Numeric_TS], optional + absolute_losses : Optional[NumericDataTS], optional The absolute loss, occur only when the Flow is on. Induces the creation of the ON-Variable on_off_parameters : OnOffParameters, optional Parameters defining the on/off behavior of the component. diff --git a/flixOpt/effects.py b/flixOpt/effects.py index fb911ceda..2e5baf7af 100644 --- a/flixOpt/effects.py +++ b/flixOpt/effects.py @@ -12,7 +12,7 @@ import numpy as np import pandas as pd -from .core import Numeric, Numeric_TS, Scalar, TimeSeries, TimeSeriesCollection +from .core import Numeric, NumericDataTS, Scalar, TimeSeries, TimeSeriesCollection from .features import ShareAllocationModel from .structure import Element, ElementModel, Model, SystemModel @@ -42,8 +42,8 @@ def __init__( maximum_operation: Optional[Scalar] = None, minimum_invest: Optional[Scalar] = None, maximum_invest: Optional[Scalar] = None, - minimum_operation_per_hour: Optional[Numeric_TS] = None, - maximum_operation_per_hour: Optional[Numeric_TS] = None, + minimum_operation_per_hour: Optional[NumericDataTS] = None, + maximum_operation_per_hour: Optional[NumericDataTS] = None, minimum_total: Optional[Scalar] = None, maximum_total: Optional[Scalar] = None, ): @@ -104,8 +104,8 @@ def __init__( ) self.minimum_operation = minimum_operation self.maximum_operation = maximum_operation - self.minimum_operation_per_hour: Numeric_TS = minimum_operation_per_hour - self.maximum_operation_per_hour: Numeric_TS = maximum_operation_per_hour + self.minimum_operation_per_hour: NumericDataTS = minimum_operation_per_hour + self.maximum_operation_per_hour: NumericDataTS = maximum_operation_per_hour self.minimum_invest = minimum_invest self.maximum_invest = maximum_invest self.minimum_total = minimum_total @@ -215,8 +215,8 @@ def do_modeling(self, system_model: SystemModel): EffectValuesExpr = Dict[EffectKey, linopy.LinearExpression] # Used to create Shares EffectValuesTS = Dict[EffectKey, TimeSeries] # Used internally to index values -EffectValuesDict = Dict[EffectKey, Numeric_TS] # How effect values are stored -EffectValuesUser = Union[Numeric_TS, Dict[EffectKey, Numeric_TS]] # User-specified Shares to Effects +EffectValuesDict = Dict[EffectKey, NumericDataTS] # How effect values are stored +EffectValuesUser = Union[NumericDataTS, Dict[EffectKey, NumericDataTS]] # User-specified Shares to Effects def effect_values_to_time_series(label_suffix: str, effect_values: EffectValuesUser, diff --git a/flixOpt/elements.py b/flixOpt/elements.py index 30e86da0d..badcca94e 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -10,7 +10,7 @@ import pandas as pd from .config import CONFIG -from .core import Numeric, Numeric_TS, Scalar, TimeSeriesCollection +from .core import Scalar, NumericData, TimeSeriesCollection from .effects import EffectValuesUser, effect_values_to_time_series from .features import InvestmentModel, OnOffModel, PreventSimultaneousUsageModel from .interface import InvestParameters, OnOffParameters @@ -97,7 +97,7 @@ class Bus(Element): """ def __init__( - self, label: str, excess_penalty_per_flow_hour: Optional[Numeric_TS] = 1e5, meta_data: Optional[Dict] = None + self, label: str, excess_penalty_per_flow_hour: Optional[NumericDataTS] = 1e5, meta_data: Optional[Dict] = None ): """ Parameters @@ -161,9 +161,9 @@ def __init__( label: str, bus: Bus, size: Union[Scalar, InvestParameters] = None, - fixed_relative_profile: Optional[Numeric_TS] = None, - relative_minimum: Numeric_TS = 0, - relative_maximum: Numeric_TS = 1, + fixed_relative_profile: Optional[NumericDataTS] = None, + relative_minimum: NumericDataTS = 0, + relative_maximum: NumericDataTS = 1, effects_per_flow_hour: EffectValuesUser = None, on_off_parameters: Optional[OnOffParameters] = None, flow_hours_total_max: Optional[Scalar] = None, diff --git a/flixOpt/interface.py b/flixOpt/interface.py index 7ba4e5104..91f76ba31 100644 --- a/flixOpt/interface.py +++ b/flixOpt/interface.py @@ -11,7 +11,7 @@ from flixOpt.core import TimeSeriesCollection from .config import CONFIG -from .core import Numeric, Numeric_TS, Scalar +from .core import Numeric, NumericDataTS, Scalar from .structure import Element, Interface if TYPE_CHECKING: @@ -148,10 +148,10 @@ def __init__( self.effects_per_running_hour: Union[EffectValues, EffectTimeSeries] = effects_per_running_hour or {} self.on_hours_total_min: Scalar = on_hours_total_min self.on_hours_total_max: Scalar = on_hours_total_max - self.consecutive_on_hours_min: Numeric_TS = consecutive_on_hours_min - self.consecutive_on_hours_max: Numeric_TS = consecutive_on_hours_max - self.consecutive_off_hours_min: Numeric_TS = consecutive_off_hours_min - self.consecutive_off_hours_max: Numeric_TS = consecutive_off_hours_max + self.consecutive_on_hours_min: NumericDataTS = consecutive_on_hours_min + self.consecutive_on_hours_max: NumericDataTS = consecutive_on_hours_max + self.consecutive_off_hours_min: NumericDataTS = consecutive_off_hours_min + self.consecutive_off_hours_max: NumericDataTS = consecutive_off_hours_max self.switch_on_total_max: Scalar = switch_on_total_max self.force_switch_on: bool = force_switch_on diff --git a/flixOpt/linear_converters.py b/flixOpt/linear_converters.py index 65e6e88b7..9d79b9717 100644 --- a/flixOpt/linear_converters.py +++ b/flixOpt/linear_converters.py @@ -8,7 +8,7 @@ import numpy as np from .components import LinearConverter -from .core import Numeric_TS, TimeSeriesData +from .core import NumericDataTS, TimeSeriesData from .elements import Flow from .interface import OnOffParameters @@ -19,7 +19,7 @@ class Boiler(LinearConverter): def __init__( self, label: str, - eta: Numeric_TS, + eta: NumericDataTS, Q_fu: Flow, Q_th: Flow, on_off_parameters: OnOffParameters = None, @@ -61,7 +61,7 @@ class Power2Heat(LinearConverter): def __init__( self, label: str, - eta: Numeric_TS, + eta: NumericDataTS, P_el: Flow, Q_th: Flow, on_off_parameters: OnOffParameters = None, @@ -102,7 +102,7 @@ class HeatPump(LinearConverter): def __init__( self, label: str, - COP: Numeric_TS, + COP: NumericDataTS, P_el: Flow, Q_th: Flow, on_off_parameters: OnOffParameters = None, @@ -142,7 +142,7 @@ class CoolingTower(LinearConverter): def __init__( self, label: str, - specific_electricity_demand: Numeric_TS, + specific_electricity_demand: NumericDataTS, P_el: Flow, Q_th: Flow, on_off_parameters: OnOffParameters = None, @@ -183,8 +183,8 @@ class CHP(LinearConverter): def __init__( self, label: str, - eta_th: Numeric_TS, - eta_el: Numeric_TS, + eta_th: NumericDataTS, + eta_el: NumericDataTS, Q_fu: Flow, P_el: Flow, Q_th: Flow, @@ -239,7 +239,7 @@ class HeatPumpWithSource(LinearConverter): def __init__( self, label: str, - COP: Numeric_TS, + COP: NumericDataTS, P_el: Flow, Q_ab: Flow, Q_th: Flow, @@ -285,22 +285,22 @@ def __init__( def check_bounds( - value: Numeric_TS, parameter_label: str, element_label: str, lower_bound: Numeric_TS, upper_bound: Numeric_TS + value: NumericDataTS, parameter_label: str, element_label: str, lower_bound: NumericDataTS, upper_bound: NumericDataTS ): """ Check if the value is within the bounds. The bounds are exclusive. If not, log a warning. Parameters ---------- - value: Numeric_TS + value: NumericDataTS The value to check. parameter_label: str The label of the value. element_label: str The label of the element. - lower_bound: Numeric_TS + lower_bound: NumericDataTS The lower bound. - upper_bound: Numeric_TS + upper_bound: NumericDataTS The upper bound. Returns diff --git a/flixOpt/structure.py b/flixOpt/structure.py index b9896c73d..2c45f9eef 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -20,7 +20,7 @@ from . import utils from .config import CONFIG -from .core import Numeric, Numeric_TS, NumericData, Scalar, TimeSeries, TimeSeriesCollection, TimeSeriesData +from .core import Numeric, NumericDataTS, NumericData, Scalar, TimeSeries, TimeSeriesCollection, TimeSeriesData if TYPE_CHECKING: # for type checking and preventing circular imports from .effects import EffectCollection From 98da606db1be94e0ad6932db9cc7ba8664032154 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 23 Feb 2025 01:31:13 +0100 Subject: [PATCH 199/507] Rename type --- flixOpt/calculation.py | 16 ++++++++-------- flixOpt/components.py | 18 +++++++++--------- flixOpt/effects.py | 2 +- flixOpt/elements.py | 6 +++--- flixOpt/features.py | 26 +++++++++++++------------- flixOpt/interface.py | 14 +++++++------- flixOpt/structure.py | 6 +++--- 7 files changed, 44 insertions(+), 44 deletions(-) diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py index 0f05ef3e5..ed0cc2820 100644 --- a/flixOpt/calculation.py +++ b/flixOpt/calculation.py @@ -23,7 +23,7 @@ from . import utils as utils from .aggregation import AggregationModel, AggregationParameters from .components import Storage -from .core import Numeric, Scalar +from .core import NumericData, Scalar from .elements import Component from .features import InvestmentModel from .flow_system import FlowSystem @@ -389,7 +389,7 @@ def do_modeling_and_solve( def results( self, combined_arrays: bool = False, combined_scalars: bool = False, individual_results: bool = False - ) -> Dict[str, Union[Numeric, Dict[str, Numeric]]]: + ) -> Dict[str, Union[NumericData, Dict[str, NumericData]]]: """ Retrieving the results of a Segmented Calculation is not as straight forward as with other Calculation types. You have 3 options: @@ -539,7 +539,7 @@ def _remove_empty_dicts(d: Dict[Any, Any]) -> Dict[Any, Any]: def _combine_nested_arrays( - *dicts: Dict[str, Union[Numeric, dict]], + *dicts: Dict[str, Union[NumericData, dict]], trim: Optional[int] = None, length_per_array: Optional[int] = None, ) -> Dict[str, Union[np.ndarray, dict]]: @@ -550,7 +550,7 @@ def _combine_nested_arrays( Parameters ---------- *dicts : Dict[str, Union[np.ndarray, dict]] - Dictionaries with matching structures and Numeric values. + Dictionaries with matching structures and NumericData values. trim : int, optional Number of elements to trim from the end of each array except the last. Defaults to None. length_per_array : int, optional @@ -574,7 +574,7 @@ def _combine_nested_arrays( ) def combine_arrays_recursively( - *values: Union[Numeric, Dict[str, Numeric], Any], + *values: Union[NumericData, Dict[str, NumericData], Any], ) -> Optional[Union[np.ndarray, Dict[str, Union[np.ndarray, dict]]]]: if all(isinstance(val, dict) for val in values): # If all values are dictionaries, recursively combine each key return {key: combine_arrays_recursively(*(val[key] for val in values)) for key in values[0]} @@ -600,7 +600,7 @@ def limit(idx: int, arr: np.ndarray) -> np.ndarray: return _remove_empty_dicts(combined_arrays) -def _combine_nested_scalars(*dicts: Dict[str, Union[Numeric, dict]]) -> Dict[str, Union[List[Scalar], dict]]: +def _combine_nested_scalars(*dicts: Dict[str, Union[NumericData, dict]]) -> Dict[str, Union[List[Scalar], dict]]: """ Combines multiple dictionaries with identical structures by combining its skalar values to a list. Filters out all other values. @@ -608,11 +608,11 @@ def _combine_nested_scalars(*dicts: Dict[str, Union[Numeric, dict]]) -> Dict[str Parameters ---------- *dicts : Dict[str, Union[np.ndarray, dict]] - Dictionaries with matching structures and Numeric values. + Dictionaries with matching structures and NumericData values. """ def combine_scalars_recursively( - *values: Union[Numeric, Dict[str, Numeric], Any], + *values: Union[NumericData, Dict[str, NumericData], Any], ) -> Optional[Union[List[Scalar], Dict[str, Union[List[Scalar], dict]]]]: # If all values are dictionaries, recursively combine each key if all(isinstance(val, dict) for val in values): diff --git a/flixOpt/components.py b/flixOpt/components.py index 77c838445..ae4ec3c91 100644 --- a/flixOpt/components.py +++ b/flixOpt/components.py @@ -10,7 +10,7 @@ import pandas as pd from . import utils -from .core import Numeric, NumericDataTS, Scalar, TimeSeries, TimeSeriesCollection +from .core import NumericData, NumericDataTS, Scalar, TimeSeries, TimeSeriesCollection from .elements import Component, ComponentModel, Flow from .features import InvestmentModel, MultipleSegmentsModel, OnOffModel from .interface import InvestParameters, OnOffParameters @@ -144,14 +144,14 @@ def __init__( charging: Flow, discharging: Flow, capacity_in_flow_hours: Union[Scalar, InvestParameters], - relative_minimum_charge_state: Numeric = 0, - relative_maximum_charge_state: Numeric = 1, + relative_minimum_charge_state: NumericData = 0, + relative_maximum_charge_state: NumericData = 1, initial_charge_state: Optional[Union[Scalar, Literal['lastValueOfSim']]] = 0, minimal_final_charge_state: Optional[Scalar] = None, maximal_final_charge_state: Optional[Scalar] = None, - eta_charge: Numeric = 1, - eta_discharge: Numeric = 1, - relative_loss_per_hour: Numeric = 0, + eta_charge: NumericData = 1, + eta_discharge: NumericData = 1, + relative_loss_per_hour: NumericData = 0, prevent_simultaneous_charge_and_discharge: bool = True, meta_data: Optional[Dict] = None, ): @@ -412,7 +412,7 @@ def do_modeling(self, system_model: SystemModel): # (linear) segments: else: # TODO: Improve Inclusion of OnOffParameters. Instead of creating a Binary in every flow, the binary could only be part of the Segment itself - segments: Dict[str, List[Tuple[Numeric, Numeric]]] = { + segments: Dict[str, List[Tuple[NumericData, NumericData]]] = { flow.model.flow_rate.name: [ (ts1.active_data, ts2.active_data) for ts1, ts2 in self.element.segmented_conversion_factors[flow] ] @@ -523,7 +523,7 @@ def _initial_and_final_charge_state(self, system_model): ) @property - def absolute_charge_state_bounds(self) -> Tuple[Numeric, Numeric]: + def absolute_charge_state_bounds(self) -> Tuple[NumericData, NumericData]: relative_lower_bound, relative_upper_bound = self.relative_charge_state_bounds if not isinstance(self.element.capacity_in_flow_hours, InvestParameters): return ( @@ -537,7 +537,7 @@ def absolute_charge_state_bounds(self) -> Tuple[Numeric, Numeric]: ) @property - def relative_charge_state_bounds(self) -> Tuple[Numeric, Numeric]: + def relative_charge_state_bounds(self) -> Tuple[NumericData, NumericData]: return ( self.element.relative_minimum_charge_state.active_data, self.element.relative_maximum_charge_state.active_data, diff --git a/flixOpt/effects.py b/flixOpt/effects.py index 2e5baf7af..8575ace85 100644 --- a/flixOpt/effects.py +++ b/flixOpt/effects.py @@ -12,7 +12,7 @@ import numpy as np import pandas as pd -from .core import Numeric, NumericDataTS, Scalar, TimeSeries, TimeSeriesCollection +from .core import NumericData, NumericDataTS, Scalar, TimeSeries, TimeSeriesCollection from .features import ShareAllocationModel from .structure import Element, ElementModel, Model, SystemModel diff --git a/flixOpt/elements.py b/flixOpt/elements.py index badcca94e..28da0f048 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -170,7 +170,7 @@ def __init__( flow_hours_total_min: Optional[Scalar] = None, load_factor_min: Optional[Scalar] = None, load_factor_max: Optional[Scalar] = None, - previous_flow_rate: Optional[Numeric] = None, + previous_flow_rate: Optional[NumericData] = None, meta_data: Optional[Dict] = None, ): r""" @@ -436,7 +436,7 @@ def fixed_relative_flow_rate(self) -> Optional[np.ndarray]: return None @property - def absolute_flow_rate_bounds(self) -> Tuple[Numeric, Numeric]: + def absolute_flow_rate_bounds(self) -> Tuple[NumericData, NumericData]: """Returns absolute flow rate bounds. Iportant for OnOffModel""" rel_min, rel_max = self.relative_flow_rate_bounds size = self.element.size @@ -448,7 +448,7 @@ def absolute_flow_rate_bounds(self) -> Tuple[Numeric, Numeric]: return rel_min * size, rel_max * size @property - def relative_flow_rate_bounds(self) -> Tuple[Numeric, Numeric]: + def relative_flow_rate_bounds(self) -> Tuple[NumericData, NumericData]: """Returns relative flow rate bounds.""" fixed_profile = self.element.fixed_relative_profile if fixed_profile is None: diff --git a/flixOpt/features.py b/flixOpt/features.py index 2d0e0892b..d63d97918 100644 --- a/flixOpt/features.py +++ b/flixOpt/features.py @@ -11,7 +11,7 @@ from . import utils from .config import CONFIG -from .core import Numeric, Scalar, TimeSeries +from .core import NumericData, Scalar, TimeSeries from .interface import InvestParameters, OnOffParameters from .structure import Model, SystemModel @@ -33,8 +33,8 @@ def __init__( label_of_element: str, parameters: InvestParameters, defining_variable: [linopy.Variable], - relative_bounds_of_defining_variable: Tuple[Numeric, Numeric], - fixed_relative_profile: Optional[Numeric] = None, + relative_bounds_of_defining_variable: Tuple[NumericData, NumericData], + fixed_relative_profile: Optional[NumericData] = None, label: Optional[str] = None, on_variable: Optional[linopy.Variable] = None, ): @@ -195,8 +195,8 @@ def __init__( on_off_parameters: OnOffParameters, label_of_element: str, defining_variables: List[linopy.Variable], - defining_bounds: List[Tuple[Numeric, Numeric]], - previous_values: List[Optional[Numeric]], + defining_bounds: List[Tuple[NumericData, NumericData]], + previous_values: List[Optional[NumericData]], label: Optional[str] = None, ): """ @@ -608,13 +608,13 @@ def previous_consecutive_off_hours(self) -> Scalar: return self.compute_consecutive_duration(self.previous_off_values, self._model.hours_per_step) @staticmethod - def compute_previous_on_states(previous_values: List[Optional[Numeric]], epsilon: float = 1e-5) -> np.ndarray: + def compute_previous_on_states(previous_values: List[Optional[NumericData]], epsilon: float = 1e-5) -> np.ndarray: """ Computes the previous 'on' states {0, 1} of defining variables as a binary array from their previous values. Parameters: ---------- - previous_values: List[Numeric] + previous_values: List[NumericData] List of previous values of the defining variables. In Range [0, inf] or None (ignored) epsilon : float, optional Tolerance for equality to determine "off" state, default is 1e-5. @@ -637,7 +637,7 @@ def compute_previous_on_states(previous_values: List[Optional[Numeric]], epsilon @staticmethod def compute_consecutive_duration( - binary_values: Numeric, + binary_values: NumericData, hours_per_timestep: Union[int, float, np.ndarray] ) -> Scalar: """ @@ -699,7 +699,7 @@ def __init__( model: SystemModel, label_of_element: str, segment_index: Union[int, str], - sample_points: Dict[str, Tuple[Union[Numeric, TimeSeries], Union[Numeric, TimeSeries]]], + sample_points: Dict[str, Tuple[Union[NumericData, TimeSeries], Union[NumericData, TimeSeries]]], as_time_series: bool = True, ): super().__init__(model, label_of_element, f'Segment{segment_index}') @@ -747,7 +747,7 @@ def __init__( self, model: SystemModel, label_of_element: str, - sample_points: Dict[str, List[Tuple[Numeric, Numeric]]], + sample_points: Dict[str, List[Tuple[NumericData, NumericData]]], can_be_outside_segments: Optional[Union[bool, linopy.Variable]], as_time_series: bool = True, label: str = 'MultipleSegments', @@ -776,7 +776,7 @@ def __init__( self._segment_models: List[SegmentModel] = [] def do_modeling(self, system_model: SystemModel): - restructured_variables_with_segments: List[Dict[str, Tuple[Numeric, Numeric]]] = [ + restructured_variables_with_segments: List[Dict[str, Tuple[NumericData, NumericData]]] = [ {key: values[i] for key, values in self._sample_points.items()} for i in range(self._nr_of_segments) ] @@ -843,8 +843,8 @@ def __init__( label: Optional[str] = None, total_max: Optional[Scalar] = None, total_min: Optional[Scalar] = None, - max_per_hour: Optional[Numeric] = None, - min_per_hour: Optional[Numeric] = None, + max_per_hour: Optional[NumericData] = None, + min_per_hour: Optional[NumericData] = None, ): super().__init__(model, label_of_element=label_of_element, label=label) if not shares_are_time_series: # If the condition is True diff --git a/flixOpt/interface.py b/flixOpt/interface.py index 91f76ba31..696892172 100644 --- a/flixOpt/interface.py +++ b/flixOpt/interface.py @@ -11,7 +11,7 @@ from flixOpt.core import TimeSeriesCollection from .config import CONFIG -from .core import Numeric, NumericDataTS, Scalar +from .core import NumericData, NumericDataTS, Scalar from .structure import Element, Interface if TYPE_CHECKING: @@ -102,14 +102,14 @@ def maximum_size(self): class OnOffParameters(Interface): def __init__( self, - effects_per_switch_on: Union[Dict, Numeric] = None, - effects_per_running_hour: Union[Dict, Numeric] = None, + effects_per_switch_on: Union[Dict, NumericData] = None, + effects_per_running_hour: Union[Dict, NumericData] = None, on_hours_total_min: Optional[int] = None, on_hours_total_max: Optional[int] = None, - consecutive_on_hours_min: Optional[Numeric] = None, - consecutive_on_hours_max: Optional[Numeric] = None, - consecutive_off_hours_min: Optional[Numeric] = None, - consecutive_off_hours_max: Optional[Numeric] = None, + consecutive_on_hours_min: Optional[NumericData] = None, + consecutive_on_hours_max: Optional[NumericData] = None, + consecutive_off_hours_min: Optional[NumericData] = None, + consecutive_off_hours_max: Optional[NumericData] = None, switch_on_total_max: Optional[int] = None, force_switch_on: bool = False, ): diff --git a/flixOpt/structure.py b/flixOpt/structure.py index 2c45f9eef..8a84bce32 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -20,7 +20,7 @@ from . import utils from .config import CONFIG -from .core import Numeric, NumericDataTS, NumericData, Scalar, TimeSeries, TimeSeriesCollection, TimeSeriesData +from .core import NumericData, NumericDataTS, NumericData, Scalar, TimeSeries, TimeSeriesCollection, TimeSeriesData if TYPE_CHECKING: # for type checking and preventing circular imports from .effects import EffectCollection @@ -263,7 +263,7 @@ def _create_time_series( extra_timestep: bool = False, ) -> Optional[TimeSeries]: """ - Tries to create a TimeSeries from Numeric Data and adds it to the time_series_collection + Tries to create a TimeSeries from NumericData Data and adds it to the time_series_collection If the data already is a TimeSeries, nothing happens and the TimeSeries gets reset and returned If the data is a TimeSeriesData, it is converted to a TimeSeries, and the aggregation weights are applied. If the data is None, nothing happens. @@ -317,7 +317,7 @@ def _create_time_series( extra_timestep: bool = False, ) -> Optional[TimeSeries]: """ - Tries to create a TimeSeries from Numeric Data and adds it to the time_series_collection + Tries to create a TimeSeries from NumericData Data and adds it to the time_series_collection If the data already is a TimeSeries, nothing happens and the TimeSeries gets reset and returned If the data is a TimeSeriesData, it is converted to a TimeSeries, and the aggregation weights are applied. If the data is None, nothing happens. From 9addffc94c3cb3ac05d8ee71af54f2a8c0ba7933 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 23 Feb 2025 01:33:25 +0100 Subject: [PATCH 200/507] Improve imports --- flixOpt/elements.py | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/flixOpt/elements.py b/flixOpt/elements.py index 28da0f048..81546796d 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -3,25 +3,17 @@ """ import logging -from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Tuple, Union +from typing import Dict, List, Literal, Optional, Tuple, Union import linopy import numpy as np -import pandas as pd from .config import CONFIG -from .core import Scalar, NumericData, TimeSeriesCollection +from .core import Scalar, NumericData, NumericDataTS, TimeSeriesCollection from .effects import EffectValuesUser, effect_values_to_time_series from .features import InvestmentModel, OnOffModel, PreventSimultaneousUsageModel from .interface import InvestParameters, OnOffParameters -from .structure import ( - Element, - ElementModel, - SystemModel, -) - -if TYPE_CHECKING: - from .flow_system import FlowSystem +from .structure import Element, ElementModel, SystemModel logger = logging.getLogger('flixOpt') From 0c55b7f22ab2facb8666ac2ad4a7e9f722f892ad Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 23 Feb 2025 01:40:20 +0100 Subject: [PATCH 201/507] Improve types --- flixOpt/effects.py | 1 + flixOpt/elements.py | 5 ++--- flixOpt/interface.py | 19 ++++++++----------- flixOpt/structure.py | 3 +-- 4 files changed, 12 insertions(+), 16 deletions(-) diff --git a/flixOpt/effects.py b/flixOpt/effects.py index 8575ace85..c60c0a7ce 100644 --- a/flixOpt/effects.py +++ b/flixOpt/effects.py @@ -217,6 +217,7 @@ def do_modeling(self, system_model: SystemModel): EffectValuesTS = Dict[EffectKey, TimeSeries] # Used internally to index values EffectValuesDict = Dict[EffectKey, NumericDataTS] # How effect values are stored EffectValuesUser = Union[NumericDataTS, Dict[EffectKey, NumericDataTS]] # User-specified Shares to Effects +EffectValuesUserScalar = Union[Scalar, Dict[EffectKey, Scalar]] # User-specified Shares to Effects def effect_values_to_time_series(label_suffix: str, effect_values: EffectValuesUser, diff --git a/flixOpt/elements.py b/flixOpt/elements.py index 81546796d..55d79a795 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -9,13 +9,12 @@ import numpy as np from .config import CONFIG -from .core import Scalar, NumericData, NumericDataTS, TimeSeriesCollection +from .core import NumericData, NumericDataTS, Scalar, TimeSeriesCollection from .effects import EffectValuesUser, effect_values_to_time_series from .features import InvestmentModel, OnOffModel, PreventSimultaneousUsageModel from .interface import InvestParameters, OnOffParameters from .structure import Element, ElementModel, SystemModel - logger = logging.getLogger('flixOpt') @@ -156,7 +155,7 @@ def __init__( fixed_relative_profile: Optional[NumericDataTS] = None, relative_minimum: NumericDataTS = 0, relative_maximum: NumericDataTS = 1, - effects_per_flow_hour: EffectValuesUser = None, + effects_per_flow_hour: Optional[EffectValuesUser] = None, on_off_parameters: Optional[OnOffParameters] = None, flow_hours_total_max: Optional[Scalar] = None, flow_hours_total_min: Optional[Scalar] = None, diff --git a/flixOpt/interface.py b/flixOpt/interface.py index 696892172..04c2e9771 100644 --- a/flixOpt/interface.py +++ b/flixOpt/interface.py @@ -6,8 +6,6 @@ import logging from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union -import pandas as pd - from flixOpt.core import TimeSeriesCollection from .config import CONFIG @@ -15,8 +13,7 @@ from .structure import Element, Interface if TYPE_CHECKING: - from .effects import Effect, EffectValuesUser - from .flow_system import FlowSystem + from .effects import Effect, EffectValuesUser, EffectValuesUserScalar logger = logging.getLogger('flixOpt') @@ -32,12 +29,12 @@ def __init__( minimum_size: Union[int, float] = 0, # TODO: Use EPSILON? maximum_size: Optional[Union[int, float]] = None, optional: bool = True, # Investition ist weglassbar - fix_effects: Union[Dict, int, float] = None, - specific_effects: Union[Dict, int, float] = None, # costs per Flow-Unit/Storage-Size/... + fix_effects: Optional['EffectValuesUserScalar'] = None, + specific_effects: Optional['EffectValuesUserScalar'] = None, # costs per Flow-Unit/Storage-Size/... effects_in_segments: Optional[ Tuple[List[Tuple[Scalar, Scalar]], Dict['Effect', List[Tuple[Scalar, Scalar]]]] ] = None, - divest_effects: Union[Dict, int, float] = None, + divest_effects: Optional['EffectValuesUserScalar'] = None, ): """ Parameters @@ -102,8 +99,8 @@ def maximum_size(self): class OnOffParameters(Interface): def __init__( self, - effects_per_switch_on: Union[Dict, NumericData] = None, - effects_per_running_hour: Union[Dict, NumericData] = None, + effects_per_switch_on: Optional['EffectValuesUser'] = None, + effects_per_running_hour: Optional['EffectValuesUser'] = None, on_hours_total_min: Optional[int] = None, on_hours_total_max: Optional[int] = None, consecutive_on_hours_min: Optional[NumericData] = None, @@ -144,8 +141,8 @@ def __init__( force_switch_on : bool force creation of switch on variable, even if there is no switch_on_total_max """ - self.effects_per_switch_on: Union[EffectValues, EffectTimeSeries] = effects_per_switch_on or {} - self.effects_per_running_hour: Union[EffectValues, EffectTimeSeries] = effects_per_running_hour or {} + self.effects_per_switch_on: EffectValuesUser = effects_per_switch_on or {} + self.effects_per_running_hour: EffectValuesUser = effects_per_running_hour or {} self.on_hours_total_min: Scalar = on_hours_total_min self.on_hours_total_max: Scalar = on_hours_total_max self.consecutive_on_hours_min: NumericDataTS = consecutive_on_hours_min diff --git a/flixOpt/structure.py b/flixOpt/structure.py index 8a84bce32..2f2fde07b 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -20,11 +20,10 @@ from . import utils from .config import CONFIG -from .core import NumericData, NumericDataTS, NumericData, Scalar, TimeSeries, TimeSeriesCollection, TimeSeriesData +from .core import NumericData, Scalar, TimeSeries, TimeSeriesCollection, TimeSeriesData if TYPE_CHECKING: # for type checking and preventing circular imports from .effects import EffectCollection - from .elements import BusModel, ComponentModel from .flow_system import FlowSystem logger = logging.getLogger('flixOpt') From 4945654d7780365d79096e6807bbc473994048ea Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 23 Feb 2025 01:53:46 +0100 Subject: [PATCH 202/507] Remove system_model from function parameters wherever possible --- flixOpt/components.py | 24 +++++++-------- flixOpt/effects.py | 26 +++++++--------- flixOpt/elements.py | 31 ++++++++++--------- flixOpt/features.py | 70 +++++++++++++++++++------------------------ flixOpt/structure.py | 9 ++++-- 5 files changed, 75 insertions(+), 85 deletions(-) diff --git a/flixOpt/components.py b/flixOpt/components.py index ae4ec3c91..168dc89a4 100644 --- a/flixOpt/components.py +++ b/flixOpt/components.py @@ -332,7 +332,7 @@ def __init__(self, model: SystemModel, element: Transmission): self.element: Transmission = element self.on_off: Optional[OnOffModel] = None - def do_modeling(self, system_model: SystemModel): + def do_modeling(self): """Initiates all FlowModels""" # Force On Variable if absolute losses are present if (self.element.absolute_losses is not None) and np.any(self.element.absolute_losses.active_data != 0): @@ -347,7 +347,7 @@ def do_modeling(self, system_model: SystemModel): ): self.element.in2.size = InvestParameters(maximum_size=self.element.in1.size.maximum_size) - super().do_modeling(system_model) + super().do_modeling() # first direction self.create_transmission_equation('dir1', self.element.in1, self.element.out1) @@ -386,8 +386,8 @@ def __init__(self, model: SystemModel, element: LinearConverter): self.element: LinearConverter = element self.on_off: Optional[OnOffModel] = None - def do_modeling(self, system_model: SystemModel): - super().do_modeling(system_model) + def do_modeling(self): + super().do_modeling() # conversion_factors: if self.element.conversion_factors: @@ -401,7 +401,7 @@ def do_modeling(self, system_model: SystemModel): used_outputs: Set = all_output_flows & used_flows self.add( - system_model.add_constraints( + self._model.add_constraints( sum([flow.model.flow_rate * conv_fact[flow].active_data for flow in used_inputs]) == sum([flow.model.flow_rate * conv_fact[flow].active_data for flow in used_outputs]), @@ -421,7 +421,7 @@ def do_modeling(self, system_model: SystemModel): linear_segments = MultipleSegmentsModel( self._model, self.label_of_element, segments, self.on_off.on if self.on_off is not None else None ) # TODO: Add Outside_segments Variable (On) - linear_segments.do_modeling(system_model) + linear_segments.do_modeling() self.sub_models.append(linear_segments) @@ -435,8 +435,8 @@ def __init__(self, model: SystemModel, element: Storage): self.netto_discharge: Optional[linopy.Variable] = None self._investment: Optional[InvestmentModel] = None - def do_modeling(self, system_model): - super().do_modeling(system_model) + def do_modeling(self): + super().do_modeling() lb, ub = self.absolute_charge_state_bounds self.charge_state = self.add(self._model.add_variables( @@ -458,7 +458,7 @@ def do_modeling(self, system_model): charge_state = self.charge_state rel_loss = self.element.relative_loss_per_hour.active_data - hours_per_step = system_model.hours_per_step + hours_per_step = self._model.hours_per_step charge_rate = self.element.charging.model.flow_rate discharge_rate = self.element.discharging.model.flow_rate eff_charge = self.element.eta_charge.active_data @@ -483,12 +483,12 @@ def do_modeling(self, system_model): relative_bounds_of_defining_variable=self.relative_charge_state_bounds, ) self.sub_models.append(self._investment) - self._investment.do_modeling(system_model) + self._investment.do_modeling() # Initial charge state - self._initial_and_final_charge_state(system_model) + self._initial_and_final_charge_state() - def _initial_and_final_charge_state(self, system_model): + def _initial_and_final_charge_state(self): if self.element.initial_charge_state is not None: name_short = 'initial_charge_state' name = f'{self.label_full}|{name_short}' diff --git a/flixOpt/effects.py b/flixOpt/effects.py index c60c0a7ce..a8ea156a3 100644 --- a/flixOpt/effects.py +++ b/flixOpt/effects.py @@ -188,12 +188,12 @@ def __init__(self, model: SystemModel, element: Effect): ) ) - def do_modeling(self, system_model: SystemModel): + def do_modeling(self): for model in self.sub_models: - model.do_modeling(system_model) + model.do_modeling() self.total = self.add( - system_model.add_variables( + self._model.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=None, @@ -203,7 +203,7 @@ def do_modeling(self, system_model: SystemModel): ) self.add( - system_model.add_constraints( + self._model.add_constraints( self.total == self.operation.total.sum() + self.invest.total.sum(), name=f'{self.label_full}|total' ), @@ -282,47 +282,43 @@ def __init__(self, model: SystemModel, effects: List[Effect]): def add_share_to_effects( self, - system_model: SystemModel, name: str, expressions: EffectValuesExpr, target: Literal['operation', 'invest'], ) -> None: for effect, expression in expressions.items(): if target == 'operation': - self[effect].model.operation.add_share(system_model, name, expression) + self[effect].model.operation.add_share(name, expression) elif target =='invest': - self[effect].model.invest.add_share(system_model, name, expression) + self[effect].model.invest.add_share(name, expression) else: raise ValueError(f'Target {target} not supported!') def add_share_to_penalty(self, system_model: SystemModel, name: str, expression: linopy.LinearExpression) -> None: if expression.ndim != 0: raise Exception(f'Penalty shares must be scalar expressions! ({expression.ndim=})') - self.penalty.add_share(system_model, name, expression) + self.penalty.add_share(name, expression) - def do_modeling(self, system_model: SystemModel): - self._model = system_model + def do_modeling(self): for effect in self.effects.values(): effect.create_model(self._model) self.penalty = self.add(ShareAllocationModel(self._model, shares_are_time_series=False, label_of_element='Penalty')) for model in [effect.model for effect in self.effects.values()] + [self.penalty]: - model.do_modeling(system_model) + model.do_modeling() - self._add_share_between_effects(system_model) + self._add_share_between_effects() - def _add_share_between_effects(self, system_model: SystemModel): + def _add_share_between_effects(self): for origin_effect in self.effects.values(): # 1. operation: -> hier sind es Zeitreihen (share_TS) for target_effect, time_series in origin_effect.specific_share_to_other_effects_operation.items(): target_effect.model.operation.add_share( - system_model, origin_effect.label_full, origin_effect.model.operation.total_per_timestep * time_series.active_data, ) # 2. invest: -> hier ist es Scalar (share) for target_effect, factor in origin_effect.specific_share_to_other_effects_invest.items(): target_effect.model.invest.add_share( - system_model, origin_effect.label_full, origin_effect.model.invest.total * factor, ) diff --git a/flixOpt/elements.py b/flixOpt/elements.py index 55d79a795..829960a12 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -293,9 +293,9 @@ def __init__(self, model: SystemModel, element: Flow): self.on_off: Optional[OnOffModel] = None self._investment: Optional[InvestmentModel] = None - def do_modeling(self, system_model: SystemModel): + def do_modeling(self): # eq relative_minimum(t) * size <= flow_rate(t) <= relative_maximum(t) * size - self.flow_rate = self.add( + self.flow_rate: linopy.Variable = self.add( self._model.add_variables( lower=self.absolute_flow_rate_bounds[0] if self.element.on_off_parameters is None else 0, upper=self.absolute_flow_rate_bounds[1], @@ -315,7 +315,7 @@ def do_modeling(self, system_model: SystemModel): # OnOff if self.element.on_off_parameters is not None: - self.on_off = self.add( + self.on_off: OnOffModel = self.add( OnOffModel( model=self._model, label_of_element=self.label_of_element, @@ -326,11 +326,11 @@ def do_modeling(self, system_model: SystemModel): ), 'on_off' ) - self.on_off.do_modeling(self._model) + self.on_off.do_modeling() # Investment if isinstance(self.element.size, InvestParameters): - self._investment = self.add( + self._investment: InvestmentModel = self.add( InvestmentModel( model=self._model, label_of_element=self.label_of_element, @@ -342,7 +342,7 @@ def do_modeling(self, system_model: SystemModel): ), 'investment' ) - self._investment.do_modeling(system_model) + self._investment.do_modeling() self.total_flow_hours = self.add( self._model.add_variables( @@ -363,16 +363,15 @@ def do_modeling(self, system_model: SystemModel): ) # Load factor - self._create_bounds_for_load_factor(system_model) + self._create_bounds_for_load_factor() # Shares - self._create_shares(system_model) + self._create_shares() - def _create_shares(self, system_model: SystemModel): + def _create_shares(self): # Arbeitskosten: if self.element.effects_per_flow_hour != {}: self._model.effects.add_share_to_effects( - self._model, name=self.label_full, # Use the full label of the element expressions={ effect: self.flow_rate * self._model.hours_per_step * factor.active_data @@ -381,7 +380,7 @@ def _create_shares(self, system_model: SystemModel): target='operation', ) - def _create_bounds_for_load_factor(self, system_model: SystemModel): + def _create_bounds_for_load_factor(self): # TODO: Add Variable load_factor for better evaluation? # eq: var_sumFlowHours <= size * dt_tot * load_factor_max @@ -457,7 +456,7 @@ def __init__(self, model: SystemModel, element: Bus): self.excess_input: Optional[linopy.Variable] = None self.excess_output: Optional[linopy.Variable] = None - def do_modeling(self, system_model: SystemModel) -> None: + def do_modeling(self) -> None: # inputs == outputs inputs = sum([flow.model.flow_rate for flow in self.element.inputs]) outputs = sum([flow.model.flow_rate for flow in self.element.outputs]) @@ -517,7 +516,7 @@ def __init__(self, model: SystemModel, element: Component): self.element: Component = element self.on_off: Optional[OnOffModel] = None - def do_modeling(self, system_model: SystemModel): + def do_modeling(self): """Initiates all FlowModels""" all_flows = self.element.inputs + self.element.outputs if self.element.on_off_parameters: @@ -534,7 +533,7 @@ def do_modeling(self, system_model: SystemModel): self.add(flow.create_model(self._model), flow.label) for sub_model in self.sub_models: - sub_model.do_modeling(self._model) + sub_model.do_modeling() if self.element.on_off_parameters: self.on_off = self.add(OnOffModel( @@ -545,13 +544,13 @@ def do_modeling(self, system_model: SystemModel): defining_bounds=[flow.model.absolute_flow_rate_bounds for flow in all_flows], previous_values=[flow.previous_flow_rate for flow in all_flows])) - self.on_off.do_modeling(self._model) + self.on_off.do_modeling() if self.element.prevent_simultaneous_flows: # Simultanious Useage --> Only One FLow is On at a time, but needs a Binary for every flow on_variables = [flow.model.on_off.on for flow in self.element.prevent_simultaneous_flows] simultaneous_use = self.add(PreventSimultaneousUsageModel(self._model, on_variables, self.label_full)) - simultaneous_use.do_modeling(self._model) + simultaneous_use.do_modeling() def solution_structured( self, diff --git a/flixOpt/features.py b/flixOpt/features.py index d63d97918..798e3e503 100644 --- a/flixOpt/features.py +++ b/flixOpt/features.py @@ -53,7 +53,7 @@ def __init__( self._fixed_relative_profile = fixed_relative_profile self.parameters = parameters - def do_modeling(self, system_model: SystemModel): + def do_modeling(self): if self.parameters.fixed_size and not self.parameters.optional: self.size = self.add(self._model.add_variables( lower=self.parameters.fixed_size, @@ -79,15 +79,14 @@ def do_modeling(self, system_model: SystemModel): # Bounds for defining variable self._create_bounds_for_defining_variable() - self._create_shares(system_model) + self._create_shares() - def _create_shares(self, system_model: SystemModel): + def _create_shares(self): # fix_effects: fix_effects = self.parameters.fix_effects if fix_effects != {}: self._model.effects.add_share_to_effects( - system_model=self._model, name=self.label_of_element, expressions={effect: self.is_invested * factor if self.is_invested is not None else factor for effect, factor in fix_effects.items()}, @@ -97,7 +96,6 @@ def _create_shares(self, system_model: SystemModel): if self.parameters.divest_effects != {} and self.parameters.optional: # share: divest_effects - isInvested * divest_effects self._model.effects.add_share_to_effects( - system_model=self._model, name=self.label_of_element, expressions={effect: -self.is_invested * factor + factor for effect, factor in fix_effects.items()}, target='invest', @@ -105,7 +103,6 @@ def _create_shares(self, system_model: SystemModel): if self.parameters.specific_effects != {}: self._model.effects.add_share_to_effects( - system_model=self._model, name=self.label_of_element, expressions={effect: self.size * factor for effect, factor in self.parameters.specific_effects.items()}, target='invest', @@ -121,7 +118,7 @@ def _create_shares(self, system_model: SystemModel): can_be_outside_segments=self.is_invested), 'segments' ) - self._segments.do_modeling(self._model) + self._segments.do_modeling() def _create_bounds_for_optional_investment(self): if self.parameters.fixed_size: @@ -238,12 +235,12 @@ def __init__( self.switch_off: Optional[linopy.Variable] = None self.switch_on_nr: Optional[linopy.Variable] = None - def do_modeling(self, system_model: SystemModel): + def do_modeling(self): self.on = self.add( self._model.add_variables( name=f'{self.label_full}|on', binary=True, - coords=system_model.coords, + coords=self._model.coords, ), 'on', ) @@ -272,7 +269,7 @@ def do_modeling(self, system_model: SystemModel): self._model.add_variables( name=f'{self.label_full}|off', binary=True, - coords=system_model.coords, + coords=self._model.coords, ), 'off' ) @@ -300,19 +297,19 @@ def do_modeling(self, system_model: SystemModel): if self.parameters.use_switch_on: self.switch_on = self.add(self._model.add_variables( - binary=True, name=f'{self.label_full}|switch_on', coords=system_model.coords),'switch_on') + binary=True, name=f'{self.label_full}|switch_on', coords=self._model.coords),'switch_on') self.switch_off = self.add(self._model.add_variables( - binary=True, name=f'{self.label_full}|switch_off', coords=system_model.coords), 'switch_off') + binary=True, name=f'{self.label_full}|switch_off', coords=self._model.coords), 'switch_off') self.switch_on_nr = self.add(self._model.add_variables( upper=self.parameters.switch_on_total_max if self.parameters.switch_on_total_max is not None else np.inf, name=f'{self.label_full}|switch_on_nr'), 'switch_on_nr') - self._add_switch_constraints(system_model) + self._add_switch_constraints() - self._create_shares(system_model) + self._create_shares() def _add_on_constraints(self): assert self.on is not None, f'On variable of {self.label_full} must be defined to add constraints' @@ -520,7 +517,7 @@ def _get_duration_in_hours( return duration_in_hours - def _add_switch_constraints(self, system_model: SystemModel): + def _add_switch_constraints(self): assert self.switch_on is not None, f'Switch On Variable of {self.label_full} must be defined to add constraints' assert self.switch_off is not None, f'Switch Off Variable of {self.label_full} must be defined to add constraints' assert self.switch_on_nr is not None, ( @@ -569,12 +566,11 @@ def _add_switch_constraints(self, system_model: SystemModel): 'switch_on_nr' ) - def _create_shares(self, system_model: SystemModel): + def _create_shares(self): # Anfahrkosten: effects_per_switch_on = self.parameters.effects_per_switch_on if effects_per_switch_on != {}: self._model.effects.add_share_to_effects( - system_model=self._model, name=self.label_of_element, expressions={effect: self.switch_on * factor for effect, factor in effects_per_switch_on.items()}, target='operation', @@ -584,7 +580,6 @@ def _create_shares(self, system_model: SystemModel): effects_per_running_hour = self.parameters.effects_per_running_hour if effects_per_running_hour != {}: self._model.effects.add_share_to_effects( - system_model=self._model, name=self.label_of_element, expressions={effect: self.on * factor * self._model.hours_per_step for effect, factor in effects_per_running_hour.items()}, @@ -711,25 +706,25 @@ def __init__( self._as_time_series = as_time_series self.sample_points = sample_points - def do_modeling(self, system_model: SystemModel): + def do_modeling(self): self.in_segment = self.add(self._model.add_variables( binary=True, name=f'{self.label_full}|in_segment', - coords=system_model.coords if self._as_time_series else None), + coords=self._model.coords if self._as_time_series else None), 'in_segment' ) self.lambda0 = self.add(self._model.add_variables( lower=0, upper=1, name=f'{self.label_full}|lambda0', - coords=system_model.coords if self._as_time_series else None), + coords=self._model.coords if self._as_time_series else None), 'lambda0' ) self.lambda1 = self.add(self._model.add_variables( lower=0, upper=1, name=f'{self.label_full}|lambda1', - coords=system_model.coords if self._as_time_series else None), + coords=self._model.coords if self._as_time_series else None), 'lambda1' ) @@ -775,7 +770,7 @@ def __init__( self._sample_points = sample_points self._segment_models: List[SegmentModel] = [] - def do_modeling(self, system_model: SystemModel): + def do_modeling(self): restructured_variables_with_segments: List[Dict[str, Tuple[NumericData, NumericData]]] = [ {key: values[i] for key, values in self._sample_points.items()} for i in range(self._nr_of_segments) ] @@ -866,29 +861,29 @@ def __init__( self._max_per_hour = max_per_hour if max_per_hour is not None else np.inf self._min_per_hour = min_per_hour if min_per_hour is not None else -np.inf - def do_modeling(self, system_model: SystemModel): + def do_modeling(self): self.total = self.add( - system_model.add_variables( + self._model.add_variables( lower=self._total_min, upper=self._total_max, coords=None, name=f'{self.label_full}|total' ), 'total' ) # eq: sum = sum(share_i) # skalar - self._eq_total = self.add(system_model.add_constraints(self.total == 0, name=f'{self.label_full}|total'), 'total') + self._eq_total = self.add(self._model.add_constraints(self.total == 0, name=f'{self.label_full}|total'), 'total') if self._shares_are_time_series: self.total_per_timestep = self.add( - system_model.add_variables( - lower=-np.inf if (self._min_per_hour is None) else np.multiply(self._min_per_hour, system_model.hours_per_step), - upper=np.inf if (self._max_per_hour is None) else np.multiply(self._max_per_hour, system_model.hours_per_step), - coords=system_model.coords, + self._model.add_variables( + lower=-np.inf if (self._min_per_hour is None) else np.multiply(self._min_per_hour, self._model.hours_per_step), + upper=np.inf if (self._max_per_hour is None) else np.multiply(self._max_per_hour, self._model.hours_per_step), + coords=self._model.coords, name=f'{self.label_full}|total_per_timestep' ), 'total_per_timestep' ) self._eq_total_per_timestep = self.add( - system_model.add_constraints(self.total_per_timestep == 0, name=f'{self.label_full}|total_per_timestep'), + self._model.add_constraints(self.total_per_timestep == 0, name=f'{self.label_full}|total_per_timestep'), 'total_per_timestep' ) @@ -897,7 +892,6 @@ def do_modeling(self, system_model: SystemModel): def add_share( self, - system_model: SystemModel, name: str, expression: linopy.LinearExpression, ): @@ -920,14 +914,14 @@ def add_share( self.share_constraints[name].lhs -= expression else: self.shares[name] = self.add( - system_model.add_variables( - coords=None if isinstance(expression, linopy.LinearExpression) and expression.ndim == 0 or not isinstance(expression, linopy.LinearExpression) else system_model.coords, + self._model.add_variables( + coords=None if isinstance(expression, linopy.LinearExpression) and expression.ndim == 0 or not isinstance(expression, linopy.LinearExpression) else self._model.coords, name=f'{name}->{self.label_full}' ), name ) self.share_constraints[name] = self.add( - system_model.add_constraints( + self._model.add_constraints( self.shares[name] == expression, name=f'{name}->{self.label_full}' ), name @@ -990,7 +984,7 @@ def __init__( self._segments_model: Optional[MultipleSegmentsModel] = None self._as_tme_series: bool = 'time' in self._variable_segments[0].indexes - def do_modeling(self, system_model: SystemModel): + def do_modeling(self): self._shares = { effect: self.add(self._model.add_variables( coords=self._model.coords if self._as_tme_series else None, @@ -1014,11 +1008,10 @@ def do_modeling(self, system_model: SystemModel): as_time_series=self._as_tme_series), 'segments' ) - self._segments_model.do_modeling(system_model) + self._segments_model.do_modeling() # Shares self._model.effects.add_share_to_effects( - system_model=self._model, name=self.label_of_element, expressions={effect: variable*1 for effect, variable in self._shares.items()}, target='operation' if self._as_tme_series else 'invest', @@ -1050,7 +1043,6 @@ def __init__(self, model: SystemModel, variables: List[linopy.Variable], label_o for variable in self._simultanious_use_variables: # classic assert variable.attrs['binary'], f'Variable {variable} must be binary for use in {self.__class__.__name__}' - def do_modeling(self, system_model: SystemModel): # eq: sum(flow_i.on(t)) <= 1.1 (1 wird etwas größer gewählt wg. Binärvariablengenauigkeit) self.add(self._model.add_constraints(sum(self._simultanious_use_variables) <= 1.1, name=f'{self.label_full}|prevent_simultaneous_use'), diff --git a/flixOpt/structure.py b/flixOpt/structure.py index 2f2fde07b..0825cfe23 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -42,13 +42,13 @@ def __init__(self, flow_system: 'FlowSystem'): def do_modeling(self): from .effects import EffectCollection self.effects = EffectCollection(self, list(self.flow_system.effects.values())) - self.effects.do_modeling(self) + self.effects.do_modeling() component_models = [component.create_model(self) for component in self.flow_system.components.values()] bus_models = [bus.create_model(self) for bus in self.flow_system.buses.values()] for component_model in component_models: - component_model.do_modeling(self) + component_model.do_modeling() for bus_model in bus_models: # Buses after Components, because FlowModels are created in ComponentModels - bus_model.do_modeling(self) + bus_model.do_modeling() self.add_objective( self.effects.objective_effect.model.total + self.effects.penalty.total @@ -373,6 +373,9 @@ def __init__(self, model: SystemModel, label_of_element: str, label: Optional[st self._sub_models_short: Dict[str, str] = {} logger.debug(f'Created {self.__class__.__name__} "{self._label}"') + def do_modeling(self): + raise NotImplementedError('Every Model needs a do_modeling() method') + def add( self, item: Union[linopy.Variable, linopy.Constraint, 'Model'], From 7f17d7bf90af9ca1ad2a893f600e7142e21fbdea Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 23 Feb 2025 12:34:15 +0100 Subject: [PATCH 203/507] Bugfix --- flixOpt/features.py | 3 ++- tests/test_timeseries.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/flixOpt/features.py b/flixOpt/features.py index 798e3e503..48449e3ab 100644 --- a/flixOpt/features.py +++ b/flixOpt/features.py @@ -788,7 +788,7 @@ def do_modeling(self): ] for segment_model in self._segment_models: - segment_model.do_modeling(system_model) + segment_model.do_modeling() # eq: - v(t) + (v_0_0 * lambda_0_0 + v_0_1 * lambda_0_1) + (v_1_0 * lambda_1_0 + v_1_1 * lambda_1_1) ... = 0 # -> v_0_0, v_0_1 = Stützstellen des Segments 0 @@ -1043,6 +1043,7 @@ def __init__(self, model: SystemModel, variables: List[linopy.Variable], label_o for variable in self._simultanious_use_variables: # classic assert variable.attrs['binary'], f'Variable {variable} must be binary for use in {self.__class__.__name__}' + def do_modeling(self): # eq: sum(flow_i.on(t)) <= 1.1 (1 wird etwas größer gewählt wg. Binärvariablengenauigkeit) self.add(self._model.add_constraints(sum(self._simultanious_use_variables) <= 1.1, name=f'{self.label_full}|prevent_simultaneous_use'), diff --git a/tests/test_timeseries.py b/tests/test_timeseries.py index 36b9e98ab..8fe4bd113 100644 --- a/tests/test_timeseries.py +++ b/tests/test_timeseries.py @@ -139,7 +139,7 @@ def test_operations_with_linopy(): (expr + timeseries1) / timeseries1 expr = var1 * timeseries1 - con = m.add_constraints((expr * timeseries1) <= 10) + m.add_constraints((expr * timeseries1) <= 10) if __name__ == "__main__": From 24d6ce2cabfb094fdf3ac8f074a444ac32431527 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 23 Feb 2025 13:45:17 +0100 Subject: [PATCH 204/507] Remove old function --- flixOpt/__init__.py | 1 - flixOpt/commons.py | 3 +-- flixOpt/flow_system.py | 59 ------------------------------------------ 3 files changed, 1 insertion(+), 62 deletions(-) diff --git a/flixOpt/__init__.py b/flixOpt/__init__.py index 4ae00829a..9550d89f8 100644 --- a/flixOpt/__init__.py +++ b/flixOpt/__init__.py @@ -22,7 +22,6 @@ TimeSeriesData, Transmission, change_logging_level, - create_datetime_array, linear_converters, plotting, results, diff --git a/flixOpt/commons.py b/flixOpt/commons.py index dfbf196e7..d88a882ed 100644 --- a/flixOpt/commons.py +++ b/flixOpt/commons.py @@ -17,7 +17,7 @@ from .core import TimeSeriesData from .effects import Effect from .elements import Bus, Flow -from .flow_system import FlowSystem, create_datetime_array +from .flow_system import FlowSystem from .interface import InvestParameters, OnOffParameters __all__ = [ @@ -34,7 +34,6 @@ 'LinearConverter', 'Transmission', 'FlowSystem', - 'create_datetime_array', 'FullCalculation', 'SegmentedCalculation', 'AggregatedCalculation', diff --git a/flixOpt/flow_system.py b/flixOpt/flow_system.py index f3084382d..3a7f60c12 100644 --- a/flixOpt/flow_system.py +++ b/flixOpt/flow_system.py @@ -293,62 +293,3 @@ def coords(self): @property def coords_extra(self): return [self.periods, self.timesteps_extra] if self.periods is not None else [self.timesteps_extra] - - -def create_datetime_array( - start: str, steps: Optional[int] = None, freq: str = '1h', end: Optional[str] = None -) -> np.ndarray[np.datetime64]: - """ - Create a NumPy array with datetime64 values. - - Parameters - ---------- - start : str - Start date in 'YYYY-MM-DD' format or a full timestamp (e.g., 'YYYY-MM-DD HH:MM'). - steps : int, optional - Number of steps in the datetime array. If `end` is provided, `steps` is ignored. - freq : str, optional - Frequency for the datetime64 array. Supports flexible intervals: - - 'Y', 'M', 'W', 'D', 'h', 'm', 's' (e.g., '1h', '15m', '2h'). - Defaults to 'h' (hourly). - end : str, optional - End date in 'YYYY-MM-DD' format or a full timestamp (e.g., 'YYYY-MM-DD HH:MM'). - If provided, the function generates an array from `start` to `end` using `freq`. - - Returns - ------- - np.ndarray - NumPy array of datetime64 values. - - Examples - -------- - Create an array with 15-minute intervals: - >>> create_datetime_array('2023-01-01', steps=5, freq='15m') - array(['2023-01-01T00:00', '2023-01-01T00:15', '2023-01-01T00:30', ...], dtype='datetime64[m]') - - Create 2-hour intervals: - >>> create_datetime_array('2023-01-01T00', steps=4, freq='2h') - array(['2023-01-01T00', '2023-01-01T02', '2023-01-01T04', ...], dtype='datetime64[h]') - - Generate minute intervals until a specified end time: - >>> create_datetime_array('2023-01-01T00:00', end='2023-01-01T01:00', freq='m') - array(['2023-01-01T00:00', '2023-01-01T00:01', ..., '2023-01-01T00:59'], dtype='datetime64[m]') - """ - # Parse the frequency and interval - unit = freq[-1] # Get the time unit (e.g., 'h', 'm', 's') - interval = int(freq[:-1]) if freq[:-1].isdigit() else 1 # Default to interval=1 if not specified - step_size = np.timedelta64(interval, unit) # Create the timedelta step size - - # Convert the start time to a datetime64 object - start_dt = np.datetime64(start) - - # Generate the array based on the parameters - if end: # If `end` is specified, create a range from start to end - end_dt = np.datetime64(end) - return np.arange(start_dt, end_dt, step_size) - - elif steps: # If `steps` is specified, create a range with the given number of steps - return np.array([start_dt + i * step_size for i in range(steps)], dtype='datetime64') - - else: # If neither `steps` nor `end` is provided, raise an error - raise ValueError('Either `steps` or `end` must be provided.') From e802aa738c3424b4e51321e22bae84a8944ca05e Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 23 Feb 2025 14:52:54 +0100 Subject: [PATCH 205/507] Save TimeSeriesCollection in SystemModel Remove some parameters for active timesteps from FlowSystem --- flixOpt/core.py | 32 ++++++++++++++++---------------- flixOpt/flow_system.py | 33 +++------------------------------ flixOpt/structure.py | 10 ++++++---- 3 files changed, 25 insertions(+), 50 deletions(-) diff --git a/flixOpt/core.py b/flixOpt/core.py index 1d1f0bbbb..e7ebfbebe 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -372,11 +372,11 @@ def __init__( periods: Optional[List[int]] ): ( - self._timesteps, - self._timesteps_extra, - self._hours_per_timestep, - self._hours_of_previous_timesteps, - self._periods + self.all_timesteps, + self.all_timesteps_extra, + self.all_hours_per_timestep, + self.hours_of_previous_timesteps, + self.all_periods ) = TimeSeriesCollection.align_dimensions(timesteps, periods, hours_of_last_timestep, @@ -467,12 +467,12 @@ def activate_indices(self, if active_timesteps is None and active_periods is None: return self.reset() - active_timesteps = active_timesteps if active_timesteps is not None else self._timesteps - active_periods = active_periods if active_periods is not None else self._periods + active_timesteps = active_timesteps if active_timesteps is not None else self.all_timesteps + active_periods = active_periods if active_periods is not None else self.all_periods - if not np.all(active_timesteps.isin(self._timesteps)): + if not np.all(active_timesteps.isin(self.all_timesteps)): raise ValueError('active_timesteps must be a subset of the timesteps of the TimeSeriesCollection') - if active_periods is not None and not np.all(active_periods.isin(self._periods)): + if active_periods is not None and not np.all(active_periods.isin(self.all_periods)): raise ValueError('active_periods must be a subset of the periods of the TimeSeriesCollection') ( @@ -482,7 +482,7 @@ def activate_indices(self, _, self._active_periods ) = TimeSeriesCollection.align_dimensions( - active_timesteps, active_periods, self.hours_of_last_timestep, self._hours_of_previous_timesteps + active_timesteps, active_periods, self.hours_of_last_timestep, self.hours_of_previous_timesteps ) self._activate_timeserieses() @@ -694,26 +694,26 @@ def coords_extra(self) -> Union[Tuple[pd.Index, pd.DatetimeIndex], Tuple[pd.Date @property def timesteps(self) -> pd.DatetimeIndex: - return self._timesteps if self._active_timesteps is None else self._active_timesteps + return self.all_timesteps if self._active_timesteps is None else self._active_timesteps @property def timesteps_extra(self) -> pd.DatetimeIndex: - return self._timesteps_extra if self._active_timesteps_extra is None else self._active_timesteps_extra + return self.all_timesteps_extra if self._active_timesteps_extra is None else self._active_timesteps_extra @property def periods(self) -> pd.Index: - return self._periods if self._active_periods is None else self._active_periods + return self.all_periods if self._active_periods is None else self._active_periods @property def hours_per_timestep(self) -> xr.DataArray: - return self._hours_per_timestep if self._active_hours_per_timestep is None else self._active_hours_per_timestep + return self.all_hours_per_timestep if self._active_hours_per_timestep is None else self._active_hours_per_timestep @property def hours_of_last_timestep(self) -> float: return self.hours_per_timestep[-1].item() def __repr__(self): - timestep_range = f"{self._timesteps[0]} to {self._timesteps[-1]}" if len(self.timesteps) > 1 else str( + timestep_range = f"{self.all_timesteps[0]} to {self.all_timesteps[-1]}" if len(self.timesteps) > 1 else str( self.timesteps[0]) periods_str = f"Periods: {len(self.periods)}" if self.periods is not None else "No periods" time_series_count = len(self.time_series_data) @@ -724,7 +724,7 @@ def __repr__(self): f" timesteps={timestep_range},\n" f" active_timesteps={np.array(self._active_timesteps) if self._active_timesteps is not None else 'None'}\n" f" hours_of_last_timestep={self.hours_of_last_timestep},\n" - f" hours_per_timestep={get_numeric_stats(self._hours_per_timestep)},\n" + f" hours_per_timestep={get_numeric_stats(self.hours_per_timestep)},\n" f" nr_of_periods={len(self.periods) if self.periods is not None else 'None'},\n" f" periods={periods_str},\n" f" active_periods={self._active_periods if self._active_periods is not None else 'None'}\n" diff --git a/flixOpt/flow_system.py b/flixOpt/flow_system.py index 3a7f60c12..3ff82aa12 100644 --- a/flixOpt/flow_system.py +++ b/flixOpt/flow_system.py @@ -156,6 +156,7 @@ def to_json(self, path: Union[str, pathlib.Path]): json.dump(data, f, indent=4, ensure_ascii=False) def results(self): + #TODO: Remove this function, as access through the FLowSystem is not correct if another calucaltion was made. return { 'Components': { comp.label: comp.model.solution_structured(mode='numpy') @@ -169,8 +170,8 @@ def results(self): effect.label: effect.model.solution_structured(mode='numpy') for effect in sorted(self.effects.values(), key=lambda effect: effect.label.upper()) }, - 'Time': self.timesteps_extra.tolist(), - 'Time intervals in hours': self.hours_per_step, + 'Time': self.time_series_collection.timesteps_extra.tolist(), + 'Time intervals in hours': self.time_series_collection.hours_per_timestep, } def visualize_network( @@ -265,31 +266,3 @@ def all_elements(self) -> Dict[str, Element]: @property def all_time_series(self) -> List[TimeSeries]: return [ts for element in self.all_elements.values() for ts in element.used_time_series] - - @property - def hours_of_previous_timesteps(self): - return self.time_series_collection.hours_of_previous_timesteps - - @property - def timesteps(self): - return self.time_series_collection.timesteps - - @property - def timesteps_extra(self): - return self.time_series_collection.timesteps_extra - - @property - def periods(self): - return self.time_series_collection.periods - - @property - def hours_per_step(self): #TODO: Rename to hours_per_timestep - return self.time_series_collection.hours_per_timestep - - @property - def coords(self): - return [self.periods, self.timesteps] if self.periods is not None else [self.timesteps] - - @property - def coords_extra(self): - return [self.periods, self.timesteps_extra] if self.periods is not None else [self.timesteps_extra] diff --git a/flixOpt/structure.py b/flixOpt/structure.py index 0825cfe23..f47cb7f53 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -34,6 +34,8 @@ class SystemModel(linopy.Model): def __init__(self, flow_system: 'FlowSystem'): super().__init__(force_dim_names=True) self.flow_system = flow_system + self.time_series_collection = flow_system.time_series_collection + self.effects: Optional[EffectCollection] = None self._solution_structure = None @@ -167,19 +169,19 @@ def infos(self) -> Dict: @property def hours_per_step(self): - return self.flow_system.hours_per_step + return self.time_series_collection.hours_per_timestep @property def hours_of_previous_timesteps(self): - return self.flow_system.hours_of_previous_timesteps + return self.time_series_collection.hours_of_previous_timesteps @property def coords(self): - return self.flow_system.coords + return self.time_series_collection.coords @property def coords_extra(self): - return self.flow_system.coords_extra + return self.time_series_collection.coords_extra class Interface: From d650275d618204565f6b8795102b3625d136e27f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 23 Feb 2025 15:02:46 +0100 Subject: [PATCH 206/507] Bugfixes --- flixOpt/calculation.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py index ed0cc2820..3f511f6fc 100644 --- a/flixOpt/calculation.py +++ b/flixOpt/calculation.py @@ -92,7 +92,7 @@ def _define_path_names(self, save_results: Union[bool, str, pathlib.Path], inclu def _save_solve_infos(self): t_start = timeit.default_timer() - indent = 4 if len(self.flow_system.timesteps) < 50 else None + indent = 4 if len(self.flow_system.time_series_collection.timesteps) < 50 else None with open(self._paths['results'], 'w', encoding='utf-8') as f: results = copy_and_convert_datatypes(self.results, use_numpy=False, use_element_label=False) json.dump(results, f, indent=indent) @@ -197,7 +197,7 @@ def __init__( flow_system: FlowSystem, aggregation_parameters: AggregationParameters, components_to_clusterize: Optional[List[Component]] = None, - active_timesteps: Optional[Union[List[int], pd.DatetimeIndex]] = None, + active_timesteps: Optional[pd.DatetimeIndex] = None, ): """ Class for Optimizing the FLowSystem including: @@ -207,20 +207,18 @@ def __init__( ---------- name : str name of calculation + flow_system : FlowSystem + flow_system which should be calculated aggregation_parameters : AggregationParameters Parameters for aggregation. See documentation of AggregationParameters class. components_to_clusterize: List[Component] or None List of Components to perform aggregation on. If None, then all components are aggregated. This means, teh variables in the components are equalized to each other, according to the typical periods computed in the DataAggregation - flow_system : FlowSystem - flow_system which should be calculated - modeling_language : 'pyomo', 'linopy' - choose optimization modeling language - time_indices : List[int] or None + active_timesteps : pd.DatetimeIndex or None list with indices, which should be used for calculation. If None, then all timesteps are used. """ - if flow_system.periods is not None: + if flow_system.time_series_collection.periods is not None: raise NotImplementedError('Multiple Periods are currently not supported in AggregatedCalculation') super().__init__(name, flow_system, active_timesteps) self.aggregation_parameters = aggregation_parameters @@ -250,14 +248,14 @@ def _perform_aggregation(self): t_start_agg = timeit.default_timer() # Validation - dt_min, dt_max = np.min(self.flow_system.hours_per_step), np.max(self.flow_system.hours_per_step) + dt_min, dt_max = np.min(self.flow_system.time_series_collection.hours_per_step), np.max(self.flow_system.time_series_collection.hours_per_step) 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_step.max() - is_integer = (self.aggregation_parameters.hours_per_period % self.flow_system.hours_per_step.max()).item() == 0 + steps_per_period = self.aggregation_parameters.hours_per_period / self.flow_system.time_series_collection.hours_per_timestep.max() + is_integer = (self.aggregation_parameters.hours_per_period % self.flow_system.time_series_collection.hours_per_timestep.max()).item() == 0 if not (steps_per_period.size == 1 and is_integer): raise Exception( f'The selected {self.aggregation_parameters.hours_per_period=} does not match the time ' From 23d4bb4314566b6c9ea26399e9a0a6a2bf523e8f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 23 Feb 2025 15:59:02 +0100 Subject: [PATCH 207/507] Bugfixes --- flixOpt/calculation.py | 1 - 1 file changed, 1 deletion(-) diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py index 3f511f6fc..d4c66bd81 100644 --- a/flixOpt/calculation.py +++ b/flixOpt/calculation.py @@ -165,7 +165,6 @@ def solve(self, self.model.solve(log_fn=self._paths['log'], solver_name=solver.name, **solver.options) - self.model.store_solution() self._results = self.flow_system.results() self.durations['solving'] = round(timeit.default_timer() - t_start, 2) From 10ae31e5a99d884043bc659fb766b82c9d908cbf Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 23 Feb 2025 15:59:16 +0100 Subject: [PATCH 208/507] Try out differnet saving strategies --- flixOpt/elements.py | 2 + flixOpt/io.py | 88 +++++++++++++++++++++++++++++++++++++++ flixOpt/results_linopy.py | 76 +++++++++++++++++++++++++++++++++ flixOpt/structure.py | 76 ++++----------------------------- 4 files changed, 174 insertions(+), 68 deletions(-) create mode 100644 flixOpt/io.py create mode 100644 flixOpt/results_linopy.py diff --git a/flixOpt/elements.py b/flixOpt/elements.py index 829960a12..0889d847b 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -458,6 +458,8 @@ def __init__(self, model: SystemModel, element: Bus): def do_modeling(self) -> None: # inputs == outputs + for flow in self.element.inputs + self.element.outputs: + self.add(flow.model.flow_rate, flow.label_full) inputs = sum([flow.model.flow_rate for flow in self.element.inputs]) outputs = sum([flow.model.flow_rate for flow in self.element.outputs]) eq_bus_balance = self.add(self._model.add_constraints( diff --git a/flixOpt/io.py b/flixOpt/io.py new file mode 100644 index 000000000..dcb8dde6e --- /dev/null +++ b/flixOpt/io.py @@ -0,0 +1,88 @@ +import linopy +import json +import pathlib +import xarray as xr +from typing import Dict, Union +import logging + + +logger = logging.getLogger('flixOpt') + + +def _solution_structure_basic(self): + return { + 'Buses': { + bus.label_full: [f':::{var}' for var in bus.model.variables] + for bus in sorted(self.flow_system.buses.values(), key=lambda bus: bus.label_full.upper()) + }, + 'Components': { + comp.label_full: [f':::{var}' for var in comp.model.variables] + for comp in sorted(self.flow_system.components.values(), key=lambda component: component.label_full.upper()) + }, + 'Effects': { + effect.label_full: [f':::{var}' for var in effect.model.variables] + for effect in sorted(self.flow_system.effects.values(), key=lambda effect: effect.label_full.upper()) + }, + 'Penalty': float(self.effects.penalty.total.solution.values), + 'Objective': self.objective.value + } + + +def model_to_netcdf(model: linopy.Model, path: Union[str, pathlib.Path] = 'system_model.nc', *args, **kwargs): + """ + Save the linopy model to a netcdf file. + """ + model.to_netcdf(path, *args, **kwargs) + logger.info(f'Saved linopy model to {path}') + + +def solution_to_netcdf(self, path: Union[str, pathlib.Path] = 'system_model.nc'): + """ + Save the model to a netcdf file. + """ + ds = self.solution + ds = ds.rename_vars({var: var.replace('/', '-slash-') for var in ds.data_vars}) + ds.attrs["structure"] = json.dumps(self._solution_structure_basic()) # Convert dict to JSON string + ds.to_netcdf(path) + + +def model_from_netcdf(path: Union[str, pathlib.Path] = 'system_model.nc', *args, **kwargs) -> linopy.Model: + """ + Read a linopy model from a netcdf file. + """ + return linopy.read_netcdf(path) + + +def solution_from_netcdf(path: Union[str, pathlib.Path] = 'flow_system.nc') -> Dict[str, Union[str, Dict, xr.DataArray]]: + """ + Load a linopy model from a netcdf file. + """ + results = xr.open_dataset(path) + return { + **_insert_dataarrays(results, json.loads(results.attrs['structure'])), + 'Solution': results + } + + +def _insert_dataarrays(dataset: xr.Dataset, structure: Dict[str, Union[str, Dict]]): + dataset = dataset.rename_vars({var: var.replace('-slash-', '/') for var in dataset.data_vars}) + result = {} + + def insert_data(value_part): + if isinstance(value_part, dict): # If the value is another nested dictionary + return _insert_dataarrays(dataset, value_part) # Recursively handle it + elif isinstance(value_part, list): + return [insert_data(v) for v in value_part] + elif isinstance(value_part, str) and value_part.startswith(':::'): + return dataset[value_part.removeprefix(':::')] + elif isinstance(value_part, str): + return value_part + elif isinstance(value_part, (int, float)): + return value_part + else: + raise ValueError(f'Loading the Dataset failed. Not able to handle {value_part}') + + for key, value in structure.items(): + result[key] = insert_data(value) + + return result diff --git a/flixOpt/results_linopy.py b/flixOpt/results_linopy.py new file mode 100644 index 000000000..97c6c683b --- /dev/null +++ b/flixOpt/results_linopy.py @@ -0,0 +1,76 @@ +import linopy +import json +import pathlib +import xarray as xr +from typing import Dict, Union, List, Literal +import logging + +from pyparsing import Literal + + +class CalculationResults: + def __init__(self, model: linopy.Model, flow_system_structure: Dict[str, Dict[str, str]]): + self.model = model + self._flow_system_structure = flow_system_structure + self.components = {label: ComponentResults(self, label, variables, constraints) + for label, variables, constraints in flow_system_structure['Components'].items()} + + +class ElementResults: + def __init__(self, + calculation_results: CalculationResults, + label: str, + variables: List[str], + constraints: List[str]): + self._calculation_results = calculation_results + self.label = label + self._variables = variables + self._constraints = constraints + + self.variables = self._calculation_results.model.variables[self._variables] + self.constraints = self._calculation_results.model.constraints[self._constraints] + +class _NodeResults(ElementResults): + def __init__(self, + calculation_results: CalculationResults, + label: str, + variables: List[str], + constraints: List[str], + inputs: Dict[str, xr.DataArray], + outputs: Dict[str, xr.DataArray]): + super().__init__(calculation_results, label, variables, constraints) + self.inputs = inputs + self.outputs = outputs + + +class BusResults(_NodeResults): + def __init__(self, + calculation_results: CalculationResults, + label: str, + variables: List[str], + constraints: List[str], + inputs: Dict[str, xr.DataArray], + outputs: Dict[str, xr.DataArray] + ): + super().__init__(calculation_results, label, variables, constraints, inputs, outputs) + + +class ComponentResults(_NodeResults): + def __init__(self, + calculation_results: CalculationResults, + label: str, + variables: List[str], + constraints: List[str], + inputs: Dict[str, xr.DataArray], + outputs: Dict[str, xr.DataArray] + ): + super().__init__(calculation_results, label, variables, constraints, inputs, outputs) + + +class EffectResults(ElementResults): + def __init__(self, + calculation_results: CalculationResults, + label: str, + variables: List[str], + constraints: List[str]): + super().__init__(calculation_results, label, variables, constraints) diff --git a/flixOpt/structure.py b/flixOpt/structure.py index f47cb7f53..5fe370892 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -35,12 +35,8 @@ def __init__(self, flow_system: 'FlowSystem'): super().__init__(force_dim_names=True) self.flow_system = flow_system self.time_series_collection = flow_system.time_series_collection - self.effects: Optional[EffectCollection] = None - self._solution_structure = None - self.solution_structured = None - def do_modeling(self): from .effects import EffectCollection self.effects = EffectCollection(self, list(self.flow_system.effects.values())) @@ -56,70 +52,6 @@ def do_modeling(self): self.effects.objective_effect.model.total + self.effects.penalty.total ) - def store_solution(self): - self._solution_structure = self._get_solution_structured(mode='structure') - solution = self.variables.solution - self.solution_structured = SystemModel._insert_dataarrays(solution, self._solution_structure) - - def _get_solution_structured(self, mode: Literal['py', 'numpy', 'xarray', 'structure'] = 'numpy'): - return { - 'Buses': { - bus.label_full: bus.model.solution_structured(mode=mode) - for bus in sorted(self.flow_system.buses.values(), key=lambda bus: bus.label_full.upper()) - }, - 'Components': { - comp.label_full: comp.model.solution_structured(mode=mode) - for comp in sorted(self.flow_system.components.values(), key=lambda component: component.label_full.upper()) - }, - 'Effects': { - effect.label_full: effect.model.solution_structured(mode=mode) - for effect in sorted(self.flow_system.effects.values(), key=lambda effect: effect.label_full.upper()) - }, - **self.effects.solution_structured(mode=mode), - 'Objective': self.objective.value, - } - - def to_netcdf(self, path: Union[str, pathlib.Path] = 'flow_system.nc'): - """ - Save the flow system to a netcdf file. - """ - ds = self.solution - ds = ds.rename_vars({var: var.replace('/', '-slash-') for var in ds.data_vars}) - ds.attrs["structure"] = json.dumps(self._solution_structure) # Convert dict to JSON string - ds.to_netcdf(path) - - @staticmethod - def from_netcdf(path: Union[str, pathlib.Path] = 'flow_system.nc') -> Dict[str, Union[str, Dict, xr.DataArray]]: - results = xr.open_dataset(path) - return { - **SystemModel._insert_dataarrays(results, json.loads(results.attrs['structure'])), - 'Solution': results - } - - @staticmethod - def _insert_dataarrays(dataset: xr.Dataset, structure: Dict[str, Union[str, Dict]]): - dataset = dataset.rename_vars({var: var.replace('-slash-', '/') for var in dataset.data_vars}) - result = {} - - def insert_data(value_part): - if isinstance(value_part, dict): # If the value is another nested dictionary - return SystemModel._insert_dataarrays(dataset, value_part) # Recursively handle it - elif isinstance(value_part, list): - return [insert_data(v) for v in value_part] - elif isinstance(value_part, str) and value_part.startswith(':::'): - return dataset[value_part.removeprefix(':::')] - elif isinstance(value_part, str): - return value_part - elif isinstance(value_part, (int, float)): - return value_part - else: - raise ValueError(f'Loading the Dataset failed. Not able to handle {value_part}') - - for key, value in structure.items(): - result[key] = insert_data(value) - - return result - @property def main_results(self) -> Dict[str, Union[Scalar, Dict]]: from flixOpt.features import InvestmentModel @@ -564,6 +496,14 @@ def __init__(self, model: SystemModel, element: Element): super().__init__(model, label_of_element=element.label_full, label=element.label, label_full=element.label_full) self.element = element + def results_structure(self): + return { + 'label': self.label, + 'label_full': self.label_full, + 'variables': list(self.variables), + 'constraints': list(self.constraints), + } + def copy_and_convert_datatypes(data: Any, use_numpy: bool = True, use_element_label: bool = False) -> Any: """ From e82fead5741173840f06022db99f1cafce9a6382 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 23 Feb 2025 16:31:26 +0100 Subject: [PATCH 209/507] Get saving working --- flixOpt/__init__.py | 1 + flixOpt/calculation.py | 17 +++++++++ flixOpt/commons.py | 3 +- flixOpt/elements.py | 10 +++++ flixOpt/io.py | 78 ++++++++------------------------------- flixOpt/results_linopy.py | 16 ++++++-- 6 files changed, 57 insertions(+), 68 deletions(-) diff --git a/flixOpt/__init__.py b/flixOpt/__init__.py index 9550d89f8..2c0a299be 100644 --- a/flixOpt/__init__.py +++ b/flixOpt/__init__.py @@ -25,6 +25,7 @@ linear_converters, plotting, results, + results_linopy, solvers, ) diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py index d4c66bd81..53703344e 100644 --- a/flixOpt/calculation.py +++ b/flixOpt/calculation.py @@ -127,6 +127,23 @@ def _save_solve_infos(self): logger.info(f'Saving calculation to .json took {self.durations["saving"]:>8.2f} seconds') logger.info(f'Saving calculation to .yaml took {(timeit.default_timer() - t_start):>8.2f} seconds') + def save_linopy(self, folder: Optional[Union[str, pathlib.Path]] = None): + """ + Save the calculation to file. + """ + from .io import structure_to_json, model_to_netcdf + folder = pathlib.Path(folder) if folder is not None else pathlib.Path.cwd() / 'results' + path = folder / self.name + if not folder.exists(): + try: + folder.mkdir() + except FileNotFoundError: + raise FileNotFoundError(f'Parent directory of {path} does not exist.' + f'Please create the directory or specify a valid path.') + model_to_netcdf(self.model, path.with_suffix('.nc')) + structure_to_json(self.flow_system, path.with_suffix('.json')) + logger.info(f'Saved calculation to {path}') + @property def results(self): return self._results diff --git a/flixOpt/commons.py b/flixOpt/commons.py index d88a882ed..35287dc31 100644 --- a/flixOpt/commons.py +++ b/flixOpt/commons.py @@ -2,7 +2,7 @@ This module makes the commonly used classes and functions available in the flixOpt framework. """ -from . import linear_converters, plotting, results, solvers +from . import linear_converters, plotting, results, solvers, results_linopy from .aggregation import AggregationParameters from .calculation import AggregatedCalculation, FullCalculation, SegmentedCalculation from .components import ( @@ -44,4 +44,5 @@ 'results', 'linear_converters', 'solvers', + 'results_linopy', ] diff --git a/flixOpt/elements.py b/flixOpt/elements.py index 0889d847b..fcc5fad60 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -489,6 +489,11 @@ def do_modeling(self) -> None: self._model, self.label_of_element, (self.excess_output * excess_penalty).sum() ) + def results_structure(self): + return {**super().results_structure(), + 'inputs': [flow.label for flow in self.element.inputs], + 'outputs': [flow.label for flow in self.element.outputs]} + def solution_structured( self, mode: Literal['py', 'numpy', 'xarray', 'structure'] = 'py', @@ -554,6 +559,11 @@ def do_modeling(self): simultaneous_use = self.add(PreventSimultaneousUsageModel(self._model, on_variables, self.label_full)) simultaneous_use.do_modeling() + def results_structure(self): + return {**super().results_structure(), + 'inputs': [flow.label for flow in self.element.inputs], + 'outputs': [flow.label for flow in self.element.outputs]} + def solution_structured( self, mode: Literal['py', 'numpy', 'xarray', 'structure'] = 'py', diff --git a/flixOpt/io.py b/flixOpt/io.py index dcb8dde6e..e03b8a785 100644 --- a/flixOpt/io.py +++ b/flixOpt/io.py @@ -5,26 +5,26 @@ from typing import Dict, Union import logging +from .flow_system import FlowSystem + logger = logging.getLogger('flixOpt') -def _solution_structure_basic(self): +def _results_structure(flow_system: FlowSystem) -> Dict[str, Dict[str, str]]: return { - 'Buses': { - bus.label_full: [f':::{var}' for var in bus.model.variables] - for bus in sorted(self.flow_system.buses.values(), key=lambda bus: bus.label_full.upper()) - }, 'Components': { - comp.label_full: [f':::{var}' for var in comp.model.variables] - for comp in sorted(self.flow_system.components.values(), key=lambda component: component.label_full.upper()) + comp.label_full: comp.model.results_structure() + for comp in sorted(flow_system.components.values(), key=lambda component: component.label_full.upper()) }, - 'Effects': { - effect.label_full: [f':::{var}' for var in effect.model.variables] - for effect in sorted(self.flow_system.effects.values(), key=lambda effect: effect.label_full.upper()) + 'Buses': { + bus.label_full: bus.model.results_structure() + for bus in sorted(flow_system.buses.values(), key=lambda bus: bus.label_full.upper()) }, - 'Penalty': float(self.effects.penalty.total.solution.values), - 'Objective': self.objective.value + 'Effects': { + effect.label_full: effect.model.results_structure() + for effect in sorted(flow_system.effects.values(), key=lambda effect: effect.label_full.upper()) + } } @@ -33,56 +33,8 @@ def model_to_netcdf(model: linopy.Model, path: Union[str, pathlib.Path] = 'syste Save the linopy model to a netcdf file. """ model.to_netcdf(path, *args, **kwargs) - logger.info(f'Saved linopy model to {path}') - - -def solution_to_netcdf(self, path: Union[str, pathlib.Path] = 'system_model.nc'): - """ - Save the model to a netcdf file. - """ - ds = self.solution - ds = ds.rename_vars({var: var.replace('/', '-slash-') for var in ds.data_vars}) - ds.attrs["structure"] = json.dumps(self._solution_structure_basic()) # Convert dict to JSON string - ds.to_netcdf(path) - - -def model_from_netcdf(path: Union[str, pathlib.Path] = 'system_model.nc', *args, **kwargs) -> linopy.Model: - """ - Read a linopy model from a netcdf file. - """ - return linopy.read_netcdf(path) - - -def solution_from_netcdf(path: Union[str, pathlib.Path] = 'flow_system.nc') -> Dict[str, Union[str, Dict, xr.DataArray]]: - """ - Load a linopy model from a netcdf file. - """ - results = xr.open_dataset(path) - return { - **_insert_dataarrays(results, json.loads(results.attrs['structure'])), - 'Solution': results - } - - -def _insert_dataarrays(dataset: xr.Dataset, structure: Dict[str, Union[str, Dict]]): - dataset = dataset.rename_vars({var: var.replace('-slash-', '/') for var in dataset.data_vars}) - result = {} - - def insert_data(value_part): - if isinstance(value_part, dict): # If the value is another nested dictionary - return _insert_dataarrays(dataset, value_part) # Recursively handle it - elif isinstance(value_part, list): - return [insert_data(v) for v in value_part] - elif isinstance(value_part, str) and value_part.startswith(':::'): - return dataset[value_part.removeprefix(':::')] - elif isinstance(value_part, str): - return value_part - elif isinstance(value_part, (int, float)): - return value_part - else: - raise ValueError(f'Loading the Dataset failed. Not able to handle {value_part}') - for key, value in structure.items(): - result[key] = insert_data(value) - return result +def structure_to_json(flow_system: FlowSystem, path: Union[str, pathlib.Path] = 'system_model.json'): + with open(path, 'w', encoding='utf-8') as f: + json.dump(_results_structure(flow_system), f, indent=4, ensure_ascii=False) diff --git a/flixOpt/results_linopy.py b/flixOpt/results_linopy.py index 97c6c683b..1e9748a52 100644 --- a/flixOpt/results_linopy.py +++ b/flixOpt/results_linopy.py @@ -5,15 +5,23 @@ from typing import Dict, Union, List, Literal import logging -from pyparsing import Literal - class CalculationResults: + @classmethod + def read_from_file(cls, folder: Union[str, pathlib.Path], name: str): + folder = pathlib.Path(folder) + path = folder / name + model = linopy.read_netcdf(path.with_suffix('.nc')) + with open(path.with_suffix('.json'), 'r', encoding='utf-8') as f: + flow_system_structure = json.load(f) + return cls(model, flow_system_structure) + def __init__(self, model: linopy.Model, flow_system_structure: Dict[str, Dict[str, str]]): self.model = model self._flow_system_structure = flow_system_structure - self.components = {label: ComponentResults(self, label, variables, constraints) - for label, variables, constraints in flow_system_structure['Components'].items()} + if False: + self.components = {label: ComponentResults(self, label, variables, constraints) + for label, variables, constraints in flow_system_structure['Components'].items()} class ElementResults: From 85fe06ed8269fb619b7f87a691879779b148995e Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 23 Feb 2025 16:50:12 +0100 Subject: [PATCH 210/507] Improve results_linopy.py --- flixOpt/results_linopy.py | 72 ++++++++++++++++++++++----------------- 1 file changed, 41 insertions(+), 31 deletions(-) diff --git a/flixOpt/results_linopy.py b/flixOpt/results_linopy.py index 1e9748a52..94792070e 100644 --- a/flixOpt/results_linopy.py +++ b/flixOpt/results_linopy.py @@ -16,15 +16,36 @@ def read_from_file(cls, folder: Union[str, pathlib.Path], name: str): flow_system_structure = json.load(f) return cls(model, flow_system_structure) - def __init__(self, model: linopy.Model, flow_system_structure: Dict[str, Dict[str, str]]): + def __init__(self, model: linopy.Model, flow_system_structure: Dict[str, Dict[str, Dict]]): self.model = model self._flow_system_structure = flow_system_structure - if False: - self.components = {label: ComponentResults(self, label, variables, constraints) - for label, variables, constraints in flow_system_structure['Components'].items()} + self.components = {label: ComponentResults.from_json(self, infos) + for label, infos in flow_system_structure['Components'].items()} + self.buses = {label: BusResults.from_json(self, infos) + for label, infos in flow_system_structure['Buses'].items()} + + self.effects = {label: EffectResults.from_json(self, infos) + for label, infos in flow_system_structure['Effects'].items()} + + def __getitem__(self, key: str) -> Union['ComponentResults', 'BusResults', 'EffectResults']: + if key in self.components: + return self.components[key] + if key in self.buses: + return self.buses[key] + if key in self.effects: + return self.effects[key] + raise KeyError(f'No element with label {key} found.') + + +class _ElementResults: + @classmethod + def from_json(cls, calculation_results, json_data: Dict): + return cls(calculation_results, + json_data['label'], + json_data['variables'], + json_data['constraints']) -class ElementResults: def __init__(self, calculation_results: CalculationResults, label: str, @@ -38,7 +59,17 @@ def __init__(self, self.variables = self._calculation_results.model.variables[self._variables] self.constraints = self._calculation_results.model.constraints[self._constraints] -class _NodeResults(ElementResults): + +class _NodeResults(_ElementResults): + @classmethod + def from_json(cls, calculation_results, json_data: Dict): + return cls(calculation_results, + json_data['label'], + json_data['variables'], + json_data['constraints'], + json_data['inputs'], + json_data['outputs']) + def __init__(self, calculation_results: CalculationResults, label: str, @@ -52,33 +83,12 @@ def __init__(self, class BusResults(_NodeResults): - def __init__(self, - calculation_results: CalculationResults, - label: str, - variables: List[str], - constraints: List[str], - inputs: Dict[str, xr.DataArray], - outputs: Dict[str, xr.DataArray] - ): - super().__init__(calculation_results, label, variables, constraints, inputs, outputs) + """Results for a Bus""" class ComponentResults(_NodeResults): - def __init__(self, - calculation_results: CalculationResults, - label: str, - variables: List[str], - constraints: List[str], - inputs: Dict[str, xr.DataArray], - outputs: Dict[str, xr.DataArray] - ): - super().__init__(calculation_results, label, variables, constraints, inputs, outputs) + """Results for a Component""" -class EffectResults(ElementResults): - def __init__(self, - calculation_results: CalculationResults, - label: str, - variables: List[str], - constraints: List[str]): - super().__init__(calculation_results, label, variables, constraints) +class EffectResults(_ElementResults): + """Results for an Effect""" From e06c6557c724af2e2b14772de77987a109140fb7 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 23 Feb 2025 17:28:18 +0100 Subject: [PATCH 211/507] Improve results_linopy.py --- flixOpt/core.py | 4 ++-- flixOpt/elements.py | 8 ++++---- flixOpt/io.py | 5 ++++- flixOpt/results_linopy.py | 36 ++++++++++++++++++++++++++++++++++-- 4 files changed, 44 insertions(+), 9 deletions(-) diff --git a/flixOpt/core.py b/flixOpt/core.py index e7ebfbebe..a11ae8169 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -581,7 +581,7 @@ def align_dimensions( hours_of_previous_timesteps = TimeSeriesCollection._calculate_hours_of_previous_timesteps( timesteps, hours_of_previous_timesteps ) - hours_per_step = TimeSeriesCollection._create_hours_per_timestep(timesteps_extra, periods) + hours_per_step = TimeSeriesCollection.create_hours_per_timestep(timesteps_extra, periods) return timesteps, timesteps_extra, hours_per_step, hours_of_previous_timesteps, periods @@ -639,7 +639,7 @@ def _calculate_hours_of_previous_timesteps( ) @staticmethod - def _create_hours_per_timestep( + def create_hours_per_timestep( timesteps_extra: pd.DatetimeIndex, periods: Optional[pd.Index] ) -> xr.DataArray: diff --git a/flixOpt/elements.py b/flixOpt/elements.py index fcc5fad60..3e089a8d7 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -491,8 +491,8 @@ def do_modeling(self) -> None: def results_structure(self): return {**super().results_structure(), - 'inputs': [flow.label for flow in self.element.inputs], - 'outputs': [flow.label for flow in self.element.outputs]} + 'inputs': [flow.model.flow_rate.name for flow in self.element.inputs], + 'outputs': [flow.model.flow_rate.name for flow in self.element.outputs]} def solution_structured( self, @@ -561,8 +561,8 @@ def do_modeling(self): def results_structure(self): return {**super().results_structure(), - 'inputs': [flow.label for flow in self.element.inputs], - 'outputs': [flow.label for flow in self.element.outputs]} + 'inputs': [flow.model.flow_rate.name for flow in self.element.inputs], + 'outputs': [flow.model.flow_rate.name for flow in self.element.outputs]} def solution_structured( self, diff --git a/flixOpt/io.py b/flixOpt/io.py index e03b8a785..2d3dfa704 100644 --- a/flixOpt/io.py +++ b/flixOpt/io.py @@ -4,6 +4,7 @@ import xarray as xr from typing import Dict, Union import logging +import datetime from .flow_system import FlowSystem @@ -24,7 +25,9 @@ def _results_structure(flow_system: FlowSystem) -> Dict[str, Dict[str, str]]: 'Effects': { effect.label_full: effect.model.results_structure() for effect in sorted(flow_system.effects.values(), key=lambda effect: effect.label_full.upper()) - } + }, + 'Time': [datetime.datetime.isoformat(date) for date in flow_system.time_series_collection.timesteps_extra], + 'Periods': flow_system.time_series_collection.periods.tolist() if flow_system.time_series_collection.periods is not None else None } diff --git a/flixOpt/results_linopy.py b/flixOpt/results_linopy.py index 94792070e..d5df0e07e 100644 --- a/flixOpt/results_linopy.py +++ b/flixOpt/results_linopy.py @@ -1,9 +1,16 @@ import linopy import json import pathlib + +import pandas as pd import xarray as xr from typing import Dict, Union, List, Literal import logging +import datetime +import numpy as np +from .core import TimeSeriesCollection + +from . import plotting, utils class CalculationResults: @@ -28,6 +35,10 @@ def __init__(self, model: linopy.Model, flow_system_structure: Dict[str, Dict[st self.effects = {label: EffectResults.from_json(self, infos) for label, infos in flow_system_structure['Effects'].items()} + self.timesteps_extra = pd.DatetimeIndex([datetime.datetime.fromisoformat(date) for date in flow_system_structure['Time']]) + self.periods = pd.Index(flow_system_structure['Periods']) if flow_system_structure['Periods'] is not None else None + self.hours_per_timestep = TimeSeriesCollection.create_hours_per_timestep(self.timesteps_extra, self.periods) + def __getitem__(self, key: str) -> Union['ComponentResults', 'BusResults', 'EffectResults']: if key in self.components: return self.components[key] @@ -40,7 +51,7 @@ def __getitem__(self, key: str) -> Union['ComponentResults', 'BusResults', 'Effe class _ElementResults: @classmethod - def from_json(cls, calculation_results, json_data: Dict): + def from_json(cls, calculation_results, json_data: Dict) -> '_ElementResults': return cls(calculation_results, json_data['label'], json_data['variables'], @@ -59,10 +70,14 @@ def __init__(self, self.variables = self._calculation_results.model.variables[self._variables] self.constraints = self._calculation_results.model.constraints[self._constraints] + @property + def variables_time(self): + return self.variables[[name for name in self._variables if 'time' in self.variables[name].dims]] + class _NodeResults(_ElementResults): @classmethod - def from_json(cls, calculation_results, json_data: Dict): + def from_json(cls, calculation_results, json_data: Dict) -> '_NodeResults': return cls(calculation_results, json_data['label'], json_data['variables'], @@ -81,6 +96,20 @@ def __init__(self, self.inputs = inputs self.outputs = outputs + def plot_balance(self, show: bool = True): + return plotting.with_plotly(self.operation_balance(), + mode='area', + title=f'Operation Balance of {self.label}', + show=show) + + def operation_balance(self, negate_inputs: bool = True, negate_outputs: bool = False): + df = self.variables_time.solution.to_dataframe() + if negate_outputs: + df[self.outputs] = -df[self.outputs] + if negate_inputs: + df[self.inputs] = -df[self.inputs] + return df + class BusResults(_NodeResults): """Results for a Bus""" @@ -92,3 +121,6 @@ class ComponentResults(_NodeResults): class EffectResults(_ElementResults): """Results for an Effect""" + + def get_shares_from(self, element: str): + return self.variables[[name for name in self._variables if name.startswith(f'{element}->')]] From a1e4d008c3537231d5eb6849cfb752239b3a384d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 23 Feb 2025 17:29:28 +0100 Subject: [PATCH 212/507] ruff check --- flixOpt/calculation.py | 6 +++--- flixOpt/commons.py | 2 +- flixOpt/io.py | 10 +++++----- flixOpt/results_linopy.py | 12 ++++++------ 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py index 53703344e..88166c3e9 100644 --- a/flixOpt/calculation.py +++ b/flixOpt/calculation.py @@ -131,15 +131,15 @@ def save_linopy(self, folder: Optional[Union[str, pathlib.Path]] = None): """ Save the calculation to file. """ - from .io import structure_to_json, model_to_netcdf + from .io import model_to_netcdf, structure_to_json folder = pathlib.Path(folder) if folder is not None else pathlib.Path.cwd() / 'results' path = folder / self.name if not folder.exists(): try: folder.mkdir() - except FileNotFoundError: + except FileNotFoundError as e: raise FileNotFoundError(f'Parent directory of {path} does not exist.' - f'Please create the directory or specify a valid path.') + f'Please create the directory or specify a valid path.') from e model_to_netcdf(self.model, path.with_suffix('.nc')) structure_to_json(self.flow_system, path.with_suffix('.json')) logger.info(f'Saved calculation to {path}') diff --git a/flixOpt/commons.py b/flixOpt/commons.py index 35287dc31..cfbf86eae 100644 --- a/flixOpt/commons.py +++ b/flixOpt/commons.py @@ -2,7 +2,7 @@ This module makes the commonly used classes and functions available in the flixOpt framework. """ -from . import linear_converters, plotting, results, solvers, results_linopy +from . import linear_converters, plotting, results, results_linopy, solvers from .aggregation import AggregationParameters from .calculation import AggregatedCalculation, FullCalculation, SegmentedCalculation from .components import ( diff --git a/flixOpt/io.py b/flixOpt/io.py index 2d3dfa704..ca45d2205 100644 --- a/flixOpt/io.py +++ b/flixOpt/io.py @@ -1,13 +1,13 @@ -import linopy +import datetime import json +import logging import pathlib -import xarray as xr from typing import Dict, Union -import logging -import datetime -from .flow_system import FlowSystem +import linopy +import xarray as xr +from .flow_system import FlowSystem logger = logging.getLogger('flixOpt') diff --git a/flixOpt/results_linopy.py b/flixOpt/results_linopy.py index d5df0e07e..c709ed604 100644 --- a/flixOpt/results_linopy.py +++ b/flixOpt/results_linopy.py @@ -1,16 +1,16 @@ -import linopy +import datetime import json +import logging import pathlib +from typing import Dict, List, Literal, Union +import linopy +import numpy as np import pandas as pd import xarray as xr -from typing import Dict, Union, List, Literal -import logging -import datetime -import numpy as np -from .core import TimeSeriesCollection from . import plotting, utils +from .core import TimeSeriesCollection class CalculationResults: From 0944f978b2c02ba8f74d0a7faa892a699ed115b1 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 23 Feb 2025 20:49:47 +0100 Subject: [PATCH 213/507] Bring all saving and loading functionality to the results_linopy.py --- flixOpt/io.py | 9 +---- flixOpt/results_linopy.py | 70 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 67 insertions(+), 12 deletions(-) diff --git a/flixOpt/io.py b/flixOpt/io.py index ca45d2205..f6fa55aa2 100644 --- a/flixOpt/io.py +++ b/flixOpt/io.py @@ -12,7 +12,7 @@ logger = logging.getLogger('flixOpt') -def _results_structure(flow_system: FlowSystem) -> Dict[str, Dict[str, str]]: +def _results_structure(flow_system: FlowSystem) -> Dict[str, Dict]: return { 'Components': { comp.label_full: comp.model.results_structure() @@ -31,13 +31,6 @@ def _results_structure(flow_system: FlowSystem) -> Dict[str, Dict[str, str]]: } -def model_to_netcdf(model: linopy.Model, path: Union[str, pathlib.Path] = 'system_model.nc', *args, **kwargs): - """ - Save the linopy model to a netcdf file. - """ - model.to_netcdf(path, *args, **kwargs) - - def structure_to_json(flow_system: FlowSystem, path: Union[str, pathlib.Path] = 'system_model.json'): with open(path, 'w', encoding='utf-8') as f: json.dump(_results_structure(flow_system), f, indent=4, ensure_ascii=False) diff --git a/flixOpt/results_linopy.py b/flixOpt/results_linopy.py index c709ed604..c5a6118b3 100644 --- a/flixOpt/results_linopy.py +++ b/flixOpt/results_linopy.py @@ -2,7 +2,7 @@ import json import logging import pathlib -from typing import Dict, List, Literal, Union +from typing import Dict, List, Literal, Union, Optional import linopy import numpy as np @@ -12,20 +12,70 @@ from . import plotting, utils from .core import TimeSeriesCollection +from .io import _results_structure +from .calculation import Calculation + +logger = logging.getLogger('flixOpt') + class CalculationResults: + """ + Results for a Calculation. + This class is used to collect the results of a Calculation. + It is used to analyze the results and to visualize the results. + + Parameters + ---------- + model : linopy.Model + The linopy model that was used to solve the calculation. + flow_system_structure : Dict[str, Dict[str, Dict]] + The structure of the flow_system that was used to solve the calculation. + + Attributes + ---------- + model : linopy.Model + The linopy model that was used to solve the calculation. + components : Dict[str, ComponentResults] + A dictionary of ComponentResults for each component in the flow_system. + buses : Dict[str, BusResults] + A dictionary of BusResults for each bus in the flow_system. + effects : Dict[str, EffectResults] + A dictionary of EffectResults for each effect in the flow_system. + timesteps_extra : pd.DatetimeIndex + The extra timesteps of the flow_system. + periods : pd.Index + The periods of the flow_system. + hours_per_timestep : xr.DataArray + The duration of each timestep in hours. + + Class Methods + ------- + from_file(folder: Union[str, pathlib.Path], name: str) + Create CalculationResults directly from file. + from_calculation(calculation: Calculation) + Create CalculationResults directly from a Calculation. + + """ @classmethod - def read_from_file(cls, folder: Union[str, pathlib.Path], name: str): + def from_file(cls, folder: Union[str, pathlib.Path], name: str): + """ Create CalculationResults directly from file""" folder = pathlib.Path(folder) path = folder / name model = linopy.read_netcdf(path.with_suffix('.nc')) with open(path.with_suffix('.json'), 'r', encoding='utf-8') as f: flow_system_structure = json.load(f) - return cls(model, flow_system_structure) + logger.info(f'Loaded calculation "{name}" from file ({path})') + return cls(model, flow_system_structure, name) - def __init__(self, model: linopy.Model, flow_system_structure: Dict[str, Dict[str, Dict]]): + @classmethod + def from_calculation(cls, calculation: Calculation): + """Create CalculationResults directly from a Calculation""" + return cls(calculation.model, _results_structure(calculation.flow_system), calculation.name) + + def __init__(self, model: linopy.Model, flow_system_structure: Dict[str, Dict[str, Dict]], name: str): self.model = model self._flow_system_structure = flow_system_structure + self.name = name self.components = {label: ComponentResults.from_json(self, infos) for label, infos in flow_system_structure['Components'].items()} @@ -48,6 +98,17 @@ def __getitem__(self, key: str) -> Union['ComponentResults', 'BusResults', 'Effe return self.effects[key] raise KeyError(f'No element with label {key} found.') + def to_file(self, folder: Union[str, pathlib.Path], name: Optional[str] = None, *args, **kwargs): + """Save the results to a file""" + folder = pathlib.Path(folder) + name = self.name if name is None else name + path = folder / name + + self.model.to_netcdf(path, *args, **kwargs) + with open(path.with_suffix('.json'), 'w', encoding='utf-8') as f: + json.dump(self._flow_system_structure, f, indent=4, ensure_ascii=False) + logger.info(f'Saved calculation "{name}" to {path}') + class _ElementResults: @classmethod @@ -124,3 +185,4 @@ class EffectResults(_ElementResults): def get_shares_from(self, element: str): return self.variables[[name for name in self._variables if name.startswith(f'{element}->')]] + From 2968899ff92611a5a721f4df6c624d5ab26366e9 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 23 Feb 2025 20:52:37 +0100 Subject: [PATCH 214/507] Bring all saving and loading functionality to the results_linopy.py --- flixOpt/calculation.py | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py index 88166c3e9..d4c66bd81 100644 --- a/flixOpt/calculation.py +++ b/flixOpt/calculation.py @@ -127,23 +127,6 @@ def _save_solve_infos(self): logger.info(f'Saving calculation to .json took {self.durations["saving"]:>8.2f} seconds') logger.info(f'Saving calculation to .yaml took {(timeit.default_timer() - t_start):>8.2f} seconds') - def save_linopy(self, folder: Optional[Union[str, pathlib.Path]] = None): - """ - Save the calculation to file. - """ - from .io import model_to_netcdf, structure_to_json - folder = pathlib.Path(folder) if folder is not None else pathlib.Path.cwd() / 'results' - path = folder / self.name - if not folder.exists(): - try: - folder.mkdir() - except FileNotFoundError as e: - raise FileNotFoundError(f'Parent directory of {path} does not exist.' - f'Please create the directory or specify a valid path.') from e - model_to_netcdf(self.model, path.with_suffix('.nc')) - structure_to_json(self.flow_system, path.with_suffix('.json')) - logger.info(f'Saved calculation to {path}') - @property def results(self): return self._results From afa67368238007dd816045d781a80bff8a72d440 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 23 Feb 2025 22:03:32 +0100 Subject: [PATCH 215/507] Update Calculation --- flixOpt/calculation.py | 137 ++++++++++++++++++-------------------- flixOpt/flow_system.py | 25 ++----- flixOpt/results_linopy.py | 9 ++- 3 files changed, 73 insertions(+), 98 deletions(-) diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py index d4c66bd81..7f4ab9c3a 100644 --- a/flixOpt/calculation.py +++ b/flixOpt/calculation.py @@ -8,7 +8,6 @@ 3. SegmentedCalculation: Solves a SystemModel for each individual Segment of the FlowSystem. """ -import datetime import json import logging import math @@ -29,6 +28,8 @@ from .flow_system import FlowSystem from .solvers import _Solver from .structure import SystemModel, copy_and_convert_datatypes, get_compact_representation +from .config import CONFIG +from .results_linopy import CalculationResults logger = logging.getLogger('flixOpt') @@ -62,82 +63,68 @@ def __init__( self.active_periods = active_periods self.durations = {'modeling': 0.0, 'solving': 0.0, 'saving': 0.0} + self.folder = pathlib.Path.cwd() / 'results' + self.results: Optional[CalculationResults] = None - self._paths: Dict[str, Optional[Union[pathlib.Path, List[pathlib.Path]]]] = { - 'log': None, - 'data': None, - 'info': None, - } - self._results = None - - def _define_path_names(self, save_results: Union[bool, str, pathlib.Path], include_timestamp: bool = False): - """ - Creates the path for saving results and alters the name of the calculation to have a timestamp - """ - if include_timestamp: - timestamp = datetime.datetime.now() - self.name = f'{timestamp.strftime("%Y-%m-%d")}_{self.name.replace(" ", "")}' - - if save_results: - if not isinstance(save_results, (str, pathlib.Path)): - save_results = 'results/' # Standard path for results - path = pathlib.Path.cwd() / save_results # absoluter Pfad: - - path.mkdir(parents=True, exist_ok=True) # Pfad anlegen, fall noch nicht vorhanden: - - self._paths['log'] = path / f'{self.name}_solver.log' - self._paths['data'] = path / f'{self.name}_data.json' - self._paths['results'] = path / f'{self.name}_results.json' - self._paths['infos'] = path / f'{self.name}_infos.yaml' - - def _save_solve_infos(self): - t_start = timeit.default_timer() - indent = 4 if len(self.flow_system.time_series_collection.timesteps) < 50 else None - with open(self._paths['results'], 'w', encoding='utf-8') as f: - results = copy_and_convert_datatypes(self.results, use_numpy=False, use_element_label=False) - json.dump(results, f, indent=indent) - - with open(self._paths['data'], 'w', encoding='utf-8') as f: - data = copy_and_convert_datatypes(self.flow_system.infos(), use_numpy=False, use_element_label=False) - json.dump(data, f, indent=indent) + def to_yaml(self): + """Save the results to a yaml file""" + path = self.folder / self.name + path.mkdir(parents=True, exist_ok=True) + with open(self.folder / f'{self.name}_infos.yaml', 'w', encoding='utf-8') as f: + yaml.dump(self.infos, f, allow_unicode=True, sort_keys=False, indent=4) - self.durations['saving'] = round(timeit.default_timer() - t_start, 2) + @property + def main_results(self) -> Dict[str, Union[Scalar, Dict]]: + from flixOpt.features import InvestmentModel - t_start = timeit.default_timer() - nodes_info, edges_info = self.flow_system.network_infos() - infos = { - 'Calculation': self.infos, - 'Model': self.model.infos, - 'FlowSystem': get_compact_representation(self.flow_system.infos(use_numpy=True, use_element_label=True)), - 'Network': {'Nodes': nodes_info, 'Edges': edges_info}, + return { + "Objective": self.model.objective.value, + "Penalty": float(self.model.effects.penalty.total.solution.values), + "Effects": { + f"{effect.label} [{effect.unit}]": { + "operation": float(effect.model.operation.total.solution.values), + "invest": float(effect.model.invest.total.solution.values), + "total": float(effect.model.total.solution.values), + } + for effect in self.flow_system.effects.values() + }, + "Invest-Decisions": { + "Invested": { + model.label_of_element: float(model.size.solution) + for component in self.flow_system.components.values() + for model in component.model.all_sub_models + if isinstance(model, InvestmentModel) and float(model.size.solution) >= CONFIG.modeling.EPSILON + }, + "Not invested": { + model.label_of_element: float(model.size.solution) + for component in self.flow_system.components.values() + for model in component.model.all_sub_models + if isinstance(model, InvestmentModel) and float(model.size.solution) < CONFIG.modeling.EPSILON + }, + }, + "Buses with excess": [ + {bus.label_full: { + "input": float(np.sum(bus.model.excess_input.solution.values)), + "output": float(np.sum(bus.model.excess_output.solution.values)) + }} + for bus in self.flow_system.buses.values() + if bus.with_excess and (float(np.sum(bus.model.excess_input.solution.values)) > 1e-3 or + float(np.sum(bus.model.excess_output.solution.values)) > 1e-3) + ], } - with open(self._paths['infos'], 'w', encoding='utf-8') as f: - yaml.dump( - infos, - f, - width=1000, # Verhinderung Zeilenumbruch für lange equations - allow_unicode=True, - sort_keys=False, - indent=4, - ) - - message = f' Saved Calculation: {self.name} ' - logger.info(f'{"":#^80}\n{message:#^80}\n{"":#^80}') - logger.info(f'Saving calculation to .json took {self.durations["saving"]:>8.2f} seconds') - logger.info(f'Saving calculation to .yaml took {(timeit.default_timer() - t_start):>8.2f} seconds') - - @property - def results(self): - return self._results - @property def infos(self): return { 'Name': self.name, - 'Number of indices': len(self.active_timesteps) if self.active_timesteps is not None else 'all', + 'Number of timesteps': len(self.flow_system.time_series_collection.timesteps), + 'Periods': self.flow_system.time_series_collection.periods, 'Calculation Type': self.__class__.__name__, + 'Constraints': self.model.constraints.ncons, + 'Variables': self.model.variables.nvars, + 'Main Results': self.main_results, 'Durations': self.durations, + 'Config': CONFIG.to_dict(), } @@ -159,24 +146,27 @@ def do_modeling(self) -> SystemModel: def solve(self, solver: _Solver, save_results: Union[bool, str, pathlib.Path] = True, + log_file: Optional[pathlib.Path] = None, log_main_results: bool = True): - self._define_path_names(save_results) t_start = timeit.default_timer() - self.model.solve(log_fn=self._paths['log'], + self.model.solve(log_fn=pathlib.Path(log_file) if log_file is not None else self.folder / f'{self.name}.log', solver_name=solver.name, **solver.options) - self._results = self.flow_system.results() self.durations['solving'] = round(timeit.default_timer() - t_start, 2) # Log the formatted output if log_main_results: logger.info(f'{" Main Results ":#^80}') - logger.info("\n" + yaml.dump( - utils.round_floats(self.flow_system.model.infos), - default_flow_style=False, sort_keys=False, allow_unicode=True, indent=4)) + logger.info( + "\n" + yaml.dump(utils.round_floats(self.main_results), + default_flow_style=False, sort_keys=False, allow_unicode=True, indent=4 + ) + ) + self.results = CalculationResults.from_calculation(self) if save_results: - self._save_solve_infos() + path = self.folder if save_results is True else pathlib.Path(save_results) + self.results.to_file(path, self.name) def _activate_time_series(self): self.flow_system.transform_data() @@ -222,7 +212,6 @@ def __init__( super().__init__(name, flow_system, active_timesteps) self.aggregation_parameters = aggregation_parameters self.components_to_clusterize = components_to_clusterize - self.time_series_for_aggregation = None self.aggregation = None def do_modeling(self) -> SystemModel: diff --git a/flixOpt/flow_system.py b/flixOpt/flow_system.py index 3ff82aa12..d7ef6f011 100644 --- a/flixOpt/flow_system.py +++ b/flixOpt/flow_system.py @@ -141,6 +141,9 @@ def infos(self, use_numpy=True, use_element_label=False) -> Dict: } return infos + def infos_compact(self): + return get_compact_representation(self.infos(use_numpy=True, use_element_label=True)), + def to_json(self, path: Union[str, pathlib.Path]): """ Saves the flow system to a json file. @@ -151,28 +154,8 @@ def to_json(self, path: Union[str, pathlib.Path]): path : Union[str, pathlib.Path] The path to the json file. """ - data = get_compact_representation(self.infos(use_numpy=True, use_element_label=True)) with open(path, 'w', encoding='utf-8') as f: - json.dump(data, f, indent=4, ensure_ascii=False) - - def results(self): - #TODO: Remove this function, as access through the FLowSystem is not correct if another calucaltion was made. - return { - 'Components': { - comp.label: comp.model.solution_structured(mode='numpy') - for comp in sorted(self.components.values(), key=lambda component: component.label.upper()) - }, - 'Buses': { - bus.label: bus.model.solution_structured(mode='numpy') - for bus in sorted(self.buses.values(), key=lambda bus: bus.label.upper()) - }, - 'Effects': { - effect.label: effect.model.solution_structured(mode='numpy') - for effect in sorted(self.effects.values(), key=lambda effect: effect.label.upper()) - }, - 'Time': self.time_series_collection.timesteps_extra.tolist(), - 'Time intervals in hours': self.time_series_collection.hours_per_timestep, - } + json.dump(self.infos_compact(), f, indent=4, ensure_ascii=False) def visualize_network( self, diff --git a/flixOpt/results_linopy.py b/flixOpt/results_linopy.py index c5a6118b3..e693da551 100644 --- a/flixOpt/results_linopy.py +++ b/flixOpt/results_linopy.py @@ -2,7 +2,7 @@ import json import logging import pathlib -from typing import Dict, List, Literal, Union, Optional +from typing import Dict, List, Literal, Union, Optional, TYPE_CHECKING import linopy import numpy as np @@ -13,7 +13,10 @@ from .core import TimeSeriesCollection from .io import _results_structure -from .calculation import Calculation + +if TYPE_CHECKING: + from .calculation import Calculation + logger = logging.getLogger('flixOpt') @@ -68,7 +71,7 @@ def from_file(cls, folder: Union[str, pathlib.Path], name: str): return cls(model, flow_system_structure, name) @classmethod - def from_calculation(cls, calculation: Calculation): + def from_calculation(cls, calculation: 'Calculation'): """Create CalculationResults directly from a Calculation""" return cls(calculation.model, _results_structure(calculation.flow_system), calculation.name) From 3d0322292b973e86455e510a571f5e4b7c8f447e Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 24 Feb 2025 16:12:41 +0100 Subject: [PATCH 216/507] Update test_functional.py --- tests/test_functional.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/tests/test_functional.py b/tests/test_functional.py index 2862c6bbb..5b2c78ced 100644 --- a/tests/test_functional.py +++ b/tests/test_functional.py @@ -95,13 +95,12 @@ def flow_system_minimal(timesteps) -> fx.FlowSystem: def solve_and_load( - flow_system: fx.FlowSystem, solver: str -) -> fx.results.CalculationResults: + flow_system: fx.FlowSystem, solver +) -> fx.results_linopy.CalculationResults: calculation = fx.FullCalculation('Calculation', flow_system) calculation.do_modeling() calculation.solve(solver, True) - results = fx.results.CalculationResults('Calculation', 'results') - return results + return calculation.results @pytest.fixture(params=['highs', 'gurobi']) @@ -124,27 +123,24 @@ def test_solve_and_load(solver_fixture, time_steps_fixture): def test_minimal_model(solver_fixture, time_steps_fixture): results = solve_and_load(flow_system_minimal(time_steps_fixture), solver_fixture) + assert_allclose(results.model.variables['costs|total'].solution.values, 80, rtol=1e-5, atol=1e-10) assert_allclose( - results.effect_results['costs'].all_results['total'], 80, rtol=1e-5, atol=1e-10 - ) - - assert_allclose( - results.component_results['Boiler'].all_results['Q_th']['flow_rate'], + results.model.variables['Boiler (Q_th)|flow_rate'].solution.values, [-0.0, 10.0, 20.0, -0.0, 10.0], rtol=1e-5, atol=1e-10, ) assert_allclose( - results.effect_results['costs'].all_results['operation']['total_per_timestep'], + results.model.variables['costs|operation|total_per_timestep'].solution.values, [-0.0, 20.0, 40.0, -0.0, 20.0], rtol=1e-5, atol=1e-10, ) assert_allclose( - results.effect_results['costs'].all_results['operation']['Shares']['Gastarif (Gas)'], + results.model.variables['Gastarif (Gas)->costs|operation'].solution.values, [-0.0, 20.0, 40.0, -0.0, 20.0], rtol=1e-5, atol=1e-10, From abd0ceebf91c866f8c077b789e2341fd1b60722e Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 24 Feb 2025 17:03:15 +0100 Subject: [PATCH 217/507] Updated folder handling in Calculation --- flixOpt/calculation.py | 129 +++++++++-------------------------------- 1 file changed, 29 insertions(+), 100 deletions(-) diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py index 7f4ab9c3a..f5522c4de 100644 --- a/flixOpt/calculation.py +++ b/flixOpt/calculation.py @@ -45,6 +45,7 @@ def __init__( flow_system: FlowSystem, active_timesteps: Optional[pd.DatetimeIndex] = None, active_periods: Optional[pd.Index] = None, + folder: Optional[pathlib.Path] = None, ): """ Parameters @@ -55,6 +56,8 @@ def __init__( flow_system which should be calculated active_timesteps : List[int] or None list with indices, which should be used for calculation. If None, then all timesteps are used. + folder : pathlib.Path or None + folder where results should be saved. If None, then the current working directory is used. """ self.name = name self.flow_system = flow_system @@ -63,13 +66,17 @@ def __init__( self.active_periods = active_periods self.durations = {'modeling': 0.0, 'solving': 0.0, 'saving': 0.0} - self.folder = pathlib.Path.cwd() / 'results' + self.folder = pathlib.Path.cwd() if folder is None else pathlib.Path(folder) self.results: Optional[CalculationResults] = None + if not self.folder.exists(): + try: + self.folder.mkdir(parents=False) + except FileNotFoundError as e: + raise FileNotFoundError(f'Folder {self.folder} and its parent do not exist. Please create them first.') from e + def to_yaml(self): """Save the results to a yaml file""" - path = self.folder / self.name - path.mkdir(parents=True, exist_ok=True) with open(self.folder / f'{self.name}_infos.yaml', 'w', encoding='utf-8') as f: yaml.dump(self.infos, f, allow_unicode=True, sort_keys=False, indent=4) @@ -145,10 +152,11 @@ def do_modeling(self) -> SystemModel: def solve(self, solver: _Solver, - save_results: Union[bool, str, pathlib.Path] = True, + save_results: bool = True, log_file: Optional[pathlib.Path] = None, log_main_results: bool = True): t_start = timeit.default_timer() + self.model.solve(log_fn=pathlib.Path(log_file) if log_file is not None else self.folder / f'{self.name}.log', solver_name=solver.name, **solver.options) @@ -165,8 +173,7 @@ def solve(self, self.results = CalculationResults.from_calculation(self) if save_results: - path = self.folder if save_results is True else pathlib.Path(save_results) - self.results.to_file(path, self.name) + self.results.to_file(self.folder, self.name) def _activate_time_series(self): self.flow_system.transform_data() @@ -187,6 +194,7 @@ def __init__( aggregation_parameters: AggregationParameters, components_to_clusterize: Optional[List[Component]] = None, active_timesteps: Optional[pd.DatetimeIndex] = None, + folder: Optional[pathlib.Path] = None ): """ Class for Optimizing the FLowSystem including: @@ -206,10 +214,12 @@ def __init__( computed in the DataAggregation active_timesteps : pd.DatetimeIndex or None list with indices, which should be used for calculation. If None, then all timesteps are used. + folder : pathlib.Path or None + folder where results should be saved. If None, then the current working directory is used. """ if flow_system.time_series_collection.periods is not None: raise NotImplementedError('Multiple Periods are currently not supported in AggregatedCalculation') - super().__init__(name, flow_system, active_timesteps) + super().__init__(name, flow_system, active_timesteps, folder=folder) self.aggregation_parameters = aggregation_parameters self.components_to_clusterize = components_to_clusterize self.aggregation = None @@ -236,7 +246,7 @@ def _perform_aggregation(self): t_start_agg = timeit.default_timer() # Validation - dt_min, dt_max = np.min(self.flow_system.time_series_collection.hours_per_step), np.max(self.flow_system.time_series_collection.hours_per_step) + dt_min, dt_max = np.min(self.flow_system.time_series_collection.hours_per_timestep), np.max(self.flow_system.time_series_collection.hours_per_timestep) if not dt_min == dt_max: raise ValueError( f'Aggregation failed due to inconsistent time step sizes:' @@ -279,6 +289,7 @@ def __init__( timesteps_per_segment: int, overlap_timesteps: int, nr_of_previous_values: int = 1, + folder: Optional[pathlib.Path] = None ): """ Dividing and Modeling the problem in (overlapping) segments. @@ -302,16 +313,18 @@ def __init__( overlap_timesteps : int The number of time_steps that are added to each individual model. Used for better results of storages) + folder : pathlib.Path or None + folder where results should be saved. If None, then the current working directory is used. """ - super().__init__(name, flow_system) - if flow_system.periods is not None: + super().__init__(name, flow_system, folder=folder) + if flow_system.time_series_collection.periods is not None: raise NotImplementedError('Multiple Periods are currently not supported in SegmentedCalculation') self.timesteps_per_segment = timesteps_per_segment self.overlap_timesteps = overlap_timesteps self.nr_of_previous_values = nr_of_previous_values self.sub_calculations: List[FullCalculation] = [] - self.all_timesteps = self.flow_system.timesteps + self.all_timesteps = self.flow_system.time_series_collection.all_timesteps self.segment_names = [f'Segment_{i + 1}' for i in range(math.ceil(len(self.all_timesteps) / self.timesteps_per_segment))] self.active_timesteps_per_segment = self._calculate_timesteps_of_segment() @@ -335,11 +348,11 @@ def __init__( def do_modeling_and_solve( self, solver: _Solver, - save_results: Union[bool, str, pathlib.Path] = True, + save_results: bool = True, + log_file: Optional[pathlib.Path] = None, log_main_results: bool = False): logger.info(f'{"":#^80}') logger.info(f'{" Segmented Solving ":#^80}') - self._define_path_names(save_results) for i, (segment_name, timesteps_of_segment) in enumerate(zip(self.segment_names, self.active_timesteps_per_segment, strict=False)): if self.sub_calculations: @@ -362,7 +375,9 @@ def do_modeling_and_solve( f'Investments are not supported in Segmented Calculation! ' f'Following InvestmentModels were found: {invest_elements}' ) - calculation.solve(solver, save_results=False, log_main_results=log_main_results) + calculation.solve(solver, save_results=save_results, + log_file=pathlib.Path(log_file) if log_file is not None else self.folder / f'{self.name}.log', + log_main_results=log_main_results) self._reset_start_values() @@ -370,92 +385,6 @@ def do_modeling_and_solve( for key, value in calc.durations.items(): self.durations[key] += value - if save_results: - self._save_solve_infos() - - def results( - self, combined_arrays: bool = False, combined_scalars: bool = False, individual_results: bool = False - ) -> Dict[str, Union[NumericData, Dict[str, NumericData]]]: - """ - Retrieving the results of a Segmented Calculation is not as straight forward as with other Calculation types. - You have 3 options: - 1. combined_arrays: - Retrieve the combined array Results of all Segments as 'combined_arrays'. All result arrays ar concatenated, - taking care of removing the overlap. These results can be directly compared to other Calculation results. - Unfortunately, Scalar values like the total of effects can not be combined in a deterministic way. - Rather convert the time series effect results to a sum yourself. - 2. combined_scalars: - Retrieve the combined scalar Results of all Segments. All Scalar Values like the total of effects are - combined and stored in a List. Take care that the total of multiple Segment is not equivalent to the - total of the total timeSeries, as it includes the Overlap! - 3. individual_results: - Retrieve the individual results of each Segment - - """ - options_chosen = combined_arrays + combined_scalars + individual_results - assert options_chosen == 1, ( - f'Exactly one of the three options to retrieve the results needs to be chosen! You chose {options_chosen}!' - ) - all_results = {calculation.name: calculation.results for calculation in self.sub_calculations} - if combined_arrays: - return { - **_combine_nested_arrays(*list(all_results.values()), length_per_array=self.timesteps_per_segment), - 'Time': self.flow_system.time_series_collection._timesteps_extra.tolist(), - 'Time intervals in hours': self.flow_system.time_series_collection._hours_per_timestep, - } - elif combined_scalars: - return _combine_nested_scalars(*list(all_results.values())) - else: - return all_results - - def _save_solve_infos(self): - t_start = timeit.default_timer() - indent = 4 if len(self.flow_system.timesteps) < 50 else None - with open(self._paths['results'], 'w', encoding='utf-8') as f: - results = copy_and_convert_datatypes( - self.results(combined_arrays=True), use_numpy=False, use_element_label=False - ) - json.dump(results, f, indent=indent) - - with open(self._paths['data'], 'w', encoding='utf-8') as f: - data = copy_and_convert_datatypes(self.flow_system.infos(), use_numpy=False, use_element_label=False) - json.dump(data, f, indent=indent) - - with open(self._paths['results'].parent / f'{self.name}_results_extra.json', 'w', encoding='utf-8') as f: - results = { - 'Individual Results': copy_and_convert_datatypes( - self.results(individual_results=True), use_numpy=False, use_element_label=False - ), - 'Scalar Results': copy_and_convert_datatypes( - self.results(combined_scalars=True), use_numpy=False, use_element_label=False - ), - } - json.dump(results, f, indent=indent) - self.durations['saving'] = round(timeit.default_timer() - t_start, 2) - - t_start = timeit.default_timer() - nodes_info, edges_info = self.flow_system.network_infos() - infos = { - 'Calculation': self.infos, - 'Model': self.sub_calculations[0].model.infos, - 'FlowSystem': get_compact_representation(self.flow_system.infos(use_numpy=True, use_element_label=True)), - 'Network': {'Nodes': nodes_info, 'Edges': edges_info}, - } - - with open(self._paths['infos'], 'w', encoding='utf-8') as f: - yaml.dump( - infos, - f, - width=1000, # Verhinderung Zeilenumbruch für lange equations - allow_unicode=True, - sort_keys=False, - ) - - message = f' Saved Calculation: {self.name} ' - logger.info(f'{"":#^80}\n{message:#^80}\n{"":#^80}') - logger.info(f'Saving calculation to .json took {self.durations["saving"]:>8.2f} seconds') - logger.info(f'Saving calculation to .yaml took {(timeit.default_timer() - t_start):>8.2f} seconds') - def _transfer_start_values(self, segment_index: int): """ This function gets the last values of the previous solved segment and From 3cf00b3cf5e18dc8802789984e2752fdf8b22275 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 24 Feb 2025 17:03:29 +0100 Subject: [PATCH 218/507] Update test --- tests/test_integration.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/tests/test_integration.py b/tests/test_integration.py index 136722a17..e0c600fc3 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -68,33 +68,32 @@ def test_model(self): def test_from_results(self): calculation = self.model(save_results=True) - results = fx.results.CalculationResults(calculation.name, 'results') + results = calculation.results # test effect results self.assert_almost_equal_numeric( - results.effect_results['costs'].all_results['total'], + results.model.variables['costs|total'].solution.values, 81.88394666666667, 'costs doesnt match expected value', ) self.assert_almost_equal_numeric( - results.effect_results['CO2'].all_results['total'], 255.09184, 'CO2 doesnt match expected value' + results.model.variables['CO2|total'].solution.values, 255.09184, 'CO2 doesnt match expected value' ) self.assert_almost_equal_numeric( - results.component_results['Boiler'].variables_flat['Q_th__flow_rate'], + results.model.variables['Boiler (Q_th)|flow_rate'].solution.values, [0, 0, 0, 28.4864, 35, 0, 0, 0, 0], 'Q_th doesnt match expected value', ) self.assert_almost_equal_numeric( - results.component_results['CHP_unit'].variables_flat['Q_th__flow_rate'], + results.model.variables['CHP_unit (Q_th)|flow_rate'].solution.values, [30.0, 26.66666667, 75.0, 75.0, 75.0, 20.0, 20.0, 20.0, 20.0], 'Q_th doesnt match expected value', ) - df = results.to_dataframe('Fernwärme', with_last_time_step=False) - comps = calculation.flow_system.components + df = results['Fernwärme'].operation_balance() self.assert_almost_equal_numeric( - comps['Wärmelast'].sink.model.flow_rate.solution.values, - df['Wärmelast__Q_th_Last'], + calculation.flow_system.components['Wärmelast'].sink.model.flow_rate.solution.values, + df['Wärmelast (Q_th_Last)|flow_rate'].values, 'Loaded Results and directly used results dont match, or loading didnt work properly', ) From 0f68aac099fc1200f8ab5a7ad6766ab873c8c67f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 24 Feb 2025 17:29:40 +0100 Subject: [PATCH 219/507] Change default folder of Calculations --- flixOpt/calculation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py index f5522c4de..8c0cdc7f3 100644 --- a/flixOpt/calculation.py +++ b/flixOpt/calculation.py @@ -66,7 +66,7 @@ def __init__( self.active_periods = active_periods self.durations = {'modeling': 0.0, 'solving': 0.0, 'saving': 0.0} - self.folder = pathlib.Path.cwd() if folder is None else pathlib.Path(folder) + self.folder = pathlib.Path.cwd() / 'results' if folder is None else pathlib.Path(folder) self.results: Optional[CalculationResults] = None if not self.folder.exists(): From 78b77fdaf3e25bf7637857a23021e750bc10ff86 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 24 Feb 2025 17:30:05 +0100 Subject: [PATCH 220/507] Remove old results.py and rename results_linopy.py --- flixOpt/__init__.py | 1 - flixOpt/calculation.py | 2 +- flixOpt/commons.py | 3 +- flixOpt/results.py | 709 +++++++++----------------------------- flixOpt/results_linopy.py | 191 ---------- tests/test_functional.py | 2 +- 6 files changed, 174 insertions(+), 734 deletions(-) delete mode 100644 flixOpt/results_linopy.py diff --git a/flixOpt/__init__.py b/flixOpt/__init__.py index 2c0a299be..9550d89f8 100644 --- a/flixOpt/__init__.py +++ b/flixOpt/__init__.py @@ -25,7 +25,6 @@ linear_converters, plotting, results, - results_linopy, solvers, ) diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py index 8c0cdc7f3..e91b3ee65 100644 --- a/flixOpt/calculation.py +++ b/flixOpt/calculation.py @@ -29,7 +29,7 @@ from .solvers import _Solver from .structure import SystemModel, copy_and_convert_datatypes, get_compact_representation from .config import CONFIG -from .results_linopy import CalculationResults +from .results import CalculationResults logger = logging.getLogger('flixOpt') diff --git a/flixOpt/commons.py b/flixOpt/commons.py index cfbf86eae..d88a882ed 100644 --- a/flixOpt/commons.py +++ b/flixOpt/commons.py @@ -2,7 +2,7 @@ This module makes the commonly used classes and functions available in the flixOpt framework. """ -from . import linear_converters, plotting, results, results_linopy, solvers +from . import linear_converters, plotting, results, solvers from .aggregation import AggregationParameters from .calculation import AggregatedCalculation, FullCalculation, SegmentedCalculation from .components import ( @@ -44,5 +44,4 @@ 'results', 'linear_converters', 'solvers', - 'results_linopy', ] diff --git a/flixOpt/results.py b/flixOpt/results.py index d88ea6c23..4048d887d 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -1,563 +1,196 @@ -""" -This module contains the Results functionality of the flixOpt framework. -It provides high level functions to analyze the results of a calculation. -It leverages the plotting.py module to plot the results. -The results can also be analyzed without this module, as the results are stored in a widely supported format. -""" - import datetime import json import logging import pathlib -import timeit -from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Tuple, Union +from typing import Dict, List, Literal, Union, Optional, TYPE_CHECKING +import linopy import numpy as np import pandas as pd -import plotly -import yaml +import xarray as xr + +from . import plotting, utils +from .core import TimeSeriesCollection -from flixOpt import plotting, utils +from .io import _results_structure if TYPE_CHECKING: - import matplotlib.pyplot as plt - import plotly.graph_objects as go - import pyvis + from .calculation import Calculation + logger = logging.getLogger('flixOpt') -class ElementResults: - def __init__(self, infos: Dict, results: Dict): - self.all_infos = infos - self.all_results = results - self.label = self.all_infos['label'] +class CalculationResults: + """ + Results for a Calculation. + This class is used to collect the results of a Calculation. + It is used to analyze the results and to visualize the results. + + Parameters + ---------- + model : linopy.Model + The linopy model that was used to solve the calculation. + flow_system_structure : Dict[str, Dict[str, Dict]] + The structure of the flow_system that was used to solve the calculation. + + Attributes + ---------- + model : linopy.Model + The linopy model that was used to solve the calculation. + components : Dict[str, ComponentResults] + A dictionary of ComponentResults for each component in the flow_system. + buses : Dict[str, BusResults] + A dictionary of BusResults for each bus in the flow_system. + effects : Dict[str, EffectResults] + A dictionary of EffectResults for each effect in the flow_system. + timesteps_extra : pd.DatetimeIndex + The extra timesteps of the flow_system. + periods : pd.Index + The periods of the flow_system. + hours_per_timestep : xr.DataArray + The duration of each timestep in hours. + + Class Methods + ------- + from_file(folder: Union[str, pathlib.Path], name: str) + Create CalculationResults directly from file. + from_calculation(calculation: Calculation) + Create CalculationResults directly from a Calculation. - def __repr__(self): - return f'{self.__class__.__name__}({self.label})' + """ + @classmethod + def from_file(cls, folder: Union[str, pathlib.Path], name: str): + """ Create CalculationResults directly from file""" + folder = pathlib.Path(folder) + path = folder / name + model = linopy.read_netcdf(path.with_suffix('.nc')) + with open(path.with_suffix('.json'), 'r', encoding='utf-8') as f: + flow_system_structure = json.load(f) + logger.info(f'Loaded calculation "{name}" from file ({path})') + return cls(model, flow_system_structure, name) + + @classmethod + def from_calculation(cls, calculation: 'Calculation'): + """Create CalculationResults directly from a Calculation""" + return cls(calculation.model, _results_structure(calculation.flow_system), calculation.name) + + def __init__(self, model: linopy.Model, flow_system_structure: Dict[str, Dict[str, Dict]], name: str): + self.model = model + self._flow_system_structure = flow_system_structure + self.name = name + self.components = {label: ComponentResults.from_json(self, infos) + for label, infos in flow_system_structure['Components'].items()} + + self.buses = {label: BusResults.from_json(self, infos) + for label, infos in flow_system_structure['Buses'].items()} + + self.effects = {label: EffectResults.from_json(self, infos) + for label, infos in flow_system_structure['Effects'].items()} + + self.timesteps_extra = pd.DatetimeIndex([datetime.datetime.fromisoformat(date) for date in flow_system_structure['Time']]) + self.periods = pd.Index(flow_system_structure['Periods']) if flow_system_structure['Periods'] is not None else None + self.hours_per_timestep = TimeSeriesCollection.create_hours_per_timestep(self.timesteps_extra, self.periods) + + def __getitem__(self, key: str) -> Union['ComponentResults', 'BusResults', 'EffectResults']: + if key in self.components: + return self.components[key] + if key in self.buses: + return self.buses[key] + if key in self.effects: + return self.effects[key] + raise KeyError(f'No element with label {key} found.') + + def to_file(self, folder: Union[str, pathlib.Path], name: Optional[str] = None, *args, **kwargs): + """Save the results to a file""" + folder = pathlib.Path(folder) + name = self.name if name is None else name + path = folder / name + if not folder.exists(): + try: + folder.mkdir(parents=False) + except FileNotFoundError as e: + raise FileNotFoundError(f'Folder {folder} and its parent do not exist. Please create them first.') from e + + self.model.to_netcdf(path.with_suffix('.nc'), *args, **kwargs) + with open(path.with_suffix('.json'), 'w', encoding='utf-8') as f: + json.dump(self._flow_system_structure, f, indent=4, ensure_ascii=False) + logger.info(f'Saved calculation "{name}" to {path}') + + +class _ElementResults: + @classmethod + def from_json(cls, calculation_results, json_data: Dict) -> '_ElementResults': + return cls(calculation_results, + json_data['label'], + json_data['variables'], + json_data['constraints']) + + def __init__(self, + calculation_results: CalculationResults, + label: str, + variables: List[str], + constraints: List[str]): + self._calculation_results = calculation_results + self.label = label + self._variables = variables + self._constraints = constraints + + self.variables = self._calculation_results.model.variables[self._variables] + self.constraints = self._calculation_results.model.constraints[self._constraints] @property - def variables_flat(self) -> Dict[str, Union[int, float, np.ndarray]]: - return flatten_dict(self.all_results) + def variables_time(self): + return self.variables[[name for name in self._variables if 'time' in self.variables[name].dims]] + + +class _NodeResults(_ElementResults): + @classmethod + def from_json(cls, calculation_results, json_data: Dict) -> '_NodeResults': + return cls(calculation_results, + json_data['label'], + json_data['variables'], + json_data['constraints'], + json_data['inputs'], + json_data['outputs']) + + def __init__(self, + calculation_results: CalculationResults, + label: str, + variables: List[str], + constraints: List[str], + inputs: Dict[str, xr.DataArray], + outputs: Dict[str, xr.DataArray]): + super().__init__(calculation_results, label, variables, constraints) + self.inputs = inputs + self.outputs = outputs + def plot_balance(self, show: bool = True): + return plotting.with_plotly(self.operation_balance(), + mode='area', + title=f'Operation Balance of {self.label}', + show=show) + + def operation_balance(self, negate_inputs: bool = True, negate_outputs: bool = False): + df = self.variables_time.solution.to_dataframe() + if negate_outputs: + df[self.outputs] = -df[self.outputs] + if negate_inputs: + df[self.inputs] = -df[self.inputs] + return df -class CalculationResults: - def __init__(self, calculation_name: str, folder: str) -> None: - self.name = calculation_name - self.folder = pathlib.Path(folder) - self._path_infos = self.folder / f'{calculation_name}_infos.yaml' - self._path_data = self.folder / f'{calculation_name}_data.json' - self._path_results = self.folder / f'{calculation_name}_results.json' - - start_time = timeit.default_timer() - with open(self._path_infos, 'rb') as f: - self.calculation_infos: Dict = yaml.safe_load(f) - logger.info(f'Loading Calculation Infos from .yaml took {(timeit.default_timer() - start_time):>8.2f} seconds') - - start_time = timeit.default_timer() - with open(self._path_results, 'rb') as f: - self.all_results: Dict = json.load(f) - self.all_results = utils.convert_numeric_lists_to_arrays(self.all_results) - logger.info(f'Loading results from .json took {(timeit.default_timer() - start_time):>8.2f} seconds') - - start_time = timeit.default_timer() - with open(self._path_data, 'rb') as f: - self.all_data: Dict = json.load(f) - self.all_data = utils.convert_numeric_lists_to_arrays(self.all_data) - logger.info(f'Loading data from .json took {(timeit.default_timer() - start_time):>8.2f} seconds') - - self.component_results: Dict[str, ComponentResults] = {} - self.effect_results: Dict[str, EffectResults] = {} - self.bus_results: Dict[str, BusResults] = {} - - self.time_with_end = np.array( - [datetime.datetime.fromisoformat(date) for date in self.all_results['Time']] - ).astype('datetime64') - self.time = self.time_with_end[:-1] - self.time_intervals_in_hours = np.array(self.all_results['Time intervals in hours']) - - self._construct_component_results() - self._construct_bus_results() - self._construct_effect_results() - - def _construct_component_results(self): - comp_results = self.all_results['Components'] - comp_infos = self.all_data['Components'] - if not comp_results.keys() == comp_infos.keys(): - logger.warning(f'Missing Component or mismatched keys: {comp_results.keys() ^ comp_infos.keys()}') - - for key in comp_results.keys(): - infos, results = comp_infos.get(key, {}), comp_results.get(key, {}) - res = ComponentResults(infos, results) - self.component_results[res.label] = res - - def _construct_effect_results(self): - effect_results = self.all_results['Effects'] - effect_infos = self.all_data['Effects'] - effect_infos['penalty'] = {'label': 'Penalty'} - if not effect_results.keys() == effect_infos.keys(): - logger.warning(f'Missing Effect or mismatched keys: {effect_results.keys() ^ effect_infos.keys()}') - - for key in effect_results.keys(): - infos, results = effect_infos.get(key, {}), effect_results.get(key, {}) - res = EffectResults(infos, results) - self.effect_results[res.label] = res - - def _construct_bus_results(self): - """This has to be called after _construct_component_results(), as its using the Flows from the Components""" - bus_results = self.all_results['Buses'] - bus_infos = self.all_data['Buses'] - if not bus_results.keys() == bus_infos.keys(): - logger.warning(f'Missing Bus or mismatched keys: {bus_results.keys() ^ bus_infos.keys()}') - - for bus_label in bus_results.keys(): - infos, results = bus_infos.get(bus_label, {}), bus_results.get(bus_label, {}) - inputs = [ - flow - for flow in self.flow_results().values() - if bus_label == flow.bus_label and not flow.is_input_in_component - ] - outputs = [ - flow - for flow in self.flow_results().values() - if bus_label == flow.bus_label and flow.is_input_in_component - ] - res = BusResults(infos, results, inputs, outputs) - self.bus_results[res.label] = res - - def flow_results(self) -> Dict[str, 'FlowResults']: - return { - flow.label_full: flow for comp in self.component_results.values() for flow in comp.inputs + comp.outputs - } - - def to_dataframe( - self, - label: str, - variable_name: str = 'flow_rate', - input_factor: Optional[Literal[1, -1]] = -1, - output_factor: Optional[Literal[1, -1]] = 1, - threshold: Optional[float] = 1e-5, - with_last_time_step: bool = True, - ) -> pd.DataFrame: - """ - Convert results of a specified element to a DataFrame. - - Parameters - ---------- - label : str - The label of the element (Component, Bus, or Flow) to retrieve data for. - variable_name : str, default='flow_rate' - The name of the variable to extract from the element's data. - input_factor : Optional[Literal[1, -1]], default=-1 - Factor to apply to input values. - output_factor : Optional[Literal[1, -1]], default=1 - Factor to apply to output values. - threshold : Optional[float], default=1e-5 - Minimum absolute value for data inclusion in the DataFrame. - with_last_time_step : bool, default=True - Whether to include the last time step in the DataFrame index. - - Returns - ------- - pd.DataFrame - A DataFrame containing the specified variable's data with a datetime index. - Dataframe is empty (no index), if no values are left after filtering. - - Raises - ------ - ValueError - If no data is found for the specified variable. - """ - - comp_or_bus = {**self.component_results, **self.bus_results}.get(label, None) - flow = self.flow_results().get(label, None) - - if comp_or_bus is not None and flow is not None: - raise Exception(f'{label=} matches both a Flow and a Component/Bus. That is an internal Error!') - elif comp_or_bus is not None: - df = comp_or_bus.to_dataframe(variable_name, input_factor, output_factor) - elif flow is not None: - df = flow.to_dataframe(variable_name) - else: - raise ValueError(f'No Element found with {label=}') - - if threshold is not None: - df = df.loc[:, ((df > threshold) | (df < -1 * threshold)).any()] # Check if any value exceeds the threshold - if df.empty: # If no values are left, return an empty DataFrame - return df - - if with_last_time_step: - if len(df) == len(self.time): - df.loc[len(df)] = df.iloc[-1] - df.index = self.time_with_end - elif len(df) == len(self.time_with_end): - df.index = self.time_with_end - else: - df.index = self.time - return df +class BusResults(_NodeResults): + """Results for a Bus""" - def plot_operation( - self, - label: str, - mode: Literal['bar', 'line', 'area', 'heatmap'] = 'area', - variable_name: str = 'flow_rate', - heatmap_periods: Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'] = 'D', - heatmap_steps_per_period: Literal['W', 'D', 'h', '15min', 'min'] = 'h', - colors: Union[str, List[str]] = 'viridis', - engine: Literal['plotly', 'matplotlib'] = 'plotly', - invert: bool = True, - show: bool = True, - save: bool = False, - path: Union[str, pathlib.Path, Literal['auto']] = 'auto', - ) -> Union['go.Figure', Tuple['plt.Figure', 'plt.Axes']]: - """ - Plots the operation results for a specified Element using the chosen plotting engine and mode. - - Parameters - ---------- - label : str - The label of the element to plot (e.g., a component or bus). - mode : {'bar', 'line', 'area', 'heatmap'}, default='area' - The type of plot to generate. - variable_name : str, default='flow_rate' - The variable to plot from the element's data. - heatmap_periods : {'YS', 'MS', 'W', 'D', 'h', '15min', 'min'}, default='D' - The period for heatmap plotting. - heatmap_steps_per_period : {'W', 'D', 'h', '15min', 'min'}, default='h' - The steps per period for heatmap plotting. - colors : str or List[str], default='viridis' - The colors or colorscale to use for the plot. - engine : {'plotly', 'matplotlib'}, default='plotly' - The plotting engine to use. - invert : bool, default=False - Whether to invert the input and output factors. - show : bool, default=True - Whether to display the plot immediately. (This includes saving the plot to file when engine='plotly') - save : bool, default=False - Whether to save the plot to a file. - path : Union[str, pathlib.Path, Literal['auto']], default='auto' - The path to save the plot to. If 'auto', the plot is saved to an automatically named file. - - Returns - ------- - Union[go.Figure, Tuple[plt.Figure, plt.Axes]] - The generated plot object, either a Plotly figure or a Matplotlib figure and axes. - - Raises - ------ - ValueError - If an invalid engine or color configuration is provided for heatmap mode. - """ - - if mode == 'heatmap' and not np.all(self.time_intervals_in_hours == self.time_intervals_in_hours[0]): - logger.warning( - 'Heat map plotting with irregular time intervals in time series can lead to unwanted effects' - ) - if mode == 'heatmap' and not isinstance(colors, str): - raise ValueError( - f'For a heatmap, you need to pass the colors as a valid name of a colormap, not {colors=}.' - f'Try "Turbo", "Hot", or "Viridis" instead.' - ) - - title = f'{variable_name.replace("_", " ").title()} of {label}' - if path == 'auto': - file_suffix = 'html' if engine == 'plotly' else 'png' - if mode == 'heatmap': - path = self.folder / f'{title} ({mode} {heatmap_periods}-{heatmap_steps_per_period}).{file_suffix}' - else: - path = self.folder / f'{title} ({mode}).{file_suffix}' - - data = self.to_dataframe( - label, variable_name, input_factor=-1 if not invert else 1, output_factor=1 if not invert else -1 - ) - if mode == 'heatmap': - heatmap_data = plotting.heat_map_data_from_df(data, heatmap_periods, heatmap_steps_per_period, 'ffill') - - if engine == 'plotly': - if mode == 'heatmap': - return plotting.heat_map_plotly( - heatmap_data, title=title, color_map=colors, show=show, save=save, path=path - ) - else: - return plotting.with_plotly( - data=data, mode=mode, show=show, title=title, colors=colors, save=save, path=path - ) - - elif engine == 'matplotlib': - if mode == 'heatmap': - return plotting.heat_map_matplotlib( - heatmap_data, color_map=colors, show=show, path=path if save else None - ) - else: - return plotting.with_matplotlib( - data=data, mode=mode, colors=colors, show=show, path=path if save else None - ) - else: - raise ValueError(f'Unknown Engine: {engine=}') - - def plot_storage( - self, - label: str, - variable_name: str = 'flow_rate', - mode: Literal['bar', 'line', 'area'] = 'area', - colors: Union[str, List[str]] = 'viridis', - invert: bool = True, - show: bool = True, - save: bool = False, - path: Union[str, pathlib.Path, Literal['auto']] = 'auto', - ): - """ - Plots the storage operation results for a specified Storage Element, including its charge state. - - Parameters - ---------- - label : str - The label of the Storage to plot - variable_name : str, default='flow_rate' - The variable to plot from the element's data. - mode : {'bar', 'line', 'area'}, default='area' - The type of plot to generate. - colors : str or List[str], default='viridis' - The colors or colorscale to use for the plot. - invert : bool, default=True - Whether to invert the input and output factors. - show : bool, default=True - Whether to display the plot immediately. (This includes saving the plot to file when engine='plotly') - save : bool, default=False - Whether to save the plot to a file. - path : Union[str, pathlib.Path, Literal['auto']], default='auto' - The path to save the plot to. If 'auto', the plot is saved to an automatically named file. - - Returns - ------- - plotly.graph_objs.Figure - The generated Plotly figure object with the storage operation plot. - """ - fig = self.plot_operation( - label, mode, variable_name, invert=invert, engine='plotly', show=False, colors=colors, save=False - ) - fig.add_trace( - plotly.graph_objs.Scatter( - x=self.time_with_end, - y={**self.component_results, **self.bus_results}[label].variables['charge_state'], - mode='lines', - name='Charge State', - ) - ) - - title = f'{variable_name.replace("_", " ").title()} and Charge State of {label}' - fig.update_layout(title=title) - - if path == 'auto': - path = self.folder / f'{title} ({mode}).html' - path = path.as_posix() - if show: - plotly.offline.plot(fig, filename=path) - elif save: # If show, the file is saved anyway - fig.write_html(path) - - return fig - - def visualize_network( - self, - path: Union[bool, str, pathlib.Path] = 'results/network.html', - controls: Union[ - bool, - List[ - Literal['nodes', 'edges', 'layout', 'interaction', 'manipulation', 'physics', 'selection', 'renderer'] - ], - ] = True, - show: bool = True, - ) -> Optional['pyvis.network.Network']: - """ - Visualizes the network structure of a FlowSystem using PyVis, saving it as an interactive HTML file. - - Parameters - ---------- - path : Union[bool, str, pathlib.Path], default='results/network.html' - Path to save the HTML visualization. If False, the visualization is created but not saved. - controls : Union[bool, List[str]], default=True - UI controls to add to the visualization. True enables all available controls, or specify a list of controls. - show : bool, default=True - Whether to open the visualization in the web browser. - - Returns - ------- - Optional[pyvis.network.Network] - The Network instance representing the visualization, or None if pyvis is not installed. - - Notes - ----- - This function requires pyvis. If not installed, the function prints a warning and returns None. - Nodes are styled based on type (e.g., circles for buses, boxes for components) and annotated with node information. - """ - from . import plotting - - return plotting.visualize_network( - self.calculation_infos['Network']['Nodes'], self.calculation_infos['Network']['Edges'], path, controls, show - ) - - -class FlowResults(ElementResults): - def __init__(self, infos: Dict, results: Dict, label_of_component: str) -> None: - super().__init__(infos, results) - self.is_input_in_component = self.all_infos['is_input_in_component'] - self.component_label = label_of_component - self.bus_label = self.all_infos['bus']['label'] - self.label_full = f'{label_of_component}__{self.label}' - self.variables = self.all_results - - def to_dataframe(self, variable_name: str = 'flow_rate') -> pd.DataFrame: - return pd.DataFrame({variable_name: self.variables[variable_name]}) - - -class ComponentResults(ElementResults): - def __init__(self, infos: Dict, results: Dict): - super().__init__(infos, results) - inputs, outputs = self._create_flow_results() - self.inputs: List[FlowResults] = inputs - self.outputs: List[FlowResults] = outputs - self.variables = {key: val for key, val in self.all_results.items() if key not in self.inputs + self.outputs} - - def _create_flow_results(self) -> Tuple[List[FlowResults], List[FlowResults]]: - flow_infos = {flow['label']: flow for flow in self.all_infos['inputs'] + self.all_infos['outputs']} - flow_results = {flow_info['label']: self.all_results[flow_info['label']] for flow_info in flow_infos.values()} - flows = [ - FlowResults(flow_info, flow_result, self.label) - for flow_info, flow_result in zip(flow_infos.values(), flow_results.values(), strict=False) - ] - inputs = [flow for flow in flows if flow.is_input_in_component] - outputs = [flow for flow in flows if not flow.is_input_in_component] - return inputs, outputs - - def to_dataframe( - self, - variable_name: str = 'flow_rate', - input_factor: Optional[Literal[1, -1]] = -1, - output_factor: Optional[Literal[1, -1]] = 1, - ) -> pd.DataFrame: - inputs, outputs = {}, {} - if input_factor is not None: - inputs = {flow.label_full: (flow.variables[variable_name] * input_factor) for flow in self.inputs} - if output_factor is not None: - outputs = {flow.label_full: flow.variables[variable_name] * output_factor for flow in self.outputs} - - return pd.DataFrame(data={**inputs, **outputs}) - - -class BusResults(ElementResults): - def __init__(self, infos: Dict, results: Dict, inputs: List[FlowResults], outputs: List[FlowResults]): - super().__init__(infos, results) - self.inputs = inputs - self.outputs = outputs - self.variables = {key: val for key, val in self.all_results.items() if key not in self.inputs + self.outputs} - - def to_dataframe( - self, - variable_name: str = 'flow_rate', - input_factor: Optional[Literal[1, -1]] = -1, - output_factor: Optional[Literal[1, -1]] = 1, - ) -> pd.DataFrame: - inputs, outputs = {}, {} - if input_factor is not None: - inputs = {flow.label_full: (flow.variables[variable_name] * input_factor) for flow in self.inputs} - if 'excess_input' in self.variables: - inputs['Excess Input'] = self.variables['excess_input'] * input_factor - if output_factor is not None: - outputs = {flow.label_full: flow.variables[variable_name] * output_factor for flow in self.outputs} - if 'excess_output' in self.variables: - outputs['Excess Output'] = self.variables['excess_output'] * output_factor - - return pd.DataFrame(data={**inputs, **outputs}) - - -class EffectResults(ElementResults): - pass - - -def extract_single_result( - results_data: dict[str, Dict[str, Union[int, float, np.ndarray, dict]]], keys: List[str] -) -> Optional[Union[int, float, np.ndarray]]: - """Goes through a nested dictionary with the given keys. Returns the value if found. Else returns None""" - for key in keys: - if isinstance(results_data, dict): - results_data = results_data.get(key, None) - else: - return None - return results_data - - -def extract_results( - results_data: dict[str, Dict[str, Union[int, float, np.ndarray, dict]]], keys: List[str], keep_none: bool = False -) -> Dict[str, Union[int, float, np.ndarray]]: - """For each item in a dictionary, goes through its sub dictionaries. - Returns the value if found. Else returns None. If specified, removes all None values - """ - data = {kind: extract_single_result(results_data.get(kind, {}), keys) for kind in results_data.keys()} - if keep_none: - return data - else: - return {key: value for key, value in data.items() if value is not None} +class ComponentResults(_NodeResults): + """Results for a Component""" -def flatten_dict(d, parent_key='', sep='__'): - """ - Recursively flattens a nested dictionary. - Parameters: - d (dict): The dictionary to flatten. - parent_key (str): The base key for the current recursion level. - sep (str): The separator to use when concatenating keys. +class EffectResults(_ElementResults): + """Results for an Effect""" + + def get_shares_from(self, element: str): + return self.variables[[name for name in self._variables if name.startswith(f'{element}->')]] - Returns: - dict: A flattened dictionary. - """ - items = [] - for k, v in d.items(): - new_key = f'{parent_key}{sep}{k}' if parent_key else k # Combine parent key and current key - if isinstance(v, dict): # If the value is a nested dictionary, recurse - items.extend(flatten_dict(v, new_key, sep=sep).items()) - else: # Otherwise, just add the key-value pair - if new_key not in items: - items.append((new_key, v)) - else: - for i in range(100000): - new_key = f'{new_key}_#{i}' - if new_key not in items: - items.append((new_key, v)) - break - return dict(items) - - -if __name__ == '__main__': - results = CalculationResults( - 'Sim1', '/Users/felix/Documents/Dokumente - eigene/Neuer Ordner/flixOpt-Fork/examples/Ex02_complex/results' - ) - - results.to_dataframe('Kessel') - results.plot_flow_rate('Kessel__Q_fu', 'heatmap') - plotting.heat_map_plotly( - plotting.heat_map_data_from_df( - pd.DataFrame(results.component_results['Speicher'].variables['charge_state'], index=results.time_with_end), - periods='D', - steps_per_period='15min', - ) - ) - - results.plot_operation('Fernwärme', 'area', engine='plotly') - fig = results.plot_operation('Fernwärme', 'area', engine='plotly') - fig = plotting.with_plotly(results.to_dataframe('Wärmelast'), 'line', fig=fig) - import plotly.offline - - plotly.offline.plot(fig) - - extract_results(results.all_results['Components'], ['Q_th', 'flow_rate']) - extract_single_result(results.all_results['Components'], ['Kessel', 'Q_th', 'flow_rate']) - - fig = plotting.with_plotly( - pd.DataFrame(extract_results(results.all_results['Components'], ['OnOff', 'on']), index=results.time), - mode='bar', - ) - fig.update_layout(barmode='group', bargap=0.2, bargroupgap=0.1) - plotly.offline.plot(fig) diff --git a/flixOpt/results_linopy.py b/flixOpt/results_linopy.py deleted file mode 100644 index e693da551..000000000 --- a/flixOpt/results_linopy.py +++ /dev/null @@ -1,191 +0,0 @@ -import datetime -import json -import logging -import pathlib -from typing import Dict, List, Literal, Union, Optional, TYPE_CHECKING - -import linopy -import numpy as np -import pandas as pd -import xarray as xr - -from . import plotting, utils -from .core import TimeSeriesCollection - -from .io import _results_structure - -if TYPE_CHECKING: - from .calculation import Calculation - - -logger = logging.getLogger('flixOpt') - - -class CalculationResults: - """ - Results for a Calculation. - This class is used to collect the results of a Calculation. - It is used to analyze the results and to visualize the results. - - Parameters - ---------- - model : linopy.Model - The linopy model that was used to solve the calculation. - flow_system_structure : Dict[str, Dict[str, Dict]] - The structure of the flow_system that was used to solve the calculation. - - Attributes - ---------- - model : linopy.Model - The linopy model that was used to solve the calculation. - components : Dict[str, ComponentResults] - A dictionary of ComponentResults for each component in the flow_system. - buses : Dict[str, BusResults] - A dictionary of BusResults for each bus in the flow_system. - effects : Dict[str, EffectResults] - A dictionary of EffectResults for each effect in the flow_system. - timesteps_extra : pd.DatetimeIndex - The extra timesteps of the flow_system. - periods : pd.Index - The periods of the flow_system. - hours_per_timestep : xr.DataArray - The duration of each timestep in hours. - - Class Methods - ------- - from_file(folder: Union[str, pathlib.Path], name: str) - Create CalculationResults directly from file. - from_calculation(calculation: Calculation) - Create CalculationResults directly from a Calculation. - - """ - @classmethod - def from_file(cls, folder: Union[str, pathlib.Path], name: str): - """ Create CalculationResults directly from file""" - folder = pathlib.Path(folder) - path = folder / name - model = linopy.read_netcdf(path.with_suffix('.nc')) - with open(path.with_suffix('.json'), 'r', encoding='utf-8') as f: - flow_system_structure = json.load(f) - logger.info(f'Loaded calculation "{name}" from file ({path})') - return cls(model, flow_system_structure, name) - - @classmethod - def from_calculation(cls, calculation: 'Calculation'): - """Create CalculationResults directly from a Calculation""" - return cls(calculation.model, _results_structure(calculation.flow_system), calculation.name) - - def __init__(self, model: linopy.Model, flow_system_structure: Dict[str, Dict[str, Dict]], name: str): - self.model = model - self._flow_system_structure = flow_system_structure - self.name = name - self.components = {label: ComponentResults.from_json(self, infos) - for label, infos in flow_system_structure['Components'].items()} - - self.buses = {label: BusResults.from_json(self, infos) - for label, infos in flow_system_structure['Buses'].items()} - - self.effects = {label: EffectResults.from_json(self, infos) - for label, infos in flow_system_structure['Effects'].items()} - - self.timesteps_extra = pd.DatetimeIndex([datetime.datetime.fromisoformat(date) for date in flow_system_structure['Time']]) - self.periods = pd.Index(flow_system_structure['Periods']) if flow_system_structure['Periods'] is not None else None - self.hours_per_timestep = TimeSeriesCollection.create_hours_per_timestep(self.timesteps_extra, self.periods) - - def __getitem__(self, key: str) -> Union['ComponentResults', 'BusResults', 'EffectResults']: - if key in self.components: - return self.components[key] - if key in self.buses: - return self.buses[key] - if key in self.effects: - return self.effects[key] - raise KeyError(f'No element with label {key} found.') - - def to_file(self, folder: Union[str, pathlib.Path], name: Optional[str] = None, *args, **kwargs): - """Save the results to a file""" - folder = pathlib.Path(folder) - name = self.name if name is None else name - path = folder / name - - self.model.to_netcdf(path, *args, **kwargs) - with open(path.with_suffix('.json'), 'w', encoding='utf-8') as f: - json.dump(self._flow_system_structure, f, indent=4, ensure_ascii=False) - logger.info(f'Saved calculation "{name}" to {path}') - - -class _ElementResults: - @classmethod - def from_json(cls, calculation_results, json_data: Dict) -> '_ElementResults': - return cls(calculation_results, - json_data['label'], - json_data['variables'], - json_data['constraints']) - - def __init__(self, - calculation_results: CalculationResults, - label: str, - variables: List[str], - constraints: List[str]): - self._calculation_results = calculation_results - self.label = label - self._variables = variables - self._constraints = constraints - - self.variables = self._calculation_results.model.variables[self._variables] - self.constraints = self._calculation_results.model.constraints[self._constraints] - - @property - def variables_time(self): - return self.variables[[name for name in self._variables if 'time' in self.variables[name].dims]] - - -class _NodeResults(_ElementResults): - @classmethod - def from_json(cls, calculation_results, json_data: Dict) -> '_NodeResults': - return cls(calculation_results, - json_data['label'], - json_data['variables'], - json_data['constraints'], - json_data['inputs'], - json_data['outputs']) - - def __init__(self, - calculation_results: CalculationResults, - label: str, - variables: List[str], - constraints: List[str], - inputs: Dict[str, xr.DataArray], - outputs: Dict[str, xr.DataArray]): - super().__init__(calculation_results, label, variables, constraints) - self.inputs = inputs - self.outputs = outputs - - def plot_balance(self, show: bool = True): - return plotting.with_plotly(self.operation_balance(), - mode='area', - title=f'Operation Balance of {self.label}', - show=show) - - def operation_balance(self, negate_inputs: bool = True, negate_outputs: bool = False): - df = self.variables_time.solution.to_dataframe() - if negate_outputs: - df[self.outputs] = -df[self.outputs] - if negate_inputs: - df[self.inputs] = -df[self.inputs] - return df - - -class BusResults(_NodeResults): - """Results for a Bus""" - - -class ComponentResults(_NodeResults): - """Results for a Component""" - - -class EffectResults(_ElementResults): - """Results for an Effect""" - - def get_shares_from(self, element: str): - return self.variables[[name for name in self._variables if name.startswith(f'{element}->')]] - diff --git a/tests/test_functional.py b/tests/test_functional.py index 5b2c78ced..c79323a91 100644 --- a/tests/test_functional.py +++ b/tests/test_functional.py @@ -96,7 +96,7 @@ def flow_system_minimal(timesteps) -> fx.FlowSystem: def solve_and_load( flow_system: fx.FlowSystem, solver -) -> fx.results_linopy.CalculationResults: +) -> fx.results.CalculationResults: calculation = fx.FullCalculation('Calculation', flow_system) calculation.do_modeling() calculation.solve(solver, True) From 7196a131af289dc84d6a84db8bb283981b6aa0cc Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 24 Feb 2025 17:40:37 +0100 Subject: [PATCH 221/507] BUGFIX in plotting.py --- flixOpt/plotting.py | 1 + 1 file changed, 1 insertion(+) diff --git a/flixOpt/plotting.py b/flixOpt/plotting.py index b3771148a..1bd43e0ea 100644 --- a/flixOpt/plotting.py +++ b/flixOpt/plotting.py @@ -124,6 +124,7 @@ def with_plotly( # Split columns into positive, negative, and mixed categories positive_columns = list(data.columns[(data >= 0).all()]) negative_columns = list(data.columns[(data <= 0).all()]) + negative_columns = [column for column in negative_columns if column not in positive_columns] mixed_columns = list(set(data.columns) - set(positive_columns + negative_columns)) if mixed_columns: logger.warning( From a62bf88240d71dd55935c927b5f1ab9bc0d2270b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 24 Feb 2025 17:47:02 +0100 Subject: [PATCH 222/507] Update minimal_example.py --- examples/00_Minmal/minimal_example.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/examples/00_Minmal/minimal_example.py b/examples/00_Minmal/minimal_example.py index 3cb08095a..525f18898 100644 --- a/examples/00_Minmal/minimal_example.py +++ b/examples/00_Minmal/minimal_example.py @@ -57,12 +57,16 @@ # --- Solve the Calculation and Save Results --- calculation.solve(fx.solvers.HighsSolver(0.01, 60), save_results=True) - # --- Load and Analyze Results --- - # Load results and plot the operation of the District Heating Bus - results = fx.results.CalculationResults(calculation.name, folder='results') - results.plot_operation('District Heating', 'area') + # --- Analyze Results --- + # Access the results of an element + df = calculation.results['costs'].variables_time.solution.to_dataframe() - # Print results to the console. Check Results in file or perform more plotting - pprint(calculation.results) - pprint('Look into .yaml and .json file for results') - pprint(calculation.flow_system.model.main_results) + # Plot the results of a specific element + calculation.results['District Heating'].plot_balance() + + # Save results to a file + df = calculation.results['costs'].variables_time.solution.to_dataframe() + # df.to_csv('results/District Heating.csv') # Save results to csv + + # Print infos to the console. + pprint(calculation.infos) From 09f4e65b737a1843584a94ad0158b07cfe4855dd Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 24 Feb 2025 22:14:26 +0100 Subject: [PATCH 223/507] Added a .save_results() method --- flixOpt/calculation.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py index e91b3ee65..987fc3cfe 100644 --- a/flixOpt/calculation.py +++ b/flixOpt/calculation.py @@ -75,11 +75,6 @@ def __init__( except FileNotFoundError as e: raise FileNotFoundError(f'Folder {self.folder} and its parent do not exist. Please create them first.') from e - def to_yaml(self): - """Save the results to a yaml file""" - with open(self.folder / f'{self.name}_infos.yaml', 'w', encoding='utf-8') as f: - yaml.dump(self.infos, f, allow_unicode=True, sort_keys=False, indent=4) - @property def main_results(self) -> Dict[str, Union[Scalar, Dict]]: from flixOpt.features import InvestmentModel @@ -152,7 +147,6 @@ def do_modeling(self) -> SystemModel: def solve(self, solver: _Solver, - save_results: bool = True, log_file: Optional[pathlib.Path] = None, log_main_results: bool = True): t_start = timeit.default_timer() @@ -172,8 +166,18 @@ def solve(self, ) self.results = CalculationResults.from_calculation(self) - if save_results: - self.results.to_file(self.folder, self.name) + + def save_results(self): + """ + Saves the results of the calculation to a folder with the name of the calculation. + The folder is created if it does not exist. + + The CalculationResults are saved as a .nc and a .json file. + The calculation infos are saved as a .yaml file. + """ + with open(self.folder / f'{self.name}_infos.yaml', 'w', encoding='utf-8') as f: + yaml.dump(self.infos, f, allow_unicode=True, sort_keys=False, indent=4) + self.results.to_file(self.folder, self.name) def _activate_time_series(self): self.flow_system.transform_data() From 12f960d15344e4e906507a320b6b12cf43516688 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 24 Feb 2025 22:19:28 +0100 Subject: [PATCH 224/507] Update minimal_example.py --- examples/00_Minmal/minimal_example.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/00_Minmal/minimal_example.py b/examples/00_Minmal/minimal_example.py index 525f18898..c1a756b6f 100644 --- a/examples/00_Minmal/minimal_example.py +++ b/examples/00_Minmal/minimal_example.py @@ -55,7 +55,7 @@ calculation.do_modeling() # --- Solve the Calculation and Save Results --- - calculation.solve(fx.solvers.HighsSolver(0.01, 60), save_results=True) + calculation.solve(fx.solvers.HighsSolver(0.01, 60)) # --- Analyze Results --- # Access the results of an element From 97b6dd1d2453d2d632802bb7c3fb7c8b3c37aa09 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 24 Feb 2025 23:06:39 +0100 Subject: [PATCH 225/507] Add excess to inputs and outputs of Bus --- flixOpt/elements.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/flixOpt/elements.py b/flixOpt/elements.py index 3e089a8d7..73ead1385 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -490,9 +490,13 @@ def do_modeling(self) -> None: ) def results_structure(self): - return {**super().results_structure(), - 'inputs': [flow.model.flow_rate.name for flow in self.element.inputs], - 'outputs': [flow.model.flow_rate.name for flow in self.element.outputs]} + inputs = [flow.model.flow_rate.name for flow in self.element.inputs] + outputs = [flow.model.flow_rate.name for flow in self.element.outputs] + if self.excess_input is not None: + inputs.append(self.excess_input.name) + if self.excess_output is not None: + outputs.append(self.excess_output.name) + return {**super().results_structure(), 'inputs': inputs, 'outputs': outputs} def solution_structured( self, From e775f6266202f2b7761a1ce9b051893ea6653528 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 24 Feb 2025 23:06:52 +0100 Subject: [PATCH 226/507] Improve plotting.py --- flixOpt/plotting.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flixOpt/plotting.py b/flixOpt/plotting.py index 1bd43e0ea..0158a766f 100644 --- a/flixOpt/plotting.py +++ b/flixOpt/plotting.py @@ -122,8 +122,8 @@ def with_plotly( elif mode == 'area': data[(data > -1e-5) & (data < 1e-5)] = 0 # Preventing issues with plotting # Split columns into positive, negative, and mixed categories - positive_columns = list(data.columns[(data >= 0).all()]) - negative_columns = list(data.columns[(data <= 0).all()]) + positive_columns = list(data.columns[(data >= 0).where(~np.isnan(data), True).all()]) + negative_columns = list(data.columns[(data <= 0).where(~np.isnan(data), True).all()]) negative_columns = [column for column in negative_columns if column not in positive_columns] mixed_columns = list(set(data.columns) - set(positive_columns + negative_columns)) if mixed_columns: From d1a8303224134951ec0c25067d5abc1b44320d77 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 24 Feb 2025 23:45:08 +0100 Subject: [PATCH 227/507] Improvements --- flixOpt/results.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/flixOpt/results.py b/flixOpt/results.py index 4048d887d..5b9392971 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -88,8 +88,8 @@ def __init__(self, model: linopy.Model, flow_system_structure: Dict[str, Dict[st self.effects = {label: EffectResults.from_json(self, infos) for label, infos in flow_system_structure['Effects'].items()} - self.timesteps_extra = pd.DatetimeIndex([datetime.datetime.fromisoformat(date) for date in flow_system_structure['Time']]) - self.periods = pd.Index(flow_system_structure['Periods']) if flow_system_structure['Periods'] is not None else None + self.timesteps_extra = pd.DatetimeIndex([datetime.datetime.fromisoformat(date) for date in flow_system_structure['Time']], name='time') + self.periods = pd.Index(flow_system_structure['Periods'], name = 'period') if flow_system_structure['Periods'] is not None else None self.hours_per_timestep = TimeSeriesCollection.create_hours_per_timestep(self.timesteps_extra, self.periods) def __getitem__(self, key: str) -> Union['ComponentResults', 'BusResults', 'EffectResults']: @@ -159,8 +159,8 @@ def __init__(self, label: str, variables: List[str], constraints: List[str], - inputs: Dict[str, xr.DataArray], - outputs: Dict[str, xr.DataArray]): + inputs: List[str], + outputs: List[str]): super().__init__(calculation_results, label, variables, constraints) self.inputs = inputs self.outputs = outputs From e016af2e663309f15acc0eb47c2433354256d1a7 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 24 Feb 2025 23:45:41 +0100 Subject: [PATCH 228/507] Add methods to quickly get flow and storage results --- flixOpt/results.py | 80 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 73 insertions(+), 7 deletions(-) diff --git a/flixOpt/results.py b/flixOpt/results.py index 5b9392971..223fbf6da 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -8,6 +8,7 @@ import numpy as np import pandas as pd import xarray as xr +import plotly from . import plotting, utils from .core import TimeSeriesCollection @@ -165,19 +166,48 @@ def __init__(self, self.inputs = inputs self.outputs = outputs - def plot_balance(self, show: bool = True): - return plotting.with_plotly(self.operation_balance(), + def plot_flow_rates(self, show: bool = True): + return plotting.with_plotly(self.flow_rates(with_last_timestep=True).to_dataframe(), mode='area', title=f'Operation Balance of {self.label}', show=show) - def operation_balance(self, negate_inputs: bool = True, negate_outputs: bool = False): - df = self.variables_time.solution.to_dataframe() + def flow_rates(self, + negate_inputs: bool = True, + negate_outputs: bool = False, + threshold: Optional[float] = 1e-5, + with_last_timestep: bool = False) -> xr.Dataset: + variables = [name for name in self.variables if name.endswith(('|flow_rate', '|excess_input', '|excess_output'))] + ds = self._sanitize_dataset( + ds=self.variables[variables].solution, + threshold=threshold, + with_last_timestep=with_last_timestep + ) + self._negate_flows(ds, negate_inputs, negate_outputs) + return ds + + def _sanitize_dataset(self, + ds: xr.Dataset, + threshold: Optional[float] = 1e-5, + with_last_timestep: bool = False) -> xr.Dataset: + if threshold is not None: + abs_ds = xr.apply_ufunc(np.abs, ds) + vars_to_drop = [var for var in ds.data_vars if (abs_ds[var] <= threshold).all()] + ds = ds.drop_vars(vars_to_drop) + if with_last_timestep and not ds.indexes['time'].equals(self._calculation_results.timesteps_extra): + ds = ds.reindex({'time': self._calculation_results.timesteps_extra}, fill_value=np.nan) + return ds + + def _negate_flows(self, ds: xr.Dataset, negate_outputs: bool = False, negate_inputs: bool = True) -> xr.Dataset: if negate_outputs: - df[self.outputs] = -df[self.outputs] + for name in self.outputs: + if name in ds: + ds[name] = -ds[name] if negate_inputs: - df[self.inputs] = -df[self.inputs] - return df + for name in self.inputs: + if name in ds: + ds[name] = -ds[name] + return ds class BusResults(_NodeResults): @@ -187,6 +217,42 @@ class BusResults(_NodeResults): class ComponentResults(_NodeResults): """Results for a Component""" + def is_storage(self): + return self._charge_state in self.variables + + @property + def _charge_state(self) -> str: + return f'{self.label}|charge_state' + + @property + def charge_state(self) -> linopy.Variable: + return self.variables[self._charge_state] + + def plot_charge_state_and_flow_rates(self, show: bool = True) -> plotly.graph_objs._figure.Figure: + fig = plotting.with_plotly(self.flow_rates(with_last_timestep=True).to_dataframe(), + mode='area', + title=f'Operation Balance of {self.label}', + show=show) + charge_state = self.charge_state.solution.to_dataframe() + fig.add_trace(plotly.graph_objs.Scatter(x=charge_state.index, + y=charge_state.values, + mode='lines', + name=self.charge_state.name)) + return fig + + def charge_state_and_flow_rates(self, + negate_inputs: bool = True, + negate_outputs: bool = False, + threshold: Optional[float] = 1e-5) -> xr.Dataset: + variables = self.inputs + self.outputs + [self._charge_state] + ds = self._sanitize_dataset( + ds=self.variables[variables].solution, + threshold=threshold, + with_last_timestep=True + ) + self._negate_flows(ds, negate_inputs, negate_outputs) + return ds + class EffectResults(_ElementResults): """Results for an Effect""" From b3b2ae23ca9a5b64be85623a0ee67c76644edb9d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 24 Feb 2025 23:54:39 +0100 Subject: [PATCH 229/507] Improve plot_charge_state --- flixOpt/results.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/flixOpt/results.py b/flixOpt/results.py index 223fbf6da..44d83c179 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -228,16 +228,18 @@ def _charge_state(self) -> str: def charge_state(self) -> linopy.Variable: return self.variables[self._charge_state] - def plot_charge_state_and_flow_rates(self, show: bool = True) -> plotly.graph_objs._figure.Figure: + def plot_charge_state(self, show: bool = True) -> plotly.graph_objs._figure.Figure: fig = plotting.with_plotly(self.flow_rates(with_last_timestep=True).to_dataframe(), mode='area', title=f'Operation Balance of {self.label}', - show=show) + show=False) charge_state = self.charge_state.solution.to_dataframe() fig.add_trace(plotly.graph_objs.Scatter(x=charge_state.index, - y=charge_state.values, + y=charge_state.values.flatten(), mode='lines', name=self.charge_state.name)) + if show: + fig.show() return fig def charge_state_and_flow_rates(self, From 5ff07f31c8d5dbbae1549c76e2873545076a8bec Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 25 Feb 2025 00:14:00 +0100 Subject: [PATCH 230/507] Add method to plot heatmap --- flixOpt/results.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/flixOpt/results.py b/flixOpt/results.py index 44d83c179..67f785cdb 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -118,6 +118,22 @@ def to_file(self, folder: Union[str, pathlib.Path], name: Optional[str] = None, json.dump(self._flow_system_structure, f, indent=4, ensure_ascii=False) logger.info(f'Saved calculation "{name}" to {path}') + def plot_heatmap(self, + variable: str, + heatmap_timeframes: Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'] = 'D', + heatmap_timesteps_per_frame: Literal['W', 'D', 'h', '15min', 'min'] = 'h', + color_map: str = 'portland', + ): + variable = self.model.variables[variable] + data = variable.solution.to_dataframe() + heatmap_data = plotting.heat_map_data_from_df(data, heatmap_timeframes, heatmap_timesteps_per_frame, 'ffill') + fig = plotting.heat_map_plotly( + heatmap_data, title=variable.name, color_map=color_map, + xlabel=f'timeframe [{heatmap_timeframes}]', ylabel=f'timesteps [{heatmap_timesteps_per_frame}]' + ) + fig.show() + return fig + class _ElementResults: @classmethod From 804ab4f638a6c1ada1219bb511f9ebb831023f97 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 25 Feb 2025 00:16:58 +0100 Subject: [PATCH 231/507] Add check before getting charge states --- flixOpt/results.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/flixOpt/results.py b/flixOpt/results.py index 67f785cdb..c61ab042f 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -10,7 +10,7 @@ import xarray as xr import plotly -from . import plotting, utils +from . import plotting from .core import TimeSeriesCollection from .io import _results_structure @@ -134,6 +134,10 @@ def plot_heatmap(self, fig.show() return fig + @property + def storages(self) -> List['ComponentResults']: + return [comp for comp in self.components.values() if comp.is_storage] + class _ElementResults: @classmethod @@ -233,7 +237,8 @@ class BusResults(_NodeResults): class ComponentResults(_NodeResults): """Results for a Component""" - def is_storage(self): + @property + def is_storage(self) -> bool: return self._charge_state in self.variables @property @@ -242,9 +247,13 @@ def _charge_state(self) -> str: @property def charge_state(self) -> linopy.Variable: + if not self.is_storage: + raise ValueError(f'Cant get charge_state. "{self.label}" is not a storage') return self.variables[self._charge_state] def plot_charge_state(self, show: bool = True) -> plotly.graph_objs._figure.Figure: + if not self.is_storage: + raise ValueError(f'Cant plot charge_state. "{self.label}" is not a storage') fig = plotting.with_plotly(self.flow_rates(with_last_timestep=True).to_dataframe(), mode='area', title=f'Operation Balance of {self.label}', @@ -262,6 +271,8 @@ def charge_state_and_flow_rates(self, negate_inputs: bool = True, negate_outputs: bool = False, threshold: Optional[float] = 1e-5) -> xr.Dataset: + if not self.is_storage: + raise ValueError(f'Cant get charge_state. "{self.label}" is not a storage') variables = self.inputs + self.outputs + [self._charge_state] ds = self._sanitize_dataset( ds=self.variables[variables].solution, @@ -277,4 +288,3 @@ class EffectResults(_ElementResults): def get_shares_from(self, element: str): return self.variables[[name for name in self._variables if name.startswith(f'{element}->')]] - From a0cd25a4c8e0ee7559c916f9aaaa46425a116679 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 25 Feb 2025 01:00:01 +0100 Subject: [PATCH 232/507] Add folder attribute to Calculation results and add utility function to save and show plots --- flixOpt/results.py | 86 ++++++++++++++++++++++++++++++++++++---------- 1 file changed, 68 insertions(+), 18 deletions(-) diff --git a/flixOpt/results.py b/flixOpt/results.py index c61ab042f..a8e004fcd 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -69,17 +69,22 @@ def from_file(cls, folder: Union[str, pathlib.Path], name: str): with open(path.with_suffix('.json'), 'r', encoding='utf-8') as f: flow_system_structure = json.load(f) logger.info(f'Loaded calculation "{name}" from file ({path})') - return cls(model, flow_system_structure, name) + return cls(model=model, flow_system_structure=flow_system_structure, name=name, folder=folder) @classmethod def from_calculation(cls, calculation: 'Calculation'): """Create CalculationResults directly from a Calculation""" - return cls(calculation.model, _results_structure(calculation.flow_system), calculation.name) + return cls(model=calculation.model, + flow_system_structure=_results_structure(calculation.flow_system), + name=calculation.name, + folder = calculation.folder) - def __init__(self, model: linopy.Model, flow_system_structure: Dict[str, Dict[str, Dict]], name: str): + def __init__(self, model: linopy.Model, flow_system_structure: Dict[str, Dict[str, Dict]], name: str, + folder: Optional[pathlib.Path] = None): self.model = model self._flow_system_structure = flow_system_structure self.name = name + self.folder = pathlib.Path(folder) if folder is not None else pathlib.Path.cwd() / 'results' self.components = {label: ComponentResults.from_json(self, infos) for label, infos in flow_system_structure['Components'].items()} @@ -123,6 +128,8 @@ def plot_heatmap(self, heatmap_timeframes: Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'] = 'D', heatmap_timesteps_per_frame: Literal['W', 'D', 'h', '15min', 'min'] = 'h', color_map: str = 'portland', + save: Union[bool, pathlib.Path] = True, + show: bool = True ): variable = self.model.variables[variable] data = variable.solution.to_dataframe() @@ -131,8 +138,12 @@ def plot_heatmap(self, heatmap_data, title=variable.name, color_map=color_map, xlabel=f'timeframe [{heatmap_timeframes}]', ylabel=f'timesteps [{heatmap_timesteps_per_frame}]' ) - fig.show() - return fig + return plotly_save_and_show( + fig, + self.folder / f'{variable.name} ({heatmap_timeframes}-{heatmap_timesteps_per_frame}).html', + user_filename=None if isinstance(save, bool) else pathlib.Path(save), + show=show, + save=True if save else False) @property def storages(self) -> List['ComponentResults']: @@ -186,11 +197,18 @@ def __init__(self, self.inputs = inputs self.outputs = outputs - def plot_flow_rates(self, show: bool = True): - return plotting.with_plotly(self.flow_rates(with_last_timestep=True).to_dataframe(), - mode='area', - title=f'Operation Balance of {self.label}', - show=show) + def plot_flow_rates(self, + save: Union[bool, pathlib.Path] = True, + show: bool = True): + fig = plotting.with_plotly( + self.flow_rates(with_last_timestep=True).to_dataframe(), mode='area', title=f'Flow rates of {self.label}' + ) + return plotly_save_and_show( + fig, + self._calculation_results.folder / f'{self.label} (flow rates).html', + user_filename=None if isinstance(save, bool) else pathlib.Path(save), + show=show, + save=True if save else False) def flow_rates(self, negate_inputs: bool = True, @@ -251,7 +269,9 @@ def charge_state(self) -> linopy.Variable: raise ValueError(f'Cant get charge_state. "{self.label}" is not a storage') return self.variables[self._charge_state] - def plot_charge_state(self, show: bool = True) -> plotly.graph_objs._figure.Figure: + def plot_charge_state(self, + save: Union[bool, pathlib.Path] = True, + show: bool = True) -> plotly.graph_objs._figure.Figure: if not self.is_storage: raise ValueError(f'Cant plot charge_state. "{self.label}" is not a storage') fig = plotting.with_plotly(self.flow_rates(with_last_timestep=True).to_dataframe(), @@ -259,13 +279,15 @@ def plot_charge_state(self, show: bool = True) -> plotly.graph_objs._figure.Figu title=f'Operation Balance of {self.label}', show=False) charge_state = self.charge_state.solution.to_dataframe() - fig.add_trace(plotly.graph_objs.Scatter(x=charge_state.index, - y=charge_state.values.flatten(), - mode='lines', - name=self.charge_state.name)) - if show: - fig.show() - return fig + fig.add_trace(plotly.graph_objs.Scatter( + x=charge_state.index, y=charge_state.values.flatten(), mode='lines', name=self.charge_state.name)) + + return plotly_save_and_show( + fig, + self._calculation_results.folder / f'{self.label} (charge state).html', + user_filename=None if isinstance(save, bool) else pathlib.Path(save), + show=show, + save=True if save else False) def charge_state_and_flow_rates(self, negate_inputs: bool = True, @@ -288,3 +310,31 @@ class EffectResults(_ElementResults): def get_shares_from(self, element: str): return self.variables[[name for name in self._variables if name.startswith(f'{element}->')]] + + +def plotly_save_and_show(fig: plotly.graph_objs.Figure, + default_filename: pathlib.Path, + user_filename: Optional[pathlib.Path] = None, + show: bool = True, + save: bool = False) -> plotly.graph_objs.Figure: + """ + Optionally saves and/or displays a Plotly figure. + + Parameters: + - fig (go.Figure): The Plotly figure to display or save. + - default_filename (Path): The default file path if no user filename is provided. + - user_filename (Optional[Path]): An optional user-specified file path. + - show (bool): Whether to display the figure (default: True). + - save (bool): Whether to save the figure (default: False). + + Returns: + - go.Figure: The input figure. + """ + filename = user_filename or default_filename + if show and not save: + fig.show() + elif save and show: + plotly.offline.plot(fig, filename=str(filename)) + elif save and not show: + fig.write_html(filename) + return fig \ No newline at end of file From 7626eefa57e9423097118d71fe3a830f67950085 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 25 Feb 2025 09:55:03 +0100 Subject: [PATCH 233/507] Update segmented calculation --- flixOpt/calculation.py | 111 ++--------------------------------------- 1 file changed, 3 insertions(+), 108 deletions(-) diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py index 987fc3cfe..a2b401a62 100644 --- a/flixOpt/calculation.py +++ b/flixOpt/calculation.py @@ -329,6 +329,7 @@ def __init__( self.sub_calculations: List[FullCalculation] = [] self.all_timesteps = self.flow_system.time_series_collection.all_timesteps + self.all_timesteps_extra = self.flow_system.time_series_collection.all_timesteps_extra self.segment_names = [f'Segment_{i + 1}' for i in range(math.ceil(len(self.all_timesteps) / self.timesteps_per_segment))] self.active_timesteps_per_segment = self._calculate_timesteps_of_segment() @@ -352,7 +353,6 @@ def __init__( def do_modeling_and_solve( self, solver: _Solver, - save_results: bool = True, log_file: Optional[pathlib.Path] = None, log_main_results: bool = False): logger.info(f'{"":#^80}') @@ -365,7 +365,7 @@ def do_modeling_and_solve( logger.info(f'{segment_name} [{i+1:>2}/{len(self.segment_names):<2}] ' f'({timesteps_of_segment[0]} -> {timesteps_of_segment[-1]}):') - calculation = FullCalculation(segment_name, self.flow_system, active_timesteps=timesteps_of_segment) + calculation = FullCalculation(f'{self.name}-{segment_name}', self.flow_system, active_timesteps=timesteps_of_segment) self.sub_calculations.append(calculation) calculation.do_modeling() invest_elements = [ @@ -379,7 +379,7 @@ def do_modeling_and_solve( f'Investments are not supported in Segmented Calculation! ' f'Following InvestmentModels were found: {invest_elements}' ) - calculation.solve(solver, save_results=save_results, + calculation.solve(solver, log_file=pathlib.Path(log_file) if log_file is not None else self.folder / f'{self.name}.log', log_main_results=log_main_results) @@ -441,108 +441,3 @@ def start_values_of_segments(self) -> Dict[int, Dict[str, Any]]: }, **{i: start_values for i, start_values in enumerate(self._transfered_start_values, 1)}, } - - -def _remove_none_values(d: Dict[Any, Optional[Any]]) -> Dict[Any, Any]: - # Remove None values from a dictionary - return {k: _remove_none_values(v) if isinstance(v, dict) else v for k, v in d.items() if v is not None} - - -def _remove_empty_dicts(d: Dict[Any, Any]) -> Dict[Any, Any]: - """Recursively removes empty dictionaries from a nested dictionary.""" - return { - k: _remove_empty_dicts(v) if isinstance(v, dict) else v - for k, v in d.items() - if not isinstance(v, dict) or _remove_empty_dicts(v) - } - - -def _combine_nested_arrays( - *dicts: Dict[str, Union[NumericData, dict]], - trim: Optional[int] = None, - length_per_array: Optional[int] = None, -) -> Dict[str, Union[np.ndarray, dict]]: - """ - Combines multiple dictionaries with identical structures by concatenating their arrays, - with optional trimming. Filters out all other values. - - Parameters - ---------- - *dicts : Dict[str, Union[np.ndarray, dict]] - Dictionaries with matching structures and NumericData values. - trim : int, optional - Number of elements to trim from the end of each array except the last. Defaults to None. - length_per_array : int, optional - Trims the arrays to the desired length. Defaults to None. - If None, then trim is used. - - Returns - ------- - Dict[str, Union[np.ndarray, dict]] - A single dictionary with concatenated arrays at each key, ignoring non-array values. - - Example - ------- - >>> dict1 = {'a': np.array([1, 2, 3]), 'b': {'c': np.array([4, 5, 6])}} - >>> dict2 = {'a': np.array([7, 8, 9]), 'b': {'c': np.array([10, 11, 12])}} - >>> _combine_nested_arrays(dict1, dict2, trim=1) - {'a': array([1, 2, 7, 8, 9]), 'b': {'c': array([4, 5, 10, 11, 12])}} - """ - assert (trim is None) != (length_per_array is None), ( - 'Either trim or length_per_array must be provided,But not both!' - ) - - def combine_arrays_recursively( - *values: Union[NumericData, Dict[str, NumericData], Any], - ) -> Optional[Union[np.ndarray, Dict[str, Union[np.ndarray, dict]]]]: - if all(isinstance(val, dict) for val in values): # If all values are dictionaries, recursively combine each key - return {key: combine_arrays_recursively(*(val[key] for val in values)) for key in values[0]} - - if all(isinstance(val, np.ndarray) for val in values) and all(val.ndim != 0 for val in values): - - def limit(idx: int, arr: np.ndarray) -> np.ndarray: - # Performs the trimming of the arrays. Doesn't trim the last array! - if trim and idx < len(values) - 1: - return arr[:-trim] - elif length_per_array and idx < len(values) - 1: - return arr[:length_per_array] - return arr - - values: List[np.ndarray] - return np.concatenate([limit(idx, arr) for idx, arr in enumerate(values)]) - - else: # Ignore non-array values - return None - - combined_arrays = combine_arrays_recursively(*dicts) - combined_arrays = _remove_none_values(combined_arrays) - return _remove_empty_dicts(combined_arrays) - - -def _combine_nested_scalars(*dicts: Dict[str, Union[NumericData, dict]]) -> Dict[str, Union[List[Scalar], dict]]: - """ - Combines multiple dictionaries with identical structures by combining its skalar values to a list. - Filters out all other values. - - Parameters - ---------- - *dicts : Dict[str, Union[np.ndarray, dict]] - Dictionaries with matching structures and NumericData values. - """ - - def combine_scalars_recursively( - *values: Union[NumericData, Dict[str, NumericData], Any], - ) -> Optional[Union[List[Scalar], Dict[str, Union[List[Scalar], dict]]]]: - # If all values are dictionaries, recursively combine each key - if all(isinstance(val, dict) for val in values): - return {key: combine_scalars_recursively(*(val[key] for val in values)) for key in values[0]} - - # Concatenate arrays with optional trimming - if all(np.isscalar(val) for val in values): - return [val for val in values] - else: # Ignore non-skalar values - return None - - combined_scalars = combine_scalars_recursively(*dicts) - combined_scalars = _remove_none_values(combined_scalars) - return _remove_empty_dicts(combined_scalars) From 364266252dbcef358af001e10e2efbefca525dcf Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 25 Feb 2025 09:55:26 +0100 Subject: [PATCH 234/507] Add SegmentedCalculationResults --- flixOpt/results.py | 87 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 85 insertions(+), 2 deletions(-) diff --git a/flixOpt/results.py b/flixOpt/results.py index a8e004fcd..93f081890 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -16,7 +16,7 @@ from .io import _results_structure if TYPE_CHECKING: - from .calculation import Calculation + from .calculation import Calculation, SegmentedCalculation logger = logging.getLogger('flixOpt') @@ -337,4 +337,87 @@ def plotly_save_and_show(fig: plotly.graph_objs.Figure, plotly.offline.plot(fig, filename=str(filename)) elif save and not show: fig.write_html(filename) - return fig \ No newline at end of file + return fig + +class SegmentedCalculationResults: + """ + Class to store the results of a SegmentedCalculation. + """ + @classmethod + def from_calculation(cls, calculation: SegmentedCalculation): + return cls([CalculationResults.from_calculation(calc) for calc in calculation.sub_calculations], + all_timesteps=calculation.all_timesteps, + timesteps_per_segment=calculation.timesteps_per_segment, + overlap_timesteps=calculation.overlap_timesteps, + name=calculation.name, + folder=calculation.folder) + + @classmethod + def from_file(cls, folder: Union[str, pathlib.Path], name: str): + """ Create SegmentedCalculationResults directly from file""" + folder = pathlib.Path(folder) + path = folder / name + with open(path.with_suffix('.json'), 'r', encoding='utf-8') as f: + meta_data = json.load(f) + logger.info(f'Loaded calculation "{name}" from file ({path})') + return cls( + [CalculationResults.from_file(folder, name) for name in meta_data['sub_calculations']], + all_timesteps=pd.DatetimeIndex([datetime.datetime.fromisoformat(date) + for date in meta_data['all_timesteps']], name='time'), + timesteps_per_segment=meta_data['timesteps_per_segment'], + overlap_timesteps=meta_data['overlap_timesteps'], + name=name, + folder=folder + ) + + def __init__(self, + segment_results: List[CalculationResults], + all_timesteps: pd.DatetimeIndex, + timesteps_per_segment: int, + overlap_timesteps: int, + name: str, + folder: Optional[pathlib.Path] = None): + self.segment_results = segment_results + self.all_timesteps = all_timesteps + self.timesteps_per_segment = timesteps_per_segment + self.overlap_timesteps = overlap_timesteps + self.name = name + self.folder = pathlib.Path(folder) if folder is not None else pathlib.Path.cwd() / 'results' + self.hours_per_timestep = TimeSeriesCollection.create_hours_per_timestep(self.all_timesteps, None) + + def solution_without_overlap(self, variable: str) -> xr.DataArray: + """Returns the solution of a variable without overlap""" + dataarrays = [result.model.variables[variable].solution.isel(time=slice(None, self.timesteps_per_segment)) + for result in self.segment_results[:-1] + ] + [self.segment_results[-1].model.variables[variable].solution] + return xr.concat(dataarrays, dim='time') + + def to_file(self, folder: Optional[Union[str, pathlib.Path]] = None, name: Optional[str] = None, *args, **kwargs): + """Save the results to a file""" + folder = self.folder if folder is None else pathlib.Path(folder) + name = self.name if name is None else name + path = folder / name + if not folder.exists(): + try: + folder.mkdir(parents=False) + except FileNotFoundError as e: + raise FileNotFoundError(f'Folder {folder} and its parent do not exist. Please create them first.') from e + for segment in self.segment_results: + segment.to_file(folder, f'{name}-{segment.name}') + + with open(path.with_suffix('.json'), 'w', encoding='utf-8') as f: + json.dump(self.meta_data, f, indent=4, ensure_ascii=False) + logger.info(f'Saved calculation "{name}" to {path}') + + @property + def meta_data(self) -> Dict[str, Union[int, List[str]]]: + return { + 'all_timesteps': [datetime.datetime.isoformat(date) for date in self.all_timesteps], + 'timesteps_per_segment': self.timesteps_per_segment, + 'overlap_timesteps': self.overlap_timesteps, + 'sub_calculations': [calc.name for calc in self.segment_results] + } + + @property + def segment_names(self) -> List[str]: + return [segment.name for segment in self.segment_results] \ No newline at end of file From a193d48a4c82e8f8190aff07357ec74ac8941a6f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 25 Feb 2025 10:51:22 +0100 Subject: [PATCH 235/507] Improve plot_heatmap --- flixOpt/results.py | 125 +++++++++++++++++++++++++++++---------------- 1 file changed, 82 insertions(+), 43 deletions(-) diff --git a/flixOpt/results.py b/flixOpt/results.py index 93f081890..70aa12fad 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -130,20 +130,16 @@ def plot_heatmap(self, color_map: str = 'portland', save: Union[bool, pathlib.Path] = True, show: bool = True - ): - variable = self.model.variables[variable] - data = variable.solution.to_dataframe() - heatmap_data = plotting.heat_map_data_from_df(data, heatmap_timeframes, heatmap_timesteps_per_frame, 'ffill') - fig = plotting.heat_map_plotly( - heatmap_data, title=variable.name, color_map=color_map, - xlabel=f'timeframe [{heatmap_timeframes}]', ylabel=f'timesteps [{heatmap_timesteps_per_frame}]' - ) - return plotly_save_and_show( - fig, - self.folder / f'{variable.name} ({heatmap_timeframes}-{heatmap_timesteps_per_frame}).html', - user_filename=None if isinstance(save, bool) else pathlib.Path(save), - show=show, - save=True if save else False) + ) -> plotly.graph_objs.Figure: + return plot_heatmap( + dataarray=self.model.variables[variable].solution, + name=variable, + folder=self.folder, + heatmap_timeframes=heatmap_timeframes, + heatmap_timesteps_per_frame=heatmap_timesteps_per_frame, + color_map=color_map, + save=save, + show=show) @property def storages(self) -> List['ComponentResults']: @@ -312,39 +308,12 @@ def get_shares_from(self, element: str): return self.variables[[name for name in self._variables if name.startswith(f'{element}->')]] -def plotly_save_and_show(fig: plotly.graph_objs.Figure, - default_filename: pathlib.Path, - user_filename: Optional[pathlib.Path] = None, - show: bool = True, - save: bool = False) -> plotly.graph_objs.Figure: - """ - Optionally saves and/or displays a Plotly figure. - - Parameters: - - fig (go.Figure): The Plotly figure to display or save. - - default_filename (Path): The default file path if no user filename is provided. - - user_filename (Optional[Path]): An optional user-specified file path. - - show (bool): Whether to display the figure (default: True). - - save (bool): Whether to save the figure (default: False). - - Returns: - - go.Figure: The input figure. - """ - filename = user_filename or default_filename - if show and not save: - fig.show() - elif save and show: - plotly.offline.plot(fig, filename=str(filename)) - elif save and not show: - fig.write_html(filename) - return fig - class SegmentedCalculationResults: """ Class to store the results of a SegmentedCalculation. """ @classmethod - def from_calculation(cls, calculation: SegmentedCalculation): + def from_calculation(cls, calculation: 'SegmentedCalculation'): return cls([CalculationResults.from_calculation(calc) for calc in calculation.sub_calculations], all_timesteps=calculation.all_timesteps, timesteps_per_segment=calculation.timesteps_per_segment, @@ -392,6 +361,25 @@ def solution_without_overlap(self, variable: str) -> xr.DataArray: ] + [self.segment_results[-1].model.variables[variable].solution] return xr.concat(dataarrays, dim='time') + def plot_heatmap( + self, + variable: str, + heatmap_timeframes: Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'] = 'D', + heatmap_timesteps_per_frame: Literal['W', 'D', 'h', '15min', 'min'] = 'h', + color_map: str = 'portland', + save: Union[bool, pathlib.Path] = True, + show: bool = True + ) -> plotly.graph_objs.Figure: + return plot_heatmap( + dataarray=self.solution_without_overlap(variable), + name=variable, + folder=self.folder, + heatmap_timeframes=heatmap_timeframes, + heatmap_timesteps_per_frame=heatmap_timesteps_per_frame, + color_map=color_map, + save=save, + show=show) + def to_file(self, folder: Optional[Union[str, pathlib.Path]] = None, name: Optional[str] = None, *args, **kwargs): """Save the results to a file""" folder = self.folder if folder is None else pathlib.Path(folder) @@ -420,4 +408,55 @@ def meta_data(self) -> Dict[str, Union[int, List[str]]]: @property def segment_names(self) -> List[str]: - return [segment.name for segment in self.segment_results] \ No newline at end of file + return [segment.name for segment in self.segment_results] + + +def plotly_save_and_show(fig: plotly.graph_objs.Figure, + default_filename: pathlib.Path, + user_filename: Optional[pathlib.Path] = None, + show: bool = True, + save: bool = False) -> plotly.graph_objs.Figure: + """ + Optionally saves and/or displays a Plotly figure. + + Parameters: + - fig (go.Figure): The Plotly figure to display or save. + - default_filename (Path): The default file path if no user filename is provided. + - user_filename (Optional[Path]): An optional user-specified file path. + - show (bool): Whether to display the figure (default: True). + - save (bool): Whether to save the figure (default: False). + + Returns: + - go.Figure: The input figure. + """ + filename = user_filename or default_filename + if show and not save: + fig.show() + elif save and show: + plotly.offline.plot(fig, filename=str(filename)) + elif save and not show: + fig.write_html(filename) + return fig + +def plot_heatmap( + dataarray: xr.DataArray, + name: str, + folder: pathlib.Path, + heatmap_timeframes: Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'] = 'D', + heatmap_timesteps_per_frame: Literal['W', 'D', 'h', '15min', 'min'] = 'h', + color_map: str = 'portland', + save: Union[bool, pathlib.Path] = True, + show: bool = True +): + heatmap_data = plotting.heat_map_data_from_df( + dataarray.to_dataframe(name), heatmap_timeframes, heatmap_timesteps_per_frame, 'ffill') + fig = plotting.heat_map_plotly( + heatmap_data, title=name, color_map=color_map, + xlabel=f'timeframe [{heatmap_timeframes}]', ylabel=f'timesteps [{heatmap_timesteps_per_frame}]' + ) + return plotly_save_and_show( + fig, + folder / f'{name} ({heatmap_timeframes}-{heatmap_timesteps_per_frame}).html', + user_filename=None if isinstance(save, bool) else pathlib.Path(save), + show=show, + save=True if save else False) From ec57432fb7f38a26d00d8d7cfa5e9c9f98e67d56 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 25 Feb 2025 10:51:36 +0100 Subject: [PATCH 236/507] Add results to SegmentedCalculation --- flixOpt/calculation.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py index a2b401a62..a30aeaf83 100644 --- a/flixOpt/calculation.py +++ b/flixOpt/calculation.py @@ -29,7 +29,7 @@ from .solvers import _Solver from .structure import SystemModel, copy_and_convert_datatypes, get_compact_representation from .config import CONFIG -from .results import CalculationResults +from .results import CalculationResults, SegmentedCalculationResults logger = logging.getLogger('flixOpt') @@ -389,6 +389,8 @@ def do_modeling_and_solve( for key, value in calc.durations.items(): self.durations[key] += value + self.results = SegmentedCalculationResults.from_calculation(self) + def _transfer_start_values(self, segment_index: int): """ This function gets the last values of the previous solved segment and From 7f9bb95b302ed7017e5e45b78095d7fab0ad8f42 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 25 Feb 2025 10:59:15 +0100 Subject: [PATCH 237/507] Move _sanitize_dataset out of class --- flixOpt/results.py | 46 ++++++++++++++++++++++++++++++---------------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/flixOpt/results.py b/flixOpt/results.py index 70aa12fad..ff9b2fe86 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -212,26 +212,14 @@ def flow_rates(self, threshold: Optional[float] = 1e-5, with_last_timestep: bool = False) -> xr.Dataset: variables = [name for name in self.variables if name.endswith(('|flow_rate', '|excess_input', '|excess_output'))] - ds = self._sanitize_dataset( + ds = _sanitize_dataset( ds=self.variables[variables].solution, threshold=threshold, - with_last_timestep=with_last_timestep + timesteps=self._calculation_results.timesteps_extra if with_last_timestep else None, ) self._negate_flows(ds, negate_inputs, negate_outputs) return ds - def _sanitize_dataset(self, - ds: xr.Dataset, - threshold: Optional[float] = 1e-5, - with_last_timestep: bool = False) -> xr.Dataset: - if threshold is not None: - abs_ds = xr.apply_ufunc(np.abs, ds) - vars_to_drop = [var for var in ds.data_vars if (abs_ds[var] <= threshold).all()] - ds = ds.drop_vars(vars_to_drop) - if with_last_timestep and not ds.indexes['time'].equals(self._calculation_results.timesteps_extra): - ds = ds.reindex({'time': self._calculation_results.timesteps_extra}, fill_value=np.nan) - return ds - def _negate_flows(self, ds: xr.Dataset, negate_outputs: bool = False, negate_inputs: bool = True) -> xr.Dataset: if negate_outputs: for name in self.outputs: @@ -292,10 +280,10 @@ def charge_state_and_flow_rates(self, if not self.is_storage: raise ValueError(f'Cant get charge_state. "{self.label}" is not a storage') variables = self.inputs + self.outputs + [self._charge_state] - ds = self._sanitize_dataset( + ds = _sanitize_dataset( ds=self.variables[variables].solution, threshold=threshold, - with_last_timestep=True + timesteps=self._calculation_results.timesteps_extra, ) self._negate_flows(ds, negate_inputs, negate_outputs) return ds @@ -361,6 +349,7 @@ def solution_without_overlap(self, variable: str) -> xr.DataArray: ] + [self.segment_results[-1].model.variables[variable].solution] return xr.concat(dataarrays, dim='time') + def plot_heatmap( self, variable: str, @@ -438,6 +427,7 @@ def plotly_save_and_show(fig: plotly.graph_objs.Figure, fig.write_html(filename) return fig + def plot_heatmap( dataarray: xr.DataArray, name: str, @@ -460,3 +450,27 @@ def plot_heatmap( user_filename=None if isinstance(save, bool) else pathlib.Path(save), show=show, save=True if save else False) + + +def _sanitize_dataset( + ds: xr.Dataset, + timesteps: Optional[pd.DatetimeIndex] = None, + threshold: Optional[float] = 1e-5) -> xr.Dataset: + """ + Sanitizes a dataset by dropping variables with small values and optionally reindexing the time axis. + + Parameters: + - ds (xr.Dataset): The dataset to sanitize. + - timesteps (Optional[pd.DatetimeIndex]): The timesteps to reindex the dataset to. If None, the original timesteps are kept. + - threshold (Optional[float]): The threshold for dropping variables. If None, no variables are dropped. + + Returns: + - xr.Dataset: The sanitized dataset. + """ + if threshold is not None: + abs_ds = xr.apply_ufunc(np.abs, ds) + vars_to_drop = [var for var in ds.data_vars if (abs_ds[var] <= threshold).all()] + ds = ds.drop_vars(vars_to_drop) + if timesteps is not None and not ds.indexes['time'].equals(timesteps): + ds = ds.reindex({'time': timesteps}, fill_value=np.nan) + return ds From 248f202a39a068edec5d5d8bc06fb4a0aaf30207 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 25 Feb 2025 11:05:57 +0100 Subject: [PATCH 238/507] Improve functions in results.py --- flixOpt/results.py | 37 +++++++++++++++++++------------------ 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/flixOpt/results.py b/flixOpt/results.py index ff9b2fe86..6c59dcbce 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -212,24 +212,16 @@ def flow_rates(self, threshold: Optional[float] = 1e-5, with_last_timestep: bool = False) -> xr.Dataset: variables = [name for name in self.variables if name.endswith(('|flow_rate', '|excess_input', '|excess_output'))] - ds = _sanitize_dataset( + return _sanitize_dataset( ds=self.variables[variables].solution, threshold=threshold, timesteps=self._calculation_results.timesteps_extra if with_last_timestep else None, + negate=( + self.outputs + self.inputs if negate_outputs and negate_inputs + else self.outputs if negate_outputs + else self.inputs if negate_inputs + else None), ) - self._negate_flows(ds, negate_inputs, negate_outputs) - return ds - - def _negate_flows(self, ds: xr.Dataset, negate_outputs: bool = False, negate_inputs: bool = True) -> xr.Dataset: - if negate_outputs: - for name in self.outputs: - if name in ds: - ds[name] = -ds[name] - if negate_inputs: - for name in self.inputs: - if name in ds: - ds[name] = -ds[name] - return ds class BusResults(_NodeResults): @@ -280,13 +272,16 @@ def charge_state_and_flow_rates(self, if not self.is_storage: raise ValueError(f'Cant get charge_state. "{self.label}" is not a storage') variables = self.inputs + self.outputs + [self._charge_state] - ds = _sanitize_dataset( + return _sanitize_dataset( ds=self.variables[variables].solution, threshold=threshold, timesteps=self._calculation_results.timesteps_extra, + negate=( + self.outputs + self.inputs if negate_outputs and negate_inputs + else self.outputs if negate_outputs + else self.inputs if negate_inputs + else None), ) - self._negate_flows(ds, negate_inputs, negate_outputs) - return ds class EffectResults(_ElementResults): @@ -455,7 +450,9 @@ def plot_heatmap( def _sanitize_dataset( ds: xr.Dataset, timesteps: Optional[pd.DatetimeIndex] = None, - threshold: Optional[float] = 1e-5) -> xr.Dataset: + threshold: Optional[float] = 1e-5, + negate: Optional[List[str]] = None, +) -> xr.Dataset: """ Sanitizes a dataset by dropping variables with small values and optionally reindexing the time axis. @@ -463,10 +460,14 @@ def _sanitize_dataset( - ds (xr.Dataset): The dataset to sanitize. - timesteps (Optional[pd.DatetimeIndex]): The timesteps to reindex the dataset to. If None, the original timesteps are kept. - threshold (Optional[float]): The threshold for dropping variables. If None, no variables are dropped. + - negate (Optional[List[str]]): The variables to negate. If None, no variables are negated. Returns: - xr.Dataset: The sanitized dataset. """ + if negate is not None: + for var in negate: + ds[var] = -ds[var] if threshold is not None: abs_ds = xr.apply_ufunc(np.abs, ds) vars_to_drop = [var for var in ds.data_vars if (abs_ds[var] <= threshold).all()] From 35ee9da175495623cb95bac694dca42357e028f1 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 25 Feb 2025 11:09:45 +0100 Subject: [PATCH 239/507] Improve examples --- examples/00_Minmal/minimal_example.py | 2 +- examples/01_Simple/simple_example.py | 20 ++++++++------------ 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/examples/00_Minmal/minimal_example.py b/examples/00_Minmal/minimal_example.py index c1a756b6f..c70a05dee 100644 --- a/examples/00_Minmal/minimal_example.py +++ b/examples/00_Minmal/minimal_example.py @@ -62,7 +62,7 @@ df = calculation.results['costs'].variables_time.solution.to_dataframe() # Plot the results of a specific element - calculation.results['District Heating'].plot_balance() + calculation.results['District Heating'].plot_flow_rates() # Save results to a file df = calculation.results['costs'].variables_time.solution.to_dataframe() diff --git a/examples/01_Simple/simple_example.py b/examples/01_Simple/simple_example.py index d67f98284..96c1df810 100644 --- a/examples/01_Simple/simple_example.py +++ b/examples/01_Simple/simple_example.py @@ -104,17 +104,13 @@ calculation.do_modeling() # Translate the model to a solvable form, creating equations and Variables # --- Solve the Calculation and Save Results --- - calculation.solve(fx.solvers.HighsSolver(mip_gap=0, time_limit_seconds=30), save_results=True) - - # --- Load and Analyze Results --- - # Load the results and plot the operation of the District Heating Bus - results = fx.results.CalculationResults(calculation.name, folder='results') - results.plot_operation('Fernwärme', 'area') - results.plot_storage('Storage') - results.plot_operation('Fernwärme', 'bar') - results.plot_operation('Fernwärme', 'line') - results.plot_operation('CHP__Q_th', 'line') - results.plot_operation('CHP__Q_th', 'heatmap') + calculation.solve(fx.solvers.HighsSolver(mip_gap=0, time_limit_seconds=30)) + + # --- Analyze Results --- + calculation.results['Fernwärme'].plot_flow_rates() + calculation.results['Storage'].plot_flow_rates() + calculation.results.plot_heatmap('CHP (Q_th)|flow_rate') # Convert the results for the storage component to a dataframe and display - results.to_dataframe('Storage') + df = calculation.results['Storage'].charge_state_and_flow_rates() + print(df) From a162291fd59a1a9854ac0a340948c7004ab58a1b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 25 Feb 2025 11:18:02 +0100 Subject: [PATCH 240/507] Improve examples --- examples/02_Complex/complex_example.py | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/examples/02_Complex/complex_example.py b/examples/02_Complex/complex_example.py index d69f64c31..93918c9b3 100644 --- a/examples/02_Complex/complex_example.py +++ b/examples/02_Complex/complex_example.py @@ -181,17 +181,13 @@ calculation = fx.FullCalculation('Sim1', flow_system, time_indices) calculation.do_modeling() - calculation.solve( - fx.solvers.HighsSolver(0.01, 60), - save_results='results', # If and where to save results - ) + calculation.solve(fx.solvers.HighsSolver(0.01, 60)) # --- Results --- - # You can analyze results directly. But it's better to save them to a file and start from there, - # letting you continue at any time - # See complex_example_evaluation.py - used_time_series = timesteps[time_indices] if time_indices else timesteps - # Analyze results directly - fig = fx.plotting.with_plotly( - data=pd.DataFrame(Gaskessel.Q_th.model.flow_rate.solution, index=used_time_series), mode='bar', show=True - ) + # You can analyze results directly or save them to file and reload them later. + calculation.results.to_file() + + # But let's plot some results anyway + calculation.results.plot_heatmap('BHKW2 (Q_th)|flow_rate') + calculation.results['BHKW2'].plot_flow_rates() + calculation.results['Speicher'].plot_charge_state() From 5cf6c96e22a5ca36c051f926a5910e9ee957f0e7 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 25 Feb 2025 11:18:18 +0100 Subject: [PATCH 241/507] Improve file management in results.py --- flixOpt/results.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flixOpt/results.py b/flixOpt/results.py index 6c59dcbce..3593d6b01 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -107,9 +107,9 @@ def __getitem__(self, key: str) -> Union['ComponentResults', 'BusResults', 'Effe return self.effects[key] raise KeyError(f'No element with label {key} found.') - def to_file(self, folder: Union[str, pathlib.Path], name: Optional[str] = None, *args, **kwargs): + def to_file(self, folder: Optional[Union[str, pathlib.Path]] = None, name: Optional[str] = None, *args, **kwargs): """Save the results to a file""" - folder = pathlib.Path(folder) + folder = self.folder if folder is None else pathlib.Path(folder) name = self.name if name is None else name path = folder / name if not folder.exists(): From a3e53e041d9b9d47d19e4de183f867484fe02203 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 25 Feb 2025 11:34:25 +0100 Subject: [PATCH 242/507] Improve logging in results.py and don't save figures by default --- flixOpt/results.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/flixOpt/results.py b/flixOpt/results.py index 3593d6b01..17070e116 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -65,10 +65,11 @@ def from_file(cls, folder: Union[str, pathlib.Path], name: str): """ Create CalculationResults directly from file""" folder = pathlib.Path(folder) path = folder / name - model = linopy.read_netcdf(path.with_suffix('.nc')) + nc_file = path.with_suffix('.nc') + logger.info(f'loading calculation "{name}" from file ("{nc_file}")') + model = linopy.read_netcdf(nc_file) with open(path.with_suffix('.json'), 'r', encoding='utf-8') as f: flow_system_structure = json.load(f) - logger.info(f'Loaded calculation "{name}" from file ({path})') return cls(model=model, flow_system_structure=flow_system_structure, name=name, folder=folder) @classmethod @@ -128,7 +129,7 @@ def plot_heatmap(self, heatmap_timeframes: Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'] = 'D', heatmap_timesteps_per_frame: Literal['W', 'D', 'h', '15min', 'min'] = 'h', color_map: str = 'portland', - save: Union[bool, pathlib.Path] = True, + save: Union[bool, pathlib.Path] = False, show: bool = True ) -> plotly.graph_objs.Figure: return plot_heatmap( @@ -194,7 +195,7 @@ def __init__(self, self.outputs = outputs def plot_flow_rates(self, - save: Union[bool, pathlib.Path] = True, + save: Union[bool, pathlib.Path] = False, show: bool = True): fig = plotting.with_plotly( self.flow_rates(with_last_timestep=True).to_dataframe(), mode='area', title=f'Flow rates of {self.label}' @@ -246,7 +247,7 @@ def charge_state(self) -> linopy.Variable: return self.variables[self._charge_state] def plot_charge_state(self, - save: Union[bool, pathlib.Path] = True, + save: Union[bool, pathlib.Path] = False, show: bool = True) -> plotly.graph_objs._figure.Figure: if not self.is_storage: raise ValueError(f'Cant plot charge_state. "{self.label}" is not a storage') @@ -309,9 +310,10 @@ def from_file(cls, folder: Union[str, pathlib.Path], name: str): """ Create SegmentedCalculationResults directly from file""" folder = pathlib.Path(folder) path = folder / name + nc_file = path.with_suffix('.nc') + logger.info(f'loading calculation "{name}" from file ("{nc_file}")') with open(path.with_suffix('.json'), 'r', encoding='utf-8') as f: meta_data = json.load(f) - logger.info(f'Loaded calculation "{name}" from file ({path})') return cls( [CalculationResults.from_file(folder, name) for name in meta_data['sub_calculations']], all_timesteps=pd.DatetimeIndex([datetime.datetime.fromisoformat(date) @@ -351,7 +353,7 @@ def plot_heatmap( heatmap_timeframes: Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'] = 'D', heatmap_timesteps_per_frame: Literal['W', 'D', 'h', '15min', 'min'] = 'h', color_map: str = 'portland', - save: Union[bool, pathlib.Path] = True, + save: Union[bool, pathlib.Path] = False, show: bool = True ) -> plotly.graph_objs.Figure: return plot_heatmap( @@ -430,7 +432,7 @@ def plot_heatmap( heatmap_timeframes: Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'] = 'D', heatmap_timesteps_per_frame: Literal['W', 'D', 'h', '15min', 'min'] = 'h', color_map: str = 'portland', - save: Union[bool, pathlib.Path] = True, + save: Union[bool, pathlib.Path] = False, show: bool = True ): heatmap_data = plotting.heat_map_data_from_df( From 7048c6f534ff498198621b7b9947690170859e34 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 25 Feb 2025 11:35:32 +0100 Subject: [PATCH 243/507] Improve complex_example.py and complex_example_results.py --- examples/02_Complex/complex_example.py | 2 +- .../02_Complex/complex_example_results.py | 31 +++++-------------- 2 files changed, 9 insertions(+), 24 deletions(-) diff --git a/examples/02_Complex/complex_example.py b/examples/02_Complex/complex_example.py index 93918c9b3..a7dd08de2 100644 --- a/examples/02_Complex/complex_example.py +++ b/examples/02_Complex/complex_example.py @@ -178,7 +178,7 @@ pprint(flow_system) # Get a string representation of the FlowSystem # --- Solve FlowSystem --- - calculation = fx.FullCalculation('Sim1', flow_system, time_indices) + calculation = fx.FullCalculation('complex example', flow_system, time_indices) calculation.do_modeling() calculation.solve(fx.solvers.HighsSolver(0.01, 60)) diff --git a/examples/02_Complex/complex_example_results.py b/examples/02_Complex/complex_example_results.py index 03b3e31bc..c4cfbd5a2 100644 --- a/examples/02_Complex/complex_example_results.py +++ b/examples/02_Complex/complex_example_results.py @@ -10,7 +10,7 @@ if __name__ == '__main__': # --- Load Results --- try: - results = fx.results.CalculationResults('Sim1', folder='results') + results = fx.results.CalculationResults.from_file('results', 'complex example') except FileNotFoundError as e: raise FileNotFoundError( f"Results file not found in the specified directory ('results'). " @@ -19,30 +19,15 @@ ) from e # --- Basic overview --- - results.visualize_network() - results.plot_operation('Fernwärme') - results.plot_operation('Fernwärme', 'bar') - results.plot_operation('Fernwärme', 'bar', engine='matplotlib') + # TODO: Add visualize_network() + results['Fernwärme'].plot_flow_rates() # --- Detailed Plots --- # In depth plot for individual flow rates ('__' is used as the delimiter between Component and Flow - results.plot_operation('Wärmelast__Q_th_Last', 'heatmap') - figs = [] - for flow_label in results.flow_results(): - if flow_label.startswith('BHKW2'): - fig = results.plot_operation(flow_label, 'heatmap', heatmap_steps_per_period='h', heatmap_periods='D') + results.plot_heatmap('Wärmelast (Q_th_Last)|flow_rate') + for flow_rate in results['BHKW2'].inputs + results['BHKW2'].outputs: + results.plot_heatmap(flow_rate) # --- Plotting internal variables manually --- - on_data = pd.DataFrame( - { - 'BHKW2 On': results.component_results['BHKW2'].variables['Q_th']['on'], - 'Kessel On': results.component_results['Kessel'].variables['Q_th']['on'], - }, - index=results.time, - ) - fig = fx.plotting.with_plotly(on_data, 'line') - fig.write_html('results/on.html') # Writing to file - - fig = fx.plotting.with_plotly(on_data, 'bar') - fig.update_layout(barmode='group', bargap=0.1) # Applying custom layout - plotly.offline.plot(fig) + results.plot_heatmap('BHKW2 (Q_th)|on') + results.plot_heatmap('Kessel (Q_th)|on') From 40ebbdf96aad4525a57d190f094689818eeb9db2 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 25 Feb 2025 17:18:28 +0100 Subject: [PATCH 244/507] Update example_calculation_types.py --- .../example_calculation_types.py | 95 ++++++++----------- 1 file changed, 41 insertions(+), 54 deletions(-) diff --git a/examples/03_Calculation_types/example_calculation_types.py b/examples/03_Calculation_types/example_calculation_types.py index 837a67e0c..a79da621d 100644 --- a/examples/03_Calculation_types/example_calculation_types.py +++ b/examples/03_Calculation_types/example_calculation_types.py @@ -8,6 +8,7 @@ import numpy as np import pandas as pd +import xarray as xr from rich.pretty import pprint # Used for pretty printing import flixOpt as fx @@ -36,7 +37,7 @@ # filtered_data = data_import[0:500] # Alternatively filter by index filtered_data.index = pd.to_datetime(filtered_data.index) - datetime_series = filtered_data.index + timesteps = filtered_data.index # Access specific columns and convert to 1D-numpy array electricity_demand = filtered_data['P_Netz/MW'].to_numpy() @@ -137,7 +138,7 @@ ) # Flow System Setup - flow_system = fx.FlowSystem(datetime_series) + flow_system = fx.FlowSystem(timesteps) flow_system.add_effects(costs, CO2, PE) flow_system.add_components( a_gaskessel, @@ -151,23 +152,20 @@ a_speicher, ) flow_system.visualize_network(controls=False) + # Calculations - kinds = ['Full', 'Segmented', 'Aggregated'] - calculations: dict = {key: None for key in kinds} - results: dict = {key: None for key in kinds} + calculations: List[Union[fx.FullCalculation, fx.AggregatedCalculation, fx.SegmentedCalculation]] = [] if full: calculation = fx.FullCalculation('Full', flow_system) calculation.do_modeling() calculation.solve(fx.solvers.HighsSolver(0, 60)) - calculations['Full'] = calculation - results['Full'] = fx.results.CalculationResults('Full', folder='results') - + calculations.append(calculation) + if segmented: calculation = fx.SegmentedCalculation('Segmented', flow_system, segment_length, overlap_length) calculation.do_modeling_and_solve(fx.solvers.HighsSolver(0, 60)) - calculations['Segmented'] = calculation - results['Segmented'] = fx.results.CalculationResults('Segmented', folder='results') + calculations.append(calculation) if aggregated: if keep_extreme_periods: @@ -176,52 +174,41 @@ calculation = fx.AggregatedCalculation('Aggregated', flow_system, aggregation_parameters) calculation.do_modeling() calculation.solve(fx.solvers.HighsSolver(0, 60)) - calculations['Aggregated'] = calculation - results['Aggregated'] = fx.results.CalculationResults('Aggregated', folder='results') - - - time_series_used = flow_system.timesteps - time_series_used_w_end = flow_system.timesteps_extra - - data = pd.DataFrame( - {mode: results[mode].component_results['Speicher'].all_results['charge_state'] for mode in results}, - index=time_series_used_w_end + calculations.append(calculation) + + # Get solutions for plotting for different calculations + def get_solutions(calcs: List, variable: str) -> xr.Dataset: + dataarrays = [] + for calc in calcs: + if calc.name == 'Segmented': + dataarrays.append(calc.results.solution_without_overlap(variable).rename(calc.name)) + else: + dataarrays.append(calc.results.model.variables[variable].solution.rename(calc.name)) + return xr.merge(dataarrays) + + # --- Plotting for comparison --- + fx.plotting.with_plotly( + get_solutions(calculations, 'Speicher|charge_state').to_dataframe(), + mode='line', title='Charge State Comparison', ylabel='Charge state', path='results/Charge State.html', save=True ) - fig = fx.plotting.with_plotly(data, 'line') - fig.update_layout(title='Charge State Comparison', xaxis_title='Time', yaxis_title='Charge state') - fig.write_html('results/Charge State.html') - data = pd.DataFrame( - {mode: results[mode].component_results['BHKW2'].all_results['Q_th']['flow_rate'] for mode in results}, - index=time_series_used + fx.plotting.with_plotly( + get_solutions(calculations, 'BHKW2 (Q_th)|flow_rate').to_dataframe(), + mode='line', title='BHKW2 (Q_th) Flow Rate Comparison', ylabel='Flow rate', path='results/BHKW2 Thermal Power.html', save=True ) - fig = fx.plotting.with_plotly(data, 'line') - fig.update_layout(title='BHKW2 Q_th Flow Rate Comparison', xaxis_title='Time', yaxis_title='Flow rate') - fig.write_html('results/BHKW2 Thermal Power.html') - data = pd.DataFrame( - {mode: results[mode].effect_results['costs'].all_results['operation']['total_per_timestep'] for mode in results}, - index=time_series_used + fx.plotting.with_plotly( + get_solutions(calculations, 'costs|operation|total_per_timestep').to_dataframe(), + mode='line', title='Operation Cost Comparison', ylabel='Costs [€]', path='results/Operation Costs.html', save=True ) - fig = fx.plotting.with_plotly(data, 'line') - fig.update_layout(title='Cost Comparison', xaxis_title='Time', yaxis_title='Costs (€)') - fig.write_html('results/Operation Costs.html') - fig = fx.plotting.with_plotly(pd.DataFrame(data.sum()).T, 'bar') - fig.update_layout(title='Total Cost Comparison', yaxis_title='Costs (€)', barmode='group') - fig.write_html('results/Total Costs.html') - - duration_data = pd.DataFrame( - { - 'Full': [calculations['Full'].durations.get(key, 0) for key in calculations['Aggregated'].durations], - 'Aggregated': [ - calculations['Aggregated'].durations.get(key, 0) for key in calculations['Aggregated'].durations - ], - 'Segmented': [ - calculations['Segmented'].durations.get(key, 0) for key in calculations['Aggregated'].durations - ], - }, - index=list(calculations['Aggregated'].durations.keys()), - ).T - fig = fx.plotting.with_plotly(duration_data, 'bar') - fig.update_layout(title='Duration Comparison', xaxis_title='Calculation type', yaxis_title='Time (s)') - fig.write_html('results/Speed Comparison.html') + + fx.plotting.with_plotly( + pd.DataFrame(get_solutions(calculations, 'costs|operation|total_per_timestep').to_dataframe().sum()).T, + mode='bar', title='Total Cost Comparison', ylabel='Costs [€]' + ).update_layout(barmode='group').write_html('results/Total Costs.html') + + fx.plotting.with_plotly( + pd.DataFrame([calc.durations for calc in calculations], index=[calc.name for calc in calculations]), 'bar' + ).update_layout( + title='Duration Comparison', xaxis_title='Calculation type', yaxis_title='Time (s)' + ).write_html('results/Speed Comparison.html') From e40aa989fabe3a9fdbf3c53990dde39e3ad7c288 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 25 Feb 2025 17:18:49 +0100 Subject: [PATCH 245/507] rename function in results.py --- flixOpt/results.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flixOpt/results.py b/flixOpt/results.py index 17070e116..21bb73662 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -213,7 +213,7 @@ def flow_rates(self, threshold: Optional[float] = 1e-5, with_last_timestep: bool = False) -> xr.Dataset: variables = [name for name in self.variables if name.endswith(('|flow_rate', '|excess_input', '|excess_output'))] - return _sanitize_dataset( + return sanitize_dataset( ds=self.variables[variables].solution, threshold=threshold, timesteps=self._calculation_results.timesteps_extra if with_last_timestep else None, @@ -273,7 +273,7 @@ def charge_state_and_flow_rates(self, if not self.is_storage: raise ValueError(f'Cant get charge_state. "{self.label}" is not a storage') variables = self.inputs + self.outputs + [self._charge_state] - return _sanitize_dataset( + return sanitize_dataset( ds=self.variables[variables].solution, threshold=threshold, timesteps=self._calculation_results.timesteps_extra, @@ -449,7 +449,7 @@ def plot_heatmap( save=True if save else False) -def _sanitize_dataset( +def sanitize_dataset( ds: xr.Dataset, timesteps: Optional[pd.DatetimeIndex] = None, threshold: Optional[float] = 1e-5, From 3a2a821b06dd7bbb5108becc5a0156a4ea7e5d5c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 25 Feb 2025 17:19:37 +0100 Subject: [PATCH 246/507] Update test of SegmentedCalculation --- tests/test_integration.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_integration.py b/tests/test_integration.py index e0c600fc3..08c9ba6fb 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -714,7 +714,7 @@ def test_aggregated(self): def test_segmented(self): calculation = self.calculate('segmented') self.assert_almost_equal_numeric( - sum(calculation.results(combined_arrays=True)['Effects']['costs']['operation']['total_per_timestep']), + sum(calculation.results.solution_without_overlap('costs|operation|total_per_timestep')), 343613, 'costs doesnt match expected value', ) @@ -826,7 +826,7 @@ def calculate(self, modeling_type: Literal['full', 'segmented', 'aggregated']): calc.solve(self.get_solver(), save_results=True) elif doSegmentedCalc: calc = fx.SegmentedCalculation('segModel', es, timesteps_per_segment=96, overlap_timesteps=1) - calc.do_modeling_and_solve(self.get_solver(), save_results=True) + calc.do_modeling_and_solve(self.get_solver()) elif doAggregatedCalc: calc = fx.AggregatedCalculation( 'aggModel', From dad4c65056ecd1f7e51ed14e5b9540c3ad45e938 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 25 Feb 2025 17:27:42 +0100 Subject: [PATCH 247/507] Update test_functional.py --- tests/test_functional.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_functional.py b/tests/test_functional.py index c79323a91..2fe946db7 100644 --- a/tests/test_functional.py +++ b/tests/test_functional.py @@ -99,7 +99,7 @@ def solve_and_load( ) -> fx.results.CalculationResults: calculation = fx.FullCalculation('Calculation', flow_system) calculation.do_modeling() - calculation.solve(solver, True) + calculation.solve(solver) return calculation.results From 142ec9dcc90b84d89997632fde9e2f2dbda6938b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 25 Feb 2025 17:31:07 +0100 Subject: [PATCH 248/507] Update test_integration.py --- tests/test_integration.py | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/tests/test_integration.py b/tests/test_integration.py index 08c9ba6fb..505aa39aa 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -66,10 +66,10 @@ def test_model(self): ) def test_from_results(self): - calculation = self.model(save_results=True) - - results = calculation.results + calculation = self.model() + calculation.results.to_file() + results = fx.results.CalculationResults.from_file(calculation.folder, calculation.name) # test effect results self.assert_almost_equal_numeric( results.model.variables['costs|total'].solution.values, @@ -90,14 +90,14 @@ def test_from_results(self): 'Q_th doesnt match expected value', ) - df = results['Fernwärme'].operation_balance() + df = results['Fernwärme'].flow_rates() self.assert_almost_equal_numeric( calculation.flow_system.components['Wärmelast'].sink.model.flow_rate.solution.values, df['Wärmelast (Q_th_Last)|flow_rate'].values, 'Loaded Results and directly used results dont match, or loading didnt work properly', ) - def model(self, save_results=False) -> fx.FullCalculation: + def model(self) -> fx.FullCalculation: # Define the components and flow_system Strom = fx.Bus('Strom') Fernwaerme = fx.Bus('Fernwärme') @@ -168,7 +168,7 @@ def model(self, save_results=False) -> fx.FullCalculation: aCalc = fx.FullCalculation('Test_Sim', es) aCalc.do_modeling() - aCalc.solve(self.get_solver(), save_results=save_results) + aCalc.solve(self.get_solver()) return aCalc @@ -270,15 +270,14 @@ def test_transmission_advanced(self): flow_system.add_elements(transmission, boiler, boiler2, last2) calculation = fx.FullCalculation('Test_Transmission', flow_system) calculation.do_modeling() - calculation.solve(self.get_solver(), save_results=True) - results = fx.results.CalculationResults(calculation.name, 'results') + calculation.solve(self.get_solver()) self.assert_almost_equal_numeric( transmission.in1.model.on_off.on.solution.values, np.array([1, 1, 1, 0, 0, 0, 0, 0, 0, 0]), 'On does not work properly' ) self.assert_almost_equal_numeric( - results.to_dataframe('Rohr', with_last_time_step=False)['Rohr__Rohr1b'].values, + calculation.results.model.variables['Rohr (Rohr1b)|flow_rate'].solution.values, transmission.out1.model.flow_rate.solution.values, 'Flow rate of Rohr__Rohr1b is not correct', ) @@ -823,7 +822,7 @@ def calculate(self, modeling_type: Literal['full', 'segmented', 'aggregated']): if doFullCalc: calc = fx.FullCalculation('fullModel', es) calc.do_modeling() - calc.solve(self.get_solver(), save_results=True) + calc.solve(self.get_solver()) elif doSegmentedCalc: calc = fx.SegmentedCalculation('segModel', es, timesteps_per_segment=96, overlap_timesteps=1) calc.do_modeling_and_solve(self.get_solver()) @@ -845,7 +844,7 @@ def calculate(self, modeling_type: Literal['full', 'segmented', 'aggregated']): calc.do_modeling() print(es) es.visualize_network() - calc.solve(self.get_solver(), save_results=True) + calc.solve(self.get_solver()) else: raise Exception('Wrong Modeling Type') From 8883a2c1f5043b70e5a4927ecef62ab8fb028f9c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 26 Feb 2025 00:24:53 +0100 Subject: [PATCH 249/507] Feature/linopy/flow system (#164) # This PR tries to eliminate the reliance on objects to create a valid Component. - Until now, to create a Component, one needed access to the desired Buses and Effects. This adds a lot of unneeded complexity, especially when not working only in one script. - **Buses** and **Effects** can now be used by simply using their label instead of the actual element. The FlowSystem handles the connections. - **Buses** need to be added to the FlowSystem now! (effects always had) P.S.: This will also greatly simplify the creation of Component Libraries and is probably needed to be able to load a FlowSystem with all Components from a file. --- examples/00_Minmal/minimal_example.py | 31 ++- examples/01_Simple/simple_example.py | 26 +- examples/02_Complex/complex_example.py | 44 ++-- .../example_calculation_types.py | 41 ++-- flixOpt/aggregation.py | 1 - flixOpt/calculation.py | 2 +- flixOpt/components.py | 52 ++-- flixOpt/effects.py | 227 +++++++++--------- flixOpt/elements.py | 90 ++++--- flixOpt/flow_system.py | 127 +++++++--- flixOpt/interface.py | 33 +-- flixOpt/io.py | 2 +- flixOpt/structure.py | 55 +---- tests/test_functional.py | 81 +++---- tests/test_integration.py | 129 +++++----- 15 files changed, 474 insertions(+), 467 deletions(-) diff --git a/examples/00_Minmal/minimal_example.py b/examples/00_Minmal/minimal_example.py index c70a05dee..e242c497d 100644 --- a/examples/00_Minmal/minimal_example.py +++ b/examples/00_Minmal/minimal_example.py @@ -9,16 +9,17 @@ import flixOpt as fx if __name__ == '__main__': + # --- Define the Flow System, that will hold all elements, and the time steps you want to model --- + timesteps = pd.date_range('2020-01-01', periods=3, freq='h') + flow_system = fx.FlowSystem(timesteps) + # --- Define Thermal Load Profile --- # Load profile (e.g., kW) for heating demand over time thermal_load_profile = np.array([30, 0, 20]) - timesteps = pd.date_range('2020-01-01', periods=3, freq='h') # --- Define Energy Buses --- - # These represent the different energy carriers in the system - electricity_bus = fx.Bus('Electricity') - heat_bus = fx.Bus('District Heating') - fuel_bus = fx.Bus('Natural Gas') + # These are balancing nodes (inputs=outputs) and balance the different energy carriers your system + flow_system.add_elements(fx.Bus('District Heating'), fx.Bus('Natural Gas')) # --- Define Objective Effect (Cost) --- # Cost effect representing the optimization objective (minimizing costs) @@ -29,44 +30,42 @@ boiler = fx.linear_converters.Boiler( 'Boiler', eta=0.5, - Q_th=fx.Flow(label='Thermal Output', bus=heat_bus, size=50), - Q_fu=fx.Flow(label='Fuel Input', bus=fuel_bus), + Q_th=fx.Flow(label='Thermal Output', bus='District Heating', size=50), + Q_fu=fx.Flow(label='Fuel Input', bus='Natural Gas'), ) # Heat load component with a fixed thermal demand profile heat_load = fx.Sink( 'Heat Demand', - sink=fx.Flow(label='Thermal Load', bus=heat_bus, size=1, fixed_relative_profile=thermal_load_profile), + sink=fx.Flow(label='Thermal Load', bus='District Heating', size=1, fixed_relative_profile=thermal_load_profile), ) # Gas source component with cost-effect per flow hour gas_source = fx.Source( 'Natural Gas Tariff', - source=fx.Flow(label='Gas Flow', bus=fuel_bus, size=1000, effects_per_flow_hour=0.04), # 0.04 €/kWh + source=fx.Flow(label='Gas Flow', bus='Natural Gas', size=1000, effects_per_flow_hour=0.04), # 0.04 €/kWh ) # --- Build the Flow System --- # Add all components and effects to the system - flow_system = fx.FlowSystem(timesteps) flow_system.add_elements(cost_effect, boiler, heat_load, gas_source) - # --- Define and Run Calculation --- + # --- Define, model and solve a Calculation --- calculation = fx.FullCalculation('Simulation1', flow_system) calculation.do_modeling() - - # --- Solve the Calculation and Save Results --- calculation.solve(fx.solvers.HighsSolver(0.01, 60)) + # --- Analyze Results --- # Access the results of an element - df = calculation.results['costs'].variables_time.solution.to_dataframe() + df1 = calculation.results['costs'].variables_time.solution.to_dataframe() # Plot the results of a specific element calculation.results['District Heating'].plot_flow_rates() # Save results to a file - df = calculation.results['costs'].variables_time.solution.to_dataframe() - # df.to_csv('results/District Heating.csv') # Save results to csv + df2 = calculation.results['District Heating'].flow_rates().to_dataframe() + # df2.to_csv('results/District Heating.csv') # Save results to csv # Print infos to the console. pprint(calculation.infos) diff --git a/examples/01_Simple/simple_example.py b/examples/01_Simple/simple_example.py index 96c1df810..c81106f72 100644 --- a/examples/01_Simple/simple_example.py +++ b/examples/01_Simple/simple_example.py @@ -16,10 +16,11 @@ # Create datetime array starting from '2020-01-01' for the given time period timesteps = pd.date_range('2020-01-01', periods=len(heat_demand_per_h), freq='h') + flow_system = fx.FlowSystem(timesteps=timesteps) # --- Define Energy Buses --- # These represent nodes, where the used medias are balanced (electricity, heat, and gas) - Strom, Fernwaerme, Gas = fx.Bus(label='Strom'), fx.Bus(label='Fernwärme'), fx.Bus(label='Gas') + flow_system.add_elements(fx.Bus(label='Strom'), fx.Bus(label='Fernwärme'), fx.Bus(label='Gas')) # --- Define Effects (Objective and CO2 Emissions) --- # Cost effect: used as the optimization objective --> minimizing costs @@ -45,8 +46,8 @@ boiler = fx.linear_converters.Boiler( label='Boiler', eta=0.5, - Q_th=fx.Flow(label='Q_th', bus=Fernwaerme, size=50, relative_minimum=0.1, relative_maximum=1), - Q_fu=fx.Flow(label='Q_fu', bus=Gas), + Q_th=fx.Flow(label='Q_th', bus='Fernwärme', size=50, relative_minimum=0.1, relative_maximum=1), + Q_fu=fx.Flow(label='Q_fu', bus='Gas'), ) # Combined Heat and Power (CHP): Generates both electricity and heat from fuel @@ -54,16 +55,16 @@ label='CHP', eta_th=0.5, eta_el=0.4, - P_el=fx.Flow('P_el', bus=Strom, size=60, relative_minimum=5 / 60), - Q_th=fx.Flow('Q_th', bus=Fernwaerme), - Q_fu=fx.Flow('Q_fu', bus=Gas), + P_el=fx.Flow('P_el', bus='Strom', size=60, relative_minimum=5 / 60), + Q_th=fx.Flow('Q_th', bus='Fernwärme'), + Q_fu=fx.Flow('Q_fu', bus='Gas'), ) # Storage: Energy storage system with charging and discharging capabilities storage = fx.Storage( label='Storage', - charging=fx.Flow('Q_th_load', bus=Fernwaerme, size=1000), - discharging=fx.Flow('Q_th_unload', bus=Fernwaerme, size=1000), + charging=fx.Flow('Q_th_load', bus='Fernwärme', size=1000), + discharging=fx.Flow('Q_th_unload', bus='Fernwärme', size=1000), capacity_in_flow_hours=fx.InvestParameters(fix_effects=20, fixed_size=30, optional=False), initial_charge_state=0, # Initial storage state: empty relative_maximum_charge_state=1 / 100 * np.array([80, 70, 80, 80, 80, 80, 80, 80, 80, 80]), @@ -76,23 +77,22 @@ # Heat Demand Sink: Represents a fixed heat demand profile heat_sink = fx.Sink( label='Heat Demand', - sink=fx.Flow(label='Q_th_Last', bus=Fernwaerme, size=1, fixed_relative_profile=heat_demand_per_h), + sink=fx.Flow(label='Q_th_Last', bus='Fernwärme', size=1, fixed_relative_profile=heat_demand_per_h), ) # Gas Source: Gas tariff source with associated costs and CO2 emissions gas_source = fx.Source( label='Gastarif', - source=fx.Flow(label='Q_Gas', bus=Gas, size=1000, effects_per_flow_hour={costs: 0.04, CO2: 0.3}), + source=fx.Flow(label='Q_Gas', bus='Gas', size=1000, effects_per_flow_hour={costs: 0.04, CO2: 0.3}), ) # Power Sink: Represents the export of electricity to the grid power_sink = fx.Sink( - label='Einspeisung', sink=fx.Flow(label='P_el', bus=Strom, effects_per_flow_hour=-1 * power_prices) + label='Einspeisung', sink=fx.Flow(label='P_el', bus='Strom', effects_per_flow_hour=-1 * power_prices) ) # --- Build the Flow System --- - # Create the flow system and add all defined components and effects - flow_system = fx.FlowSystem(timesteps=timesteps) + # Add all defined components and effects to the flow system flow_system.add_elements(costs, CO2, boiler, storage, chp, heat_sink, gas_source, power_sink) # Visualize the flow system for validation purposes diff --git a/examples/02_Complex/complex_example.py b/examples/02_Complex/complex_example.py index a7dd08de2..c6b792915 100644 --- a/examples/02_Complex/complex_example.py +++ b/examples/02_Complex/complex_example.py @@ -26,13 +26,17 @@ ) electricity_price = np.array([40, 40, 40, 40, 40, 40, 40, 40, 40]) + # --- Define the Flow System, that will hold all elements, and the time steps you want to model --- timesteps = pd.date_range('2020-01-01', periods=len(heat_demand), freq='h') - + flow_system = fx.FlowSystem(timesteps) # Create FlowSystem + # --- Define Energy Buses --- - # Represent different energy carriers (electricity, heat, gas) in the system - Strom = fx.Bus('Strom', excess_penalty_per_flow_hour=excess_penalty) - Fernwaerme = fx.Bus('Fernwärme', excess_penalty_per_flow_hour=excess_penalty) - Gas = fx.Bus('Gas', excess_penalty_per_flow_hour=excess_penalty) + # Represent node balances (inputs=outputs) for the different energy carriers (electricity, heat, gas) in the system + flow_system.add_elements( + fx.Bus('Strom', excess_penalty_per_flow_hour=excess_penalty), + fx.Bus('Fernwärme', excess_penalty_per_flow_hour=excess_penalty), + fx.Bus('Gas', excess_penalty_per_flow_hour=excess_penalty), + ) # --- Define Effects --- # Specify effects related to costs, CO2 emissions, and primary energy consumption @@ -49,7 +53,7 @@ on_off_parameters=fx.OnOffParameters(effects_per_running_hour={Costs: 0, CO2: 1000}), # CO2 emissions per hour Q_th=fx.Flow( label='Q_th', # Thermal output - bus=Fernwaerme, # Linked bus + bus='Fernwärme', # Linked bus size=fx.InvestParameters( fix_effects=1000, # Fixed investment costs fixed_size=50, # Fixed size @@ -73,7 +77,7 @@ switch_on_total_max=1000, # Max number of starts ), ), - Q_fu=fx.Flow(label='Q_fu', bus=Gas, size=200), + Q_fu=fx.Flow(label='Q_fu', bus='Gas', size=200), ) # 2. Define CHP Unit @@ -83,16 +87,16 @@ eta_th=0.5, eta_el=0.4, on_off_parameters=fx.OnOffParameters(effects_per_switch_on=0.01), - P_el=fx.Flow('P_el', bus=Strom, size=60, relative_minimum=5 / 60), - Q_th=fx.Flow('Q_th', bus=Fernwaerme, size=1e3), - Q_fu=fx.Flow('Q_fu', bus=Gas, size=1e3, previous_flow_rate=20), # The CHP was ON previously + P_el=fx.Flow('P_el', bus='Strom', size=60, relative_minimum=5 / 60), + Q_th=fx.Flow('Q_th', bus='Fernwärme', size=1e3), + Q_fu=fx.Flow('Q_fu', bus='Gas', size=1e3, previous_flow_rate=20), # The CHP was ON previously ) # 3. Define CHP with Linear Segments # This CHP unit uses linear segments for more dynamic behavior over time - P_el = fx.Flow('P_el', bus=Strom, size=60, previous_flow_rate=20) - Q_th = fx.Flow('Q_th', bus=Fernwaerme) - Q_fu = fx.Flow('Q_fu', bus=Gas) + P_el = fx.Flow('P_el', bus='Strom', size=60, previous_flow_rate=20) + Q_th = fx.Flow('Q_th', bus='Fernwärme') + Q_fu = fx.Flow('Q_fu', bus='Gas') segmented_conversion_factors = { P_el: [(5, 30), (40, 60)], # Similar to eta_th, each factor here can be an array Q_th: [(6, 35), (45, 100)], @@ -119,8 +123,8 @@ speicher = fx.Storage( 'Speicher', - charging=fx.Flow('Q_th_load', bus=Fernwaerme, size=1e4), - discharging=fx.Flow('Q_th_unload', bus=Fernwaerme, size=1e4), + charging=fx.Flow('Q_th_load', bus='Fernwärme', size=1e4), + discharging=fx.Flow('Q_th_unload', bus='Fernwärme', size=1e4), capacity_in_flow_hours=fx.InvestParameters( effects_in_segments=segmented_investment_effects, # Investment effects optional=False, # Forced investment @@ -141,7 +145,7 @@ 'Wärmelast', sink=fx.Flow( 'Q_th_Last', # Heat sink - bus=Fernwaerme, # Linked bus + bus='Fernwärme', # Linked bus size=1, fixed_relative_profile=heat_demand, # Fixed demand profile ), @@ -152,7 +156,7 @@ 'Gastarif', source=fx.Flow( 'Q_Gas', - bus=Gas, # Gas source + bus='Gas', # Gas source size=1000, # Nominal size effects_per_flow_hour={Costs: 0.04, CO2: 0.3}, ), @@ -163,15 +167,13 @@ 'Einspeisung', sink=fx.Flow( 'P_el', - bus=Strom, # Feed-in tariff for electricity + bus='Strom', # Feed-in tariff for electricity effects_per_flow_hour=-1 * electricity_price, # Negative price for feed-in ), ) # --- Build FlowSystem --- - # Select components to be included in the final system model - flow_system = fx.FlowSystem(timesteps) # Create FlowSystem - + # Select components to be included in the flow system flow_system.add_elements(Costs, CO2, PE, Gaskessel, Waermelast, Gasbezug, Stromverkauf, speicher) flow_system.add_elements(bhkw_2) if use_chp_with_segments else flow_system.add_components(bhkw) diff --git a/examples/03_Calculation_types/example_calculation_types.py b/examples/03_Calculation_types/example_calculation_types.py index a79da621d..4a50115b5 100644 --- a/examples/03_Calculation_types/example_calculation_types.py +++ b/examples/03_Calculation_types/example_calculation_types.py @@ -30,6 +30,7 @@ penalty_of_period_freedom=0, ) keep_extreme_periods = True + excess_penalty = 1e5 # or set to None if not needed # Data Import data_import = pd.read_csv(pathlib.Path('Zeitreihen2020.csv'), index_col=0).sort_index() @@ -51,12 +52,13 @@ TS_electricity_price_sell = fx.TimeSeriesData(-(electricity_demand - 0.5), agg_group='p_el') TS_electricity_price_buy = fx.TimeSeriesData(electricity_price + 0.5, agg_group='p_el') - # Bus Definitions - excess_penalty = 1e5 # or set to None if not needed - Strom = fx.Bus('Strom', excess_penalty_per_flow_hour=excess_penalty) - Fernwaerme = fx.Bus('Fernwärme', excess_penalty_per_flow_hour=excess_penalty) - Gas = fx.Bus('Gas', excess_penalty_per_flow_hour=excess_penalty) - Kohle = fx.Bus('Kohle', excess_penalty_per_flow_hour=excess_penalty) + flow_system = fx.FlowSystem(timesteps) + flow_system.add_elements( + fx.Bus('Strom', excess_penalty_per_flow_hour=excess_penalty), + fx.Bus('Fernwärme', excess_penalty_per_flow_hour=excess_penalty), + fx.Bus('Gas', excess_penalty_per_flow_hour=excess_penalty), + fx.Bus('Kohle', excess_penalty_per_flow_hour=excess_penalty), + ) # Effects costs = fx.Effect('costs', '€', 'Kosten', is_standard=True, is_objective=True) @@ -69,10 +71,10 @@ a_gaskessel = fx.linear_converters.Boiler( 'Kessel', eta=0.85, - Q_th=fx.Flow(label='Q_th', bus=Fernwaerme), + Q_th=fx.Flow(label='Q_th', bus='Fernwärme'), Q_fu=fx.Flow( label='Q_fu', - bus=Gas, + bus='Gas', size=95, relative_minimum=12 / 95, previous_flow_rate=20, @@ -86,9 +88,9 @@ eta_th=0.58, eta_el=0.22, on_off_parameters=fx.OnOffParameters(effects_per_switch_on=24000), - P_el=fx.Flow('P_el', bus=Strom, size=200), - Q_th=fx.Flow('Q_th', bus=Fernwaerme, size=200), - Q_fu=fx.Flow('Q_fu', bus=Kohle, size=288, relative_minimum=87 / 288, previous_flow_rate=100), + P_el=fx.Flow('P_el', bus='Strom', size=200), + Q_th=fx.Flow('Q_th', bus='Fernwärme', size=200), + Q_fu=fx.Flow('Q_fu', bus='Kohle', size=288, relative_minimum=87 / 288, previous_flow_rate=100), ) # 3. Storage @@ -102,43 +104,42 @@ eta_discharge=1, relative_loss_per_hour=0.001, prevent_simultaneous_charge_and_discharge=True, - charging=fx.Flow('Q_th_load', size=137, bus=Fernwaerme), - discharging=fx.Flow('Q_th_unload', size=158, bus=Fernwaerme), + charging=fx.Flow('Q_th_load', size=137, bus='Fernwärme'), + discharging=fx.Flow('Q_th_unload', size=158, bus='Fernwärme'), ) # 4. Sinks and Sources # Heat Load Profile a_waermelast = fx.Sink( - 'Wärmelast', sink=fx.Flow('Q_th_Last', bus=Fernwaerme, size=1, fixed_relative_profile=TS_heat_demand) + 'Wärmelast', sink=fx.Flow('Q_th_Last', bus='Fernwärme', size=1, fixed_relative_profile=TS_heat_demand) ) # Electricity Feed-in a_strom_last = fx.Sink( - 'Stromlast', sink=fx.Flow('P_el_Last', bus=Strom, size=1, fixed_relative_profile=TS_electricity_demand) + 'Stromlast', sink=fx.Flow('P_el_Last', bus='Strom', size=1, fixed_relative_profile=TS_electricity_demand) ) # Gas Tariff a_gas_tarif = fx.Source( - 'Gastarif', source=fx.Flow('Q_Gas', bus=Gas, size=1000, effects_per_flow_hour={costs: gas_price, CO2: 0.3}) + 'Gastarif', source=fx.Flow('Q_Gas', bus='Gas', size=1000, effects_per_flow_hour={costs: gas_price, CO2: 0.3}) ) # Coal Tariff a_kohle_tarif = fx.Source( - 'Kohletarif', source=fx.Flow('Q_Kohle', bus=Kohle, size=1000, effects_per_flow_hour={costs: 4.6, CO2: 0.3}) + 'Kohletarif', source=fx.Flow('Q_Kohle', bus='Kohle', size=1000, effects_per_flow_hour={costs: 4.6, CO2: 0.3}) ) # Electricity Tariff and Feed-in a_strom_einspeisung = fx.Sink( - 'Einspeisung', sink=fx.Flow('P_el', bus=Strom, size=1000, effects_per_flow_hour=TS_electricity_price_sell) + 'Einspeisung', sink=fx.Flow('P_el', bus='Strom', size=1000, effects_per_flow_hour=TS_electricity_price_sell) ) a_strom_tarif = fx.Source( 'Stromtarif', - source=fx.Flow('P_el', bus=Strom, size=1000, effects_per_flow_hour={costs: TS_electricity_price_buy, CO2: 0.3}), + source=fx.Flow('P_el', bus='Strom', size=1000, effects_per_flow_hour={costs: TS_electricity_price_buy, CO2: 0.3}), ) # Flow System Setup - flow_system = fx.FlowSystem(timesteps) flow_system.add_effects(costs, CO2, PE) flow_system.add_components( a_gaskessel, diff --git a/flixOpt/aggregation.py b/flixOpt/aggregation.py index 427456368..eddf4d464 100644 --- a/flixOpt/aggregation.py +++ b/flixOpt/aggregation.py @@ -318,7 +318,6 @@ def do_modeling(self): if (self.aggregation_parameters.percentage_of_period_freedom > 0) and penalty != 0: for variable in self.variables_direct.values(): self._model.effects.add_share_to_penalty( - self._model, 'Aggregation', variable * penalty ) diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py index a30aeaf83..0d731fbe9 100644 --- a/flixOpt/calculation.py +++ b/flixOpt/calculation.py @@ -88,7 +88,7 @@ def main_results(self) -> Dict[str, Union[Scalar, Dict]]: "invest": float(effect.model.invest.total.solution.values), "total": float(effect.model.total.solution.values), } - for effect in self.flow_system.effects.values() + for effect in self.flow_system.effects }, "Invest-Decisions": { "Invested": { diff --git a/flixOpt/components.py b/flixOpt/components.py index 168dc89a4..def8f6523 100644 --- a/flixOpt/components.py +++ b/flixOpt/components.py @@ -95,30 +95,32 @@ def _plausibility_checks(self) -> None: f'(in flow {flow.label_full}) do not make sense together!' ) - def transform_data(self, time_series_collection: TimeSeriesCollection): - super().transform_data(time_series_collection) + def transform_data(self, flow_system: 'FlowSystem'): + super().transform_data(flow_system) if self.conversion_factors: - self.conversion_factors = self._transform_conversion_factors(time_series_collection) + self.conversion_factors = self._transform_conversion_factors(flow_system) else: segmented_conversion_factors = {} for flow, segments in self.segmented_conversion_factors.items(): segmented_conversion_factors[flow] = [ ( - self._create_time_series(f'{flow.label}|Stützstelle|{idx}a', segment[0], time_series_collection), - self._create_time_series(f'{flow.label}|Stützstelle|{idx}b', segment[1], time_series_collection), + flow_system.create_time_series(f'{flow.label_full}|Stützstelle|{idx}a', segment[0]), + flow_system.create_time_series(f'{flow.label_full}|Stützstelle|{idx}b', segment[1]), ) for idx, segment in enumerate(segments) ] self.segmented_conversion_factors = segmented_conversion_factors - def _transform_conversion_factors(self, time_series_collection: TimeSeriesCollection) -> List[Dict[Flow, TimeSeries]]: + def _transform_conversion_factors(self, flow_system: 'FlowSystem') -> List[Dict[Flow, TimeSeries]]: """macht alle Faktoren, die nicht TimeSeries sind, zu TimeSeries""" list_of_conversion_factors = [] for idx, conversion_factor in enumerate(self.conversion_factors): transformed_dict = {} for flow, values in conversion_factor.items(): # TODO: Might be better to use the label of the component instead of the flow - transformed_dict[flow] = flow._create_time_series(f'conversion_factor{idx}', values, time_series_collection) + transformed_dict[flow] = flow_system.create_time_series( + f'{flow.label_full}|conversion_factor{idx}', values + ) list_of_conversion_factors.append(transformed_dict) return list_of_conversion_factors @@ -219,21 +221,21 @@ def create_model(self, model: SystemModel) -> 'StorageModel': self.model = StorageModel(model, self) return self.model - def transform_data(self, time_series_collection: TimeSeriesCollection) -> None: - super().transform_data(time_series_collection) - self.relative_minimum_charge_state = self._create_time_series( - 'relative_minimum_charge_state', self.relative_minimum_charge_state, time_series_collection, - extra_timestep=True + def transform_data(self, flow_system: 'FlowSystem') -> None: + super().transform_data(flow_system) + self.relative_minimum_charge_state = flow_system.create_time_series( + f'{self.label_full}|relative_minimum_charge_state', self.relative_minimum_charge_state, extra_timestep=True + ) + self.relative_maximum_charge_state = flow_system.create_time_series( + f'{self.label_full}|relative_maximum_charge_state', self.relative_maximum_charge_state, extra_timestep=True ) - self.relative_maximum_charge_state = self._create_time_series( - 'relative_maximum_charge_state', self.relative_maximum_charge_state, time_series_collection, - extra_timestep=True + self.eta_charge = flow_system.create_time_series(f'{self.label_full}|eta_charge', self.eta_charge) + self.eta_discharge = flow_system.create_time_series(f'{self.label_full}|eta_discharge', self.eta_discharge) + self.relative_loss_per_hour = flow_system.create_time_series( + f'{self.label_full}|relative_loss_per_hour', self.relative_loss_per_hour ) - self.eta_charge = self._create_time_series('eta_charge', self.eta_charge, time_series_collection) - self.eta_discharge = self._create_time_series('eta_discharge', self.eta_discharge, time_series_collection) - self.relative_loss_per_hour = self._create_time_series('relative_loss_per_hour', self.relative_loss_per_hour, time_series_collection) if isinstance(self.capacity_in_flow_hours, InvestParameters): - self.capacity_in_flow_hours.transform_data(time_series_collection) + self.capacity_in_flow_hours.transform_data(flow_system) class Transmission(Component): @@ -320,10 +322,14 @@ def create_model(self, model) -> 'TransmissionModel': self.model = TransmissionModel(model, self) return self.model - def transform_data(self, time_series_collection: TimeSeriesCollection) -> None: - super().transform_data(time_series_collection) - self.relative_losses = self._create_time_series('relative_losses', self.relative_losses, time_series_collection) - self.absolute_losses = self._create_time_series('absolute_losses', self.absolute_losses, time_series_collection) + def transform_data(self, flow_system: 'FlowSystem') -> None: + super().transform_data(flow_system) + self.relative_losses = flow_system.create_time_series( + f'{self.label_full}|relative_losses', self.relative_losses + ) + self.absolute_losses = flow_system.create_time_series( + f'{self.label_full}|absolute_losses', self.absolute_losses + ) class TransmissionModel(ComponentModel): diff --git a/flixOpt/effects.py b/flixOpt/effects.py index a8ea156a3..06fdd0a15 100644 --- a/flixOpt/effects.py +++ b/flixOpt/effects.py @@ -6,7 +6,7 @@ """ import logging -from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Union +from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Union, Iterator import linopy import numpy as np @@ -14,7 +14,7 @@ from .core import NumericData, NumericDataTS, Scalar, TimeSeries, TimeSeriesCollection from .features import ShareAllocationModel -from .structure import Element, ElementModel, Model, SystemModel +from .structure import Element, ElementModel, Model, SystemModel, Interface if TYPE_CHECKING: from .flow_system import FlowSystem @@ -78,12 +78,10 @@ def __init__( minimal sum (only invest) of the effect maximum_invest : scalar, optional maximal sum (only invest) of the effect - minimum_total : sclalar, optional + minimum_total : scalar, optional min sum of effect (invest+operation). maximum_total : scalar, optional max sum of effect (invest+operation). - **kwargs : TYPE - DESCRIPTION. Returns ------- @@ -111,43 +109,18 @@ def __init__( self.minimum_total = minimum_total self.maximum_total = maximum_total - self._plausibility_checks() - - def _plausibility_checks(self) -> None: - # Check circular loops in effects: (Effekte fügen sich gegenseitig Shares hinzu): - # TODO: Improve checks!! Only most basic case covered... - - def error_str(effect_label: str, share_ffect_label: str): - return ( - f' {effect_label} -> has share in: {share_ffect_label}\n' - f' {share_ffect_label} -> has share in: {effect_label}' - ) - - # Effekt darf nicht selber als Share in seinen ShareEffekten auftauchen: - # operation: - for target_effect in self.specific_share_to_other_effects_operation.keys(): - assert self not in target_effect.specific_share_to_other_effects_operation.keys(), ( - f'Error: circular operation-shares \n{error_str(target_effect.label, target_effect.label)}' - ) - # invest: - for target_effect in self.specific_share_to_other_effects_invest.keys(): - assert self not in target_effect.specific_share_to_other_effects_invest.keys(), ( - f'Error: circular invest-shares \n{error_str(target_effect.label, target_effect.label)}' - ) - - def transform_data(self, time_series_collection: TimeSeriesCollection): - self.minimum_operation_per_hour = self._create_time_series( - 'minimum_operation_per_hour', self.minimum_operation_per_hour, time_series_collection + def transform_data(self, flow_system: 'FlowSystem'): + self.minimum_operation_per_hour = flow_system.create_time_series( + f'{self.label_full}|minimum_operation_per_hour', self.minimum_operation_per_hour ) - self.maximum_operation_per_hour = self._create_time_series( - 'maximum_operation_per_hour', self.maximum_operation_per_hour, time_series_collection + self.maximum_operation_per_hour = flow_system.create_time_series( + f'{self.label_full}|maximum_operation_per_hour', self.maximum_operation_per_hour, flow_system ) - self.specific_share_to_other_effects_operation = effect_values_to_time_series( - 'operation_to', + self.specific_share_to_other_effects_operation = flow_system.create_effect_time_series( + f'{self.label_full}|operation->', self.specific_share_to_other_effects_operation, - self, - time_series_collection + 'operation' ) def create_model(self, model: SystemModel) -> 'EffectModel': @@ -219,33 +192,6 @@ def do_modeling(self): EffectValuesUser = Union[NumericDataTS, Dict[EffectKey, NumericDataTS]] # User-specified Shares to Effects EffectValuesUserScalar = Union[Scalar, Dict[EffectKey, Scalar]] # User-specified Shares to Effects -def effect_values_to_time_series(label_suffix: str, - effect_values: EffectValuesUser, - parent_element: Element, - time_series_collection: TimeSeriesCollection) -> Optional[EffectValuesTS]: - """ - Transform EffectValues to EffectValuesTS. - Creates a TimeSeries for each key in the nested_values dictionary, using the value as the data. - - The resulting label of the TimeSeries is the label of the parent_element, - followed by the label of the Effect in the nested_values and the label_suffix. - If the key in the EffectValues is None, the alias 'Standard_Effect' is used - """ - effect_values: Optional[EffectValuesDict] = effect_values_to_dict(effect_values) - if effect_values is None: - return None - - effect_values_ts: EffectValuesTS = { - effect: parent_element._create_time_series( - f'{effect.label_full if effect is not None else "Standard_Effect"}|{label_suffix}', - value, - time_series_collection - ) - for effect, value in effect_values.items() - } - - return effect_values_ts - def effect_values_to_dict(effect_values_user: EffectValuesUser) -> Optional[EffectValuesDict]: """ @@ -266,61 +212,56 @@ def effect_values_to_dict(effect_values_user: EffectValuesUser) -> Optional[Effe None: effect_values_user} if effect_values_user is not None else None -class EffectCollection(Model): +class EffectCollection: """ Handling all Effects """ - def __init__(self, model: SystemModel, effects: List[Effect]): - super().__init__(model, label_of_element='Effects') + def __init__(self, *effects: List[Effect]): self._effects = {} self._standard_effect: Optional[Effect] = None self._objective_effect: Optional[Effect] = None - self.effects = effects # Performs some validation - self.penalty: Optional[ShareAllocationModel] = None + self.model: Optional[EffectCollectionModel] = None + self.add_effects(*effects) - def add_share_to_effects( - self, - name: str, - expressions: EffectValuesExpr, - target: Literal['operation', 'invest'], - ) -> None: - for effect, expression in expressions.items(): - if target == 'operation': - self[effect].model.operation.add_share(name, expression) - elif target =='invest': - self[effect].model.invest.add_share(name, expression) - else: - raise ValueError(f'Target {target} not supported!') + def create_model(self, model: SystemModel) -> 'EffectCollectionModel': + self.model = EffectCollectionModel(model, self) + return self.model - def add_share_to_penalty(self, system_model: SystemModel, name: str, expression: linopy.LinearExpression) -> None: - if expression.ndim != 0: - raise Exception(f'Penalty shares must be scalar expressions! ({expression.ndim=})') - self.penalty.add_share(name, expression) + def add_effects(self, *effects: Effect) -> None: + for effect in list(effects): + if effect in self: + raise Exception(f'Effect with label "{effect.label=}" already added!') + if effect.is_standard: + self.standard_effect = effect + if effect.is_objective: + self.objective_effect = effect + self._effects[effect.label] = effect + logger.info(f'Registered new Effect: {effect.label}') - def do_modeling(self): - for effect in self.effects.values(): - effect.create_model(self._model) - self.penalty = self.add(ShareAllocationModel(self._model, shares_are_time_series=False, label_of_element='Penalty')) - for model in [effect.model for effect in self.effects.values()] + [self.penalty]: - model.do_modeling() + self._plausibility_checks() - self._add_share_between_effects() + def _plausibility_checks(self) -> None: + # Check circular loops in effects: + # TODO: Improve checks!! Only most basic case covered... - def _add_share_between_effects(self): - for origin_effect in self.effects.values(): - # 1. operation: -> hier sind es Zeitreihen (share_TS) - for target_effect, time_series in origin_effect.specific_share_to_other_effects_operation.items(): - target_effect.model.operation.add_share( - origin_effect.label_full, - origin_effect.model.operation.total_per_timestep * time_series.active_data, + def error_str(effect_label: str, share_ffect_label: str): + return ( + f' {effect_label} -> has share in: {share_ffect_label}\n' + f' {share_ffect_label} -> has share in: {effect_label}' + ) + for effect in self.effects.values(): + # Effekt darf nicht selber als Share in seinen ShareEffekten auftauchen: + # operation: + for target_effect in effect.specific_share_to_other_effects_operation.keys(): + assert effect not in self[target_effect].specific_share_to_other_effects_operation.keys(), ( + f'Error: circular operation-shares \n{error_str(target_effect.label, target_effect.label)}' ) - # 2. invest: -> hier ist es Scalar (share) - for target_effect, factor in origin_effect.specific_share_to_other_effects_invest.items(): - target_effect.model.invest.add_share( - origin_effect.label_full, - origin_effect.model.invest.total * factor, + # invest: + for target_effect in effect.specific_share_to_other_effects_invest.keys(): + assert effect not in self[target_effect].specific_share_to_other_effects_invest.keys(), ( + f'Error: circular invest-shares \n{error_str(target_effect.label, target_effect.label)}' ) def __getitem__(self, effect: Union[str, Effect]) -> 'Effect': @@ -341,7 +282,10 @@ def __getitem__(self, effect: Union[str, Effect]) -> 'Effect': try: return self.effects[effect] except KeyError as e: - raise KeyError(f'No effect with label {effect} found!') from e + raise KeyError(f'Effect "{effect}" not found! Add it to the FlowSystem first!') from e + + def __iter__(self) -> Iterator[Effect]: + return iter(self._effects.values()) def __contains__(self, item: Union[str, 'Effect']) -> bool: """Check if the effect exists. Checks for label or object""" @@ -355,17 +299,6 @@ def __contains__(self, item: Union[str, 'Effect']) -> bool: def effects(self) -> Dict[str, Effect]: return self._effects - @effects.setter - def effects(self, value: List[Effect]): - for effect in value: - if effect.is_standard: - self.standard_effect = effect - if effect.is_objective: - self.objective_effect = effect - if effect in self: - raise Exception(f'Effect with label "{effect.label=}" already added!') - self._effects[effect.label] = effect - @property def standard_effect(self) -> Effect: if self._standard_effect is None: @@ -389,3 +322,61 @@ def objective_effect(self, value: Effect) -> None: if self._objective_effect is not None: raise ValueError(f'An objective-effect already exists! ({self._objective_effect.label=})') self._objective_effect = value + + +class EffectCollectionModel(Model): + """ + Handling all Effects + """ + + def __init__(self, model: SystemModel, effects: EffectCollection): + super().__init__(model, label_of_element='Effects') + self.effects = effects + self.penalty: Optional[ShareAllocationModel] = None + + def add_share_to_effects( + self, + name: str, + expressions: EffectValuesExpr, + target: Literal['operation', 'invest'], + ) -> None: + for effect, expression in expressions.items(): + if target == 'operation': + self.effects[effect].model.operation.add_share(name, expression) + elif target =='invest': + self.effects[effect].model.invest.add_share(name, expression) + else: + raise ValueError(f'Target {target} not supported!') + + def add_share_to_penalty(self, name: str, expression: linopy.LinearExpression) -> None: + if expression.ndim != 0: + raise Exception(f'Penalty shares must be scalar expressions! ({expression.ndim=})') + self.penalty.add_share(name, expression) + + def do_modeling(self): + for effect in self.effects: + effect.create_model(self._model) + self.penalty = self.add(ShareAllocationModel(self._model, shares_are_time_series=False, label_of_element='Penalty')) + for model in [effect.model for effect in self.effects] + [self.penalty]: + model.do_modeling() + + self._add_share_between_effects() + + self._model.add_objective( + self.effects.objective_effect.model.total + self.penalty.total + ) + + def _add_share_between_effects(self): + for origin_effect in self.effects: + # 1. operation: -> hier sind es Zeitreihen (share_TS) + for target_effect, time_series in origin_effect.specific_share_to_other_effects_operation.items(): + self.effects[target_effect].model.operation.add_share( + origin_effect.label_full, + origin_effect.model.operation.total_per_timestep * time_series.active_data, + ) + # 2. invest: -> hier ist es Scalar (share) + for target_effect, factor in origin_effect.specific_share_to_other_effects_invest.items(): + self.effects[target_effect].model.invest.add_share( + origin_effect.label_full, + origin_effect.model.invest.total * factor, + ) diff --git a/flixOpt/elements.py b/flixOpt/elements.py index 73ead1385..49e837a88 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -3,17 +3,20 @@ """ import logging -from typing import Dict, List, Literal, Optional, Tuple, Union +import warnings +from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Tuple, Union import linopy import numpy as np from .config import CONFIG from .core import NumericData, NumericDataTS, Scalar, TimeSeriesCollection -from .effects import EffectValuesUser, effect_values_to_time_series +from .effects import EffectValuesUser from .features import InvestmentModel, OnOffModel, PreventSimultaneousUsageModel from .interface import InvestParameters, OnOffParameters from .structure import Element, ElementModel, SystemModel +if TYPE_CHECKING: + from .flow_system import FlowSystem logger = logging.getLogger('flixOpt') @@ -60,19 +63,9 @@ def create_model(self, model: SystemModel) -> 'ComponentModel': self.model = ComponentModel(model, self) return self.model - def transform_data(self, time_series_collection: TimeSeriesCollection) -> None: + def transform_data(self, flow_system: 'FlowSystem') -> None: if self.on_off_parameters is not None: - self.on_off_parameters.transform_data(time_series_collection, self) - - def register_component_in_flows(self) -> None: - for flow in self.inputs + self.outputs: - flow.comp = self - - def register_flows_in_bus(self) -> None: - for flow in self.inputs: - flow.bus.add_output(flow) - for flow in self.outputs: - flow.bus.add_input(flow) + self.on_off_parameters.transform_data(flow_system, self.label_full) def infos(self, use_numpy=True, use_element_label=False) -> Dict: infos = super().infos(use_numpy, use_element_label) @@ -111,19 +104,11 @@ def create_model(self, model: SystemModel) -> 'BusModel': self.model = BusModel(model, self) return self.model - def transform_data(self, time_series_collection: TimeSeriesCollection): - self.excess_penalty_per_flow_hour = self._create_time_series( - 'excess_penalty_per_flow_hour', self.excess_penalty_per_flow_hour, time_series_collection + def transform_data(self, flow_system: 'FlowSystem'): + self.excess_penalty_per_flow_hour = flow_system.create_time_series( + f'{self.label_full}|excess_penalty_per_flow_hour', self.excess_penalty_per_flow_hour ) - def add_input(self, flow) -> None: - flow: Flow - self.inputs.append(flow) - - def add_output(self, flow) -> None: - flow: Flow - self.outputs.append(flow) - def _plausibility_checks(self) -> None: if self.excess_penalty_per_flow_hour == 0: logger.warning(f'In Bus {self.label}, the excess_penalty_per_flow_hour is 0. Use "None" or a value > 0.') @@ -150,7 +135,7 @@ class Flow(Element): def __init__( self, label: str, - bus: Bus, + bus: str, size: Union[Scalar, InvestParameters] = None, fixed_relative_profile: Optional[NumericDataTS] = None, relative_minimum: NumericDataTS = 0, @@ -224,8 +209,17 @@ def __init__( self.previous_flow_rate = previous_flow_rate - self.bus = bus - self.comp: Optional[Component] = None + self.component: str = 'UnknownComponent' + self.is_input_in_component: Optional[bool] = None + if isinstance(bus, Bus): + self.bus = bus.label_full + warnings.warn( + f'Bus {bus.label} is passed as a Bus object to {self.label}. This is deprecated and will be removed ' + f'in the future. Add the Bus to the FlowSystem instead and pass its label to the Flow.') + self._bus_object = bus + else: + self.bus = bus + self._bus_object = None self._plausibility_checks() @@ -233,19 +227,27 @@ def create_model(self, model: SystemModel) -> 'FlowModel': self.model = FlowModel(model, self) return self.model - def transform_data(self, time_series_collection: TimeSeriesCollection): - self.relative_minimum = self._create_time_series('relative_minimum', self.relative_minimum, time_series_collection) - self.relative_maximum = self._create_time_series('relative_maximum', self.relative_maximum, time_series_collection) - self.fixed_relative_profile = self._create_time_series('fixed_relative_profile', self.fixed_relative_profile, time_series_collection) - self.effects_per_flow_hour = effect_values_to_time_series('per_flow_hour', self.effects_per_flow_hour, self, time_series_collection) + def transform_data(self, flow_system: 'FlowSystem'): + self.relative_minimum = flow_system.create_time_series( + f'{self.label_full}|relative_minimum', self.relative_minimum + ) + self.relative_maximum = flow_system.create_time_series( + f'{self.label_full}|relative_maximum', self.relative_maximum + ) + self.fixed_relative_profile = flow_system.create_time_series( + f'{self.label_full}|fixed_relative_profile', self.fixed_relative_profile + ) + self.effects_per_flow_hour = flow_system.create_effect_time_series( + self.label_full, self.effects_per_flow_hour, 'per_flow_hour' + ) if self.on_off_parameters is not None: - self.on_off_parameters.transform_data(time_series_collection, self) + self.on_off_parameters.transform_data(flow_system, self.label_full) if isinstance(self.size, InvestParameters): - self.size.transform_data(time_series_collection) + self.size.transform_data(flow_system) def infos(self, use_numpy=True, use_element_label=False) -> Dict: infos = super().infos(use_numpy, use_element_label) - infos['is_input_in_component'] = self.is_input_in_comp + infos['is_input_in_component'] = self.is_input_in_component return infos def _plausibility_checks(self) -> None: @@ -264,13 +266,7 @@ def _plausibility_checks(self) -> None: @property def label_full(self) -> str: - # Wenn im Erstellungsprozess comp noch nicht bekannt: - comp_label = 'unknownComp' if self.comp is None else self.comp.label - return f'{comp_label} ({self.label})' - - @property # Richtung - def is_input_in_comp(self) -> bool: - return True if self in self.comp.inputs else False + return f'{self.component} ({self.label})' @property def size_is_fixed(self) -> bool: @@ -482,12 +478,8 @@ def do_modeling(self) -> None: ) eq_bus_balance.lhs -= -self.excess_input + self.excess_output - self._model.effects.add_share_to_penalty( - self._model, self.label_of_element, (self.excess_input * excess_penalty).sum() - ) - self._model.effects.add_share_to_penalty( - self._model, self.label_of_element, (self.excess_output * excess_penalty).sum() - ) + self._model.effects.add_share_to_penalty(self.label_of_element, (self.excess_input * excess_penalty).sum()) + self._model.effects.add_share_to_penalty(self.label_of_element, (self.excess_output * excess_penalty).sum()) def results_structure(self): inputs = [flow.model.flow_rate.name for flow in self.element.inputs] diff --git a/flixOpt/flow_system.py b/flixOpt/flow_system.py index d7ef6f011..33dc9d584 100644 --- a/flixOpt/flow_system.py +++ b/flixOpt/flow_system.py @@ -12,8 +12,8 @@ import xarray as xr from . import utils -from .core import TimeSeries, TimeSeriesCollection -from .effects import Effect +from .core import TimeSeries, TimeSeriesCollection, NumericData, NumericDataTS, TimeSeriesData +from .effects import Effect, EffectCollection, EffectValuesTS, EffectValuesUser, effect_values_to_dict, EffectValuesDict from .elements import Bus, Component, Flow from .structure import Element, SystemModel, get_compact_representation, get_str_representation @@ -60,49 +60,122 @@ def __init__( # defaults: self.components: Dict[str, Component] = {} - self.effects: Dict[str, Effect] = {} + self.buses: Dict[str, Bus] = {} + self.effects: EffectCollection = EffectCollection() self.model: Optional[SystemModel] = None def add_effects(self, *args: Effect) -> None: - for new_effect in list(args): - if new_effect.label in self.effects: - raise Exception(f'Effect with label "{new_effect.label=}" already added!') - self.effects[new_effect.label] = new_effect - logger.info(f'Registered new Effect: {new_effect.label}') - - def add_components(self, *args: Component) -> None: - # Komponenten registrieren: - new_components = list(args) - for new_component in new_components: + self.effects.add_effects(*args) + + def add_components(self, *components: Component) -> None: + for new_component in list(components): logger.info(f'Registered new Component: {new_component.label}') self._check_if_element_is_unique(new_component) # check if already exists: - new_component.register_component_in_flows() # Komponente in Flow registrieren - new_component.register_flows_in_bus() # Flows in Bus registrieren: self.components[new_component.label] = new_component # Add to existing components - def add_elements(self, *args: Element) -> None: + def add_elements(self, *elements: Element) -> None: """ add all modeling elements, like storages, boilers, heatpumps, buses, ... Parameters ---------- - *args : childs of Element like Boiler, HeatPump, Bus,... + *elements : childs of Element like Boiler, HeatPump, Bus,... modeling Elements """ - for new_element in list(args): + for new_element in list(elements): if isinstance(new_element, Component): self.add_components(new_element) elif isinstance(new_element, Effect): self.add_effects(new_element) + elif isinstance(new_element, Bus): + self.add_buses(new_element) else: raise Exception('argument is not instance of a modeling Element (Element)') + def add_buses(self, *buses: Bus): + for new_bus in list(buses): + logger.info(f'Registered new Bus: {new_bus.label}') + self._check_if_element_is_unique(new_bus) # check if already exists: + self.buses[new_bus.label] = new_bus # Add to existing components + + def _connect_network(self): + """Connects the network of components and buses. Can be rerun without changes if no elements were added""" + for component in self.components.values(): + for flow in component.inputs + component.outputs: + flow.component = component.label_full + flow.is_input_in_component = True if flow in component.inputs else False + + # Add Bus if not already added (deprecated) + if flow._bus_object is not None and flow._bus_object not in self.buses.values(): + self.add_buses(flow._bus_object) + + # Connect Buses + bus = self.buses.get(flow.bus) + if bus is None: + raise KeyError(f'Bus {flow.bus} not found in the FlowSystem, but used by "{flow.label_full}". ' + f'Please add it first.') + if flow.is_input_in_component and flow not in bus.outputs: + bus.outputs.append(flow) + elif not flow.is_input_in_component and flow not in bus.inputs: + bus.inputs.append(flow) + def transform_data(self): + self._connect_network() for element in self.all_elements.values(): - element.transform_data(self.time_series_collection) + element.transform_data(self) + + def create_time_series( + self, + name: str, + data: Optional[Union[NumericData, TimeSeriesData, TimeSeries]], + extra_timestep: bool = False, + ) -> Optional[TimeSeries]: + """ + Tries to create a TimeSeries from NumericData Data and adds it to the time_series_collection + If the data already is a TimeSeries, nothing happens and the TimeSeries gets reset and returned + If the data is a TimeSeriesData, it is converted to a TimeSeries, and the aggregation weights are applied. + If the data is None, nothing happens. + """ + + if data is None: + return None + elif isinstance(data, TimeSeries): + data.restore_data() + return data + return self.time_series_collection.create_time_series( + data=data, + name=name, + extra_timestep=extra_timestep + ) + + def create_effect_time_series(self, + label_prefix: Optional[str], + effect_values: EffectValuesUser, + label_suffix: Optional[str] = None, + ) -> Optional[EffectValuesTS]: + """ + Transform EffectValues to EffectValuesTS. + Creates a TimeSeries for each key in the nested_values dictionary, using the value as the data. + + The resulting label of the TimeSeries is the label of the parent_element, + followed by the label of the Effect in the nested_values and the label_suffix. + If the key in the EffectValues is None, the alias 'Standard_Effect' is used + """ + effect_values: Optional[EffectValuesDict] = effect_values_to_dict(effect_values) + if effect_values is None: + return None + + return { + effect: self.create_time_series( + '|'.join(filter(None, [label_prefix, f'{self.effects[effect].label_full}', label_suffix])), + value + ) + for effect, value in effect_values.items() + } def network_infos(self) -> Tuple[Dict[str, Dict[str, str]], Dict[str, Dict[str, str]]]: + self._connect_network() nodes = { node.label_full: { 'label': node.label, @@ -115,8 +188,8 @@ def network_infos(self) -> Tuple[Dict[str, Dict[str, str]], Dict[str, Dict[str, edges = { flow.label_full: { 'label': flow.label, - 'start': flow.bus.label_full if flow.is_input_in_comp else flow.comp.label_full, - 'end': flow.comp.label_full if flow.is_input_in_comp else flow.bus.label_full, + 'start': flow.bus if flow.is_input_in_component else flow.component, + 'end': flow.component if flow.is_input_in_component else flow.bus, 'infos': flow.__str__(), } for flow in self.flows.values() @@ -136,7 +209,7 @@ def infos(self, use_numpy=True, use_element_label=False) -> Dict: }, 'Effects': { effect.label: effect.infos(use_numpy, use_element_label) - for effect in sorted(self.effects.values(), key=lambda effect: effect.label.upper()) + for effect in sorted(self.effects, key=lambda effect: effect.label.upper()) }, } return infos @@ -238,14 +311,6 @@ def flows(self) -> Dict[str, Flow]: set_of_flows = {flow for comp in self.components.values() for flow in comp.inputs + comp.outputs} return {flow.label_full: flow for flow in set_of_flows} - @property - def buses(self) -> Dict[str, Bus]: - return {flow.bus.label: flow.bus for flow in self.flows.values()} - @property def all_elements(self) -> Dict[str, Element]: - return {**self.components, **self.effects, **self.flows, **self.buses} - - @property - def all_time_series(self) -> List[TimeSeries]: - return [ts for element in self.all_elements.values() for ts in element.used_time_series] + return {**self.components, **self.effects.effects, **self.flows, **self.buses} diff --git a/flixOpt/interface.py b/flixOpt/interface.py index 04c2e9771..55434132a 100644 --- a/flixOpt/interface.py +++ b/flixOpt/interface.py @@ -12,6 +12,9 @@ from .core import NumericData, NumericDataTS, Scalar from .structure import Element, Interface +if TYPE_CHECKING: # for type checking and preventing circular imports + from .flow_system import FlowSystem + if TYPE_CHECKING: from .effects import Effect, EffectValuesUser, EffectValuesUserScalar @@ -80,7 +83,7 @@ def __init__( self._minimum_size = minimum_size self._maximum_size = maximum_size or CONFIG.modeling.BIG # default maximum - def transform_data(self, time_series_collection: TimeSeriesCollection): + def transform_data(self, flow_system: 'FlowSystem'): from .effects import effect_values_to_dict self.fix_effects = effect_values_to_dict(self.fix_effects) @@ -152,26 +155,24 @@ def __init__( self.switch_on_total_max: Scalar = switch_on_total_max self.force_switch_on: bool = force_switch_on - def transform_data(self, time_series_collection: TimeSeriesCollection, name_prefix: str): - from .effects import effect_values_to_time_series - - self.effects_per_switch_on = effect_values_to_time_series( - 'per_switch_on', self.effects_per_switch_on, name_prefix, time_series_collection + def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): + self.effects_per_switch_on = flow_system.create_effect_time_series( + name_prefix, self.effects_per_switch_on, 'per_switch_on' ) - self.effects_per_running_hour = effect_values_to_time_series( - 'per_running_hour', self.effects_per_running_hour, name_prefix, time_series_collection + self.effects_per_running_hour = flow_system.create_effect_time_series( + name_prefix, self.effects_per_running_hour, 'per_running_hour' ) - self.consecutive_on_hours_min = self._create_time_series( - 'consecutive_on_hours_min', self.consecutive_on_hours_min, time_series_collection + self.consecutive_on_hours_min = flow_system.create_time_series( + f'{name_prefix}|consecutive_on_hours_min', self.consecutive_on_hours_min ) - self.consecutive_on_hours_max = self._create_time_series( - 'consecutive_on_hours_max', self.consecutive_on_hours_max, time_series_collection + self.consecutive_on_hours_max = flow_system.create_time_series( + f'{name_prefix}|consecutive_on_hours_max', self.consecutive_on_hours_max ) - self.consecutive_off_hours_min = self._create_time_series( - 'consecutive_off_hours_min', self.consecutive_off_hours_min, time_series_collection + self.consecutive_off_hours_min = flow_system.create_time_series( + f'{name_prefix}|consecutive_off_hours_min', self.consecutive_off_hours_min ) - self.consecutive_off_hours_max = self._create_time_series( - 'consecutive_off_hours_max', self.consecutive_off_hours_max, time_series_collection + self.consecutive_off_hours_max = flow_system.create_time_series( + f'{name_prefix}|consecutive_off_hours_max', self.consecutive_off_hours_max ) @property diff --git a/flixOpt/io.py b/flixOpt/io.py index f6fa55aa2..77a81864c 100644 --- a/flixOpt/io.py +++ b/flixOpt/io.py @@ -24,7 +24,7 @@ def _results_structure(flow_system: FlowSystem) -> Dict[str, Dict]: }, 'Effects': { effect.label_full: effect.model.results_structure() - for effect in sorted(flow_system.effects.values(), key=lambda effect: effect.label_full.upper()) + for effect in sorted(flow_system.effects, key=lambda effect: effect.label_full.upper()) }, 'Time': [datetime.datetime.isoformat(date) for date in flow_system.time_series_collection.timesteps_extra], 'Periods': flow_system.time_series_collection.periods.tolist() if flow_system.time_series_collection.periods is not None else None diff --git a/flixOpt/structure.py b/flixOpt/structure.py index 5fe370892..12429f5f8 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -23,7 +23,7 @@ from .core import NumericData, Scalar, TimeSeries, TimeSeriesCollection, TimeSeriesData if TYPE_CHECKING: # for type checking and preventing circular imports - from .effects import EffectCollection + from .effects import EffectCollectionModel from .flow_system import FlowSystem logger = logging.getLogger('flixOpt') @@ -35,11 +35,10 @@ def __init__(self, flow_system: 'FlowSystem'): super().__init__(force_dim_names=True) self.flow_system = flow_system self.time_series_collection = flow_system.time_series_collection - self.effects: Optional[EffectCollection] = None + self.effects: Optional[EffectCollectionModel] = None def do_modeling(self): - from .effects import EffectCollection - self.effects = EffectCollection(self, list(self.flow_system.effects.values())) + self.effects = self.flow_system.effects.create_model(self) self.effects.do_modeling() component_models = [component.create_model(self) for component in self.flow_system.components.values()] bus_models = [bus.create_model(self) for bus in self.flow_system.buses.values()] @@ -48,10 +47,6 @@ def do_modeling(self): for bus_model in bus_models: # Buses after Components, because FlowModels are created in ComponentModels bus_model.do_modeling() - self.add_objective( - self.effects.objective_effect.model.total + self.effects.penalty.total - ) - @property def main_results(self) -> Dict[str, Union[Scalar, Dict]]: from flixOpt.features import InvestmentModel @@ -65,7 +60,7 @@ def main_results(self) -> Dict[str, Union[Scalar, Dict]]: "invest": float(effect.model.invest.total.solution.values), "total": float(effect.model.total.solution.values), } - for effect in self.flow_system.effects.values() + for effect in self.flow_system.effects }, "Invest-Decisions": { "Invested": { @@ -121,7 +116,7 @@ class Interface: This class is used to collect arguments about a Model. """ - def transform_data(self, time_series_collection: TimeSeriesCollection): + def transform_data(self, flow_system: 'FlowSystem'): """ Transforms the data of the interface to match the FlowSystem's dimensions""" raise NotImplementedError('Every Interface needs a transform_data() method') @@ -188,31 +183,6 @@ def __repr__(self): def __str__(self): return get_str_representation(self.infos(use_numpy=True, use_element_label=True)) - @staticmethod - def _create_time_series( - name: str, - data: Optional[Union[NumericData, TimeSeriesData, TimeSeries]], - time_series_collection: TimeSeriesCollection, - extra_timestep: bool = False, - ) -> Optional[TimeSeries]: - """ - Tries to create a TimeSeries from NumericData Data and adds it to the time_series_collection - If the data already is a TimeSeries, nothing happens and the TimeSeries gets reset and returned - If the data is a TimeSeriesData, it is converted to a TimeSeries, and the aggregation weights are applied. - If the data is None, nothing happens. - """ - - if data is None: - return None - elif isinstance(data, TimeSeries): - data.restore_data() - return data - return time_series_collection.create_time_series( - data=data, - name=name, - extra_timestep=extra_timestep, - ) - class Element(Interface): """Basic Element of flixOpt""" @@ -242,21 +212,6 @@ def create_model(self, model: SystemModel) -> 'ElementModel': def label_full(self) -> str: return self.label - def _create_time_series( - self, - name: str, - data: Optional[Union[NumericData, TimeSeriesData, TimeSeries]], - time_series_collection: TimeSeriesCollection, - extra_timestep: bool = False, - ) -> Optional[TimeSeries]: - """ - Tries to create a TimeSeries from NumericData Data and adds it to the time_series_collection - If the data already is a TimeSeries, nothing happens and the TimeSeries gets reset and returned - If the data is a TimeSeriesData, it is converted to a TimeSeries, and the aggregation weights are applied. - If the data is None, nothing happens. - """ - return super()._create_time_series(f'{self.label_full}|{name}', data, time_series_collection, extra_timestep) - @staticmethod def _valid_label(label: str) -> str: """ diff --git a/tests/test_functional.py b/tests/test_functional.py index 2fe946db7..c405ae79e 100644 --- a/tests/test_functional.py +++ b/tests/test_functional.py @@ -65,30 +65,29 @@ def flow_system_base(timesteps: pd.DatetimeIndex) -> fx.FlowSystem: data = Data(len(timesteps)) flow_system = fx.FlowSystem(timesteps) - buses = { - 'Fernwärme': fx.Bus('Fernwärme', excess_penalty_per_flow_hour=None), - 'Gas': fx.Bus('Gas', excess_penalty_per_flow_hour=None), - } + flow_system.add_elements( + fx.Bus('Fernwärme', excess_penalty_per_flow_hour=None), + fx.Bus('Gas', excess_penalty_per_flow_hour=None), + ) flow_system.add_elements(fx.Effect('costs', '€', 'Kosten', is_standard=True, is_objective=True)) flow_system.add_elements( fx.Sink( label='Wärmelast', - sink=fx.Flow(label='Wärme', bus=buses['Fernwärme'], fixed_relative_profile=data.thermal_demand, size=1), + sink=fx.Flow(label='Wärme', bus='Fernwärme', fixed_relative_profile=data.thermal_demand, size=1), ), - fx.Source(label='Gastarif', source=fx.Flow(label='Gas', bus=buses['Gas'], effects_per_flow_hour=1)), + fx.Source(label='Gastarif', source=fx.Flow(label='Gas', bus='Gas', effects_per_flow_hour=1)), ) return flow_system def flow_system_minimal(timesteps) -> fx.FlowSystem: flow_system = flow_system_base(timesteps) - buses = flow_system.buses flow_system.add_elements( fx.linear_converters.Boiler( 'Boiler', 0.5, - Q_fu=fx.Flow('Q_fu', bus=buses['Gas']), - Q_th=fx.Flow('Q_th', bus=buses['Fernwärme']), + Q_fu=fx.Flow('Q_fu', bus='Gas'), + Q_th=fx.Flow('Q_th', bus='Fernwärme'), ) ) return flow_system @@ -153,10 +152,10 @@ def test_fixed_size(solver_fixture, time_steps_fixture): fx.linear_converters.Boiler( 'Boiler', 0.5, - Q_fu=fx.Flow('Q_fu', bus=flow_system.buses['Gas']), + Q_fu=fx.Flow('Q_fu', bus='Gas'), Q_th=fx.Flow( 'Q_th', - bus=flow_system.buses['Fernwärme'], + bus='Fernwärme', size=fx.InvestParameters(fixed_size=1000, fix_effects=10, specific_effects=1), ), ) @@ -194,10 +193,10 @@ def test_optimize_size(solver_fixture, time_steps_fixture): fx.linear_converters.Boiler( 'Boiler', 0.5, - Q_fu=fx.Flow('Q_fu', bus=flow_system.buses['Gas']), + Q_fu=fx.Flow('Q_fu', bus='Gas'), Q_th=fx.Flow( 'Q_th', - bus=flow_system.buses['Fernwärme'], + bus='Fernwärme', size=fx.InvestParameters(fix_effects=10, specific_effects=1), ), ) @@ -235,10 +234,10 @@ def test_size_bounds(solver_fixture, time_steps_fixture): fx.linear_converters.Boiler( 'Boiler', 0.5, - Q_fu=fx.Flow('Q_fu', bus=flow_system.buses['Gas']), + Q_fu=fx.Flow('Q_fu', bus='Gas'), Q_th=fx.Flow( 'Q_th', - bus=flow_system.buses['Fernwärme'], + bus='Fernwärme', size=fx.InvestParameters(minimum_size=40, fix_effects=10, specific_effects=1), ), ) @@ -276,20 +275,20 @@ def test_optional_invest(solver_fixture, time_steps_fixture): fx.linear_converters.Boiler( 'Boiler', 0.5, - Q_fu=fx.Flow('Q_fu', bus=flow_system.buses['Gas']), + Q_fu=fx.Flow('Q_fu', bus='Gas'), Q_th=fx.Flow( 'Q_th', - bus=flow_system.buses['Fernwärme'], + bus='Fernwärme', size=fx.InvestParameters(optional=True, minimum_size=40, fix_effects=10, specific_effects=1), ), ), fx.linear_converters.Boiler( 'Boiler_optional', 0.5, - Q_fu=fx.Flow('Q_fu', bus=flow_system.buses['Gas']), + Q_fu=fx.Flow('Q_fu', bus='Gas'), Q_th=fx.Flow( 'Q_th', - bus=flow_system.buses['Fernwärme'], + bus='Fernwärme', size=fx.InvestParameters(optional=True, minimum_size=50, fix_effects=10, specific_effects=1), ), ), @@ -344,8 +343,8 @@ def test_on(solver_fixture, time_steps_fixture): fx.linear_converters.Boiler( 'Boiler', 0.5, - Q_fu=fx.Flow('Q_fu', bus=flow_system.buses['Gas']), - Q_th=fx.Flow('Q_th', bus=flow_system.buses['Fernwärme'], size=100, on_off_parameters=fx.OnOffParameters() + Q_fu=fx.Flow('Q_fu', bus='Gas'), + Q_th=fx.Flow('Q_th', bus='Fernwärme', size=100, on_off_parameters=fx.OnOffParameters() ), )) @@ -383,10 +382,10 @@ def test_off(solver_fixture, time_steps_fixture): fx.linear_converters.Boiler( 'Boiler', 0.5, - Q_fu=fx.Flow('Q_fu', bus=flow_system.buses['Gas']), + Q_fu=fx.Flow('Q_fu', bus='Gas'), Q_th=fx.Flow( 'Q_th', - bus=flow_system.buses['Fernwärme'], + bus='Fernwärme', size=100, on_off_parameters=fx.OnOffParameters(consecutive_off_hours_max=100), ), @@ -434,10 +433,10 @@ def test_switch_on_off(solver_fixture, time_steps_fixture): fx.linear_converters.Boiler( 'Boiler', 0.5, - Q_fu=fx.Flow('Q_fu', bus=flow_system.buses['Gas']), + Q_fu=fx.Flow('Q_fu', bus='Gas'), Q_th=fx.Flow( 'Q_th', - bus=flow_system.buses['Fernwärme'], + bus='Fernwärme', size=100, on_off_parameters=fx.OnOffParameters(force_switch_on=True), ), @@ -492,10 +491,10 @@ def test_on_total_max(solver_fixture, time_steps_fixture): fx.linear_converters.Boiler( 'Boiler', 0.5, - Q_fu=fx.Flow('Q_fu', bus=flow_system.buses['Gas']), + Q_fu=fx.Flow('Q_fu', bus='Gas'), Q_th=fx.Flow( 'Q_th', - bus=flow_system.buses['Fernwärme'], + bus='Fernwärme', size=100, on_off_parameters=fx.OnOffParameters(on_hours_total_max=1), ), @@ -503,8 +502,8 @@ def test_on_total_max(solver_fixture, time_steps_fixture): fx.linear_converters.Boiler( 'Boiler_backup', 0.2, - Q_fu=fx.Flow('Q_fu', bus=flow_system.buses['Gas']), - Q_th=fx.Flow('Q_th', bus=flow_system.buses['Fernwärme'], size=100), + Q_fu=fx.Flow('Q_fu', bus='Gas'), + Q_th=fx.Flow('Q_th', bus='Fernwärme', size=100), ), ) @@ -542,10 +541,10 @@ def test_on_total_bounds(solver_fixture, time_steps_fixture): fx.linear_converters.Boiler( 'Boiler', 0.5, - Q_fu=fx.Flow('Q_fu', bus=flow_system.buses['Gas']), + Q_fu=fx.Flow('Q_fu', bus='Gas'), Q_th=fx.Flow( 'Q_th', - bus=flow_system.buses['Fernwärme'], + bus='Fernwärme', size=100, on_off_parameters=fx.OnOffParameters(on_hours_total_max=2), ), @@ -553,10 +552,10 @@ def test_on_total_bounds(solver_fixture, time_steps_fixture): fx.linear_converters.Boiler( 'Boiler_backup', 0.2, - Q_fu=fx.Flow('Q_fu', bus=flow_system.buses['Gas']), + Q_fu=fx.Flow('Q_fu', bus='Gas'), Q_th=fx.Flow( 'Q_th', - bus=flow_system.buses['Fernwärme'], + bus='Fernwärme', size=100, on_off_parameters=fx.OnOffParameters(on_hours_total_min=3), ), @@ -614,10 +613,10 @@ def test_consecutive_on_off(solver_fixture, time_steps_fixture): fx.linear_converters.Boiler( 'Boiler', 0.5, - Q_fu=fx.Flow('Q_fu', bus=flow_system.buses['Gas']), + Q_fu=fx.Flow('Q_fu', bus='Gas'), Q_th=fx.Flow( 'Q_th', - bus=flow_system.buses['Fernwärme'], + bus='Fernwärme', size=100, on_off_parameters=fx.OnOffParameters(consecutive_on_hours_max=2, consecutive_on_hours_min=2), ), @@ -625,8 +624,8 @@ def test_consecutive_on_off(solver_fixture, time_steps_fixture): fx.linear_converters.Boiler( 'Boiler_backup', 0.2, - Q_fu=fx.Flow('Q_fu', bus=flow_system.buses['Gas']), - Q_th=fx.Flow('Q_th', bus=flow_system.buses['Fernwärme'], size=100), + Q_fu=fx.Flow('Q_fu', bus='Gas'), + Q_th=fx.Flow('Q_th', bus='Fernwärme', size=100), ), ) flow_system.all_elements['Wärmelast'].sink.fixed_relative_profile = np.array([5, 10, 20, 18, 12]) @@ -675,16 +674,16 @@ def test_consecutive_off(solver_fixture, time_steps_fixture): fx.linear_converters.Boiler( 'Boiler', 0.5, - Q_fu=fx.Flow('Q_fu', bus=flow_system.buses['Gas']), - Q_th=fx.Flow('Q_th', bus=flow_system.buses['Fernwärme']), + Q_fu=fx.Flow('Q_fu', bus='Gas'), + Q_th=fx.Flow('Q_th', bus='Fernwärme'), ), fx.linear_converters.Boiler( 'Boiler_backup', 0.2, - Q_fu=fx.Flow('Q_fu', bus=flow_system.buses['Gas']), + Q_fu=fx.Flow('Q_fu', bus='Gas'), Q_th=fx.Flow( 'Q_th', - bus=flow_system.buses['Fernwärme'], + bus='Fernwärme', size=100, previous_flow_rate=np.array([20]), # Otherwise its Off before the start on_off_parameters=fx.OnOffParameters(consecutive_off_hours_max=2, consecutive_off_hours_min=2), diff --git a/tests/test_integration.py b/tests/test_integration.py index 505aa39aa..e597dd5dd 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -99,10 +99,6 @@ def test_from_results(self): def model(self) -> fx.FullCalculation: # Define the components and flow_system - Strom = fx.Bus('Strom') - Fernwaerme = fx.Bus('Fernwärme') - Gas = fx.Bus('Gas') - costs = fx.Effect('costs', '€', 'Kosten', is_standard=True, is_objective=True) CO2 = fx.Effect( 'CO2', @@ -117,26 +113,26 @@ def model(self) -> fx.FullCalculation: eta=0.5, Q_th=fx.Flow( 'Q_th', - bus=Fernwaerme, + bus='Fernwärme', size=50, relative_minimum=5 / 50, relative_maximum=1, on_off_parameters=fx.OnOffParameters(), ), - Q_fu=fx.Flow('Q_fu', bus=Gas), + Q_fu=fx.Flow('Q_fu', bus='Gas'), ) aKWK = fx.linear_converters.CHP( 'CHP_unit', eta_th=0.5, eta_el=0.4, - P_el=fx.Flow('P_el', bus=Strom, size=60, relative_minimum=5 / 60, on_off_parameters=fx.OnOffParameters()), - Q_th=fx.Flow('Q_th', bus=Fernwaerme), - Q_fu=fx.Flow('Q_fu', bus=Gas), + P_el=fx.Flow('P_el', bus='Strom', size=60, relative_minimum=5 / 60, on_off_parameters=fx.OnOffParameters()), + Q_th=fx.Flow('Q_th', bus='Fernwärme'), + Q_fu=fx.Flow('Q_fu', bus='Gas'), ) aSpeicher = fx.Storage( 'Speicher', - charging=fx.Flow('Q_th_load', bus=Fernwaerme, size=1e4), - discharging=fx.Flow('Q_th_unload', bus=Fernwaerme, size=1e4), + charging=fx.Flow('Q_th_load', bus='Fernwärme', size=1e4), + discharging=fx.Flow('Q_th_unload', bus='Fernwärme', size=1e4), capacity_in_flow_hours=fx.InvestParameters(fix_effects=20, fixed_size=30, optional=False), initial_charge_state=0, relative_maximum_charge_state=1 / 100 * np.array([80.0, 70.0, 80.0, 80, 80, 80, 80, 80, 80, 80]), @@ -146,16 +142,17 @@ def model(self) -> fx.FullCalculation: prevent_simultaneous_charge_and_discharge=True, ) aWaermeLast = fx.Sink( - 'Wärmelast', sink=fx.Flow('Q_th_Last', bus=Fernwaerme, size=1, fixed_relative_profile=self.Q_th_Last) + 'Wärmelast', sink=fx.Flow('Q_th_Last', bus='Fernwärme', size=1, fixed_relative_profile=self.Q_th_Last) ) aGasTarif = fx.Source( - 'Gastarif', source=fx.Flow('Q_Gas', bus=Gas, size=1000, effects_per_flow_hour={costs: 0.04, CO2: 0.3}) + 'Gastarif', source=fx.Flow('Q_Gas', bus='Gas', size=1000, effects_per_flow_hour={costs: 0.04, CO2: 0.3}) ) aStromEinspeisung = fx.Sink( - 'Einspeisung', sink=fx.Flow('P_el', bus=Strom, effects_per_flow_hour=-1 * self.p_el) + 'Einspeisung', sink=fx.Flow('P_el', bus='Strom', effects_per_flow_hour=-1 * self.p_el) ) es = fx.FlowSystem(self.timesteps) + es.add_elements(fx.Bus('Strom'), fx.Bus('Fernwärme'), fx.Bus('Gas')) es.add_components(aSpeicher) es.add_effects(costs, CO2) es.add_components(aBoiler, aWaermeLast, aGasTarif) @@ -232,26 +229,26 @@ def test_transmission_advanced(self): self.create_basic_elements() flow_system = fx.FlowSystem(self.timesteps) flow_system.add_elements(*(list(self.effects.values()) + list(self.components.values()))) - extra_bus = fx.Bus('Wärme lokal') + flow_system.add_elements(fx.Bus('Wärme lokal')) boiler = fx.linear_converters.Boiler( 'Boiler_Standard', eta=0.9, Q_th=fx.Flow( - 'Q_th', bus=self.busses['Fernwärme'], relative_maximum=np.array([0, 0, 0, 1, 1, 1, 1, 1, 1, 1]) + 'Q_th', bus='Fernwärme', relative_maximum=np.array([0, 0, 0, 1, 1, 1, 1, 1, 1, 1]) ), - Q_fu=fx.Flow('Q_fu', bus=self.busses['Gas']), + Q_fu=fx.Flow('Q_fu', bus='Gas'), ) boiler2 = fx.linear_converters.Boiler( - 'Boiler_backup', eta=0.4, Q_th=fx.Flow('Q_th', bus=extra_bus), Q_fu=fx.Flow('Q_fu', bus=self.busses['Gas']) + 'Boiler_backup', eta=0.4, Q_th=fx.Flow('Q_th', bus='Wärme lokal'), Q_fu=fx.Flow('Q_fu', bus='Gas') ) last2 = fx.Sink( 'Wärmelast2', sink=fx.Flow( 'Q_th_Last', - bus=extra_bus, + bus='Wärme lokal', size=1, fixed_relative_profile=self.Q_th_Last * np.array([0, 0, 0, 0, 0, 1, 1, 1, 1, 1]), ), @@ -261,10 +258,10 @@ def test_transmission_advanced(self): 'Rohr', relative_losses=0.2, absolute_losses=20, - in1=fx.Flow('Rohr1a', bus=extra_bus, size=fx.InvestParameters(specific_effects=5, maximum_size=1000)), - out1=fx.Flow('Rohr1b', self.busses['Fernwärme'], size=1000), - in2=fx.Flow('Rohr2a', self.busses['Fernwärme'], size=1000), - out2=fx.Flow('Rohr2b', bus=extra_bus, size=1000), + in1=fx.Flow('Rohr1a', bus='Wärme lokal', size=fx.InvestParameters(specific_effects=5, maximum_size=1000)), + out1=fx.Flow('Rohr1b', 'Fernwärme', size=1000), + in2=fx.Flow('Rohr2a', 'Fernwärme', size=1000), + out2=fx.Flow('Rohr2b', bus='Wärme lokal', size=1000), ) flow_system.add_elements(transmission, boiler, boiler2, last2) @@ -485,10 +482,6 @@ def test_segments_of_flows(self): def basic_model(self) -> fx.FullCalculation: # Define the components and flow_system - Strom = fx.Bus('Strom', excess_penalty_per_flow_hour=self.excessCosts) - Fernwaerme = fx.Bus('Fernwärme', excess_penalty_per_flow_hour=self.excessCosts) - Gas = fx.Bus('Gas', excess_penalty_per_flow_hour=self.excessCosts) - costs = fx.Effect('costs', '€', 'Kosten', is_standard=True, is_objective=True) CO2 = fx.Effect('CO2', 'kg', 'CO2_e-Emissionen', specific_share_to_other_effects_operation={costs: 0.2}) PE = fx.Effect('PE', 'kWh_PE', 'Primärenergie', maximum_total=3.5e3) @@ -499,7 +492,7 @@ def basic_model(self) -> fx.FullCalculation: on_off_parameters=fx.OnOffParameters(effects_per_running_hour={costs: 0, CO2: 1000}), Q_th=fx.Flow( 'Q_th', - bus=Fernwaerme, + bus='Fernwärme', load_factor_max=1.0, load_factor_min=0.1, relative_minimum=5 / 50, @@ -519,7 +512,7 @@ def basic_model(self) -> fx.FullCalculation: ), flow_hours_total_max=1e6, ), - Q_fu=fx.Flow('Q_fu', bus=Gas, size=200, relative_minimum=0, relative_maximum=1), + Q_fu=fx.Flow('Q_fu', bus='Gas', size=200, relative_minimum=0, relative_maximum=1), ) aKWK = fx.linear_converters.CHP( @@ -527,9 +520,9 @@ def basic_model(self) -> fx.FullCalculation: eta_th=0.5, eta_el=0.4, on_off_parameters=fx.OnOffParameters(effects_per_switch_on=0.01), - P_el=fx.Flow('P_el', bus=Strom, size=60, relative_minimum=5 / 60, previous_flow_rate=10), - Q_th=fx.Flow('Q_th', bus=Fernwaerme, size=1e3), - Q_fu=fx.Flow('Q_fu', bus=Gas, size=1e3), + P_el=fx.Flow('P_el', bus='Strom', size=60, relative_minimum=5 / 60, previous_flow_rate=10), + Q_th=fx.Flow('Q_th', bus='Fernwärme', size=1e3), + Q_fu=fx.Flow('Q_fu', bus='Gas', size=1e3), ) costsInvestsizeSegments = ([(5, 25), (25, 100)], {costs: [(50, 250), (250, 800)], PE: [(5, 25), (25, 100)]}) @@ -543,8 +536,8 @@ def basic_model(self) -> fx.FullCalculation: ) aSpeicher = fx.Storage( 'Speicher', - charging=fx.Flow('Q_th_load', bus=Fernwaerme, size=1e4), - discharging=fx.Flow('Q_th_unload', bus=Fernwaerme, size=1e4), + charging=fx.Flow('Q_th_load', bus='Fernwärme', size=1e4), + discharging=fx.Flow('Q_th_unload', bus='Fernwärme', size=1e4), capacity_in_flow_hours=invest_Speicher, initial_charge_state=0, maximal_final_charge_state=10, @@ -557,20 +550,23 @@ def basic_model(self) -> fx.FullCalculation: aWaermeLast = fx.Sink( 'Wärmelast', sink=fx.Flow( - 'Q_th_Last', bus=Fernwaerme, size=1, relative_minimum=0, fixed_relative_profile=self.Q_th_Last + 'Q_th_Last', bus='Fernwärme', size=1, relative_minimum=0, fixed_relative_profile=self.Q_th_Last ), ) aGasTarif = fx.Source( - 'Gastarif', source=fx.Flow('Q_Gas', bus=Gas, size=1000, effects_per_flow_hour={costs: 0.04, CO2: 0.3}) + 'Gastarif', source=fx.Flow('Q_Gas', bus='Gas', size=1000, effects_per_flow_hour={costs: 0.04, CO2: 0.3}) ) aStromEinspeisung = fx.Sink( - 'Einspeisung', sink=fx.Flow('P_el', bus=Strom, effects_per_flow_hour=-1 * np.array(self.P_el_Last)) + 'Einspeisung', sink=fx.Flow('P_el', bus='Strom', effects_per_flow_hour=-1 * np.array(self.P_el_Last)) ) es = fx.FlowSystem(self.timesteps) es.add_effects(costs, CO2, PE) es.add_components(aGaskessel, aWaermeLast, aGasTarif, aStromEinspeisung, aKWK, aSpeicher) - + es.add_elements(fx.Bus('Strom', excess_penalty_per_flow_hour=self.excessCosts), + fx.Bus('Fernwärme', excess_penalty_per_flow_hour=self.excessCosts), + fx.Bus('Gas', excess_penalty_per_flow_hour=self.excessCosts) + ) print(es) es.visualize_network() @@ -583,10 +579,6 @@ def basic_model(self) -> fx.FullCalculation: def segments_of_flows_model(self): # Define the components and flow_system - Strom = fx.Bus('Strom', excess_penalty_per_flow_hour=self.excessCosts) - Fernwaerme = fx.Bus('Fernwärme', excess_penalty_per_flow_hour=self.excessCosts) - Gas = fx.Bus('Gas', excess_penalty_per_flow_hour=self.excessCosts) - costs = fx.Effect('costs', '€', 'Kosten', is_standard=True, is_objective=True) CO2 = fx.Effect('CO2', 'kg', 'CO2_e-Emissionen', specific_share_to_other_effects_operation={costs: 0.2}) PE = fx.Effect('PE', 'kWh_PE', 'Primärenergie', maximum_total=3.5e3) @@ -600,7 +592,7 @@ def segments_of_flows_model(self): on_off_parameters=fx.OnOffParameters(effects_per_running_hour={costs: 0, CO2: 1000}), Q_th=fx.Flow( 'Q_th', - bus=Fernwaerme, + bus='Fernwärme', size=invest_Gaskessel, load_factor_max=1.0, load_factor_min=0.1, @@ -617,12 +609,12 @@ def segments_of_flows_model(self): previous_flow_rate=50, flow_hours_total_max=1e6, ), - Q_fu=fx.Flow('Q_fu', bus=Gas, size=200, relative_minimum=0, relative_maximum=1), + Q_fu=fx.Flow('Q_fu', bus='Gas', size=200, relative_minimum=0, relative_maximum=1), ) - P_el = fx.Flow('P_el', bus=Strom, size=60, relative_maximum=55, previous_flow_rate=10) - Q_th = fx.Flow('Q_th', bus=Fernwaerme) - Q_fu = fx.Flow('Q_fu', bus=Gas) + P_el = fx.Flow('P_el', bus='Strom', size=60, relative_maximum=55, previous_flow_rate=10) + Q_th = fx.Flow('Q_th', bus='Fernwärme') + Q_fu = fx.Flow('Q_fu', bus='Gas') segmented_conversion_factors = { P_el: [(5, 30), (40, 60)], Q_th: [(6, 35), (45, 100)], @@ -647,8 +639,8 @@ def segments_of_flows_model(self): ) aSpeicher = fx.Storage( 'Speicher', - charging=fx.Flow('Q_th_load', bus=Fernwaerme, size=1e4), - discharging=fx.Flow('Q_th_unload', bus=Fernwaerme, size=1e4), + charging=fx.Flow('Q_th_load', bus='Fernwärme', size=1e4), + discharging=fx.Flow('Q_th_unload', bus='Fernwärme', size=1e4), capacity_in_flow_hours=invest_Speicher, initial_charge_state=0, maximal_final_charge_state=10, @@ -661,20 +653,25 @@ def segments_of_flows_model(self): aWaermeLast = fx.Sink( 'Wärmelast', sink=fx.Flow( - 'Q_th_Last', bus=Fernwaerme, size=1, relative_minimum=0, fixed_relative_profile=self.Q_th_Last + 'Q_th_Last', bus='Fernwärme', size=1, relative_minimum=0, fixed_relative_profile=self.Q_th_Last ), ) aGasTarif = fx.Source( - 'Gastarif', source=fx.Flow('Q_Gas', bus=Gas, size=1000, effects_per_flow_hour={costs: 0.04, CO2: 0.3}) + 'Gastarif', source=fx.Flow('Q_Gas', bus='Gas', size=1000, effects_per_flow_hour={costs: 0.04, CO2: 0.3}) ) aStromEinspeisung = fx.Sink( - 'Einspeisung', sink=fx.Flow('P_el', bus=Strom, effects_per_flow_hour=-1 * np.array(self.P_el_Last)) + 'Einspeisung', sink=fx.Flow('P_el', bus='Strom', effects_per_flow_hour=-1 * np.array(self.P_el_Last)) ) es = fx.FlowSystem(self.timesteps) es.add_effects(costs, CO2, PE) es.add_components(aGaskessel, aWaermeLast, aGasTarif, aStromEinspeisung, aKWK) es.add_components(aSpeicher) + es.add_elements(fx.Bus('Strom', excess_penalty_per_flow_hour=self.excessCosts), + fx.Bus('Fernwärme', excess_penalty_per_flow_hour=self.excessCosts), + fx.Bus('Gas', excess_penalty_per_flow_hour=self.excessCosts) + ) + print(es) es.visualize_network() @@ -738,7 +735,6 @@ def calculate(self, modeling_type: Literal['full', 'segmented', 'aggregated']): ) timesteps = pd.DatetimeIndex(data.index) - Strom, Fernwaerme, Gas, Kohle = fx.Bus('Strom'), fx.Bus('Fernwärme'), fx.Bus('Gas'), fx.Bus('Kohle') costs, CO2, PE = ( fx.Effect('costs', '€', 'Kosten', is_standard=True, is_objective=True), fx.Effect('CO2', 'kg', 'CO2_e-Emissionen'), @@ -748,10 +744,10 @@ def calculate(self, modeling_type: Literal['full', 'segmented', 'aggregated']): aGaskessel = fx.linear_converters.Boiler( 'Kessel', eta=0.85, - Q_th=fx.Flow(label='Q_th', bus=Fernwaerme), + Q_th=fx.Flow(label='Q_th', bus='Fernwärme'), Q_fu=fx.Flow( label='Q_fu', - bus=Gas, + bus='Gas', size=95, relative_minimum=12 / 95, previous_flow_rate=0, @@ -763,14 +759,14 @@ def calculate(self, modeling_type: Literal['full', 'segmented', 'aggregated']): eta_th=0.58, eta_el=0.22, on_off_parameters=fx.OnOffParameters(effects_per_switch_on=24000), - P_el=fx.Flow('P_el', bus=Strom), - Q_th=fx.Flow('Q_th', bus=Fernwaerme), - Q_fu=fx.Flow('Q_fu', bus=Kohle, size=288, relative_minimum=87 / 288), + P_el=fx.Flow('P_el', bus='Strom'), + Q_th=fx.Flow('Q_th', bus='Fernwärme'), + Q_fu=fx.Flow('Q_fu', bus='Kohle', size=288, relative_minimum=87 / 288), ) aSpeicher = fx.Storage( 'Speicher', - charging=fx.Flow('Q_th_load', size=137, bus=Fernwaerme), - discharging=fx.Flow('Q_th_unload', size=158, bus=Fernwaerme), + charging=fx.Flow('Q_th_load', size=137, bus='Fernwärme'), + discharging=fx.Flow('Q_th_unload', size=158, bus='Fernwärme'), capacity_in_flow_hours=684, initial_charge_state=137, minimal_final_charge_state=137, @@ -784,17 +780,17 @@ def calculate(self, modeling_type: Literal['full', 'segmented', 'aggregated']): TS_Q_th_Last, TS_P_el_Last = fx.TimeSeriesData(Q_th_Last), fx.TimeSeriesData(P_el_Last, agg_weight=0.7) aWaermeLast, aStromLast = ( fx.Sink( - 'Wärmelast', sink=fx.Flow('Q_th_Last', bus=Fernwaerme, size=1, fixed_relative_profile=TS_Q_th_Last) + 'Wärmelast', sink=fx.Flow('Q_th_Last', bus='Fernwärme', size=1, fixed_relative_profile=TS_Q_th_Last) ), - fx.Sink('Stromlast', sink=fx.Flow('P_el_Last', bus=Strom, size=1, fixed_relative_profile=TS_P_el_Last)), + fx.Sink('Stromlast', sink=fx.Flow('P_el_Last', bus='Strom', size=1, fixed_relative_profile=TS_P_el_Last)), ) aKohleTarif, aGasTarif = ( fx.Source( 'Kohletarif', - source=fx.Flow('Q_Kohle', bus=Kohle, size=1000, effects_per_flow_hour={costs: 4.6, CO2: 0.3}), + source=fx.Flow('Q_Kohle', bus='Kohle', size=1000, effects_per_flow_hour={costs: 4.6, CO2: 0.3}), ), fx.Source( - 'Gastarif', source=fx.Flow('Q_Gas', bus=Gas, size=1000, effects_per_flow_hour={costs: gP, CO2: 0.3}) + 'Gastarif', source=fx.Flow('Q_Gas', bus='Gas', size=1000, effects_per_flow_hour={costs: gP, CO2: 0.3}) ), ) @@ -803,10 +799,10 @@ def calculate(self, modeling_type: Literal['full', 'segmented', 'aggregated']): fx.TimeSeriesData(p_el + 0.5, agg_group='p_el'), ) aStromEinspeisung, aStromTarif = ( - fx.Sink('Einspeisung', sink=fx.Flow('P_el', bus=Strom, size=1000, effects_per_flow_hour=p_feed_in)), + fx.Sink('Einspeisung', sink=fx.Flow('P_el', bus='Strom', size=1000, effects_per_flow_hour=p_feed_in)), fx.Source( 'Stromtarif', - source=fx.Flow('P_el', bus=Strom, size=1000, effects_per_flow_hour={costs: p_sell, CO2: 0.3}), + source=fx.Flow('P_el', bus='Strom', size=1000, effects_per_flow_hour={costs: p_sell, CO2: 0.3}), ), ) @@ -815,6 +811,7 @@ def calculate(self, modeling_type: Literal['full', 'segmented', 'aggregated']): es.add_components( aGaskessel, aWaermeLast, aStromLast, aGasTarif, aKohleTarif, aStromEinspeisung, aStromTarif, aKWK, aSpeicher ) + es.add_elements(fx.Bus('Strom'), fx.Bus('Fernwärme'), fx.Bus('Gas'), fx.Bus('Kohle')) print(es) es.visualize_network() From ca010fcfda163737ef2a3f132cf8908e891484b7 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 26 Feb 2025 12:36:41 +0100 Subject: [PATCH 250/507] DOnt show the network plot by default, but in examples --- examples/01_Simple/simple_example.py | 2 +- examples/03_Calculation_types/example_calculation_types.py | 2 +- flixOpt/flow_system.py | 2 +- flixOpt/plotting.py | 6 +++--- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/examples/01_Simple/simple_example.py b/examples/01_Simple/simple_example.py index c81106f72..bed269f8b 100644 --- a/examples/01_Simple/simple_example.py +++ b/examples/01_Simple/simple_example.py @@ -96,7 +96,7 @@ flow_system.add_elements(costs, CO2, boiler, storage, chp, heat_sink, gas_source, power_sink) # Visualize the flow system for validation purposes - flow_system.visualize_network() + flow_system.visualize_network(show=True) # --- Define and Run Calculation --- # Create a calculation object to model the Flow System diff --git a/examples/03_Calculation_types/example_calculation_types.py b/examples/03_Calculation_types/example_calculation_types.py index 4a50115b5..ad4a2495b 100644 --- a/examples/03_Calculation_types/example_calculation_types.py +++ b/examples/03_Calculation_types/example_calculation_types.py @@ -152,7 +152,7 @@ a_kwk, a_speicher, ) - flow_system.visualize_network(controls=False) + flow_system.visualize_network(controls=False, show=True) # Calculations calculations: List[Union[fx.FullCalculation, fx.AggregatedCalculation, fx.SegmentedCalculation]] = [] diff --git a/flixOpt/flow_system.py b/flixOpt/flow_system.py index 33dc9d584..8859d60f4 100644 --- a/flixOpt/flow_system.py +++ b/flixOpt/flow_system.py @@ -239,7 +239,7 @@ def visualize_network( Literal['nodes', 'edges', 'layout', 'interaction', 'manipulation', 'physics', 'selection', 'renderer'] ], ] = True, - show: bool = True, + show: bool = False, ) -> Optional['pyvis.network.Network']: """ Visualizes the network structure of a FlowSystem using PyVis, saving it as an interactive HTML file. diff --git a/flixOpt/plotting.py b/flixOpt/plotting.py index 0158a766f..b52beec0d 100644 --- a/flixOpt/plotting.py +++ b/flixOpt/plotting.py @@ -615,7 +615,7 @@ def visualize_network( bool, List[Literal['nodes', 'edges', 'layout', 'interaction', 'manipulation', 'physics', 'selection', 'renderer']], ] = True, - show: bool = True, + show: bool = False, ) -> Optional['pyvis.network.Network']: """ Visualizes the network structure of a FlowSystem using PyVis, using info-dictionaries. @@ -646,8 +646,8 @@ def visualize_network( - Visualize and open the network with default options: >>> self.visualize_network() - - Save the visualization without opening: - >>> self.visualize_network(show=False) + - Save the visualization with opening: + >>> self.visualize_network(show=True) - Visualize with custom controls and path: >>> self.visualize_network(path='output/custom_network.html', controls=['nodes', 'layout']) From f277a5e0b36d9b117304d88b0ec3ae932a232cad Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 26 Feb 2025 12:36:55 +0100 Subject: [PATCH 251/507] Remove old functions of SystemModel --- flixOpt/structure.py | 47 -------------------------------------------- 1 file changed, 47 deletions(-) diff --git a/flixOpt/structure.py b/flixOpt/structure.py index 12429f5f8..7bbc77316 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -47,53 +47,6 @@ def do_modeling(self): for bus_model in bus_models: # Buses after Components, because FlowModels are created in ComponentModels bus_model.do_modeling() - @property - def main_results(self) -> Dict[str, Union[Scalar, Dict]]: - from flixOpt.features import InvestmentModel - - return { - "Objective": self.objective.value, - "Penalty": float(self.effects.penalty.total.solution.values), - "Effects": { - f"{effect.label} [{effect.unit}]": { - "operation": float(effect.model.operation.total.solution.values), - "invest": float(effect.model.invest.total.solution.values), - "total": float(effect.model.total.solution.values), - } - for effect in self.flow_system.effects - }, - "Invest-Decisions": { - "Invested": { - model.label_of_element: float(model.size.solution) - for component in self.flow_system.components.values() - for model in component.model.all_sub_models - if isinstance(model, InvestmentModel) and float(model.size.solution) >= CONFIG.modeling.EPSILON - }, - "Not invested": { - model.label_of_element: float(model.size.solution) - for component in self.flow_system.components.values() - for model in component.model.all_sub_models - if isinstance(model, InvestmentModel) and float(model.size.solution) < CONFIG.modeling.EPSILON - }, - }, - "Buses with excess": [ - {bus.label_full: { - "input": float(np.sum(bus.model.excess_input.solution.values)), - "output": float(np.sum(bus.model.excess_output.solution.values)) - }} - for bus in self.flow_system.buses.values() - if bus.with_excess and (float(np.sum(bus.model.excess_input.solution.values)) > 1e-3 or - float(np.sum(bus.model.excess_output.solution.values)) > 1e-3) - ], - } - - @property - def infos(self) -> Dict: - return {'Main Results': self.main_results, - 'Constraints': self.constraints.ncons, - 'Variables': self.variables.nvars, - 'Config': CONFIG.to_dict()} - @property def hours_per_step(self): return self.time_series_collection.hours_per_timestep From aa8562851a6be55c857bd697fd0c52998d713acc Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 26 Feb 2025 10:55:29 +0100 Subject: [PATCH 252/507] Use effect label instead of actual effect in dicts --- flixOpt/effects.py | 44 ++++++++++++++++++++++-------------------- flixOpt/flow_system.py | 10 +++++----- flixOpt/interface.py | 8 +++----- 3 files changed, 31 insertions(+), 31 deletions(-) diff --git a/flixOpt/effects.py b/flixOpt/effects.py index 06fdd0a15..fe3011c54 100644 --- a/flixOpt/effects.py +++ b/flixOpt/effects.py @@ -187,31 +187,12 @@ def do_modeling(self): EffectKey = Optional[Union[str, Effect]] # Common key type for effect-related dicts EffectValuesExpr = Dict[EffectKey, linopy.LinearExpression] # Used to create Shares -EffectValuesTS = Dict[EffectKey, TimeSeries] # Used internally to index values -EffectValuesDict = Dict[EffectKey, NumericDataTS] # How effect values are stored +EffectTimeSeries = Dict[Effect, TimeSeries] # Used internally to index values +EffectValuesDict = Dict[Effect, NumericDataTS] # How effect values are stored EffectValuesUser = Union[NumericDataTS, Dict[EffectKey, NumericDataTS]] # User-specified Shares to Effects EffectValuesUserScalar = Union[Scalar, Dict[EffectKey, Scalar]] # User-specified Shares to Effects -def effect_values_to_dict(effect_values_user: EffectValuesUser) -> Optional[EffectValuesDict]: - """ - Converts effect values into a dictionary. If a scalar is provided, it is associated with a default effect type. - - 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} - - Returns - ------- - dict or None - A dictionary with None or Effect as the key, or None if input is None. - """ - return effect_values_user if isinstance(effect_values_user, dict) else { - None: effect_values_user} if effect_values_user is not None else None - - class EffectCollection: """ Handling all Effects @@ -242,6 +223,27 @@ def add_effects(self, *effects: Effect) -> None: self._plausibility_checks() + def create_effect_values_dict(self, effect_values_user: EffectValuesUser) -> Optional[EffectValuesDict]: + """ + Converts effect values into a dictionary. If a scalar is provided, it is associated with a default effect type. + + 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} + + Returns + ------- + dict or None + A dictionary with None or Effect as the key, or None if input is None. + """ + if effect_values_user is None: + return None + if isinstance(effect_values_user, dict): + return {self[effect]: value for effect, value in effect_values_user.items()} + return {self.standard_effect: effect_values_user} + def _plausibility_checks(self) -> None: # Check circular loops in effects: # TODO: Improve checks!! Only most basic case covered... diff --git a/flixOpt/flow_system.py b/flixOpt/flow_system.py index 8859d60f4..54ce1d5a8 100644 --- a/flixOpt/flow_system.py +++ b/flixOpt/flow_system.py @@ -13,7 +13,7 @@ from . import utils from .core import TimeSeries, TimeSeriesCollection, NumericData, NumericDataTS, TimeSeriesData -from .effects import Effect, EffectCollection, EffectValuesTS, EffectValuesUser, effect_values_to_dict, EffectValuesDict +from .effects import Effect, EffectCollection, EffectTimeSeries, EffectValuesUser, EffectValuesDict from .elements import Bus, Component, Flow from .structure import Element, SystemModel, get_compact_representation, get_str_representation @@ -153,22 +153,22 @@ def create_effect_time_series(self, label_prefix: Optional[str], effect_values: EffectValuesUser, label_suffix: Optional[str] = None, - ) -> Optional[EffectValuesTS]: + ) -> Optional[EffectTimeSeries]: """ - Transform EffectValues to EffectValuesTS. + Transform EffectValues to EffectTimeSeries. Creates a TimeSeries for each key in the nested_values dictionary, using the value as the data. The resulting label of the TimeSeries is the label of the parent_element, followed by the label of the Effect in the nested_values and the label_suffix. If the key in the EffectValues is None, the alias 'Standard_Effect' is used """ - effect_values: Optional[EffectValuesDict] = effect_values_to_dict(effect_values) + effect_values: Optional[EffectValuesDict] = self.effects.create_effect_values_dict(effect_values) if effect_values is None: return None return { effect: self.create_time_series( - '|'.join(filter(None, [label_prefix, f'{self.effects[effect].label_full}', label_suffix])), + '|'.join(filter(None, [label_prefix, f'{effect.label_full}', label_suffix])), value ) for effect, value in effect_values.items() diff --git a/flixOpt/interface.py b/flixOpt/interface.py index 55434132a..eb33f62d5 100644 --- a/flixOpt/interface.py +++ b/flixOpt/interface.py @@ -84,11 +84,9 @@ def __init__( self._maximum_size = maximum_size or CONFIG.modeling.BIG # default maximum def transform_data(self, flow_system: 'FlowSystem'): - from .effects import effect_values_to_dict - - self.fix_effects = effect_values_to_dict(self.fix_effects) - self.divest_effects = effect_values_to_dict(self.divest_effects) - self.specific_effects = effect_values_to_dict(self.specific_effects) + self.fix_effects = flow_system.effects.create_effect_values_dict(self.fix_effects) + self.divest_effects = flow_system.effects.create_effect_values_dict(self.divest_effects) + self.specific_effects = flow_system.effects.create_effect_values_dict(self.specific_effects) @property def minimum_size(self): From bfa7aaf45ece9feaefbe9f08857cee7e8dc94b7d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 26 Feb 2025 14:48:02 +0100 Subject: [PATCH 253/507] Never use effects in dicts. Always use their label --- flixOpt/effects.py | 27 +++++++++++++++++---------- flixOpt/flow_system.py | 2 +- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/flixOpt/effects.py b/flixOpt/effects.py index fe3011c54..ab76f7404 100644 --- a/flixOpt/effects.py +++ b/flixOpt/effects.py @@ -6,6 +6,7 @@ """ import logging +import warnings from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Union, Iterator import linopy @@ -183,14 +184,11 @@ def do_modeling(self): 'total' ) - -EffectKey = Optional[Union[str, Effect]] # Common key type for effect-related dicts - -EffectValuesExpr = Dict[EffectKey, linopy.LinearExpression] # Used to create Shares -EffectTimeSeries = Dict[Effect, TimeSeries] # Used internally to index values -EffectValuesDict = Dict[Effect, NumericDataTS] # How effect values are stored -EffectValuesUser = Union[NumericDataTS, Dict[EffectKey, NumericDataTS]] # User-specified Shares to Effects -EffectValuesUserScalar = Union[Scalar, Dict[EffectKey, Scalar]] # User-specified Shares to Effects +EffectValuesExpr = Dict[str, linopy.LinearExpression] # Used to create Shares +EffectTimeSeries = Dict[str, TimeSeries] # Used internally to index values +EffectValuesDict = Dict[str, NumericDataTS] # How effect values are stored +EffectValuesUser = Union[NumericDataTS, Dict[str, NumericDataTS]] # User-specified Shares to Effects +EffectValuesUserScalar = Union[Scalar, Dict[str, Scalar]] # User-specified Shares to Effects class EffectCollection: @@ -238,11 +236,20 @@ def create_effect_values_dict(self, effect_values_user: EffectValuesUser) -> Opt dict or None A dictionary with None or Effect as the key, or None if input is None. """ + + def get_effect_label(eff: Union[Effect, str]) -> str: + """ Temporary function to get the label of an effect and warn for deprecation """ + if isinstance(eff, Effect): + warnings.deprecated(f'The use of effect objects in EffectValues is deprecated. Use the label of the effect instead.') + return eff.label_full + else: + return eff + if effect_values_user is None: return None if isinstance(effect_values_user, dict): - return {self[effect]: value for effect, value in effect_values_user.items()} - return {self.standard_effect: effect_values_user} + return {get_effect_label(effect): value for effect, value in effect_values_user.items()} + return {self.standard_effect.label_full: effect_values_user} def _plausibility_checks(self) -> None: # Check circular loops in effects: diff --git a/flixOpt/flow_system.py b/flixOpt/flow_system.py index 54ce1d5a8..eb7536bad 100644 --- a/flixOpt/flow_system.py +++ b/flixOpt/flow_system.py @@ -168,7 +168,7 @@ def create_effect_time_series(self, return { effect: self.create_time_series( - '|'.join(filter(None, [label_prefix, f'{effect.label_full}', label_suffix])), + '|'.join(filter(None, [label_prefix, effect, label_suffix])), value ) for effect, value in effect_values.items() From c10b99a9282fa7855f901d5696f7021d49899799 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 26 Feb 2025 16:34:16 +0100 Subject: [PATCH 254/507] Never use effects in dicts. Always use their label --- flixOpt/effects.py | 6 +++++- flixOpt/elements.py | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/flixOpt/effects.py b/flixOpt/effects.py index ab76f7404..5fe4a9876 100644 --- a/flixOpt/effects.py +++ b/flixOpt/effects.py @@ -240,7 +240,11 @@ def create_effect_values_dict(self, effect_values_user: EffectValuesUser) -> Opt def get_effect_label(eff: Union[Effect, str]) -> str: """ Temporary function to get the label of an effect and warn for deprecation """ if isinstance(eff, Effect): - warnings.deprecated(f'The use of effect objects in EffectValues is deprecated. Use the label of the effect instead.') + warnings.warn( + "The use of effect objects when specifying EffectValues is deprecated. Use the label of the effect instead.", + DeprecationWarning, + stacklevel=2 + ) return eff.label_full else: return eff diff --git a/flixOpt/elements.py b/flixOpt/elements.py index 49e837a88..6a998b23d 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -215,7 +215,9 @@ def __init__( self.bus = bus.label_full warnings.warn( f'Bus {bus.label} is passed as a Bus object to {self.label}. This is deprecated and will be removed ' - f'in the future. Add the Bus to the FlowSystem instead and pass its label to the Flow.') + f'in the future. Add the Bus to the FlowSystem instead and pass its label to the Flow.', + DeprecationWarning, + stacklevel=2) self._bus_object = bus else: self.bus = bus From 3a7b6ad2610055d0390aca93489c0b3e31f1c26f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 26 Feb 2025 22:27:26 +0100 Subject: [PATCH 255/507] make some functions in FLowSystem private --- examples/02_Complex/complex_example.py | 2 +- .../example_calculation_types.py | 4 +-- flixOpt/flow_system.py | 28 +++++++++---------- tests/test_integration.py | 24 ++++++++-------- 4 files changed, 29 insertions(+), 29 deletions(-) diff --git a/examples/02_Complex/complex_example.py b/examples/02_Complex/complex_example.py index c6b792915..b12bdc1e2 100644 --- a/examples/02_Complex/complex_example.py +++ b/examples/02_Complex/complex_example.py @@ -175,7 +175,7 @@ # --- Build FlowSystem --- # Select components to be included in the flow system flow_system.add_elements(Costs, CO2, PE, Gaskessel, Waermelast, Gasbezug, Stromverkauf, speicher) - flow_system.add_elements(bhkw_2) if use_chp_with_segments else flow_system.add_components(bhkw) + flow_system.add_elements(bhkw_2) if use_chp_with_segments else flow_system._add_components(bhkw) pprint(flow_system) # Get a string representation of the FlowSystem diff --git a/examples/03_Calculation_types/example_calculation_types.py b/examples/03_Calculation_types/example_calculation_types.py index ad4a2495b..37a2f9746 100644 --- a/examples/03_Calculation_types/example_calculation_types.py +++ b/examples/03_Calculation_types/example_calculation_types.py @@ -140,8 +140,8 @@ ) # Flow System Setup - flow_system.add_effects(costs, CO2, PE) - flow_system.add_components( + flow_system._add_effects(costs, CO2, PE) + flow_system._add_components( a_gaskessel, a_waermelast, a_strom_last, diff --git a/flixOpt/flow_system.py b/flixOpt/flow_system.py index eb7536bad..15ebe5b5e 100644 --- a/flixOpt/flow_system.py +++ b/flixOpt/flow_system.py @@ -64,15 +64,6 @@ def __init__( self.effects: EffectCollection = EffectCollection() self.model: Optional[SystemModel] = None - def add_effects(self, *args: Effect) -> None: - self.effects.add_effects(*args) - - def add_components(self, *components: Component) -> None: - for new_component in list(components): - logger.info(f'Registered new Component: {new_component.label}') - self._check_if_element_is_unique(new_component) # check if already exists: - self.components[new_component.label] = new_component # Add to existing components - def add_elements(self, *elements: Element) -> None: """ add all modeling elements, like storages, boilers, heatpumps, buses, ... @@ -85,15 +76,24 @@ def add_elements(self, *elements: Element) -> None: """ for new_element in list(elements): if isinstance(new_element, Component): - self.add_components(new_element) + self._add_components(new_element) elif isinstance(new_element, Effect): - self.add_effects(new_element) + self._add_effects(new_element) elif isinstance(new_element, Bus): - self.add_buses(new_element) + self._add_buses(new_element) else: raise Exception('argument is not instance of a modeling Element (Element)') - def add_buses(self, *buses: Bus): + def _add_effects(self, *args: Effect) -> None: + self.effects.add_effects(*args) + + def _add_components(self, *components: Component) -> None: + for new_component in list(components): + logger.info(f'Registered new Component: {new_component.label}') + self._check_if_element_is_unique(new_component) # check if already exists: + self.components[new_component.label] = new_component # Add to existing components + + def _add_buses(self, *buses: Bus): for new_bus in list(buses): logger.info(f'Registered new Bus: {new_bus.label}') self._check_if_element_is_unique(new_bus) # check if already exists: @@ -108,7 +108,7 @@ def _connect_network(self): # Add Bus if not already added (deprecated) if flow._bus_object is not None and flow._bus_object not in self.buses.values(): - self.add_buses(flow._bus_object) + self._add_buses(flow._bus_object) # Connect Buses bus = self.buses.get(flow.bus) diff --git a/tests/test_integration.py b/tests/test_integration.py index e597dd5dd..a4ae82268 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -153,11 +153,11 @@ def model(self) -> fx.FullCalculation: es = fx.FlowSystem(self.timesteps) es.add_elements(fx.Bus('Strom'), fx.Bus('Fernwärme'), fx.Bus('Gas')) - es.add_components(aSpeicher) - es.add_effects(costs, CO2) - es.add_components(aBoiler, aWaermeLast, aGasTarif) - es.add_components(aStromEinspeisung) - es.add_components(aKWK) + es._add_components(aSpeicher) + es._add_effects(costs, CO2) + es._add_components(aBoiler, aWaermeLast, aGasTarif) + es._add_components(aStromEinspeisung) + es._add_components(aKWK) print(es) es.visualize_network() @@ -561,8 +561,8 @@ def basic_model(self) -> fx.FullCalculation: ) es = fx.FlowSystem(self.timesteps) - es.add_effects(costs, CO2, PE) - es.add_components(aGaskessel, aWaermeLast, aGasTarif, aStromEinspeisung, aKWK, aSpeicher) + es._add_effects(costs, CO2, PE) + es._add_components(aGaskessel, aWaermeLast, aGasTarif, aStromEinspeisung, aKWK, aSpeicher) es.add_elements(fx.Bus('Strom', excess_penalty_per_flow_hour=self.excessCosts), fx.Bus('Fernwärme', excess_penalty_per_flow_hour=self.excessCosts), fx.Bus('Gas', excess_penalty_per_flow_hour=self.excessCosts) @@ -664,9 +664,9 @@ def segments_of_flows_model(self): ) es = fx.FlowSystem(self.timesteps) - es.add_effects(costs, CO2, PE) - es.add_components(aGaskessel, aWaermeLast, aGasTarif, aStromEinspeisung, aKWK) - es.add_components(aSpeicher) + es._add_effects(costs, CO2, PE) + es._add_components(aGaskessel, aWaermeLast, aGasTarif, aStromEinspeisung, aKWK) + es._add_components(aSpeicher) es.add_elements(fx.Bus('Strom', excess_penalty_per_flow_hour=self.excessCosts), fx.Bus('Fernwärme', excess_penalty_per_flow_hour=self.excessCosts), fx.Bus('Gas', excess_penalty_per_flow_hour=self.excessCosts) @@ -807,8 +807,8 @@ def calculate(self, modeling_type: Literal['full', 'segmented', 'aggregated']): ) es = fx.FlowSystem(timesteps) - es.add_effects(costs, CO2, PE) - es.add_components( + es._add_effects(costs, CO2, PE) + es._add_components( aGaskessel, aWaermeLast, aStromLast, aGasTarif, aKohleTarif, aStromEinspeisung, aStromTarif, aKWK, aSpeicher ) es.add_elements(fx.Bus('Strom'), fx.Bus('Fernwärme'), fx.Bus('Gas'), fx.Bus('Kohle')) From 21f3fc5cf91fd34f1fbdd9094d77ccb886013845 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 26 Feb 2025 22:29:22 +0100 Subject: [PATCH 256/507] Always use add_elements() --- examples/02_Complex/complex_example.py | 2 +- .../example_calculation_types.py | 4 ++-- tests/test_integration.py | 24 +++++++++---------- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/examples/02_Complex/complex_example.py b/examples/02_Complex/complex_example.py index b12bdc1e2..307b2e885 100644 --- a/examples/02_Complex/complex_example.py +++ b/examples/02_Complex/complex_example.py @@ -175,7 +175,7 @@ # --- Build FlowSystem --- # Select components to be included in the flow system flow_system.add_elements(Costs, CO2, PE, Gaskessel, Waermelast, Gasbezug, Stromverkauf, speicher) - flow_system.add_elements(bhkw_2) if use_chp_with_segments else flow_system._add_components(bhkw) + flow_system.add_elements(bhkw_2) if use_chp_with_segments else flow_system.add_elements(bhkw) pprint(flow_system) # Get a string representation of the FlowSystem diff --git a/examples/03_Calculation_types/example_calculation_types.py b/examples/03_Calculation_types/example_calculation_types.py index 37a2f9746..b70de9461 100644 --- a/examples/03_Calculation_types/example_calculation_types.py +++ b/examples/03_Calculation_types/example_calculation_types.py @@ -140,8 +140,8 @@ ) # Flow System Setup - flow_system._add_effects(costs, CO2, PE) - flow_system._add_components( + flow_system.add_elements(costs, CO2, PE) + flow_system.add_elements( a_gaskessel, a_waermelast, a_strom_last, diff --git a/tests/test_integration.py b/tests/test_integration.py index a4ae82268..3b2c45c3a 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -153,11 +153,11 @@ def model(self) -> fx.FullCalculation: es = fx.FlowSystem(self.timesteps) es.add_elements(fx.Bus('Strom'), fx.Bus('Fernwärme'), fx.Bus('Gas')) - es._add_components(aSpeicher) - es._add_effects(costs, CO2) - es._add_components(aBoiler, aWaermeLast, aGasTarif) - es._add_components(aStromEinspeisung) - es._add_components(aKWK) + es.add_elements(aSpeicher) + es.add_elements(costs, CO2) + es.add_elements(aBoiler, aWaermeLast, aGasTarif) + es.add_elements(aStromEinspeisung) + es.add_elements(aKWK) print(es) es.visualize_network() @@ -561,8 +561,8 @@ def basic_model(self) -> fx.FullCalculation: ) es = fx.FlowSystem(self.timesteps) - es._add_effects(costs, CO2, PE) - es._add_components(aGaskessel, aWaermeLast, aGasTarif, aStromEinspeisung, aKWK, aSpeicher) + es.add_elements(costs, CO2, PE) + es.add_elements(aGaskessel, aWaermeLast, aGasTarif, aStromEinspeisung, aKWK, aSpeicher) es.add_elements(fx.Bus('Strom', excess_penalty_per_flow_hour=self.excessCosts), fx.Bus('Fernwärme', excess_penalty_per_flow_hour=self.excessCosts), fx.Bus('Gas', excess_penalty_per_flow_hour=self.excessCosts) @@ -664,9 +664,9 @@ def segments_of_flows_model(self): ) es = fx.FlowSystem(self.timesteps) - es._add_effects(costs, CO2, PE) - es._add_components(aGaskessel, aWaermeLast, aGasTarif, aStromEinspeisung, aKWK) - es._add_components(aSpeicher) + es.add_elements(costs, CO2, PE) + es.add_elements(aGaskessel, aWaermeLast, aGasTarif, aStromEinspeisung, aKWK) + es.add_elements(aSpeicher) es.add_elements(fx.Bus('Strom', excess_penalty_per_flow_hour=self.excessCosts), fx.Bus('Fernwärme', excess_penalty_per_flow_hour=self.excessCosts), fx.Bus('Gas', excess_penalty_per_flow_hour=self.excessCosts) @@ -807,8 +807,8 @@ def calculate(self, modeling_type: Literal['full', 'segmented', 'aggregated']): ) es = fx.FlowSystem(timesteps) - es._add_effects(costs, CO2, PE) - es._add_components( + es.add_elements(costs, CO2, PE) + es.add_elements( aGaskessel, aWaermeLast, aStromLast, aGasTarif, aKohleTarif, aStromEinspeisung, aStromTarif, aKWK, aSpeicher ) es.add_elements(fx.Bus('Strom'), fx.Bus('Fernwärme'), fx.Bus('Gas'), fx.Bus('Kohle')) From 2b637fac4f988dd50559b602ab854a3225675f55 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 26 Feb 2025 22:32:24 +0100 Subject: [PATCH 257/507] Add additional warning --- flixOpt/flow_system.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/flixOpt/flow_system.py b/flixOpt/flow_system.py index 15ebe5b5e..63b3c5658 100644 --- a/flixOpt/flow_system.py +++ b/flixOpt/flow_system.py @@ -3,6 +3,7 @@ """ import json +import warnings import logging import pathlib from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Tuple, Union @@ -109,6 +110,11 @@ def _connect_network(self): # Add Bus if not already added (deprecated) if flow._bus_object is not None and flow._bus_object not in self.buses.values(): self._add_buses(flow._bus_object) + warnings.warn( + f'Bus {flow._bus_object.label} was passed as a Bus object to {flow.label_full} and not added to the FlowSystem.' + f' Add the Bus to the FlowSystem instead and pass its label to the Flow.', + DeprecationWarning, + stacklevel=2) # Connect Buses bus = self.buses.get(flow.bus) From 7ba709f03e941509077e2843bed46696b92d3c1d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 26 Feb 2025 22:54:12 +0100 Subject: [PATCH 258/507] Rename parameter in results.py --- flixOpt/results.py | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/flixOpt/results.py b/flixOpt/results.py index 21bb73662..5c95b25fb 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -32,7 +32,9 @@ class CalculationResults: ---------- model : linopy.Model The linopy model that was used to solve the calculation. - flow_system_structure : Dict[str, Dict[str, Dict]] + infos : Dict + Information about the calculation, + results_structure : Dict[str, Dict[str, Dict]] The structure of the flow_system that was used to solve the calculation. Attributes @@ -69,34 +71,34 @@ def from_file(cls, folder: Union[str, pathlib.Path], name: str): logger.info(f'loading calculation "{name}" from file ("{nc_file}")') model = linopy.read_netcdf(nc_file) with open(path.with_suffix('.json'), 'r', encoding='utf-8') as f: - flow_system_structure = json.load(f) - return cls(model=model, flow_system_structure=flow_system_structure, name=name, folder=folder) + results_structure = json.load(f) + return cls(model=model, results_structure=results_structure, name=name, folder=folder) @classmethod def from_calculation(cls, calculation: 'Calculation'): """Create CalculationResults directly from a Calculation""" return cls(model=calculation.model, - flow_system_structure=_results_structure(calculation.flow_system), + results_structure=_results_structure(calculation.flow_system), name=calculation.name, - folder = calculation.folder) + folder=calculation.folder) - def __init__(self, model: linopy.Model, flow_system_structure: Dict[str, Dict[str, Dict]], name: str, + def __init__(self, model: linopy.Model, results_structure: Dict[str, Dict[str, Dict]], name: str, folder: Optional[pathlib.Path] = None): self.model = model - self._flow_system_structure = flow_system_structure + self._results_structure = results_structure self.name = name self.folder = pathlib.Path(folder) if folder is not None else pathlib.Path.cwd() / 'results' self.components = {label: ComponentResults.from_json(self, infos) - for label, infos in flow_system_structure['Components'].items()} + for label, infos in results_structure['Components'].items()} self.buses = {label: BusResults.from_json(self, infos) - for label, infos in flow_system_structure['Buses'].items()} + for label, infos in results_structure['Buses'].items()} self.effects = {label: EffectResults.from_json(self, infos) - for label, infos in flow_system_structure['Effects'].items()} + for label, infos in results_structure['Effects'].items()} - self.timesteps_extra = pd.DatetimeIndex([datetime.datetime.fromisoformat(date) for date in flow_system_structure['Time']], name='time') - self.periods = pd.Index(flow_system_structure['Periods'], name = 'period') if flow_system_structure['Periods'] is not None else None + self.timesteps_extra = pd.DatetimeIndex([datetime.datetime.fromisoformat(date) for date in results_structure['Time']], name='time') + self.periods = pd.Index(results_structure['Periods'], name = 'period') if results_structure['Periods'] is not None else None self.hours_per_timestep = TimeSeriesCollection.create_hours_per_timestep(self.timesteps_extra, self.periods) def __getitem__(self, key: str) -> Union['ComponentResults', 'BusResults', 'EffectResults']: @@ -121,7 +123,7 @@ def to_file(self, folder: Optional[Union[str, pathlib.Path]] = None, name: Optio self.model.to_netcdf(path.with_suffix('.nc'), *args, **kwargs) with open(path.with_suffix('.json'), 'w', encoding='utf-8') as f: - json.dump(self._flow_system_structure, f, indent=4, ensure_ascii=False) + json.dump(self._results_structure, f, indent=4, ensure_ascii=False) logger.info(f'Saved calculation "{name}" to {path}') def plot_heatmap(self, From c6d238817f96ca87cc405e432d1f9de9cd7211a2 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 26 Feb 2025 23:15:22 +0100 Subject: [PATCH 259/507] Save network infos in Results and rename to plot_network() --- examples/01_Simple/simple_example.py | 2 +- .../02_Complex/complex_example_results.py | 2 +- .../example_calculation_types.py | 2 +- flixOpt/flow_system.py | 10 +++---- flixOpt/plotting.py | 8 +++--- flixOpt/results.py | 26 +++++++++++++++---- tests/test_integration.py | 10 +++---- 7 files changed, 38 insertions(+), 22 deletions(-) diff --git a/examples/01_Simple/simple_example.py b/examples/01_Simple/simple_example.py index bed269f8b..4ead18e94 100644 --- a/examples/01_Simple/simple_example.py +++ b/examples/01_Simple/simple_example.py @@ -96,7 +96,7 @@ flow_system.add_elements(costs, CO2, boiler, storage, chp, heat_sink, gas_source, power_sink) # Visualize the flow system for validation purposes - flow_system.visualize_network(show=True) + flow_system.plot_network(show=True) # --- Define and Run Calculation --- # Create a calculation object to model the Flow System diff --git a/examples/02_Complex/complex_example_results.py b/examples/02_Complex/complex_example_results.py index c4cfbd5a2..ef9afdafd 100644 --- a/examples/02_Complex/complex_example_results.py +++ b/examples/02_Complex/complex_example_results.py @@ -19,7 +19,7 @@ ) from e # --- Basic overview --- - # TODO: Add visualize_network() + fx.plotting.plot_network(*results.network_infos, show=True) results['Fernwärme'].plot_flow_rates() # --- Detailed Plots --- diff --git a/examples/03_Calculation_types/example_calculation_types.py b/examples/03_Calculation_types/example_calculation_types.py index b70de9461..8b8e0692a 100644 --- a/examples/03_Calculation_types/example_calculation_types.py +++ b/examples/03_Calculation_types/example_calculation_types.py @@ -152,7 +152,7 @@ a_kwk, a_speicher, ) - flow_system.visualize_network(controls=False, show=True) + flow_system.plot_network(controls=False, show=True) # Calculations calculations: List[Union[fx.FullCalculation, fx.AggregatedCalculation, fx.SegmentedCalculation]] = [] diff --git a/flixOpt/flow_system.py b/flixOpt/flow_system.py index 63b3c5658..80579ca43 100644 --- a/flixOpt/flow_system.py +++ b/flixOpt/flow_system.py @@ -236,7 +236,7 @@ def to_json(self, path: Union[str, pathlib.Path]): with open(path, 'w', encoding='utf-8') as f: json.dump(self.infos_compact(), f, indent=4, ensure_ascii=False) - def visualize_network( + def plot_network( self, path: Union[bool, str, pathlib.Path] = 'flow_system.html', controls: Union[ @@ -270,13 +270,13 @@ def visualize_network( Usage: - Visualize and open the network with default options: - >>> self.visualize_network() + >>> self.plot_network() - Save the visualization without opening: - >>> self.visualize_network(show=False) + >>> self.plot_network(show=False) - Visualize with custom controls and path: - >>> self.visualize_network(path='output/custom_network.html', controls=['nodes', 'layout']) + >>> self.plot_network(path='output/custom_network.html', controls=['nodes', 'layout']) Notes: - This function requires `pyvis`. If not installed, the function prints a warning and returns `None`. @@ -285,7 +285,7 @@ def visualize_network( from . import plotting node_infos, edge_infos = self.network_infos() - return plotting.visualize_network(node_infos, edge_infos, path, controls, show) + return plotting.plot_network(node_infos, edge_infos, path, controls, show) def create_model(self) -> SystemModel: self.model = SystemModel(self) diff --git a/flixOpt/plotting.py b/flixOpt/plotting.py index b52beec0d..85ae8f1fa 100644 --- a/flixOpt/plotting.py +++ b/flixOpt/plotting.py @@ -607,7 +607,7 @@ def heat_map_data_from_df( return df_pivoted -def visualize_network( +def plot_network( node_infos: dict, edge_infos: dict, path: Optional[Union[str, pathlib.Path]] = None, @@ -644,13 +644,13 @@ def visualize_network( Usage: - Visualize and open the network with default options: - >>> self.visualize_network() + >>> self.plot_network() - Save the visualization with opening: - >>> self.visualize_network(show=True) + >>> self.plot_network(show=True) - Visualize with custom controls and path: - >>> self.visualize_network(path='output/custom_network.html', controls=['nodes', 'layout']) + >>> self.plot_network(path='output/custom_network.html', controls=['nodes', 'layout']) Notes: - This function requires `pyvis`. If not installed, the function prints a warning and returns `None`. diff --git a/flixOpt/results.py b/flixOpt/results.py index 5c95b25fb..de9b94173 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -2,7 +2,7 @@ import json import logging import pathlib -from typing import Dict, List, Literal, Union, Optional, TYPE_CHECKING +from typing import Dict, List, Literal, Union, Optional, TYPE_CHECKING, Tuple import linopy import numpy as np @@ -71,21 +71,30 @@ def from_file(cls, folder: Union[str, pathlib.Path], name: str): logger.info(f'loading calculation "{name}" from file ("{nc_file}")') model = linopy.read_netcdf(nc_file) with open(path.with_suffix('.json'), 'r', encoding='utf-8') as f: - results_structure = json.load(f) - return cls(model=model, results_structure=results_structure, name=name, folder=folder) + meta_data = json.load(f) + return cls(model=model, name=name, folder= folder, **meta_data) @classmethod def from_calculation(cls, calculation: 'Calculation'): """Create CalculationResults directly from a Calculation""" return cls(model=calculation.model, results_structure=_results_structure(calculation.flow_system), + infos=calculation.infos, + network_infos=calculation.flow_system.network_infos(), name=calculation.name, folder=calculation.folder) - def __init__(self, model: linopy.Model, results_structure: Dict[str, Dict[str, Dict]], name: str, + def __init__(self, + model: linopy.Model, + results_structure: Dict[str, Dict[str, Dict]], + name: str, + infos: Dict, + network_infos: Dict, folder: Optional[pathlib.Path] = None): self.model = model self._results_structure = results_structure + self.infos = infos + self.network_infos = network_infos self.name = name self.folder = pathlib.Path(folder) if folder is not None else pathlib.Path.cwd() / 'results' self.components = {label: ComponentResults.from_json(self, infos) @@ -123,9 +132,16 @@ def to_file(self, folder: Optional[Union[str, pathlib.Path]] = None, name: Optio self.model.to_netcdf(path.with_suffix('.nc'), *args, **kwargs) with open(path.with_suffix('.json'), 'w', encoding='utf-8') as f: - json.dump(self._results_structure, f, indent=4, ensure_ascii=False) + json.dump(self._get_meta_data(), f, indent=4, ensure_ascii=False) logger.info(f'Saved calculation "{name}" to {path}') + def _get_meta_data(self) -> Dict: + return { + 'results_structure': self._results_structure, + 'infos': self.infos, + 'network_infos': self.network_infos, + } + def plot_heatmap(self, variable: str, heatmap_timeframes: Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'] = 'D', diff --git a/tests/test_integration.py b/tests/test_integration.py index 3b2c45c3a..8d82691b5 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -160,7 +160,7 @@ def model(self) -> fx.FullCalculation: es.add_elements(aKWK) print(es) - es.visualize_network() + es.plot_network() aCalc = fx.FullCalculation('Test_Sim', es) aCalc.do_modeling() @@ -568,7 +568,7 @@ def basic_model(self) -> fx.FullCalculation: fx.Bus('Gas', excess_penalty_per_flow_hour=self.excessCosts) ) print(es) - es.visualize_network() + es.plot_network() aCalc = fx.FullCalculation('Sim1', es) aCalc.do_modeling() @@ -673,7 +673,7 @@ def segments_of_flows_model(self): ) print(es) - es.visualize_network() + es.plot_network() aCalc = fx.FullCalculation('Sim1', es) aCalc.do_modeling() @@ -814,7 +814,7 @@ def calculate(self, modeling_type: Literal['full', 'segmented', 'aggregated']): es.add_elements(fx.Bus('Strom'), fx.Bus('Fernwärme'), fx.Bus('Gas'), fx.Bus('Kohle')) print(es) - es.visualize_network() + es.plot_network() if doFullCalc: calc = fx.FullCalculation('fullModel', es) @@ -840,7 +840,7 @@ def calculate(self, modeling_type: Literal['full', 'segmented', 'aggregated']): ) calc.do_modeling() print(es) - es.visualize_network() + es.plot_network() calc.solve(self.get_solver()) else: raise Exception('Wrong Modeling Type') From e82ade0eae306576300808cd6e18f51472976b71 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 26 Feb 2025 23:26:04 +0100 Subject: [PATCH 260/507] ruff check --- examples/02_Complex/complex_example.py | 2 +- .../example_calculation_types.py | 2 +- examples/linopy_native_experiments.py | 11 +++++------ flixOpt/calculation.py | 4 ++-- flixOpt/effects.py | 4 ++-- flixOpt/elements.py | 1 + flixOpt/flow_system.py | 6 +++--- flixOpt/results.py | 13 ++++++------- 8 files changed, 21 insertions(+), 22 deletions(-) diff --git a/examples/02_Complex/complex_example.py b/examples/02_Complex/complex_example.py index 307b2e885..c0fee4d10 100644 --- a/examples/02_Complex/complex_example.py +++ b/examples/02_Complex/complex_example.py @@ -29,7 +29,7 @@ # --- Define the Flow System, that will hold all elements, and the time steps you want to model --- timesteps = pd.date_range('2020-01-01', periods=len(heat_demand), freq='h') flow_system = fx.FlowSystem(timesteps) # Create FlowSystem - + # --- Define Energy Buses --- # Represent node balances (inputs=outputs) for the different energy carriers (electricity, heat, gas) in the system flow_system.add_elements( diff --git a/examples/03_Calculation_types/example_calculation_types.py b/examples/03_Calculation_types/example_calculation_types.py index 8b8e0692a..0b3326c60 100644 --- a/examples/03_Calculation_types/example_calculation_types.py +++ b/examples/03_Calculation_types/example_calculation_types.py @@ -162,7 +162,7 @@ calculation.do_modeling() calculation.solve(fx.solvers.HighsSolver(0, 60)) calculations.append(calculation) - + if segmented: calculation = fx.SegmentedCalculation('Segmented', flow_system, segment_length, overlap_length) calculation.do_modeling_and_solve(fx.solvers.HighsSolver(0, 60)) diff --git a/examples/linopy_native_experiments.py b/examples/linopy_native_experiments.py index 58736615c..6407d42f1 100644 --- a/examples/linopy_native_experiments.py +++ b/examples/linopy_native_experiments.py @@ -1,14 +1,13 @@ -import pandas as pd -import xarray as xr -import numpy as np +from typing import Dict, List, Literal, Optional, Tuple import linopy -import plotly.express as px import matplotlib.pyplot as plt +import numpy as np +import pandas as pd +import plotly.express as px +import xarray as xr -from typing import List, Optional, Tuple, Literal, Dict - class SystemModel(linopy.Model): def __init__( self, diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py index 0d731fbe9..ce28d87b3 100644 --- a/flixOpt/calculation.py +++ b/flixOpt/calculation.py @@ -22,14 +22,14 @@ from . import utils as utils from .aggregation import AggregationModel, AggregationParameters from .components import Storage +from .config import CONFIG from .core import NumericData, Scalar from .elements import Component from .features import InvestmentModel from .flow_system import FlowSystem +from .results import CalculationResults, SegmentedCalculationResults from .solvers import _Solver from .structure import SystemModel, copy_and_convert_datatypes, get_compact_representation -from .config import CONFIG -from .results import CalculationResults, SegmentedCalculationResults logger = logging.getLogger('flixOpt') diff --git a/flixOpt/effects.py b/flixOpt/effects.py index 5fe4a9876..834fc9b08 100644 --- a/flixOpt/effects.py +++ b/flixOpt/effects.py @@ -7,7 +7,7 @@ import logging import warnings -from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Union, Iterator +from typing import TYPE_CHECKING, Dict, Iterator, List, Literal, Optional, Union import linopy import numpy as np @@ -15,7 +15,7 @@ from .core import NumericData, NumericDataTS, Scalar, TimeSeries, TimeSeriesCollection from .features import ShareAllocationModel -from .structure import Element, ElementModel, Model, SystemModel, Interface +from .structure import Element, ElementModel, Interface, Model, SystemModel if TYPE_CHECKING: from .flow_system import FlowSystem diff --git a/flixOpt/elements.py b/flixOpt/elements.py index 6a998b23d..c4b952883 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -15,6 +15,7 @@ from .features import InvestmentModel, OnOffModel, PreventSimultaneousUsageModel from .interface import InvestParameters, OnOffParameters from .structure import Element, ElementModel, SystemModel + if TYPE_CHECKING: from .flow_system import FlowSystem diff --git a/flixOpt/flow_system.py b/flixOpt/flow_system.py index 80579ca43..26bdeabe6 100644 --- a/flixOpt/flow_system.py +++ b/flixOpt/flow_system.py @@ -3,9 +3,9 @@ """ import json -import warnings import logging import pathlib +import warnings from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Tuple, Union import numpy as np @@ -13,8 +13,8 @@ import xarray as xr from . import utils -from .core import TimeSeries, TimeSeriesCollection, NumericData, NumericDataTS, TimeSeriesData -from .effects import Effect, EffectCollection, EffectTimeSeries, EffectValuesUser, EffectValuesDict +from .core import NumericData, NumericDataTS, TimeSeries, TimeSeriesCollection, TimeSeriesData +from .effects import Effect, EffectCollection, EffectTimeSeries, EffectValuesDict, EffectValuesUser from .elements import Bus, Component, Flow from .structure import Element, SystemModel, get_compact_representation, get_str_representation diff --git a/flixOpt/results.py b/flixOpt/results.py index de9b94173..256639015 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -2,17 +2,16 @@ import json import logging import pathlib -from typing import Dict, List, Literal, Union, Optional, TYPE_CHECKING, Tuple +from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Tuple, Union import linopy import numpy as np import pandas as pd -import xarray as xr import plotly +import xarray as xr from . import plotting from .core import TimeSeriesCollection - from .io import _results_structure if TYPE_CHECKING: @@ -77,11 +76,11 @@ def from_file(cls, folder: Union[str, pathlib.Path], name: str): @classmethod def from_calculation(cls, calculation: 'Calculation'): """Create CalculationResults directly from a Calculation""" - return cls(model=calculation.model, + return cls(model=calculation.model, results_structure=_results_structure(calculation.flow_system), infos=calculation.infos, network_infos=calculation.flow_system.network_infos(), - name=calculation.name, + name=calculation.name, folder=calculation.folder) def __init__(self, @@ -219,7 +218,7 @@ def plot_flow_rates(self, self.flow_rates(with_last_timestep=True).to_dataframe(), mode='area', title=f'Flow rates of {self.label}' ) return plotly_save_and_show( - fig, + fig, self._calculation_results.folder / f'{self.label} (flow rates).html', user_filename=None if isinstance(save, bool) else pathlib.Path(save), show=show, @@ -276,7 +275,7 @@ def plot_charge_state(self, charge_state = self.charge_state.solution.to_dataframe() fig.add_trace(plotly.graph_objs.Scatter( x=charge_state.index, y=charge_state.values.flatten(), mode='lines', name=self.charge_state.name)) - + return plotly_save_and_show( fig, self._calculation_results.folder / f'{self.label} (charge state).html', From 1abd9681322a71f13de61f85612507feddd5bb85 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 26 Feb 2025 23:32:43 +0100 Subject: [PATCH 261/507] Remove unused utils.py --- flixOpt/utils.py | 108 ----------------------------------------------- 1 file changed, 108 deletions(-) diff --git a/flixOpt/utils.py b/flixOpt/utils.py index b910fbf26..d43c5d999 100644 --- a/flixOpt/utils.py +++ b/flixOpt/utils.py @@ -11,35 +11,6 @@ logger = logging.getLogger('flixOpt') -def as_vector(value: Union[int, float, np.ndarray, List], length: int) -> np.ndarray: - """ - Macht aus Scalar einen Vektor. Vektor bleibt Vektor. - -> Idee dahinter: Aufruf aus abgespeichertem Vektor schneller, als für jede i-te Gleichung zu Checken ob Vektor oder Scalar) - - Parameters - ---------- - - aValue: skalar, list, np.array - aLen : skalar - """ - # dtype = 'float64' # -> muss mit übergeben werden, sonst entstehen evtl. int32 Reihen (dort ist kein +/-inf möglich) - # TODO: as_vector() -> int32 Vektoren möglich machen - - # Wenn Scalar oder None, return directly as array - if value is None: - return np.array([None] * length) - if np.isscalar(value): - return np.ones(length) * value - - if len(value) != length: # Wenn Vektor nicht richtige Länge - raise Exception(f'error in changing to {length=}; vector has already {len(value)=}') - - if isinstance(value, np.ndarray): - return value - else: - return np.array(value) - - def is_number(number_alias: Union[int, float, str]): """Returns True is string is a number.""" try: @@ -49,23 +20,6 @@ def is_number(number_alias: Union[int, float, str]): return False -def check_time_series(label: str, time_series: np.ndarray[np.datetime64]): - # check sowohl für globale Zeitreihe, als auch für chosenIndexe: - - # Zeitdifferenz: - # zweites bis Letztes - erstes bis Vorletztes - dt = time_series[1:] - time_series[0:-1] - # dt_in_hours = dt.total_seconds() / 3600 - dt_in_hours = dt / np.timedelta64(1, 'h') - - # unterschiedliche dt: - if np.max(dt_in_hours) - np.min(dt_in_hours) != 0: - logger.warning(f'{label}: !! Achtung !! unterschiedliche delta_t von {min(dt)} h bis {max(dt)} h') - # negative dt: - if np.min(dt_in_hours) < 0: - raise Exception(label + ': Zeitreihe besitzt Zurücksprünge - vermutlich Zeitumstellung nicht beseitigt!') - - def round_floats(obj, decimals=2): if isinstance(obj, dict): return {k: round_floats(v, decimals) for k, v in obj.items()} @@ -75,67 +29,6 @@ def round_floats(obj, decimals=2): return round(obj, decimals) return obj -def apply_formating( - data_dict: Dict[str, Union[int, float]], - key_format: str = '<17', - value_format: str = '>10.2f', - indent: int = 0, - sort_by: Optional[Literal['key', 'value']] = None, -) -> str: - if sort_by == 'key': - sorted_keys = sorted(data_dict.keys(), key=str.lower) - elif sort_by == 'value': - sorted_keys = sorted(data_dict, key=lambda k: data_dict[k], reverse=True) - else: - sorted_keys = data_dict.keys() - - lines = [f'{indent * " "}{key:{key_format}}: {data_dict[key]:{value_format}}' for key in sorted_keys] - return '\n'.join(lines) - - -def convert_numeric_lists_to_arrays( - d: Union[Dict[str, Any], List[Any], tuple], -) -> Union[Dict[str, Any], List[Any], tuple]: - """ - Recursively converts all lists of numeric values in a nested dictionary to numpy arrays. - Handles nested lists, tuples, and dictionaries. Does not alter the original dictionary. - """ - - def convert_list_to_array_if_numeric(sequence: Union[List[Any], tuple]) -> Union[np.ndarray, List[Any]]: - """ - Converts a Sequence to a numpy array if all elements are numeric. - Recursively processes each element. - Does not alter the original sequence. - Returns an empty list if the sequence is empty. - """ - # Check if the list is empty - if len(sequence) == 0: - return [] - # Check if all elements are numeric in the list - elif isinstance(sequence, list) and all(isinstance(item, (int, float)) for item in sequence): - return np.array(sequence) - else: - return [ - convert_numeric_lists_to_arrays(item) if isinstance(item, (dict, list, tuple)) else item - for item in sequence - ] - - if isinstance(d, dict): - d_copy = {} # Reconstruct the dict from ground up to not modify the original dictionary.' - for key, value in d.items(): - if isinstance(value, (list, tuple)): - d_copy[key] = convert_list_to_array_if_numeric(value) - elif isinstance(value, dict): - d_copy[key] = convert_numeric_lists_to_arrays(value) # Recursively process nested dictionaries - else: - d_copy[key] = value - return d_copy - elif isinstance(d, (list, tuple)): - # If the input itself is a list or tuple, process it as a sequence - return convert_list_to_array_if_numeric(d) - else: - return d - def convert_dataarray(data: xr.DataArray, mode: Literal['py', 'numpy', 'xarray', 'structure']) -> Union[List, np.ndarray, xr.DataArray, str]: """ @@ -162,4 +55,3 @@ def convert_dataarray(data: xr.DataArray, mode: Literal['py', 'numpy', 'xarray', return f':::{data.name}' else: raise ValueError(f'Unknown mode {mode}') - From 56b92549cb0eccdde13452bf4e4bafd0287ff969 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 26 Feb 2025 23:38:55 +0100 Subject: [PATCH 262/507] Remove more unused code --- flixOpt/elements.py | 44 ----------------------------- flixOpt/features.py | 30 -------------------- flixOpt/structure.py | 66 -------------------------------------------- 3 files changed, 140 deletions(-) diff --git a/flixOpt/elements.py b/flixOpt/elements.py index c4b952883..d2e58af1e 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -493,28 +493,6 @@ def results_structure(self): outputs.append(self.excess_output.name) return {**super().results_structure(), 'inputs': inputs, 'outputs': outputs} - def solution_structured( - self, - mode: Literal['py', 'numpy', 'xarray', 'structure'] = 'py', - ) -> Dict[str, Union[np.ndarray, Dict]]: - """ - Return the structure of the SystemModel solution. - - Parameters - ---------- - mode : Literal['py', 'numpy', 'xarray', 'structure'] - Whether to return the solution as a dictionary of - - python native types (for json) - - numpy arrays - - xarray.DataArrays - - strings (for structure, storing variable names) - """ - results = super().solution_structured(mode) - results['inputs'] = [flow.label for flow in self.element.inputs] - results['outputs'] = [flow.label for flow in self.element.outputs] - - return results - class ComponentModel(ElementModel): def __init__(self, model: SystemModel, element: Component): @@ -562,25 +540,3 @@ def results_structure(self): return {**super().results_structure(), 'inputs': [flow.model.flow_rate.name for flow in self.element.inputs], 'outputs': [flow.model.flow_rate.name for flow in self.element.outputs]} - - def solution_structured( - self, - mode: Literal['py', 'numpy', 'xarray', 'structure'] = 'py', - ) -> Dict[str, Union[np.ndarray, Dict]]: - """ - Return the structure of the SystemModel solution. - - Parameters - ---------- - mode : Literal['py', 'numpy', 'xarray', 'structure'] - Whether to return the solution as a dictionary of - - python native types (for json) - - numpy arrays - - xarray.DataArrays - - strings (for structure, storing variable names) - """ - results = super().solution_structured(mode) - results['inputs'] = [flow.label for flow in self.element.inputs] - results['outputs'] = [flow.label for flow in self.element.outputs] - - return results diff --git a/flixOpt/features.py b/flixOpt/features.py index 48449e3ab..e8ac36cae 100644 --- a/flixOpt/features.py +++ b/flixOpt/features.py @@ -931,36 +931,6 @@ def add_share( else: self._eq_total_per_timestep.lhs -= self.shares[name] - def solution_structured( - self, - mode: Literal['py', 'numpy', 'xarray', 'structure'] = 'py', - ) -> Dict[str, Union[np.ndarray, Dict]]: - """ - Return the structure of the SystemModel solution. - - Parameters - ---------- - mode : Literal['py', 'numpy', 'xarray', 'structure'] - Whether to return the solution as a dictionary of - - python native types (for json) - - numpy arrays - - xarray.DataArrays - - strings (for structure, storing variable names) - """ - shares_var_names = [var.name for var in self.shares.values()] - results = { - self._variables_short[var_name]: utils.convert_dataarray(var, mode) - for var_name, var in self.variables_direct.solution.data_vars.items() if var_name not in shares_var_names - } - results['Shares'] = { - self._variables_short[var_name]: utils.convert_dataarray(var, mode) - for var_name, var in self.variables_direct.solution.data_vars.items() if var_name in shares_var_names - } - return { - **results, - **{sub_model.label: sub_model.solution_structured(mode) for sub_model in self.sub_models} - } - class SegmentedSharesModel(Model): # TODO: Length... diff --git a/flixOpt/structure.py b/flixOpt/structure.py index 7bbc77316..2373f13d7 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -269,72 +269,6 @@ def filter_variables(self, return all_variables[[name for name in all_variables if 'time' in all_variables[name].dims]] raise ValueError(f'Invalid length "{length}", must be one of "scalar", "time" or None') - def solution_structured( - self, - mode: Literal['py', 'numpy', 'xarray', 'structure'] = 'numpy', - ) -> Dict[str, Union[np.ndarray, Dict]]: - """ - Return the structure of the SystemModel solution. - - Parameters - ---------- - mode : Literal['py', 'numpy', 'xarray', 'structure'] - Whether to return the solution as a dictionary of - - python native types (for json) - - numpy arrays - - xarray.DataArrays - - strings (for structure, storing variable names) - """ - - results = { - self._variables_short[var_name]: utils.convert_dataarray(var, mode) - for var_name, var in self.variables_direct.solution.data_vars.items() - } - - for sub_model in self.sub_models: - sub_solution = sub_model.solution_structured(mode) - if sub_solution == {}: # If the submodel has no variables, skip it - continue - if sub_model.label_full == self.label_full: - if any(key in results for key in sub_solution): - conflict_keys = [key for key in sub_solution if key in results] - raise ValueError(f"Key conflict in {self.label_full}: {conflict_keys}") - results.update(sub_solution) - else: - results[sub_model.label] = sub_solution - - return results - - def solution_numeric( - self, - use_numpy: bool = True, - all_variables: bool = True, - decimals: Optional[int] = None - ) -> Union[Dict[str, np.ndarray], Dict[str, Union[List, int, float]]]: - """ - Returns the solution of the element as a dictionary of numeric values. - - Parameters: - ----------- - use_numpy bool: - Whether to return the solution as a numpy array. Defaults to True. - If True, numeric numpy arrays (`np.ndarray`) are preserved as-is. - If False, they are converted to lists. - all_variables bool: - Whether to return the solution for all variables (including sub-models) or only the variables of the element. - Defaults to True. - decimals int: - Number of decimal places to round the solution to. Defaults to None. - """ - vars = self.model.variables if all_variables else self.model.variables_direct - if decimals is not None: - results = {var: vars.solution[var].round(decimals).values for var in vars.solution.data_vars} - else: - results = {var: vars.solution[var].values for var in vars.solution.data_vars} - if use_numpy: - return {k: v.item() if v.ndim == 0 else v for k, v in results.items()} - return {k: v.tolist() for k, v in results.items()} - @property def label(self) -> str: return self._label if self._label is not None else self.label_of_element From 6775a2fecf025a85a15481ae417e86083397e237 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 27 Feb 2025 23:04:19 +0100 Subject: [PATCH 263/507] Add __len__ for EffectsCollection --- flixOpt/effects.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/flixOpt/effects.py b/flixOpt/effects.py index 834fc9b08..0c0a91cbb 100644 --- a/flixOpt/effects.py +++ b/flixOpt/effects.py @@ -300,6 +300,9 @@ def __getitem__(self, effect: Union[str, Effect]) -> 'Effect': def __iter__(self) -> Iterator[Effect]: return iter(self._effects.values()) + def __len__(self) -> int: + return len(self._effects) + def __contains__(self, item: Union[str, 'Effect']) -> bool: """Check if the effect exists. Checks for label or object""" if isinstance(item, str): From 1905bf5143dc45d47ef038cbfd492462d7331d75 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 27 Feb 2025 23:20:58 +0100 Subject: [PATCH 264/507] Improve the repr of TimeSeriesCollection --- flixOpt/core.py | 27 ++++++++++----------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/flixOpt/core.py b/flixOpt/core.py index a11ae8169..691f92ca6 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -713,24 +713,17 @@ def hours_of_last_timestep(self) -> float: return self.hours_per_timestep[-1].item() def __repr__(self): - timestep_range = f"{self.all_timesteps[0]} to {self.all_timesteps[-1]}" if len(self.timesteps) > 1 else str( - self.timesteps[0]) - periods_str = f"Periods: {len(self.periods)}" if self.periods is not None else "No periods" - time_series_count = len(self.time_series_data) + ds = self.to_dataset() - return ( - f"TimeSeriesCollection(\n" - f" nr_of_timesteps={len(self.timesteps)},\n" - f" timesteps={timestep_range},\n" - f" active_timesteps={np.array(self._active_timesteps) if self._active_timesteps is not None else 'None'}\n" - f" hours_of_last_timestep={self.hours_of_last_timestep},\n" - f" hours_per_timestep={get_numeric_stats(self.hours_per_timestep)},\n" - f" nr_of_periods={len(self.periods) if self.periods is not None else 'None'},\n" - f" periods={periods_str},\n" - f" active_periods={self._active_periods if self._active_periods is not None else 'None'}\n" - f" time_series_count={time_series_count},\n" - f")" - ) + # Store metadata as attributes in the Dataset + ds.attrs.update({ + "timesteps": f"{self.all_timesteps[0]} ... {self.all_timesteps[-1]} | len={len(self.timesteps)}", + "hours_of_last_timestep": self.hours_of_last_timestep, + "hours_per_timestep": get_numeric_stats(self.hours_per_timestep), + "periods": f"{self.periods[0]} ... {self.periods[-1]} | len={len(self.periods)}" if self.periods is not None else None, + }) + + return f"TimeSeriesCollection:\n{ds}" def __str__(self): longest_name = max([time_series.name for time_series in self.time_series_data], key=len) From 742b772fd77b45d16c9be21314d313f66e29a22a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 27 Feb 2025 23:26:17 +0100 Subject: [PATCH 265/507] Improve the repr and to_dataset() of TimeSeriesCollection --- flixOpt/core.py | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/flixOpt/core.py b/flixOpt/core.py index 691f92ca6..9d3be7b2e 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -612,7 +612,14 @@ def to_dataframe(self, filtered: Literal['all', 'constant', 'non_constant'] = 'n def to_dataset(self) -> xr.Dataset: """Combine all stored DataArrays into a single Dataset.""" - return xr.Dataset({time_series.name: time_series.active_data for time_series in self.time_series_data}) + ds = xr.Dataset({time_series.name: time_series.active_data for time_series in self.time_series_data}) + + ds.attrs.update({ + "timesteps": f"{self.all_timesteps[0]} ... {self.all_timesteps[-1]} | len={len(self.timesteps)}", + "hours_per_timestep": get_numeric_stats(self.hours_per_timestep), + "periods": f"{self.periods[0]} ... {self.periods[-1]} | len={len(self.periods)}" if self.periods is not None else None, + }) + return ds @staticmethod def _create_extra_timestep(timesteps: pd.DatetimeIndex, @@ -713,17 +720,7 @@ def hours_of_last_timestep(self) -> float: return self.hours_per_timestep[-1].item() def __repr__(self): - ds = self.to_dataset() - - # Store metadata as attributes in the Dataset - ds.attrs.update({ - "timesteps": f"{self.all_timesteps[0]} ... {self.all_timesteps[-1]} | len={len(self.timesteps)}", - "hours_of_last_timestep": self.hours_of_last_timestep, - "hours_per_timestep": get_numeric_stats(self.hours_per_timestep), - "periods": f"{self.periods[0]} ... {self.periods[-1]} | len={len(self.periods)}" if self.periods is not None else None, - }) - - return f"TimeSeriesCollection:\n{ds}" + return f"TimeSeriesCollection:\n{self.to_dataset()}" def __str__(self): longest_name = max([time_series.name for time_series in self.time_series_data], key=len) From d98dad392ef5a75b921bfa158c454c8ea703125f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 28 Feb 2025 00:02:24 +0100 Subject: [PATCH 266/507] Add dunder methods to TimeSeriesCollection --- flixOpt/core.py | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/flixOpt/core.py b/flixOpt/core.py index 9d3be7b2e..15d7c4364 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -4,9 +4,11 @@ """ import inspect +import json import logging +import pathlib from collections import Counter -from typing import Any, Dict, List, Literal, Optional, Tuple, Union +from typing import Any, Dict, List, Literal, Optional, Tuple, Union, Iterator import numpy as np import pandas as pd @@ -683,6 +685,33 @@ def _check_unique_labels(self): duplicates = [label for label, count in label_counts.items() if count > 1] assert duplicates == [], 'Duplicate TimeSeries labels found: {}.'.format(', '.join(duplicates)) + def __getitem__(self, name: str) -> 'TimeSeries': + """ + Get a time_series by label + + Raises: + KeyError: If no time_series with the given label is found. + """ + #TODO: This is not efficient! Use a dict instead + for time_series in self.time_series_data: # TODO: This is not efficient! Use a dict instead + if time_series.name == name: + return time_series + raise KeyError(f'TimeSeries "{name}" not found!') + + def __iter__(self) -> Iterator[TimeSeries]: + return iter(self.time_series_data) + + def __len__(self) -> int: + return len(self.time_series_data) + + def __contains__(self, item: Union[str, TimeSeries]) -> bool: + """Check if the effect exists. Checks for label or object""" + if isinstance(item, str): + return item in [ts.name for ts in self.time_series_data] # Check if the label exists + elif isinstance(item, TimeSeries): + return item in self.time_series_data # Check if the object exists + return False + @property def non_constants(self) -> List[TimeSeries]: return [time_series for time_series in self.time_series_data if not time_series.all_equal] From a2ba3189a315bc3d9a4063bf40fbf66d578ebc17 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 28 Feb 2025 00:02:42 +0100 Subject: [PATCH 267/507] Add to_json and from_json method to TimeSeries --- flixOpt/core.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/flixOpt/core.py b/flixOpt/core.py index 15d7c4364..c7fac5060 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -192,6 +192,24 @@ def from_datasource(cls, data = cls(DataConverter.as_dataarray(data, timesteps, periods), name, aggregation_weight, aggregation_group) return data + @classmethod + def from_json(cls, data: Optional[Dict[str, Any]] = None, path: Optional[str] = None) -> 'TimeSeries': + """ + Load a TimeSeries from a dictionary or json file + """ + if path is not None and data is not None: + raise ValueError("Only one of path and data can be provided") + if path is not None: + with open(path, 'r') as f: + data = json.load(f) + data["data"]["coords"]["time"]["data"] = pd.to_datetime(data["data"]["coords"]["time"]["data"]) + return cls( + data=xr.DataArray.from_dict(data["data"]), + name=data["name"], + aggregation_weight=data["aggregation_weight"], + aggregation_group=data["aggregation_group"], + ) + def __init__(self, data: xr.DataArray, name: str, @@ -232,6 +250,22 @@ def restore_data(self): self._stored_data = self._backup.copy() self.reset() + def to_json(self, path: Optional[pathlib.Path] = None) -> Dict[str, Any]: + """ + Save the TimeSeries to a dictionary or json file + """ + data = { + "name": self.name, + "aggregation_weight": self.aggregation_weight, + "aggregation_group": self.aggregation_group, + "data": self.active_data.to_dict(), + } + data["data"]["coords"]["time"]["data"] = [date.isoformat() for date in data["data"]["coords"]["time"]["data"]] + if path is not None: + with open(path, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=4 if len(self.active_timesteps) <= 480 else None, ensure_ascii=False) + return data + def _update_active_data(self): """Update the active data.""" if 'period' in self._stored_data.indexes: From 867e0c8b7f3494972adf1a2e3a34e18af4044766 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 28 Feb 2025 09:28:30 +0100 Subject: [PATCH 268/507] Change how labels are composed --- flixOpt/effects.py | 12 ++++++++---- flixOpt/elements.py | 2 +- flixOpt/features.py | 3 ++- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/flixOpt/effects.py b/flixOpt/effects.py index 0c0a91cbb..a587eded9 100644 --- a/flixOpt/effects.py +++ b/flixOpt/effects.py @@ -140,6 +140,7 @@ def __init__(self, model: SystemModel, element: Effect): False, self.label_of_element, 'invest', + label_full=f'{self.label_full}(invest)', total_max=self.element.maximum_invest, total_min=self.element.minimum_invest ) @@ -151,6 +152,7 @@ def __init__(self, model: SystemModel, element: Effect): True, self.label_of_element, 'operation', + label_full=f'{self.label_full}(operation)', total_max=self.element.maximum_operation, total_min=self.element.minimum_operation, min_per_hour=self.element.minimum_operation_per_hour.active_data @@ -386,13 +388,15 @@ def _add_share_between_effects(self): for origin_effect in self.effects: # 1. operation: -> hier sind es Zeitreihen (share_TS) for target_effect, time_series in origin_effect.specific_share_to_other_effects_operation.items(): - self.effects[target_effect].model.operation.add_share( - origin_effect.label_full, + model = self.effects[target_effect].model.operation + model.add_share( + model.label_full, origin_effect.model.operation.total_per_timestep * time_series.active_data, ) # 2. invest: -> hier ist es Scalar (share) for target_effect, factor in origin_effect.specific_share_to_other_effects_invest.items(): - self.effects[target_effect].model.invest.add_share( - origin_effect.label_full, + model = self.effects[target_effect].model.invest.operation + model.add_share( + model.label_full, origin_effect.model.invest.total * factor, ) diff --git a/flixOpt/elements.py b/flixOpt/elements.py index d2e58af1e..df2349e3e 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -269,7 +269,7 @@ def _plausibility_checks(self) -> None: @property def label_full(self) -> str: - return f'{self.component} ({self.label})' + return f'{self.component}({self.label})' @property def size_is_fixed(self) -> bool: diff --git a/flixOpt/features.py b/flixOpt/features.py index e8ac36cae..d12167332 100644 --- a/flixOpt/features.py +++ b/flixOpt/features.py @@ -836,12 +836,13 @@ def __init__( shares_are_time_series: bool, label_of_element: Optional[str] = None, label: Optional[str] = None, + label_full: Optional[str] = None, total_max: Optional[Scalar] = None, total_min: Optional[Scalar] = None, max_per_hour: Optional[NumericData] = None, min_per_hour: Optional[NumericData] = None, ): - super().__init__(model, label_of_element=label_of_element, label=label) + super().__init__(model, label_of_element=label_of_element, label=label, label_full=label_full) if not shares_are_time_series: # If the condition is True assert max_per_hour is None and min_per_hour is None, ( 'Both max_per_hour and min_per_hour cannot be used when shares_are_time_series is False' From 0d13f8eebf1415dc6fe0d2176d0e74cdff26180d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 28 Feb 2025 12:16:40 +0100 Subject: [PATCH 269/507] Improve used warnings --- flixOpt/effects.py | 7 ++++--- flixOpt/elements.py | 4 ++-- flixOpt/flow_system.py | 8 ++++---- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/flixOpt/effects.py b/flixOpt/effects.py index a587eded9..06f071672 100644 --- a/flixOpt/effects.py +++ b/flixOpt/effects.py @@ -243,9 +243,10 @@ def get_effect_label(eff: Union[Effect, str]) -> str: """ Temporary function to get the label of an effect and warn for deprecation """ if isinstance(eff, Effect): warnings.warn( - "The use of effect objects when specifying EffectValues is deprecated. Use the label of the effect instead.", - DeprecationWarning, - stacklevel=2 + f"The use of effect objects when specifying EffectValues is deprecated. " + f"Use the label of the effect instead. Used effect: {effect.label_full}", + UserWarning, + stacklevel=2, ) return eff.label_full else: diff --git a/flixOpt/elements.py b/flixOpt/elements.py index df2349e3e..3e348d096 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -217,8 +217,8 @@ def __init__( warnings.warn( f'Bus {bus.label} is passed as a Bus object to {self.label}. This is deprecated and will be removed ' f'in the future. Add the Bus to the FlowSystem instead and pass its label to the Flow.', - DeprecationWarning, - stacklevel=2) + UserWarning, + ) self._bus_object = bus else: self.bus = bus diff --git a/flixOpt/flow_system.py b/flixOpt/flow_system.py index 26bdeabe6..8655aee96 100644 --- a/flixOpt/flow_system.py +++ b/flixOpt/flow_system.py @@ -111,10 +111,10 @@ def _connect_network(self): if flow._bus_object is not None and flow._bus_object not in self.buses.values(): self._add_buses(flow._bus_object) warnings.warn( - f'Bus {flow._bus_object.label} was passed as a Bus object to {flow.label_full} and not added to the FlowSystem.' - f' Add the Bus to the FlowSystem instead and pass its label to the Flow.', - DeprecationWarning, - stacklevel=2) + f'The Bus {flow._bus_object.label} was added to the FlowSystem from {flow.label_full}.' + f'This is deprecated and will be removed in the future. ' + f'Please pass the Bus.label to the Flow and the Bus to the FlowSystem instead.', + UserWarning) # Connect Buses bus = self.buses.get(flow.bus) From 55c0ff7c27083db67f145ddec47391f69ece5292 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 28 Feb 2025 12:38:34 +0100 Subject: [PATCH 270/507] Bugfix in naming of Shares --- flixOpt/effects.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/flixOpt/effects.py b/flixOpt/effects.py index 06f071672..37c323be9 100644 --- a/flixOpt/effects.py +++ b/flixOpt/effects.py @@ -244,7 +244,7 @@ def get_effect_label(eff: Union[Effect, str]) -> str: if isinstance(eff, Effect): warnings.warn( f"The use of effect objects when specifying EffectValues is deprecated. " - f"Use the label of the effect instead. Used effect: {effect.label_full}", + f"Use the label of the effect instead. Used effect: {eff.label_full}", UserWarning, stacklevel=2, ) @@ -389,15 +389,13 @@ def _add_share_between_effects(self): for origin_effect in self.effects: # 1. operation: -> hier sind es Zeitreihen (share_TS) for target_effect, time_series in origin_effect.specific_share_to_other_effects_operation.items(): - model = self.effects[target_effect].model.operation - model.add_share( - model.label_full, + self.effects[target_effect].model.operation.add_share( + origin_effect.model.operation.label_full, origin_effect.model.operation.total_per_timestep * time_series.active_data, ) # 2. invest: -> hier ist es Scalar (share) for target_effect, factor in origin_effect.specific_share_to_other_effects_invest.items(): - model = self.effects[target_effect].model.invest.operation - model.add_share( - model.label_full, + self.effects[target_effect].model.invest.add_share( + origin_effect.model.invest.label_full, origin_effect.model.invest.total * factor, ) From 1e4b8d79db8b701994fd0f0623c41ad34dbd82ea Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 28 Feb 2025 12:38:47 +0100 Subject: [PATCH 271/507] Adopting new names in all tests and examples --- examples/01_Simple/simple_example.py | 2 +- examples/02_Complex/complex_example.py | 2 +- .../02_Complex/complex_example_results.py | 6 +++--- .../example_calculation_types.py | 8 ++++---- tests/test_functional.py | 6 +++--- tests/test_integration.py | 20 +++++++++---------- 6 files changed, 22 insertions(+), 22 deletions(-) diff --git a/examples/01_Simple/simple_example.py b/examples/01_Simple/simple_example.py index 4ead18e94..42752f6a7 100644 --- a/examples/01_Simple/simple_example.py +++ b/examples/01_Simple/simple_example.py @@ -109,7 +109,7 @@ # --- Analyze Results --- calculation.results['Fernwärme'].plot_flow_rates() calculation.results['Storage'].plot_flow_rates() - calculation.results.plot_heatmap('CHP (Q_th)|flow_rate') + calculation.results.plot_heatmap('CHP(Q_th)|flow_rate') # Convert the results for the storage component to a dataframe and display df = calculation.results['Storage'].charge_state_and_flow_rates() diff --git a/examples/02_Complex/complex_example.py b/examples/02_Complex/complex_example.py index c0fee4d10..81951fe46 100644 --- a/examples/02_Complex/complex_example.py +++ b/examples/02_Complex/complex_example.py @@ -190,6 +190,6 @@ calculation.results.to_file() # But let's plot some results anyway - calculation.results.plot_heatmap('BHKW2 (Q_th)|flow_rate') + calculation.results.plot_heatmap('BHKW2(Q_th)|flow_rate') calculation.results['BHKW2'].plot_flow_rates() calculation.results['Speicher'].plot_charge_state() diff --git a/examples/02_Complex/complex_example_results.py b/examples/02_Complex/complex_example_results.py index ef9afdafd..f551a862c 100644 --- a/examples/02_Complex/complex_example_results.py +++ b/examples/02_Complex/complex_example_results.py @@ -24,10 +24,10 @@ # --- Detailed Plots --- # In depth plot for individual flow rates ('__' is used as the delimiter between Component and Flow - results.plot_heatmap('Wärmelast (Q_th_Last)|flow_rate') + results.plot_heatmap('Wärmelast(Q_th_Last)|flow_rate') for flow_rate in results['BHKW2'].inputs + results['BHKW2'].outputs: results.plot_heatmap(flow_rate) # --- Plotting internal variables manually --- - results.plot_heatmap('BHKW2 (Q_th)|on') - results.plot_heatmap('Kessel (Q_th)|on') + results.plot_heatmap('BHKW2(Q_th)|on') + results.plot_heatmap('Kessel(Q_th)|on') diff --git a/examples/03_Calculation_types/example_calculation_types.py b/examples/03_Calculation_types/example_calculation_types.py index 0b3326c60..54a856371 100644 --- a/examples/03_Calculation_types/example_calculation_types.py +++ b/examples/03_Calculation_types/example_calculation_types.py @@ -194,17 +194,17 @@ def get_solutions(calcs: List, variable: str) -> xr.Dataset: ) fx.plotting.with_plotly( - get_solutions(calculations, 'BHKW2 (Q_th)|flow_rate').to_dataframe(), - mode='line', title='BHKW2 (Q_th) Flow Rate Comparison', ylabel='Flow rate', path='results/BHKW2 Thermal Power.html', save=True + get_solutions(calculations, 'BHKW2(Q_th)|flow_rate').to_dataframe(), + mode='line', title='BHKW2(Q_th) Flow Rate Comparison', ylabel='Flow rate', path='results/BHKW2 Thermal Power.html', save=True ) fx.plotting.with_plotly( - get_solutions(calculations, 'costs|operation|total_per_timestep').to_dataframe(), + get_solutions(calculations, 'costs(operation)|total_per_timestep').to_dataframe(), mode='line', title='Operation Cost Comparison', ylabel='Costs [€]', path='results/Operation Costs.html', save=True ) fx.plotting.with_plotly( - pd.DataFrame(get_solutions(calculations, 'costs|operation|total_per_timestep').to_dataframe().sum()).T, + pd.DataFrame(get_solutions(calculations, 'costs(operation)|total_per_timestep').to_dataframe().sum()).T, mode='bar', title='Total Cost Comparison', ylabel='Costs [€]' ).update_layout(barmode='group').write_html('results/Total Costs.html') diff --git a/tests/test_functional.py b/tests/test_functional.py index c405ae79e..752dd81b8 100644 --- a/tests/test_functional.py +++ b/tests/test_functional.py @@ -125,21 +125,21 @@ def test_minimal_model(solver_fixture, time_steps_fixture): assert_allclose(results.model.variables['costs|total'].solution.values, 80, rtol=1e-5, atol=1e-10) assert_allclose( - results.model.variables['Boiler (Q_th)|flow_rate'].solution.values, + results.model.variables['Boiler(Q_th)|flow_rate'].solution.values, [-0.0, 10.0, 20.0, -0.0, 10.0], rtol=1e-5, atol=1e-10, ) assert_allclose( - results.model.variables['costs|operation|total_per_timestep'].solution.values, + results.model.variables['costs(operation)|total_per_timestep'].solution.values, [-0.0, 20.0, 40.0, -0.0, 20.0], rtol=1e-5, atol=1e-10, ) assert_allclose( - results.model.variables['Gastarif (Gas)->costs|operation'].solution.values, + results.model.variables['Gastarif(Gas)->costs(operation)'].solution.values, [-0.0, 20.0, 40.0, -0.0, 20.0], rtol=1e-5, atol=1e-10, diff --git a/tests/test_integration.py b/tests/test_integration.py index 8d82691b5..83acea143 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -80,12 +80,12 @@ def test_from_results(self): results.model.variables['CO2|total'].solution.values, 255.09184, 'CO2 doesnt match expected value' ) self.assert_almost_equal_numeric( - results.model.variables['Boiler (Q_th)|flow_rate'].solution.values, + results.model.variables['Boiler(Q_th)|flow_rate'].solution.values, [0, 0, 0, 28.4864, 35, 0, 0, 0, 0], 'Q_th doesnt match expected value', ) self.assert_almost_equal_numeric( - results.model.variables['CHP_unit (Q_th)|flow_rate'].solution.values, + results.model.variables['CHP_unit(Q_th)|flow_rate'].solution.values, [30.0, 26.66666667, 75.0, 75.0, 75.0, 20.0, 20.0, 20.0, 20.0], 'Q_th doesnt match expected value', ) @@ -93,7 +93,7 @@ def test_from_results(self): df = results['Fernwärme'].flow_rates() self.assert_almost_equal_numeric( calculation.flow_system.components['Wärmelast'].sink.model.flow_rate.solution.values, - df['Wärmelast (Q_th_Last)|flow_rate'].values, + df['Wärmelast(Q_th_Last)|flow_rate'].values, 'Loaded Results and directly used results dont match, or loading didnt work properly', ) @@ -274,7 +274,7 @@ def test_transmission_advanced(self): ) self.assert_almost_equal_numeric( - calculation.results.model.variables['Rohr (Rohr1b)|flow_rate'].solution.values, + calculation.results.model.variables['Rohr(Rohr1b)|flow_rate'].solution.values, transmission.out1.model.flow_rate.solution.values, 'Flow rate of Rohr__Rohr1b is not correct', ) @@ -337,12 +337,12 @@ def test_basic(self): ) self.assert_almost_equal_numeric( - sum(effects['costs'].model.operation.shares['CO2'].solution.values), + sum(effects['costs'].model.operation.shares['CO2(operation)'].solution.values), 258.63729669618675, 'costs doesnt match expected value', ) self.assert_almost_equal_numeric( - sum(effects['costs'].model.operation.shares['Kessel (Q_th)'].solution.values), + sum(effects['costs'].model.operation.shares['Kessel(Q_th)'].solution.values), 0.01, 'costs doesnt match expected value', ) @@ -352,12 +352,12 @@ def test_basic(self): 'costs doesnt match expected value', ) self.assert_almost_equal_numeric( - sum(effects['costs'].model.operation.shares['Gastarif (Q_Gas)'].solution.values), + sum(effects['costs'].model.operation.shares['Gastarif(Q_Gas)'].solution.values), 39.09153113079115, 'costs doesnt match expected value', ) self.assert_almost_equal_numeric( - sum(effects['costs'].model.operation.shares['Einspeisung (P_el)'].solution.values), + sum(effects['costs'].model.operation.shares['Einspeisung(P_el)'].solution.values), -14196.61245231646, 'costs doesnt match expected value', ) @@ -368,7 +368,7 @@ def test_basic(self): ) self.assert_almost_equal_numeric( - effects['costs'].model.invest.shares['Kessel (Q_th)'].solution.values, + effects['costs'].model.invest.shares['Kessel(Q_th)'].solution.values, 1000 + 500, 'costs doesnt match expected value', ) @@ -710,7 +710,7 @@ def test_aggregated(self): def test_segmented(self): calculation = self.calculate('segmented') self.assert_almost_equal_numeric( - sum(calculation.results.solution_without_overlap('costs|operation|total_per_timestep')), + sum(calculation.results.solution_without_overlap('costs(operation)|total_per_timestep')), 343613, 'costs doesnt match expected value', ) From 4dfd44444e1f843f2a9bc28244057ae22452368f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 28 Feb 2025 13:01:53 +0100 Subject: [PATCH 272/507] Remove unused attribute --- flixOpt/structure.py | 1 - 1 file changed, 1 deletion(-) diff --git a/flixOpt/structure.py b/flixOpt/structure.py index 2373f13d7..13ad91172 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -151,7 +151,6 @@ def __init__(self, label: str, meta_data: Dict = None): """ self.label = Element._valid_label(label) self.meta_data = meta_data if meta_data is not None else {} - self.used_time_series: List[TimeSeries] = [] # Used for better access self.model: Optional[ElementModel] = None def _plausibility_checks(self) -> None: From 117ff47c58994ffbcd98520d5ad74798add2b69f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 28 Feb 2025 15:19:32 +0100 Subject: [PATCH 273/507] Add to_dict() methods for ELements --- flixOpt/core.py | 5 +++++ flixOpt/effects.py | 23 +++++++++++++++++++++++ flixOpt/elements.py | 42 ++++++++++++++++++++++++++++++++++++++++++ flixOpt/flow_system.py | 22 +++++++++++++++++++++- flixOpt/io.py | 38 ++++++++++++++++++++++++++++++++++---- flixOpt/structure.py | 17 ++++++++++++++++- 6 files changed, 141 insertions(+), 6 deletions(-) diff --git a/flixOpt/core.py b/flixOpt/core.py index c7fac5060..921cfebdf 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -266,6 +266,11 @@ def to_json(self, path: Optional[pathlib.Path] = None) -> Dict[str, Any]: json.dump(data, f, indent=4 if len(self.active_timesteps) <= 480 else None, ensure_ascii=False) return data + @property + def stats(self) -> str: + """Return a statistical summary of the active data, considering periods if available.""" + return get_numeric_stats(self.active_data, padd=0) + def _update_active_data(self): """Update the active data.""" if 'period' in self._stored_data.indexes: diff --git a/flixOpt/effects.py b/flixOpt/effects.py index 37c323be9..88fce670f 100644 --- a/flixOpt/effects.py +++ b/flixOpt/effects.py @@ -124,6 +124,29 @@ def transform_data(self, flow_system: 'FlowSystem'): 'operation' ) + def to_dict(self) -> Dict: + data = super().to_dict() + + # Add attributes + data.update({ + "unit": self.unit, + "description": self.description, + "is_standard": self.is_standard, + "is_objective": self.is_objective, + "specific_share_to_other_effects_operation": self.specific_share_to_other_effects_operation, + "specific_share_to_other_effects_invest": self.specific_share_to_other_effects_invest, + "minimum_operation": self.minimum_operation, + "maximum_operation": self.maximum_operation, + "minimum_operation_per_hour": self.minimum_operation_per_hour, + "maximum_operation_per_hour": self.maximum_operation_per_hour, + "minimum_invest": self.minimum_invest, + "maximum_invest": self.maximum_invest, + "minimum_total": self.minimum_total, + "maximum_total": self.maximum_total, + }) + + return data + def create_model(self, model: SystemModel) -> 'EffectModel': self.model = EffectModel(model, self) return self.model diff --git a/flixOpt/elements.py b/flixOpt/elements.py index 3e348d096..6b4375768 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -74,6 +74,17 @@ def infos(self, use_numpy=True, use_element_label=False) -> Dict: infos['outputs'] = [flow.infos(use_numpy, use_element_label) for flow in self.outputs] return infos + def to_dict(self) -> Dict: + """Convert the object to a dictionary representation.""" + data = super().to_dict() + data.update({ + "inputs": [flow.to_dict() for flow in self.inputs], + "outputs": [flow.to_dict() for flow in self.outputs], + "on_off_parameters": self.on_off_parameters.to_dict() if self.on_off_parameters else None, + "prevent_simultaneous_flows": [flow.label_full for flow in self.prevent_simultaneous_flows], + }) + return data + class Bus(Element): """ @@ -110,6 +121,14 @@ def transform_data(self, flow_system: 'FlowSystem'): f'{self.label_full}|excess_penalty_per_flow_hour', self.excess_penalty_per_flow_hour ) + def to_dict(self) -> Dict: + """Convert the object to a dictionary representation.""" + data = super().to_dict() + data.update({ + "excess_penalty_per_flow_hour": self.excess_penalty_per_flow_hour, + }) + return data + def _plausibility_checks(self) -> None: if self.excess_penalty_per_flow_hour == 0: logger.warning(f'In Bus {self.label}, the excess_penalty_per_flow_hour is 0. Use "None" or a value > 0.') @@ -253,6 +272,29 @@ def infos(self, use_numpy=True, use_element_label=False) -> Dict: infos['is_input_in_component'] = self.is_input_in_component return infos + def to_dict(self): + """ + Exports the Element to a format that can be saved and loaded from and to file. + Probably a combination of json for sturcture and xr.Dataset for data. + """ + data = super().to_dict() + data.update({ + 'bus': self.bus, + 'size': self.size.to_dict() if isinstance(self.size, InvestParameters) else self.size, + 'fixed_relative_profile': self.fixed_relative_profile, + 'relative_minimum': self.relative_minimum, + 'relative_maximum': self.relative_maximum, + 'on_off_parameters': self.on_off_parameters.to_dict() if isinstance(self.on_off_parameters, + OnOffParameters) else self.on_off_parameters, + 'load_factor_min': self.load_factor_min, + 'load_factor_max': self.load_factor_max, + 'effects_per_flow_hour': self.effects_per_flow_hour, + 'flow_hours_total_max': self.flow_hours_total_max, + 'flow_hours_total_min': self.flow_hours_total_min, + 'previous_flow_rate': self.previous_flow_rate, + }) + return data + def _plausibility_checks(self) -> None: # TODO: Incorporate into Variable? (Lower_bound can not be greater than upper bound if np.any(self.relative_minimum > self.relative_maximum): diff --git a/flixOpt/flow_system.py b/flixOpt/flow_system.py index 8655aee96..458f74568 100644 --- a/flixOpt/flow_system.py +++ b/flixOpt/flow_system.py @@ -12,7 +12,7 @@ import pandas as pd import xarray as xr -from . import utils +from . import io from .core import NumericData, NumericDataTS, TimeSeries, TimeSeriesCollection, TimeSeriesData from .effects import Effect, EffectCollection, EffectTimeSeries, EffectValuesDict, EffectValuesUser from .elements import Bus, Component, Flow @@ -236,6 +236,26 @@ def to_json(self, path: Union[str, pathlib.Path]): with open(path, 'w', encoding='utf-8') as f: json.dump(self.infos_compact(), f, indent=4, ensure_ascii=False) + def to_dict(self, data_mode: Literal['name', 'stats'] = 'name') -> Dict: + """Convert the object to a dictionary representation.""" + data = { + "components": { + comp.label: comp.to_dict() + for comp in sorted(self.components.values(), key=lambda component: component.label.upper()) + }, + "buses": { + bus.label: bus.to_dict() + for bus in sorted(self.buses.values(), key=lambda bus: bus.label.upper()) + }, + "effects": { + effect.label: effect.to_dict() + for effect in sorted(self.effects, key=lambda effect: effect.label.upper()) + }, + "timesteps_extra": self.time_series_collection.timesteps_extra, + "periods": self.time_series_collection.periods, + } + return io.remove_none_and_empty(io.replace_timeseries(data, data_mode)) + def plot_network( self, path: Union[bool, str, pathlib.Path] = 'flow_system.html', diff --git a/flixOpt/io.py b/flixOpt/io.py index 77a81864c..04c818d16 100644 --- a/flixOpt/io.py +++ b/flixOpt/io.py @@ -2,12 +2,10 @@ import json import logging import pathlib -from typing import Dict, Union - -import linopy -import xarray as xr +from typing import Dict, Union, Literal from .flow_system import FlowSystem +from .core import TimeSeries logger = logging.getLogger('flixOpt') @@ -34,3 +32,35 @@ def _results_structure(flow_system: FlowSystem) -> Dict[str, Dict]: def structure_to_json(flow_system: FlowSystem, path: Union[str, pathlib.Path] = 'system_model.json'): with open(path, 'w', encoding='utf-8') as f: json.dump(_results_structure(flow_system), f, indent=4, ensure_ascii=False) + + +def replace_timeseries(obj, mode: Literal['name', 'stats'] = 'name'): + """Recursively replaces TimeSeries objects with their names prefixed by '::::'.""" + if isinstance(obj, dict): + return {k: replace_timeseries(v, mode) for k, v in obj.items()} + elif isinstance(obj, list): + return [replace_timeseries(v, mode) for v in obj] + elif isinstance(obj, TimeSeries): # Adjust this based on the actual class + if mode == 'name': + return f"::::{obj.name}" + elif mode == 'stats': + return obj.stats + else: + raise ValueError(f"Invalid mode {mode}") + else: + return obj + + +def remove_none_and_empty(obj): + """Recursively removes None and empty dicts and lists values from a dictionary or list.""" + + if isinstance(obj, dict): + return {k: remove_none_and_empty(v) for k, v in obj.items() if + not (v is None or (isinstance(v, (list, dict)) and not v))} + + elif isinstance(obj, list): + return [remove_none_and_empty(v) for v in obj if + not (v is None or (isinstance(v, (list, dict)) and not v))] + + else: + return obj diff --git a/flixOpt/structure.py b/flixOpt/structure.py index 13ad91172..6b0d4c2c0 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -18,7 +18,6 @@ from rich.console import Console from rich.pretty import Pretty -from . import utils from .config import CONFIG from .core import NumericData, Scalar, TimeSeries, TimeSeriesCollection, TimeSeriesData @@ -124,6 +123,10 @@ def to_json(self, path: Union[str, pathlib.Path]): with open(path, 'w', encoding='utf-8') as f: json.dump(data, f, indent=4, ensure_ascii=False) + def to_dict(self) -> Dict: + """Convert the object to a dictionary representation.""" + raise NotImplementedError('Every Interface needs a to_dict() method') + def __repr__(self): # Get the constructor arguments and their current values init_signature = inspect.signature(self.__init__) @@ -160,6 +163,18 @@ def _plausibility_checks(self) -> None: def create_model(self, model: SystemModel) -> 'ElementModel': raise NotImplementedError('Every Element needs a create_model() method') + def to_dict(self) -> Dict: + """Convert the object to a dictionary representation.""" + return { + "label": self.label, + "meta_data": self.meta_data, + } + + @classmethod + def from_dict(cls, data: Dict) -> 'Element': + """Create an Element from a dictionary representation.""" + return cls(label=data['label'], meta_data=data['meta_data']) + @property def label_full(self) -> str: return self.label From 34b2c5056667de0b57d7d9a8d9b5cd71090189ae Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 28 Feb 2025 16:06:09 +0100 Subject: [PATCH 274/507] Add to_dict() methods for all interfaces and for the FlowSystem --- flixOpt/core.py | 6 ++++++ flixOpt/effects.py | 4 ++++ flixOpt/elements.py | 18 +++++++++++++++++ flixOpt/flow_system.py | 45 ++++++++++++++++++++++++++++++++++++++---- flixOpt/interface.py | 26 ++++++++++++++++++++++++ flixOpt/structure.py | 9 ++++----- 6 files changed, 99 insertions(+), 9 deletions(-) diff --git a/flixOpt/core.py b/flixOpt/core.py index 921cfebdf..463530c45 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -398,6 +398,12 @@ def __pos__(self): def __abs__(self): return abs(self.active_data) + def __gt__(self, other): + """Compare two TimeSeries instances based on their xarray.DataArray.""" + if isinstance(other, TimeSeries): + return (self.active_data > other.active_data).all() + return NotImplemented # In case the comparison is with something else + def __array_ufunc__(self, ufunc, method, *inputs, **kwargs): """Ensures NumPy functions like np.add(TimeSeries, xarray) work correctly.""" inputs = [x.active_data if isinstance(x, TimeSeries) else x for x in inputs] diff --git a/flixOpt/effects.py b/flixOpt/effects.py index 88fce670f..b9bc5fc35 100644 --- a/flixOpt/effects.py +++ b/flixOpt/effects.py @@ -147,6 +147,10 @@ def to_dict(self) -> Dict: return data + @classmethod + def from_dict(cls, data: Dict) -> 'Effect': + return cls(**data) + def create_model(self, model: SystemModel) -> 'EffectModel': self.model = EffectModel(model, self) return self.model diff --git a/flixOpt/elements.py b/flixOpt/elements.py index 6b4375768..f3d711cc2 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -85,6 +85,15 @@ def to_dict(self) -> Dict: }) return data + @classmethod + def from_dict(cls, data: Dict) -> 'Component': + data['on_off_parameters'] = OnOffParameters.from_dict(data['on_off_parameters']) if data.get('on_off_parameters') is not None else None + data['inputs'] = [Flow.from_dict(flow) for flow in data['inputs']] + data['outputs'] = [Flow.from_dict(flow) for flow in data['outputs']] + flows = {flow.label: flow for flow in data['inputs'] + data['outputs']} + data['prevent_simultaneous_flows'] = [flows[label] for label in data['prevent_simultaneous_flows']] if data['prevent_simultaneous_flows'] else None + return cls(**data) + class Bus(Element): """ @@ -137,6 +146,10 @@ def _plausibility_checks(self) -> None: def with_excess(self) -> bool: return False if self.excess_penalty_per_flow_hour is None else True + @classmethod + def from_dict(cls, data: Dict) -> 'Bus': + return cls(**data) + class Connection: # input/output-dock (TODO: @@ -295,6 +308,11 @@ def to_dict(self): }) return data + @classmethod + def from_dict(cls, data: Dict) -> 'Flow': + data['on_off_parameters'] = OnOffParameters.from_dict(data['on_off_parameters']) if data.get('on_off_parameters') is not None else None + return cls(**data) + def _plausibility_checks(self) -> None: # TODO: Incorporate into Variable? (Lower_bound can not be greater than upper bound if np.any(self.relative_minimum > self.relative_maximum): diff --git a/flixOpt/flow_system.py b/flixOpt/flow_system.py index 458f74568..bf0fa93be 100644 --- a/flixOpt/flow_system.py +++ b/flixOpt/flow_system.py @@ -2,6 +2,7 @@ This module contains the FlowSystem class, which is used to collect instances of many other classes by the end User. """ +from io import StringIO import json import logging import pathlib @@ -10,7 +11,8 @@ import numpy as np import pandas as pd -import xarray as xr +from rich.pretty import Pretty +from rich.console import Console from . import io from .core import NumericData, NumericDataTS, TimeSeries, TimeSeriesCollection, TimeSeriesData @@ -236,7 +238,7 @@ def to_json(self, path: Union[str, pathlib.Path]): with open(path, 'w', encoding='utf-8') as f: json.dump(self.infos_compact(), f, indent=4, ensure_ascii=False) - def to_dict(self, data_mode: Literal['name', 'stats'] = 'name') -> Dict: + def to_dict(self, data_mode: Literal['data', 'name', 'stats'] = 'data') -> Dict: """Convert the object to a dictionary representation.""" data = { "components": { @@ -253,8 +255,40 @@ def to_dict(self, data_mode: Literal['name', 'stats'] = 'name') -> Dict: }, "timesteps_extra": self.time_series_collection.timesteps_extra, "periods": self.time_series_collection.periods, + "hours_of_previous_timesteps": self.time_series_collection.hours_of_previous_timesteps, } - return io.remove_none_and_empty(io.replace_timeseries(data, data_mode)) + if data_mode == 'data': + return data + else: + return io.replace_timeseries(data, data_mode) + + @classmethod + def from_dict(cls, data: Dict) -> 'FlowSystem': + timesteps_extra = data['timesteps_extra'] + hours_of_last_timestep = TimeSeriesCollection.create_hours_per_timestep( + timesteps_extra, None + ).isel(time=-1).item() + + flow_system = FlowSystem(timesteps=timesteps_extra[:-1], + hours_of_last_timestep=hours_of_last_timestep, + hours_of_previous_timesteps=data['hours_of_previous_timesteps'], + periods=data['periods']) + + flow_system.add_elements( + *[Bus.from_dict(bus) for bus in data['buses'].values()] + ) + + flow_system.add_elements( + *[Effect.from_dict(effect) for effect in data['effects'].values()] + ) + + flow_system.add_elements( + *[Component.from_dict(comp) for comp in data['components'].values()] + ) + + flow_system.transform_data() + + return flow_system def plot_network( self, @@ -330,7 +364,10 @@ def __repr__(self): return f'<{self.__class__.__name__} with {len(self.components)} components and {len(self.effects)} effects>' def __str__(self): - return get_str_representation(self.infos(use_numpy=True, use_element_label=True)) + with StringIO() as output_buffer: + console = Console(file=output_buffer, width=1000) # Adjust width as needed + console.print(Pretty(io.remove_none_and_empty(self.to_dict('stats')), expand_all=True, indent_guides=True)) + return output_buffer.getvalue() @property def flows(self) -> Dict[str, Flow]: diff --git a/flixOpt/interface.py b/flixOpt/interface.py index eb33f62d5..a6a60c2fc 100644 --- a/flixOpt/interface.py +++ b/flixOpt/interface.py @@ -88,6 +88,18 @@ def transform_data(self, flow_system: 'FlowSystem'): self.divest_effects = flow_system.effects.create_effect_values_dict(self.divest_effects) self.specific_effects = flow_system.effects.create_effect_values_dict(self.specific_effects) + def to_dict(self) -> Dict: + return { + "divest_effects": self.divest_effects, + "effects_in_segments": self.effects_in_segments, + "fix_effects": self.fix_effects, + "fixed_size": self.fixed_size, + "maximum_size": self._maximum_size, + "minimum_size": self._minimum_size, + "optional": self.optional, + "specific_effects": self.specific_effects, + } + @property def minimum_size(self): return self.fixed_size or self._minimum_size @@ -173,6 +185,20 @@ def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): f'{name_prefix}|consecutive_off_hours_max', self.consecutive_off_hours_max ) + def to_dict(self): + return { + "effects_per_switch_on": self.effects_per_switch_on, + "effects_per_running_hour": self.effects_per_running_hour, + "on_hours_total_min": self.on_hours_total_min, + "on_hours_total_max": self.on_hours_total_max, + "consecutive_on_hours_min": self.consecutive_on_hours_min, + "consecutive_on_hours_max": self.consecutive_on_hours_max, + "consecutive_off_hours_min": self.consecutive_off_hours_min, + "consecutive_off_hours_max": self.consecutive_off_hours_max, + "switch_on_total_max": self.switch_on_total_max, + "force_switch_on": self.force_switch_on, + } + @property def use_off(self) -> bool: """Determines wether the OFF Variable is needed or not""" diff --git a/flixOpt/structure.py b/flixOpt/structure.py index 6b0d4c2c0..763e5dbb8 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -127,6 +127,10 @@ def to_dict(self) -> Dict: """Convert the object to a dictionary representation.""" raise NotImplementedError('Every Interface needs a to_dict() method') + @classmethod + def from_dict(cls, data: Dict) -> 'Interface': + return cls(**data) + def __repr__(self): # Get the constructor arguments and their current values init_signature = inspect.signature(self.__init__) @@ -170,11 +174,6 @@ def to_dict(self) -> Dict: "meta_data": self.meta_data, } - @classmethod - def from_dict(cls, data: Dict) -> 'Element': - """Create an Element from a dictionary representation.""" - return cls(label=data['label'], meta_data=data['meta_data']) - @property def label_full(self) -> str: return self.label From 5704b598699137b72cf925ce99e4997286b80b82 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 28 Feb 2025 16:07:57 +0100 Subject: [PATCH 275/507] Add to_dict() methods for all interfaces and for the FlowSystem --- flixOpt/flow_system.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/flixOpt/flow_system.py b/flixOpt/flow_system.py index bf0fa93be..63a2d12f0 100644 --- a/flixOpt/flow_system.py +++ b/flixOpt/flow_system.py @@ -367,7 +367,8 @@ def __str__(self): with StringIO() as output_buffer: console = Console(file=output_buffer, width=1000) # Adjust width as needed console.print(Pretty(io.remove_none_and_empty(self.to_dict('stats')), expand_all=True, indent_guides=True)) - return output_buffer.getvalue() + value = output_buffer.getvalue() + return value @property def flows(self) -> Dict[str, Flow]: From 972f189c33acf42d628abe90d09227ce2b2ef26b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 3 Mar 2025 21:43:20 +0100 Subject: [PATCH 276/507] Improve serialization for all elements --- flixOpt/components.py | 62 +++++++++- flixOpt/effects.py | 7 +- flixOpt/elements.py | 29 +++-- flixOpt/flow_system.py | 23 ++-- flixOpt/linear_converters.py | 213 ++++++++++++++++++++++++++++++++--- flixOpt/structure.py | 29 ++++- 6 files changed, 317 insertions(+), 46 deletions(-) diff --git a/flixOpt/components.py b/flixOpt/components.py index def8f6523..a81c7f757 100644 --- a/flixOpt/components.py +++ b/flixOpt/components.py @@ -14,14 +14,14 @@ from .elements import Component, ComponentModel, Flow from .features import InvestmentModel, MultipleSegmentsModel, OnOffModel from .interface import InvestParameters, OnOffParameters -from .structure import SystemModel +from .structure import SystemModel, register_class_for_io if TYPE_CHECKING: from .flow_system import FlowSystem logger = logging.getLogger('flixOpt') - +@register_class_for_io class LinearConverter(Component): """ Converts one FLow into another via linear conversion factors @@ -111,6 +111,35 @@ def transform_data(self, flow_system: 'FlowSystem'): ] self.segmented_conversion_factors = segmented_conversion_factors + def to_dict(self) -> Dict: + data = super().to_dict() + data.update({ + "conversion_factors": [ + {flow.label: value for flow, value in conversion_factor.items()} + for conversion_factor in self.conversion_factors + ], + "segmented_conversion_factors": { + flow.label: [segment for segment in segments] + for flow, segments in self.segmented_conversion_factors.items() + } + }) + return data + + @classmethod + def _from_dict(cls, data: Dict) -> Dict: + """ Load data from a dict to initialize an object""" + data = super()._from_dict(data) + flows = {flow.label: flow for flow in data['inputs'] + data['outputs']} + data['conversion_factors'] = [ + {flows[flow]: factor for flow, factor in conversion_factor.items()} + for conversion_factor in data['conversion_factors'] + ] + data['segmented_conversion_factors'] = { + flows[flow]: [segment for segment in segments] + for flow, segments in data['segmented_conversion_factors'].items() + } + return data + def _transform_conversion_factors(self, flow_system: 'FlowSystem') -> List[Dict[Flow, TimeSeries]]: """macht alle Faktoren, die nicht TimeSeries sind, zu TimeSeries""" list_of_conversion_factors = [] @@ -129,6 +158,7 @@ def degrees_of_freedom(self): return len(self.inputs + self.outputs) - len(self.conversion_factors) +@register_class_for_io class Storage(Component): """ Klasse Storage @@ -237,7 +267,30 @@ def transform_data(self, flow_system: 'FlowSystem') -> None: if isinstance(self.capacity_in_flow_hours, InvestParameters): self.capacity_in_flow_hours.transform_data(flow_system) - + def to_dict(self) -> Dict: + data = super().to_dict() + data.update({ + "relative_minimum_charge_state": self.relative_minimum_charge_state, + "relative_maximum_charge_state": self.relative_maximum_charge_state, + "initial_charge_state": self.initial_charge_state, + "minimal_final_charge_state": self.minimal_final_charge_state, + "maximal_final_charge_state": self.maximal_final_charge_state, + "eta_charge": self.eta_charge, + "eta_discharge": self.eta_discharge, + "relative_loss_per_hour": self.relative_loss_per_hour, + "capacity_in_flow_hours": self.capacity_in_flow_hours.to_dict() if isinstance(self.capacity_in_flow_hours, InvestParameters) else self.capacity_in_flow_hours, + }) + return data + + @classmethod + def _from_dict(cls, data: Dict) -> Dict: + """ Load data from a dict to initialize an object""" + data = super()._from_dict(data) + data['capacity_in_flow_hours'] = InvestParameters.from_dict(data['capacity_in_flow_hours']) if data.get('capacity_in_flow_hours') is not None else data['capacity_in_flow_hours'] + return data + + +@register_class_for_io class Transmission(Component): # TODO: automatic on-Value in Flows if loss_abs # TODO: loss_abs must be: investment_size * loss_abs_rel!!! @@ -550,6 +603,7 @@ def relative_charge_state_bounds(self) -> Tuple[NumericData, NumericData]: ) +@register_class_for_io class SourceAndSink(Component): """ class for source (output-flow) and sink (input-flow) in one commponent @@ -593,6 +647,7 @@ def __init__( self.sink = sink +@register_class_for_io class Source(Component): def __init__(self, label: str, source: Flow, meta_data: Optional[Dict] = None): """ @@ -609,6 +664,7 @@ def __init__(self, label: str, source: Flow, meta_data: Optional[Dict] = None): self.source = source +@register_class_for_io class Sink(Component): def __init__(self, label: str, sink: Flow, meta_data: Optional[Dict] = None): """ diff --git a/flixOpt/effects.py b/flixOpt/effects.py index b9bc5fc35..323d57b95 100644 --- a/flixOpt/effects.py +++ b/flixOpt/effects.py @@ -15,7 +15,7 @@ from .core import NumericData, NumericDataTS, Scalar, TimeSeries, TimeSeriesCollection from .features import ShareAllocationModel -from .structure import Element, ElementModel, Interface, Model, SystemModel +from .structure import Element, ElementModel, Interface, Model, SystemModel, register_class_for_io if TYPE_CHECKING: from .flow_system import FlowSystem @@ -23,6 +23,7 @@ logger = logging.getLogger('flixOpt') +@register_class_for_io class Effect(Element): """ Effect, i.g. costs, CO2 emissions, area, ... @@ -147,10 +148,6 @@ def to_dict(self) -> Dict: return data - @classmethod - def from_dict(cls, data: Dict) -> 'Effect': - return cls(**data) - def create_model(self, model: SystemModel) -> 'EffectModel': self.model = EffectModel(model, self) return self.model diff --git a/flixOpt/elements.py b/flixOpt/elements.py index f3d711cc2..c4af031af 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -14,7 +14,7 @@ from .effects import EffectValuesUser from .features import InvestmentModel, OnOffModel, PreventSimultaneousUsageModel from .interface import InvestParameters, OnOffParameters -from .structure import Element, ElementModel, SystemModel +from .structure import Element, ElementModel, SystemModel, register_class_for_io if TYPE_CHECKING: from .flow_system import FlowSystem @@ -22,6 +22,7 @@ logger = logging.getLogger('flixOpt') +@register_class_for_io class Component(Element): """ basic component class for all components @@ -86,15 +87,20 @@ def to_dict(self) -> Dict: return data @classmethod - def from_dict(cls, data: Dict) -> 'Component': + def _from_dict(cls, data: Dict) -> Dict: + """ Load data from a dict to initialize an object""" + data = super()._from_dict(data) data['on_off_parameters'] = OnOffParameters.from_dict(data['on_off_parameters']) if data.get('on_off_parameters') is not None else None data['inputs'] = [Flow.from_dict(flow) for flow in data['inputs']] data['outputs'] = [Flow.from_dict(flow) for flow in data['outputs']] flows = {flow.label: flow for flow in data['inputs'] + data['outputs']} - data['prevent_simultaneous_flows'] = [flows[label] for label in data['prevent_simultaneous_flows']] if data['prevent_simultaneous_flows'] else None - return cls(**data) + data['prevent_simultaneous_flows'] = [ + flows[label] for label in data['prevent_simultaneous_flows'] + ] if data['prevent_simultaneous_flows'] is not None else None + return data +@register_class_for_io class Bus(Element): """ realizing balance of all linked flows @@ -146,11 +152,8 @@ def _plausibility_checks(self) -> None: def with_excess(self) -> bool: return False if self.excess_penalty_per_flow_hour is None else True - @classmethod - def from_dict(cls, data: Dict) -> 'Bus': - return cls(**data) - +@register_class_for_io class Connection: # input/output-dock (TODO: # -> wäre cool, damit Komponenten auch auch ohne Knoten verbindbar @@ -160,6 +163,7 @@ def __init__(self): raise NotImplementedError() +@register_class_for_io class Flow(Element): """ flows are inputs and outputs of components @@ -309,9 +313,12 @@ def to_dict(self): return data @classmethod - def from_dict(cls, data: Dict) -> 'Flow': - data['on_off_parameters'] = OnOffParameters.from_dict(data['on_off_parameters']) if data.get('on_off_parameters') is not None else None - return cls(**data) + def _from_dict(cls, data: Dict) -> Dict: + """ Load data from a dict to initialize an object""" + data = super()._from_dict(data) + data['on_off_parameters'] = OnOffParameters.from_dict(data['on_off_parameters']) if data.get( + 'on_off_parameters') is not None else None + return data def _plausibility_checks(self) -> None: # TODO: Incorporate into Variable? (Lower_bound can not be greater than upper bound diff --git a/flixOpt/flow_system.py b/flixOpt/flow_system.py index 63a2d12f0..60534da89 100644 --- a/flixOpt/flow_system.py +++ b/flixOpt/flow_system.py @@ -18,7 +18,7 @@ from .core import NumericData, NumericDataTS, TimeSeries, TimeSeriesCollection, TimeSeriesData from .effects import Effect, EffectCollection, EffectTimeSeries, EffectValuesDict, EffectValuesUser from .elements import Bus, Component, Flow -from .structure import Element, SystemModel, get_compact_representation, get_str_representation +from .structure import Element, SystemModel, get_compact_representation, get_str_representation, class_registry if TYPE_CHECKING: import pyvis @@ -150,7 +150,13 @@ def create_time_series( return None elif isinstance(data, TimeSeries): data.restore_data() - return data + if data in self.time_series_collection: + return data + return self.time_series_collection.create_time_series( + data=data.active_data, + name=name, + extra_timestep=extra_timestep + ) return self.time_series_collection.create_time_series( data=data, name=name, @@ -253,18 +259,19 @@ def to_dict(self, data_mode: Literal['data', 'name', 'stats'] = 'data') -> Dict: effect.label: effect.to_dict() for effect in sorted(self.effects, key=lambda effect: effect.label.upper()) }, - "timesteps_extra": self.time_series_collection.timesteps_extra, + "timesteps_extra": [date.isoformat() for date in self.time_series_collection.timesteps_extra], "periods": self.time_series_collection.periods, "hours_of_previous_timesteps": self.time_series_collection.hours_of_previous_timesteps, } if data_mode == 'data': return data - else: - return io.replace_timeseries(data, data_mode) + elif data_mode == 'stats': + return io.remove_none_and_empty(io.replace_timeseries(data, data_mode)) + return io.replace_timeseries(data, data_mode) @classmethod def from_dict(cls, data: Dict) -> 'FlowSystem': - timesteps_extra = data['timesteps_extra'] + timesteps_extra = pd.DatetimeIndex(data['timesteps_extra'], name='time') hours_of_last_timestep = TimeSeriesCollection.create_hours_per_timestep( timesteps_extra, None ).isel(time=-1).item() @@ -272,7 +279,7 @@ def from_dict(cls, data: Dict) -> 'FlowSystem': flow_system = FlowSystem(timesteps=timesteps_extra[:-1], hours_of_last_timestep=hours_of_last_timestep, hours_of_previous_timesteps=data['hours_of_previous_timesteps'], - periods=data['periods']) + periods=pd.Index(data['periods'], name='period') if data.get('periods') is not None else None) flow_system.add_elements( *[Bus.from_dict(bus) for bus in data['buses'].values()] @@ -283,7 +290,7 @@ def from_dict(cls, data: Dict) -> 'FlowSystem': ) flow_system.add_elements( - *[Component.from_dict(comp) for comp in data['components'].values()] + *[class_registry[comp['__class__']].from_dict(comp) for comp in data['components'].values()] ) flow_system.transform_data() diff --git a/flixOpt/linear_converters.py b/flixOpt/linear_converters.py index 9d79b9717..024f3dadd 100644 --- a/flixOpt/linear_converters.py +++ b/flixOpt/linear_converters.py @@ -11,10 +11,12 @@ from .core import NumericDataTS, TimeSeriesData from .elements import Flow from .interface import OnOffParameters +from .structure import register_class_for_io logger = logging.getLogger('flixOpt') +@register_class_for_io class Boiler(LinearConverter): def __init__( self, @@ -50,13 +52,40 @@ def __init__( meta_data=meta_data, ) - self.eta = eta self.Q_fu = Q_fu self.Q_th = Q_th - check_bounds(eta, 'eta', self.label_full, 0, 1) - + @property + def eta(self): + return self.conversion_factors[0][self.Q_th] + + @eta.setter + def eta(self, value): + check_bounds(value, 'eta', self.label_full, 0, 1) + self.conversion_factors[0][self.Q_th] = value + + def to_dict(self) -> Dict: + return { + '__class__': 'Boiler', + 'label': self.label, + "eta": self.eta, + 'Q_th': self.Q_th.to_dict(), + 'Q_fu': self.Q_fu.to_dict(), + 'on_off_parameters': self.on_off_parameters.to_dict() if isinstance(self.on_off_parameters, + OnOffParameters) else self.on_off_parameters, + 'meta_data': self.meta_data, + } + + @classmethod + def _from_dict(cls, data: Dict) -> Dict: + data['on_off_parameters'] = OnOffParameters.from_dict(data['on_off_parameters']) if data.get( + 'on_off_parameters') is not None else None + data['Q_fu'] = Flow.from_dict(data['Q_fu']) + data['Q_th'] = Flow.from_dict(data['Q_th']) + return data + +@register_class_for_io class Power2Heat(LinearConverter): def __init__( self, @@ -91,13 +120,41 @@ def __init__( meta_data=meta_data, ) - self.eta = eta self.P_el = P_el self.Q_th = Q_th - check_bounds(eta, 'eta', self.label_full, 0, 1) - + @property + def eta(self): + return self.conversion_factors[0][self.Q_th] + + @eta.setter + def eta(self, value): + check_bounds(value, 'eta', self.label_full, 0, 1) + self.conversion_factors[0][self.Q_th] = value + + def to_dict(self) -> Dict: + return { + '__class__': 'Boiler', + 'label': self.label, + "eta": self.eta, + 'Q_th': self.Q_th.to_dict(), + 'P_el': self.P_el.to_dict(), + 'on_off_parameters': self.on_off_parameters.to_dict() if isinstance(self.on_off_parameters, + OnOffParameters) else self.on_off_parameters, + 'meta_data': self.meta_data, + } + + @classmethod + def _from_dict(cls, data: Dict) -> Dict: + data['on_off_parameters'] = OnOffParameters.from_dict(data['on_off_parameters']) if data.get( + 'on_off_parameters') is not None else None + data['P_el'] = Flow.from_dict(data['P_el']) + data['Q_th'] = Flow.from_dict(data['Q_th']) + return data + + +@register_class_for_io class HeatPump(LinearConverter): def __init__( self, @@ -137,7 +194,37 @@ def __init__( check_bounds(COP, 'COP', self.label_full, 1, 20) - + @property + def COP(self): + return self.conversion_factors[0][self.Q_th] + + @COP.setter + def COP(self, value): + check_bounds(value, 'COP', self.label_full, 1, 20) + self.conversion_factors[0][self.Q_th] = value + + def to_dict(self) -> Dict: + return { + '__class__': 'Boiler', + 'label': self.label, + "COP": self.COP, + 'Q_th': self.Q_th.to_dict(), + 'P_el': self.P_el.to_dict(), + 'on_off_parameters': self.on_off_parameters.to_dict() if isinstance(self.on_off_parameters, + OnOffParameters) else self.on_off_parameters, + 'meta_data': self.meta_data, + } + + @classmethod + def _from_dict(cls, data: Dict) -> Dict: + data['on_off_parameters'] = OnOffParameters.from_dict(data['on_off_parameters']) if data.get( + 'on_off_parameters') is not None else None + data['P_el'] = Flow.from_dict(data['P_el']) + data['Q_th'] = Flow.from_dict(data['Q_th']) + return data + + +@register_class_for_io class CoolingTower(LinearConverter): def __init__( self, @@ -178,7 +265,37 @@ def __init__( check_bounds(specific_electricity_demand, 'specific_electricity_demand', self.label_full, 0, 1) - + @property + def specific_electricity_demand(self): + return -self.conversion_factors[0][self.Q_th] + + @specific_electricity_demand.setter + def specific_electricity_demand(self, value): + check_bounds(value, 'specific_electricity_demand', self.label_full, 0, 1) + self.conversion_factors[0][self.Q_th] = -value + + def to_dict(self) -> Dict: + return { + '__class__': 'Boiler', + 'label': self.label, + "specific_electricity_demand": self.specific_electricity_demand, + 'Q_th': self.Q_th.to_dict(), + 'P_el': self.P_el.to_dict(), + 'on_off_parameters': self.on_off_parameters.to_dict() if isinstance(self.on_off_parameters, + OnOffParameters) else self.on_off_parameters, + 'meta_data': self.meta_data, + } + + @classmethod + def _from_dict(cls, data: Dict) -> Dict: + data['on_off_parameters'] = OnOffParameters.from_dict(data['on_off_parameters']) if data.get( + 'on_off_parameters') is not None else None + data['P_el'] = Flow.from_dict(data['P_el']) + data['Q_th'] = Flow.from_dict(data['Q_th']) + return data + + +@register_class_for_io class CHP(LinearConverter): def __init__( self, @@ -223,9 +340,6 @@ def __init__( meta_data=meta_data, ) - # args to attributes: - self.eta_th = eta_th - self.eta_el = eta_el self.Q_fu = Q_fu self.P_el = P_el self.Q_th = Q_th @@ -234,7 +348,48 @@ def __init__( check_bounds(eta_el, 'eta_el', self.label_full, 0, 1) check_bounds(eta_el + eta_th, 'eta_th+eta_el', self.label_full, 0, 1) - + @property + def eta_th(self): + return self.conversion_factors[0][self.Q_fu] + + @eta_th.setter + def eta_th(self, value): + check_bounds(value, 'eta_th', self.label_full, 0, 1) + self.conversion_factors[0][self.Q_fu] = value + + @property + def eta_el(self): + return self.conversion_factors[1][self.Q_fu] + + @eta_el.setter + def eta_el(self, value): + check_bounds(value, 'eta_el', self.label_full, 0, 1) + self.conversion_factors[1][self.Q_fu] = value + + def to_dict(self) -> Dict: + return { + '__class__': 'Boiler', + 'label': self.label, + "eta_th": self.eta_th, + "eta_el": self.eta_el, + 'Q_fu': self.Q_fu.to_dict(), + 'Q_th': self.Q_th.to_dict(), + 'P_el': self.P_el.to_dict(), + 'on_off_parameters': self.on_off_parameters.to_dict() if isinstance(self.on_off_parameters, + OnOffParameters) else self.on_off_parameters, + 'meta_data': self.meta_data, + } + + @classmethod + def _from_dict(cls, data: Dict) -> Dict: + data['on_off_parameters'] = OnOffParameters.from_dict(data['on_off_parameters']) if data.get( + 'on_off_parameters') is not None else None + data['P_el'] = Flow.from_dict(data['P_el']) + data['Q_th'] = Flow.from_dict(data['Q_th']) + data['Q_fu'] = Flow.from_dict(data['Q_fu']) + return data + +@register_class_for_io class HeatPumpWithSource(LinearConverter): def __init__( self, @@ -281,7 +436,39 @@ def __init__( self.Q_ab = Q_ab self.Q_th = Q_th - check_bounds(COP, 'eta_th', self.label_full, 1, 20) + check_bounds(COP, 'COP', self.label_full, 1, 20) + + @property + def COP(self): + return self.conversion_factors[0][self.Q_th] + + @COP.setter + def COP(self, value): + check_bounds(value, 'COP', self.label_full, 1, 20) + self.conversion_factors[0][self.Q_th] = value + self.conversion_factors[1][self.Q_th] = value / (value - 1) + + def to_dict(self) -> Dict: + return { + '__class__': 'Boiler', + 'label': self.label, + "COP": self.COP, + 'Q_th': self.Q_th.to_dict(), + 'P_el': self.P_el.to_dict(), + 'Q_ab': self.Q_ab.to_dict(), + 'on_off_parameters': self.on_off_parameters.to_dict() if isinstance(self.on_off_parameters, + OnOffParameters) else self.on_off_parameters, + 'meta_data': self.meta_data, + } + + @classmethod + def _from_dict(cls, data: Dict) -> Dict: + data['on_off_parameters'] = OnOffParameters.from_dict(data['on_off_parameters']) if data.get( + 'on_off_parameters') is not None else None + data['P_el'] = Flow.from_dict(data['P_el']) + data['Q_th'] = Flow.from_dict(data['Q_th']) + data['Q_ab'] = Flow.from_dict(data['Q_ab']) + return data def check_bounds( diff --git a/flixOpt/structure.py b/flixOpt/structure.py index 763e5dbb8..53ee07e89 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -28,6 +28,18 @@ logger = logging.getLogger('flixOpt') +class_registry = {} + +def register_class_for_io(cls): + """Register a class for serialization/deserialization.""" + name = cls.__name__ + if name in class_registry: + raise ValueError(f'Class {name} already registered! Use a different Name for the class! ' + f'This error should only happen in developement') + class_registry[name] = cls + return cls + + class SystemModel(linopy.Model): def __init__(self, flow_system: 'FlowSystem'): @@ -125,11 +137,17 @@ def to_json(self, path: Union[str, pathlib.Path]): def to_dict(self) -> Dict: """Convert the object to a dictionary representation.""" - raise NotImplementedError('Every Interface needs a to_dict() method') + return {"__class__": self.__class__.__name__} @classmethod def from_dict(cls, data: Dict) -> 'Interface': - return cls(**data) + data.pop('__class__') + return cls(**cls._from_dict(data)) + + @classmethod + def _from_dict(cls, data: Dict) -> Dict: + """ Load data from a dict to initialize an object""" + return data def __repr__(self): # Get the constructor arguments and their current values @@ -169,10 +187,9 @@ def create_model(self, model: SystemModel) -> 'ElementModel': def to_dict(self) -> Dict: """Convert the object to a dictionary representation.""" - return { - "label": self.label, - "meta_data": self.meta_data, - } + return {**super().to_dict(), + **{"label": self.label,"meta_data": self.meta_data} + } @property def label_full(self) -> str: From 3378b2394d157edf06b4e079fab64ce6f3123acc Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 4 Mar 2025 11:21:24 +0100 Subject: [PATCH 277/507] Unify the serialization and deserialization --- flixOpt/components.py | 51 --------------- flixOpt/effects.py | 23 ------- flixOpt/elements.py | 63 ------------------ flixOpt/linear_converters.py | 123 ----------------------------------- flixOpt/structure.py | 82 +++++++++++++++++++---- 5 files changed, 69 insertions(+), 273 deletions(-) diff --git a/flixOpt/components.py b/flixOpt/components.py index a81c7f757..4bd346f4d 100644 --- a/flixOpt/components.py +++ b/flixOpt/components.py @@ -111,35 +111,6 @@ def transform_data(self, flow_system: 'FlowSystem'): ] self.segmented_conversion_factors = segmented_conversion_factors - def to_dict(self) -> Dict: - data = super().to_dict() - data.update({ - "conversion_factors": [ - {flow.label: value for flow, value in conversion_factor.items()} - for conversion_factor in self.conversion_factors - ], - "segmented_conversion_factors": { - flow.label: [segment for segment in segments] - for flow, segments in self.segmented_conversion_factors.items() - } - }) - return data - - @classmethod - def _from_dict(cls, data: Dict) -> Dict: - """ Load data from a dict to initialize an object""" - data = super()._from_dict(data) - flows = {flow.label: flow for flow in data['inputs'] + data['outputs']} - data['conversion_factors'] = [ - {flows[flow]: factor for flow, factor in conversion_factor.items()} - for conversion_factor in data['conversion_factors'] - ] - data['segmented_conversion_factors'] = { - flows[flow]: [segment for segment in segments] - for flow, segments in data['segmented_conversion_factors'].items() - } - return data - def _transform_conversion_factors(self, flow_system: 'FlowSystem') -> List[Dict[Flow, TimeSeries]]: """macht alle Faktoren, die nicht TimeSeries sind, zu TimeSeries""" list_of_conversion_factors = [] @@ -267,28 +238,6 @@ def transform_data(self, flow_system: 'FlowSystem') -> None: if isinstance(self.capacity_in_flow_hours, InvestParameters): self.capacity_in_flow_hours.transform_data(flow_system) - def to_dict(self) -> Dict: - data = super().to_dict() - data.update({ - "relative_minimum_charge_state": self.relative_minimum_charge_state, - "relative_maximum_charge_state": self.relative_maximum_charge_state, - "initial_charge_state": self.initial_charge_state, - "minimal_final_charge_state": self.minimal_final_charge_state, - "maximal_final_charge_state": self.maximal_final_charge_state, - "eta_charge": self.eta_charge, - "eta_discharge": self.eta_discharge, - "relative_loss_per_hour": self.relative_loss_per_hour, - "capacity_in_flow_hours": self.capacity_in_flow_hours.to_dict() if isinstance(self.capacity_in_flow_hours, InvestParameters) else self.capacity_in_flow_hours, - }) - return data - - @classmethod - def _from_dict(cls, data: Dict) -> Dict: - """ Load data from a dict to initialize an object""" - data = super()._from_dict(data) - data['capacity_in_flow_hours'] = InvestParameters.from_dict(data['capacity_in_flow_hours']) if data.get('capacity_in_flow_hours') is not None else data['capacity_in_flow_hours'] - return data - @register_class_for_io class Transmission(Component): diff --git a/flixOpt/effects.py b/flixOpt/effects.py index 323d57b95..340e5a653 100644 --- a/flixOpt/effects.py +++ b/flixOpt/effects.py @@ -125,29 +125,6 @@ def transform_data(self, flow_system: 'FlowSystem'): 'operation' ) - def to_dict(self) -> Dict: - data = super().to_dict() - - # Add attributes - data.update({ - "unit": self.unit, - "description": self.description, - "is_standard": self.is_standard, - "is_objective": self.is_objective, - "specific_share_to_other_effects_operation": self.specific_share_to_other_effects_operation, - "specific_share_to_other_effects_invest": self.specific_share_to_other_effects_invest, - "minimum_operation": self.minimum_operation, - "maximum_operation": self.maximum_operation, - "minimum_operation_per_hour": self.minimum_operation_per_hour, - "maximum_operation_per_hour": self.maximum_operation_per_hour, - "minimum_invest": self.minimum_invest, - "maximum_invest": self.maximum_invest, - "minimum_total": self.minimum_total, - "maximum_total": self.maximum_total, - }) - - return data - def create_model(self, model: SystemModel) -> 'EffectModel': self.model = EffectModel(model, self) return self.model diff --git a/flixOpt/elements.py b/flixOpt/elements.py index c4af031af..dace69297 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -75,30 +75,6 @@ def infos(self, use_numpy=True, use_element_label=False) -> Dict: infos['outputs'] = [flow.infos(use_numpy, use_element_label) for flow in self.outputs] return infos - def to_dict(self) -> Dict: - """Convert the object to a dictionary representation.""" - data = super().to_dict() - data.update({ - "inputs": [flow.to_dict() for flow in self.inputs], - "outputs": [flow.to_dict() for flow in self.outputs], - "on_off_parameters": self.on_off_parameters.to_dict() if self.on_off_parameters else None, - "prevent_simultaneous_flows": [flow.label_full for flow in self.prevent_simultaneous_flows], - }) - return data - - @classmethod - def _from_dict(cls, data: Dict) -> Dict: - """ Load data from a dict to initialize an object""" - data = super()._from_dict(data) - data['on_off_parameters'] = OnOffParameters.from_dict(data['on_off_parameters']) if data.get('on_off_parameters') is not None else None - data['inputs'] = [Flow.from_dict(flow) for flow in data['inputs']] - data['outputs'] = [Flow.from_dict(flow) for flow in data['outputs']] - flows = {flow.label: flow for flow in data['inputs'] + data['outputs']} - data['prevent_simultaneous_flows'] = [ - flows[label] for label in data['prevent_simultaneous_flows'] - ] if data['prevent_simultaneous_flows'] is not None else None - return data - @register_class_for_io class Bus(Element): @@ -136,14 +112,6 @@ def transform_data(self, flow_system: 'FlowSystem'): f'{self.label_full}|excess_penalty_per_flow_hour', self.excess_penalty_per_flow_hour ) - def to_dict(self) -> Dict: - """Convert the object to a dictionary representation.""" - data = super().to_dict() - data.update({ - "excess_penalty_per_flow_hour": self.excess_penalty_per_flow_hour, - }) - return data - def _plausibility_checks(self) -> None: if self.excess_penalty_per_flow_hour == 0: logger.warning(f'In Bus {self.label}, the excess_penalty_per_flow_hour is 0. Use "None" or a value > 0.') @@ -289,37 +257,6 @@ def infos(self, use_numpy=True, use_element_label=False) -> Dict: infos['is_input_in_component'] = self.is_input_in_component return infos - def to_dict(self): - """ - Exports the Element to a format that can be saved and loaded from and to file. - Probably a combination of json for sturcture and xr.Dataset for data. - """ - data = super().to_dict() - data.update({ - 'bus': self.bus, - 'size': self.size.to_dict() if isinstance(self.size, InvestParameters) else self.size, - 'fixed_relative_profile': self.fixed_relative_profile, - 'relative_minimum': self.relative_minimum, - 'relative_maximum': self.relative_maximum, - 'on_off_parameters': self.on_off_parameters.to_dict() if isinstance(self.on_off_parameters, - OnOffParameters) else self.on_off_parameters, - 'load_factor_min': self.load_factor_min, - 'load_factor_max': self.load_factor_max, - 'effects_per_flow_hour': self.effects_per_flow_hour, - 'flow_hours_total_max': self.flow_hours_total_max, - 'flow_hours_total_min': self.flow_hours_total_min, - 'previous_flow_rate': self.previous_flow_rate, - }) - return data - - @classmethod - def _from_dict(cls, data: Dict) -> Dict: - """ Load data from a dict to initialize an object""" - data = super()._from_dict(data) - data['on_off_parameters'] = OnOffParameters.from_dict(data['on_off_parameters']) if data.get( - 'on_off_parameters') is not None else None - return data - def _plausibility_checks(self) -> None: # TODO: Incorporate into Variable? (Lower_bound can not be greater than upper bound if np.any(self.relative_minimum > self.relative_maximum): diff --git a/flixOpt/linear_converters.py b/flixOpt/linear_converters.py index 024f3dadd..9c81eb43b 100644 --- a/flixOpt/linear_converters.py +++ b/flixOpt/linear_converters.py @@ -65,25 +65,6 @@ def eta(self, value): check_bounds(value, 'eta', self.label_full, 0, 1) self.conversion_factors[0][self.Q_th] = value - def to_dict(self) -> Dict: - return { - '__class__': 'Boiler', - 'label': self.label, - "eta": self.eta, - 'Q_th': self.Q_th.to_dict(), - 'Q_fu': self.Q_fu.to_dict(), - 'on_off_parameters': self.on_off_parameters.to_dict() if isinstance(self.on_off_parameters, - OnOffParameters) else self.on_off_parameters, - 'meta_data': self.meta_data, - } - - @classmethod - def _from_dict(cls, data: Dict) -> Dict: - data['on_off_parameters'] = OnOffParameters.from_dict(data['on_off_parameters']) if data.get( - 'on_off_parameters') is not None else None - data['Q_fu'] = Flow.from_dict(data['Q_fu']) - data['Q_th'] = Flow.from_dict(data['Q_th']) - return data @register_class_for_io class Power2Heat(LinearConverter): @@ -133,26 +114,6 @@ def eta(self, value): check_bounds(value, 'eta', self.label_full, 0, 1) self.conversion_factors[0][self.Q_th] = value - def to_dict(self) -> Dict: - return { - '__class__': 'Boiler', - 'label': self.label, - "eta": self.eta, - 'Q_th': self.Q_th.to_dict(), - 'P_el': self.P_el.to_dict(), - 'on_off_parameters': self.on_off_parameters.to_dict() if isinstance(self.on_off_parameters, - OnOffParameters) else self.on_off_parameters, - 'meta_data': self.meta_data, - } - - @classmethod - def _from_dict(cls, data: Dict) -> Dict: - data['on_off_parameters'] = OnOffParameters.from_dict(data['on_off_parameters']) if data.get( - 'on_off_parameters') is not None else None - data['P_el'] = Flow.from_dict(data['P_el']) - data['Q_th'] = Flow.from_dict(data['Q_th']) - return data - @register_class_for_io class HeatPump(LinearConverter): @@ -203,26 +164,6 @@ def COP(self, value): check_bounds(value, 'COP', self.label_full, 1, 20) self.conversion_factors[0][self.Q_th] = value - def to_dict(self) -> Dict: - return { - '__class__': 'Boiler', - 'label': self.label, - "COP": self.COP, - 'Q_th': self.Q_th.to_dict(), - 'P_el': self.P_el.to_dict(), - 'on_off_parameters': self.on_off_parameters.to_dict() if isinstance(self.on_off_parameters, - OnOffParameters) else self.on_off_parameters, - 'meta_data': self.meta_data, - } - - @classmethod - def _from_dict(cls, data: Dict) -> Dict: - data['on_off_parameters'] = OnOffParameters.from_dict(data['on_off_parameters']) if data.get( - 'on_off_parameters') is not None else None - data['P_el'] = Flow.from_dict(data['P_el']) - data['Q_th'] = Flow.from_dict(data['Q_th']) - return data - @register_class_for_io class CoolingTower(LinearConverter): @@ -274,26 +215,6 @@ def specific_electricity_demand(self, value): check_bounds(value, 'specific_electricity_demand', self.label_full, 0, 1) self.conversion_factors[0][self.Q_th] = -value - def to_dict(self) -> Dict: - return { - '__class__': 'Boiler', - 'label': self.label, - "specific_electricity_demand": self.specific_electricity_demand, - 'Q_th': self.Q_th.to_dict(), - 'P_el': self.P_el.to_dict(), - 'on_off_parameters': self.on_off_parameters.to_dict() if isinstance(self.on_off_parameters, - OnOffParameters) else self.on_off_parameters, - 'meta_data': self.meta_data, - } - - @classmethod - def _from_dict(cls, data: Dict) -> Dict: - data['on_off_parameters'] = OnOffParameters.from_dict(data['on_off_parameters']) if data.get( - 'on_off_parameters') is not None else None - data['P_el'] = Flow.from_dict(data['P_el']) - data['Q_th'] = Flow.from_dict(data['Q_th']) - return data - @register_class_for_io class CHP(LinearConverter): @@ -366,28 +287,6 @@ def eta_el(self, value): check_bounds(value, 'eta_el', self.label_full, 0, 1) self.conversion_factors[1][self.Q_fu] = value - def to_dict(self) -> Dict: - return { - '__class__': 'Boiler', - 'label': self.label, - "eta_th": self.eta_th, - "eta_el": self.eta_el, - 'Q_fu': self.Q_fu.to_dict(), - 'Q_th': self.Q_th.to_dict(), - 'P_el': self.P_el.to_dict(), - 'on_off_parameters': self.on_off_parameters.to_dict() if isinstance(self.on_off_parameters, - OnOffParameters) else self.on_off_parameters, - 'meta_data': self.meta_data, - } - - @classmethod - def _from_dict(cls, data: Dict) -> Dict: - data['on_off_parameters'] = OnOffParameters.from_dict(data['on_off_parameters']) if data.get( - 'on_off_parameters') is not None else None - data['P_el'] = Flow.from_dict(data['P_el']) - data['Q_th'] = Flow.from_dict(data['Q_th']) - data['Q_fu'] = Flow.from_dict(data['Q_fu']) - return data @register_class_for_io class HeatPumpWithSource(LinearConverter): @@ -448,28 +347,6 @@ def COP(self, value): self.conversion_factors[0][self.Q_th] = value self.conversion_factors[1][self.Q_th] = value / (value - 1) - def to_dict(self) -> Dict: - return { - '__class__': 'Boiler', - 'label': self.label, - "COP": self.COP, - 'Q_th': self.Q_th.to_dict(), - 'P_el': self.P_el.to_dict(), - 'Q_ab': self.Q_ab.to_dict(), - 'on_off_parameters': self.on_off_parameters.to_dict() if isinstance(self.on_off_parameters, - OnOffParameters) else self.on_off_parameters, - 'meta_data': self.meta_data, - } - - @classmethod - def _from_dict(cls, data: Dict) -> Dict: - data['on_off_parameters'] = OnOffParameters.from_dict(data['on_off_parameters']) if data.get( - 'on_off_parameters') is not None else None - data['P_el'] = Flow.from_dict(data['P_el']) - data['Q_th'] = Flow.from_dict(data['Q_th']) - data['Q_ab'] = Flow.from_dict(data['Q_ab']) - return data - def check_bounds( value: NumericDataTS, parameter_label: str, element_label: str, lower_bound: NumericDataTS, upper_bound: NumericDataTS diff --git a/flixOpt/structure.py b/flixOpt/structure.py index 53ee07e89..3f9a89600 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -137,17 +137,79 @@ def to_json(self, path: Union[str, pathlib.Path]): def to_dict(self) -> Dict: """Convert the object to a dictionary representation.""" - return {"__class__": self.__class__.__name__} + data = {'__class__': self.__class__.__name__} + + # Get the constructor parameters + init_params = inspect.signature(self.__init__).parameters + + for name, param in init_params.items(): + if name == 'self': + continue + + value = getattr(self, name, None) + data[name] = self._serialize_value(value) + + return data + + def _serialize_value(self, value: Any): + """Helper method to serialize a value based on its type.""" + if value is None: + return None + elif isinstance(value, Interface): + return value.to_dict() + elif isinstance(value, (list, tuple)): + return self._serialize_list(value) + elif isinstance(value, dict): + return self._serialize_dict(value) + else: + return value + + def _serialize_list(self, items): + """Serialize a list of items.""" + return [self._serialize_value(item) for item in items] + + def _serialize_dict(self, d): + """Serialize a dictionary of items.""" + return {k: self._serialize_value(v) for k, v in d.items()} @classmethod - def from_dict(cls, data: Dict) -> 'Interface': - data.pop('__class__') - return cls(**cls._from_dict(data)) + def from_dict(cls, data: Dict): + """Create an instance from a dictionary representation.""" + # Remove class name if present + data = data.copy() + + # For child classes that need custom deserialization + init_args = cls._prepare_init_args(data) + + return cls(**init_args) @classmethod - def _from_dict(cls, data: Dict) -> Dict: - """ Load data from a dict to initialize an object""" - return data + def _prepare_init_args(cls, data: Dict): + """Recursively deserialize nested objects.""" + result = {} + + for key, value in data.items(): + if isinstance(value, dict) and '__class__' in value: + class_name = value.pop('__class__') + try: + class_type = class_registry[class_name] + if issubclass(class_registry[class_name], Interface): + result[key] = class_type.from_dict(value) + else: + raise ValueError(f'Class "{class_name}" is not an Interface.') + except (AttributeError, KeyError) as e: + raise ValueError(f'Class "{class_name}" could not get reconstructed.') from e + elif isinstance(value, dict): + result[key] = cls._prepare_init_args(value) + elif isinstance(value, list): + result[key] = [ + cls._prepare_init_args(item) if isinstance(item, dict) and '__class__' in item + else item + for item in value + ] + else: + result[key] = value + return result def __repr__(self): # Get the constructor arguments and their current values @@ -185,12 +247,6 @@ def _plausibility_checks(self) -> None: def create_model(self, model: SystemModel) -> 'ElementModel': raise NotImplementedError('Every Element needs a create_model() method') - def to_dict(self) -> Dict: - """Convert the object to a dictionary representation.""" - return {**super().to_dict(), - **{"label": self.label,"meta_data": self.meta_data} - } - @property def label_full(self) -> str: return self.label From 2943ecadb1eb226832e9d5475739bbcd6f459096 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 4 Mar 2025 13:22:42 +0100 Subject: [PATCH 278/507] Unify the serialization and deserialization --- flixOpt/components.py | 30 ++++++++-------- flixOpt/flow_system.py | 4 +-- flixOpt/linear_converters.py | 46 ++++++++++++------------ flixOpt/structure.py | 70 ++++++++++++++++++------------------ 4 files changed, 74 insertions(+), 76 deletions(-) diff --git a/flixOpt/components.py b/flixOpt/components.py index 4bd346f4d..6865296f2 100644 --- a/flixOpt/components.py +++ b/flixOpt/components.py @@ -33,8 +33,8 @@ def __init__( inputs: List[Flow], outputs: List[Flow], on_off_parameters: OnOffParameters = None, - conversion_factors: List[Dict[Flow, NumericDataTS]] = None, - segmented_conversion_factors: Dict[Flow, List[Tuple[NumericDataTS, NumericDataTS]]] = None, + conversion_factors: List[Dict[str, NumericDataTS]] = None, + segmented_conversion_factors: Dict[str, List[Tuple[NumericDataTS, NumericDataTS]]] = None, meta_data: Optional[Dict] = None, ): """ @@ -83,12 +83,12 @@ def _plausibility_checks(self) -> None: for conversion_factor in self.conversion_factors: for flow in conversion_factor: - if flow not in (self.inputs + self.outputs): + if flow not in self.flows: raise Exception( - f'{self.label}: Flow {flow.label} in conversion_factors is not in inputs/outputs' + f'{self.label}: Flow {flow} in conversion_factors is not in inputs/outputs' ) if self.segmented_conversion_factors: - for flow in self.inputs + self.outputs: + for flow in self.flows.values(): if isinstance(flow.size, InvestParameters) and flow.size.fixed_size is None: raise Exception( f'segmented_conversion_factors (in {self.label_full}) and variable size ' @@ -104,14 +104,14 @@ def transform_data(self, flow_system: 'FlowSystem'): for flow, segments in self.segmented_conversion_factors.items(): segmented_conversion_factors[flow] = [ ( - flow_system.create_time_series(f'{flow.label_full}|Stützstelle|{idx}a', segment[0]), - flow_system.create_time_series(f'{flow.label_full}|Stützstelle|{idx}b', segment[1]), + flow_system.create_time_series(f'{self.flows[flow].label_full}|Stützstelle|{idx}a', segment[0]), + flow_system.create_time_series(f'{self.flows[flow].label_full}|Stützstelle|{idx}b', segment[1]), ) for idx, segment in enumerate(segments) ] self.segmented_conversion_factors = segmented_conversion_factors - def _transform_conversion_factors(self, flow_system: 'FlowSystem') -> List[Dict[Flow, TimeSeries]]: + def _transform_conversion_factors(self, flow_system: 'FlowSystem') -> List[Dict[str, TimeSeries]]: """macht alle Faktoren, die nicht TimeSeries sind, zu TimeSeries""" list_of_conversion_factors = [] for idx, conversion_factor in enumerate(self.conversion_factors): @@ -119,7 +119,7 @@ def _transform_conversion_factors(self, flow_system: 'FlowSystem') -> List[Dict[ for flow, values in conversion_factor.items(): # TODO: Might be better to use the label of the component instead of the flow transformed_dict[flow] = flow_system.create_time_series( - f'{flow.label_full}|conversion_factor{idx}', values + f'{self.flows[flow].label_full}|conversion_factor{idx}', values ) list_of_conversion_factors.append(transformed_dict) return list_of_conversion_factors @@ -403,16 +403,16 @@ def do_modeling(self): all_output_flows = set(self.element.outputs) # für alle linearen Gleichungen: - for i, conv_fact in enumerate(self.element.conversion_factors): - used_flows = set(conv_fact.keys()) + for i, conv_factors in enumerate(self.element.conversion_factors): + used_flows = set([self.element.flows[flow_label] for flow_label in conv_factors]) used_inputs: Set = all_input_flows & used_flows used_outputs: Set = all_output_flows & used_flows self.add( self._model.add_constraints( - sum([flow.model.flow_rate * conv_fact[flow].active_data for flow in used_inputs]) + sum([flow.model.flow_rate * conv_factors[flow.label].active_data for flow in used_inputs]) == - sum([flow.model.flow_rate * conv_fact[flow].active_data for flow in used_outputs]), + sum([flow.model.flow_rate * conv_factors[flow.label].active_data for flow in used_outputs]), name=f'{self.label_full}|conversion_{i}' ) ) @@ -421,10 +421,10 @@ def do_modeling(self): else: # TODO: Improve Inclusion of OnOffParameters. Instead of creating a Binary in every flow, the binary could only be part of the Segment itself segments: Dict[str, List[Tuple[NumericData, NumericData]]] = { - flow.model.flow_rate.name: [ + self.element.flows[flow].model.flow_rate.name: [ (ts1.active_data, ts2.active_data) for ts1, ts2 in self.element.segmented_conversion_factors[flow] ] - for flow in self.element.inputs + self.element.outputs + for flow in self.element.flows } linear_segments = MultipleSegmentsModel( self._model, self.label_of_element, segments, self.on_off.on if self.on_off is not None else None diff --git a/flixOpt/flow_system.py b/flixOpt/flow_system.py index 60534da89..28f8c5807 100644 --- a/flixOpt/flow_system.py +++ b/flixOpt/flow_system.py @@ -18,7 +18,7 @@ from .core import NumericData, NumericDataTS, TimeSeries, TimeSeriesCollection, TimeSeriesData from .effects import Effect, EffectCollection, EffectTimeSeries, EffectValuesDict, EffectValuesUser from .elements import Bus, Component, Flow -from .structure import Element, SystemModel, get_compact_representation, get_str_representation, class_registry +from .structure import Element, SystemModel, get_compact_representation, get_str_representation, CLASS_REGISTRY if TYPE_CHECKING: import pyvis @@ -290,7 +290,7 @@ def from_dict(cls, data: Dict) -> 'FlowSystem': ) flow_system.add_elements( - *[class_registry[comp['__class__']].from_dict(comp) for comp in data['components'].values()] + *[CLASS_REGISTRY[comp['__class__']].from_dict(comp) for comp in data['components'].values()] ) flow_system.transform_data() diff --git a/flixOpt/linear_converters.py b/flixOpt/linear_converters.py index 9c81eb43b..0ec1f2d21 100644 --- a/flixOpt/linear_converters.py +++ b/flixOpt/linear_converters.py @@ -47,7 +47,7 @@ def __init__( label, inputs=[Q_fu], outputs=[Q_th], - conversion_factors=[{Q_fu: eta, Q_th: 1}], + conversion_factors=[{Q_fu.label: eta, Q_th.label: 1}], on_off_parameters=on_off_parameters, meta_data=meta_data, ) @@ -58,12 +58,12 @@ def __init__( @property def eta(self): - return self.conversion_factors[0][self.Q_th] + return self.conversion_factors[0][self.Q_th.label] @eta.setter def eta(self, value): check_bounds(value, 'eta', self.label_full, 0, 1) - self.conversion_factors[0][self.Q_th] = value + self.conversion_factors[0][self.Q_th.label] = value @register_class_for_io @@ -96,7 +96,7 @@ def __init__( label, inputs=[P_el], outputs=[Q_th], - conversion_factors=[{P_el: eta, Q_th: 1}], + conversion_factors=[{P_el.label: eta, Q_th.label: 1}], on_off_parameters=on_off_parameters, meta_data=meta_data, ) @@ -107,12 +107,12 @@ def __init__( @property def eta(self): - return self.conversion_factors[0][self.Q_th] + return self.conversion_factors[0][self.Q_th.label] @eta.setter def eta(self, value): check_bounds(value, 'eta', self.label_full, 0, 1) - self.conversion_factors[0][self.Q_th] = value + self.conversion_factors[0][self.Q_th.label] = value @register_class_for_io @@ -144,7 +144,7 @@ def __init__( label, inputs=[P_el], outputs=[Q_th], - conversion_factors=[{P_el: COP, Q_th: 1}], + conversion_factors=[{P_el.label: COP, Q_th.label: 1}], on_off_parameters=on_off_parameters, meta_data=meta_data, ) @@ -157,12 +157,12 @@ def __init__( @property def COP(self): - return self.conversion_factors[0][self.Q_th] + return self.conversion_factors[0][self.Q_th.label] @COP.setter def COP(self, value): check_bounds(value, 'COP', self.label_full, 1, 20) - self.conversion_factors[0][self.Q_th] = value + self.conversion_factors[0][self.Q_th.label] = value @register_class_for_io @@ -195,7 +195,7 @@ def __init__( label, inputs=[P_el, Q_th], outputs=[], - conversion_factors=[{P_el: 1, Q_th: -specific_electricity_demand}], + conversion_factors=[{P_el.label: 1, Q_th.label: -specific_electricity_demand}], on_off_parameters=on_off_parameters, meta_data=meta_data, ) @@ -208,12 +208,12 @@ def __init__( @property def specific_electricity_demand(self): - return -self.conversion_factors[0][self.Q_th] + return -self.conversion_factors[0][self.Q_th.label] @specific_electricity_demand.setter def specific_electricity_demand(self, value): check_bounds(value, 'specific_electricity_demand', self.label_full, 0, 1) - self.conversion_factors[0][self.Q_th] = -value + self.conversion_factors[0][self.Q_th.label] = -value @register_class_for_io @@ -249,8 +249,8 @@ def __init__( meta_data : Optional[Dict] used to store more information about the element. Is not used internally, but saved in the results """ - heat = {Q_fu: eta_th, Q_th: 1} - electricity = {Q_fu: eta_el, P_el: 1} + heat = {Q_fu.label: eta_th, Q_th.label: 1} + electricity = {Q_fu.label: eta_el, P_el.label: 1} super().__init__( label, @@ -271,21 +271,21 @@ def __init__( @property def eta_th(self): - return self.conversion_factors[0][self.Q_fu] + return self.conversion_factors[0][self.Q_fu.label] @eta_th.setter def eta_th(self, value): check_bounds(value, 'eta_th', self.label_full, 0, 1) - self.conversion_factors[0][self.Q_fu] = value + self.conversion_factors[0][self.Q_fu.label] = value @property def eta_el(self): - return self.conversion_factors[1][self.Q_fu] + return self.conversion_factors[1][self.Q_fu.label] @eta_el.setter def eta_el(self, value): check_bounds(value, 'eta_el', self.label_full, 0, 1) - self.conversion_factors[1][self.Q_fu] = value + self.conversion_factors[1][self.Q_fu.label] = value @register_class_for_io @@ -318,8 +318,8 @@ def __init__( """ # super: - electricity = {P_el: COP, Q_th: 1} - heat_source = {Q_ab: COP / (COP - 1), Q_th: 1} + electricity = {P_el.label: COP, Q_th.label: 1} + heat_source = {Q_ab.label: COP / (COP - 1), Q_th.label: 1} super().__init__( label, @@ -339,13 +339,13 @@ def __init__( @property def COP(self): - return self.conversion_factors[0][self.Q_th] + return self.conversion_factors[0][self.Q_th.label] @COP.setter def COP(self, value): check_bounds(value, 'COP', self.label_full, 1, 20) - self.conversion_factors[0][self.Q_th] = value - self.conversion_factors[1][self.Q_th] = value / (value - 1) + self.conversion_factors[0][self.Q_th.label] = value + self.conversion_factors[1][self.Q_th.label] = value / (value - 1) def check_bounds( diff --git a/flixOpt/structure.py b/flixOpt/structure.py index 3f9a89600..997aa47a7 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -28,15 +28,15 @@ logger = logging.getLogger('flixOpt') -class_registry = {} +CLASS_REGISTRY = {} def register_class_for_io(cls): """Register a class for serialization/deserialization.""" name = cls.__name__ - if name in class_registry: + if name in CLASS_REGISTRY: raise ValueError(f'Class {name} already registered! Use a different Name for the class! ' f'This error should only happen in developement') - class_registry[name] = cls + CLASS_REGISTRY[name] = cls return cls @@ -173,43 +173,41 @@ def _serialize_dict(self, d): return {k: self._serialize_value(v) for k, v in d.items()} @classmethod - def from_dict(cls, data: Dict): - """Create an instance from a dictionary representation.""" - # Remove class name if present - data = data.copy() + def _deserialize_dict(cls, data: Dict) -> Union[Dict, 'Interface']: + if '__class__' in data: + class_name = data.pop('__class__') + try: + class_type = CLASS_REGISTRY[class_name] + if issubclass(class_type, Interface): + # Use _deserialize_dict to process the arguments + processed_data = {k: cls._deserialize_value(v) for k, v in data.items()} + return class_type(**processed_data) + else: + raise ValueError(f'Class "{class_name}" is not an Interface.') + except (AttributeError, KeyError) as e: + raise ValueError(f'Class "{class_name}" could not get reconstructed.') from e + else: + return {k: cls._deserialize_value(v) for k, v in data.items()} - # For child classes that need custom deserialization - init_args = cls._prepare_init_args(data) + @classmethod + def _deserialize_list(cls, data: List) -> List: + return [cls._deserialize_value(value) for value in data] - return cls(**init_args) + @classmethod + def _deserialize_value(cls, value: Any): + """Helper method to deserialize a value based on its type.""" + if value is None: + return None + elif isinstance(value, dict): + return cls._deserialize_dict(value) + elif isinstance(value, list): + return cls._deserialize_list(value) + return value @classmethod - def _prepare_init_args(cls, data: Dict): - """Recursively deserialize nested objects.""" - result = {} - - for key, value in data.items(): - if isinstance(value, dict) and '__class__' in value: - class_name = value.pop('__class__') - try: - class_type = class_registry[class_name] - if issubclass(class_registry[class_name], Interface): - result[key] = class_type.from_dict(value) - else: - raise ValueError(f'Class "{class_name}" is not an Interface.') - except (AttributeError, KeyError) as e: - raise ValueError(f'Class "{class_name}" could not get reconstructed.') from e - elif isinstance(value, dict): - result[key] = cls._prepare_init_args(value) - elif isinstance(value, list): - result[key] = [ - cls._prepare_init_args(item) if isinstance(item, dict) and '__class__' in item - else item - for item in value - ] - else: - result[key] = value - return result + def from_dict(cls, data: Dict) -> 'Interface': + """Create an instance from a dictionary representation.""" + return cls._deserialize_dict(data) def __repr__(self): # Get the constructor arguments and their current values From d01a50f94dcec1fada4ed9a4cec2369bc6bce79d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 4 Mar 2025 14:08:33 +0100 Subject: [PATCH 279/507] Unify the serialization and deserialization --- flixOpt/components.py | 1 + flixOpt/effects.py | 3 +-- flixOpt/features.py | 6 +++--- flixOpt/interface.py | 31 +++---------------------------- flixOpt/linear_converters.py | 27 +++++++-------------------- 5 files changed, 15 insertions(+), 53 deletions(-) diff --git a/flixOpt/components.py b/flixOpt/components.py index 6865296f2..dc9456c9e 100644 --- a/flixOpt/components.py +++ b/flixOpt/components.py @@ -217,6 +217,7 @@ def __init__( self.eta_charge: NumericDataTS = eta_charge self.eta_discharge: NumericDataTS = eta_discharge self.relative_loss_per_hour: NumericDataTS = relative_loss_per_hour + self.prevent_simultaneous_charge_and_discharge = prevent_simultaneous_charge_and_discharge def create_model(self, model: SystemModel) -> 'StorageModel': self.model = StorageModel(model, self) diff --git a/flixOpt/effects.py b/flixOpt/effects.py index 340e5a653..4d695633c 100644 --- a/flixOpt/effects.py +++ b/flixOpt/effects.py @@ -208,6 +208,7 @@ def __init__(self, *effects: List[Effect]): self.add_effects(*effects) def create_model(self, model: SystemModel) -> 'EffectCollectionModel': + self._plausibility_checks() self.model = EffectCollectionModel(model, self) return self.model @@ -222,8 +223,6 @@ def add_effects(self, *effects: Effect) -> None: self._effects[effect.label] = effect logger.info(f'Registered new Effect: {effect.label}') - self._plausibility_checks() - def create_effect_values_dict(self, effect_values_user: EffectValuesUser) -> Optional[EffectValuesDict]: """ Converts effect values into a dictionary. If a scalar is provided, it is associated with a default effect type. diff --git a/flixOpt/features.py b/flixOpt/features.py index d12167332..7cd0b6013 100644 --- a/flixOpt/features.py +++ b/flixOpt/features.py @@ -940,7 +940,7 @@ def __init__( model: SystemModel, label_of_element: str, variable_segments: Tuple[linopy.Variable, List[Tuple[Scalar, Scalar]]], - share_segments: Dict['Effect', List[Tuple[Scalar, Scalar]]], + share_segments: Dict[str, List[Tuple[Scalar, Scalar]]], can_be_outside_segments: Optional[Union[bool, linopy.Variable]], label: str = 'SegmentedShares', ): @@ -959,8 +959,8 @@ def do_modeling(self): self._shares = { effect: self.add(self._model.add_variables( coords=self._model.coords if self._as_tme_series else None, - name=f'{self.label_full}|{effect.label}'), - f'{effect.label}' + name=f'{self.label_full}|{effect}'), + f'{effect}' ) for effect in self._share_segments } diff --git a/flixOpt/interface.py b/flixOpt/interface.py index a6a60c2fc..a82451d22 100644 --- a/flixOpt/interface.py +++ b/flixOpt/interface.py @@ -10,7 +10,7 @@ from .config import CONFIG from .core import NumericData, NumericDataTS, Scalar -from .structure import Element, Interface +from .structure import Element, Interface, register_class_for_io if TYPE_CHECKING: # for type checking and preventing circular imports from .flow_system import FlowSystem @@ -21,6 +21,7 @@ logger = logging.getLogger('flixOpt') +@register_class_for_io class InvestParameters(Interface): """ collects arguments for invest-stuff @@ -88,18 +89,6 @@ def transform_data(self, flow_system: 'FlowSystem'): self.divest_effects = flow_system.effects.create_effect_values_dict(self.divest_effects) self.specific_effects = flow_system.effects.create_effect_values_dict(self.specific_effects) - def to_dict(self) -> Dict: - return { - "divest_effects": self.divest_effects, - "effects_in_segments": self.effects_in_segments, - "fix_effects": self.fix_effects, - "fixed_size": self.fixed_size, - "maximum_size": self._maximum_size, - "minimum_size": self._minimum_size, - "optional": self.optional, - "specific_effects": self.specific_effects, - } - @property def minimum_size(self): return self.fixed_size or self._minimum_size @@ -108,7 +97,7 @@ def minimum_size(self): def maximum_size(self): return self.fixed_size or self._maximum_size - +@register_class_for_io class OnOffParameters(Interface): def __init__( self, @@ -185,20 +174,6 @@ def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): f'{name_prefix}|consecutive_off_hours_max', self.consecutive_off_hours_max ) - def to_dict(self): - return { - "effects_per_switch_on": self.effects_per_switch_on, - "effects_per_running_hour": self.effects_per_running_hour, - "on_hours_total_min": self.on_hours_total_min, - "on_hours_total_max": self.on_hours_total_max, - "consecutive_on_hours_min": self.consecutive_on_hours_min, - "consecutive_on_hours_max": self.consecutive_on_hours_max, - "consecutive_off_hours_min": self.consecutive_off_hours_min, - "consecutive_off_hours_max": self.consecutive_off_hours_max, - "switch_on_total_max": self.switch_on_total_max, - "force_switch_on": self.force_switch_on, - } - @property def use_off(self) -> bool: """Determines wether the OFF Variable is needed or not""" diff --git a/flixOpt/linear_converters.py b/flixOpt/linear_converters.py index 0ec1f2d21..4f7d9ce2d 100644 --- a/flixOpt/linear_converters.py +++ b/flixOpt/linear_converters.py @@ -51,19 +51,17 @@ def __init__( on_off_parameters=on_off_parameters, meta_data=meta_data, ) - self.Q_fu = Q_fu self.Q_th = Q_th - check_bounds(eta, 'eta', self.label_full, 0, 1) @property def eta(self): - return self.conversion_factors[0][self.Q_th.label] + return self.conversion_factors[0][self.Q_fu.label] @eta.setter def eta(self, value): check_bounds(value, 'eta', self.label_full, 0, 1) - self.conversion_factors[0][self.Q_th.label] = value + self.conversion_factors[0][self.Q_fu.label] = value @register_class_for_io @@ -103,16 +101,15 @@ def __init__( self.P_el = P_el self.Q_th = Q_th - check_bounds(eta, 'eta', self.label_full, 0, 1) @property def eta(self): - return self.conversion_factors[0][self.Q_th.label] + return self.conversion_factors[0][self.P_el.label] @eta.setter def eta(self, value): check_bounds(value, 'eta', self.label_full, 0, 1) - self.conversion_factors[0][self.Q_th.label] = value + self.conversion_factors[0][self.P_el.label] = value @register_class_for_io @@ -148,21 +145,18 @@ def __init__( on_off_parameters=on_off_parameters, meta_data=meta_data, ) - - self.COP = COP self.P_el = P_el self.Q_th = Q_th - - check_bounds(COP, 'COP', self.label_full, 1, 20) + self.COP = COP @property def COP(self): - return self.conversion_factors[0][self.Q_th.label] + return self.conversion_factors[0][self.P_el.label] @COP.setter def COP(self, value): check_bounds(value, 'COP', self.label_full, 1, 20) - self.conversion_factors[0][self.Q_th.label] = value + self.conversion_factors[0][self.P_el.label] = value @register_class_for_io @@ -200,7 +194,6 @@ def __init__( meta_data=meta_data, ) - self.specific_electricity_demand = specific_electricity_demand self.P_el = P_el self.Q_th = Q_th @@ -265,8 +258,6 @@ def __init__( self.P_el = P_el self.Q_th = Q_th - check_bounds(eta_th, 'eta_th', self.label_full, 0, 1) - check_bounds(eta_el, 'eta_el', self.label_full, 0, 1) check_bounds(eta_el + eta_th, 'eta_th+eta_el', self.label_full, 0, 1) @property @@ -329,14 +320,10 @@ def __init__( on_off_parameters=on_off_parameters, meta_data=meta_data, ) - - self.COP = COP self.P_el = P_el self.Q_ab = Q_ab self.Q_th = Q_th - check_bounds(COP, 'COP', self.label_full, 1, 20) - @property def COP(self): return self.conversion_factors[0][self.Q_th.label] From 97a80e2b1d42e314579f3af5a05a0663b2bcddd3 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 4 Mar 2025 14:34:15 +0100 Subject: [PATCH 280/507] Save FlowSystem as dataset --- flixOpt/flow_system.py | 26 ++++++++++++++++++++++++++ flixOpt/io.py | 13 +++++++++++++ 2 files changed, 39 insertions(+) diff --git a/flixOpt/flow_system.py b/flixOpt/flow_system.py index 28f8c5807..a43bd7c64 100644 --- a/flixOpt/flow_system.py +++ b/flixOpt/flow_system.py @@ -13,6 +13,7 @@ import pandas as pd from rich.pretty import Pretty from rich.console import Console +import xarray as xr from . import io from .core import NumericData, NumericDataTS, TimeSeries, TimeSeriesCollection, TimeSeriesData @@ -269,6 +270,31 @@ def to_dict(self, data_mode: Literal['data', 'name', 'stats'] = 'data') -> Dict: return io.remove_none_and_empty(io.replace_timeseries(data, data_mode)) return io.replace_timeseries(data, data_mode) + def as_dataset(self) -> xr.Dataset: + ds = self.time_series_collection.to_dataset() + ds.attrs = self.to_dict(data_mode='name') + return ds + + @classmethod + def from_dataset(cls, ds: xr.Dataset): + timesteps_extra = pd.DatetimeIndex(ds.attrs['timesteps_extra'], name='time') + hours_of_last_timestep = TimeSeriesCollection.create_hours_per_timestep( + timesteps_extra, None + ).isel(time=-1).item() + + flow_system = FlowSystem(timesteps=timesteps_extra[:-1], + hours_of_last_timestep=hours_of_last_timestep, + hours_of_previous_timesteps=ds.attrs['hours_of_previous_timesteps'], + periods=pd.Index(ds.attrs['periods'], name='period') if ds.attrs.get('periods') is not None else None) + + structure = io.insert_timeseries({key: ds.attrs[key] for key in ['components', 'buses', 'effects']}, ds) + flow_system.add_elements( + * [Bus.from_dict(bus) for bus in structure['buses'].values()] + + [Effect.from_dict(effect) for effect in structure['effects'].values()] + + [CLASS_REGISTRY[comp['__class__']].from_dict(comp) for comp in structure['components'].values()] + ) + return flow_system + @classmethod def from_dict(cls, data: Dict) -> 'FlowSystem': timesteps_extra = pd.DatetimeIndex(data['timesteps_extra'], name='time') diff --git a/flixOpt/io.py b/flixOpt/io.py index 04c818d16..777ce3976 100644 --- a/flixOpt/io.py +++ b/flixOpt/io.py @@ -4,6 +4,8 @@ import pathlib from typing import Dict, Union, Literal +import xarray as xr + from .flow_system import FlowSystem from .core import TimeSeries @@ -50,6 +52,17 @@ def replace_timeseries(obj, mode: Literal['name', 'stats'] = 'name'): else: return obj +def insert_timeseries(obj, ds: xr.Dataset): + """Recursively inserts TimeSeries objects into a dataset.""" + if isinstance(obj, dict): + return {k: insert_timeseries(v, ds) for k, v in obj.items()} + elif isinstance(obj, list): + return [insert_timeseries(v, ds) for v in obj] + elif isinstance(obj, str) and obj.startswith("::::"): + return ds[obj[4:]] + else: + return obj + def remove_none_and_empty(obj): """Recursively removes None and empty dicts and lists values from a dictionary or list.""" From 54c961f14669108124bc05223c32aebc6517720d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 4 Mar 2025 14:48:54 +0100 Subject: [PATCH 281/507] Check if the last timestep is nan and change function name --- flixOpt/flow_system.py | 2 +- flixOpt/io.py | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/flixOpt/flow_system.py b/flixOpt/flow_system.py index a43bd7c64..52239f653 100644 --- a/flixOpt/flow_system.py +++ b/flixOpt/flow_system.py @@ -287,7 +287,7 @@ def from_dataset(cls, ds: xr.Dataset): hours_of_previous_timesteps=ds.attrs['hours_of_previous_timesteps'], periods=pd.Index(ds.attrs['periods'], name='period') if ds.attrs.get('periods') is not None else None) - structure = io.insert_timeseries({key: ds.attrs[key] for key in ['components', 'buses', 'effects']}, ds) + structure = io.insert_dataarray({key: ds.attrs[key] for key in ['components', 'buses', 'effects']}, ds) flow_system.add_elements( * [Bus.from_dict(bus) for bus in structure['buses'].values()] + [Effect.from_dict(effect) for effect in structure['effects'].values()] diff --git a/flixOpt/io.py b/flixOpt/io.py index 777ce3976..6db6d1334 100644 --- a/flixOpt/io.py +++ b/flixOpt/io.py @@ -52,14 +52,17 @@ def replace_timeseries(obj, mode: Literal['name', 'stats'] = 'name'): else: return obj -def insert_timeseries(obj, ds: xr.Dataset): +def insert_dataarray(obj, ds: xr.Dataset): """Recursively inserts TimeSeries objects into a dataset.""" if isinstance(obj, dict): - return {k: insert_timeseries(v, ds) for k, v in obj.items()} + return {k: insert_dataarray(v, ds) for k, v in obj.items()} elif isinstance(obj, list): - return [insert_timeseries(v, ds) for v in obj] + return [insert_dataarray(v, ds) for v in obj] elif isinstance(obj, str) and obj.startswith("::::"): - return ds[obj[4:]] + da = ds[obj[4:]] + if da.isel(time=-1).isnull(): + return da.isel(time=slice(0, -1)) + return da else: return obj From 3c0cabe78fa01d3816b04562603cd68e726cd669 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 4 Mar 2025 14:56:12 +0100 Subject: [PATCH 282/507] ruff checks --- flixOpt/core.py | 2 +- flixOpt/elements.py | 1 + flixOpt/flow_system.py | 11 ++++++----- flixOpt/io.py | 4 ++-- flixOpt/linear_converters.py | 8 ++++---- flixOpt/structure.py | 2 +- 6 files changed, 15 insertions(+), 13 deletions(-) diff --git a/flixOpt/core.py b/flixOpt/core.py index 463530c45..9f0b7a693 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -8,7 +8,7 @@ import logging import pathlib from collections import Counter -from typing import Any, Dict, List, Literal, Optional, Tuple, Union, Iterator +from typing import Any, Dict, Iterator, List, Literal, Optional, Tuple, Union import numpy as np import pandas as pd diff --git a/flixOpt/elements.py b/flixOpt/elements.py index dace69297..a2c9274ee 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -222,6 +222,7 @@ def __init__( f'Bus {bus.label} is passed as a Bus object to {self.label}. This is deprecated and will be removed ' f'in the future. Add the Bus to the FlowSystem instead and pass its label to the Flow.', UserWarning, + stacklevel=1, ) self._bus_object = bus else: diff --git a/flixOpt/flow_system.py b/flixOpt/flow_system.py index 52239f653..4b01459d6 100644 --- a/flixOpt/flow_system.py +++ b/flixOpt/flow_system.py @@ -2,24 +2,24 @@ This module contains the FlowSystem class, which is used to collect instances of many other classes by the end User. """ -from io import StringIO import json import logging import pathlib import warnings +from io import StringIO from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Tuple, Union import numpy as np import pandas as pd -from rich.pretty import Pretty -from rich.console import Console import xarray as xr +from rich.console import Console +from rich.pretty import Pretty from . import io from .core import NumericData, NumericDataTS, TimeSeries, TimeSeriesCollection, TimeSeriesData from .effects import Effect, EffectCollection, EffectTimeSeries, EffectValuesDict, EffectValuesUser from .elements import Bus, Component, Flow -from .structure import Element, SystemModel, get_compact_representation, get_str_representation, CLASS_REGISTRY +from .structure import CLASS_REGISTRY, Element, SystemModel, get_compact_representation, get_str_representation if TYPE_CHECKING: import pyvis @@ -117,7 +117,8 @@ def _connect_network(self): f'The Bus {flow._bus_object.label} was added to the FlowSystem from {flow.label_full}.' f'This is deprecated and will be removed in the future. ' f'Please pass the Bus.label to the Flow and the Bus to the FlowSystem instead.', - UserWarning) + UserWarning, + stacklevel=1) # Connect Buses bus = self.buses.get(flow.bus) diff --git a/flixOpt/io.py b/flixOpt/io.py index 6db6d1334..4e939eb5d 100644 --- a/flixOpt/io.py +++ b/flixOpt/io.py @@ -2,12 +2,12 @@ import json import logging import pathlib -from typing import Dict, Union, Literal +from typing import Dict, Literal, Union import xarray as xr -from .flow_system import FlowSystem from .core import TimeSeries +from .flow_system import FlowSystem logger = logging.getLogger('flixOpt') diff --git a/flixOpt/linear_converters.py b/flixOpt/linear_converters.py index 4f7d9ce2d..cbb33772e 100644 --- a/flixOpt/linear_converters.py +++ b/flixOpt/linear_converters.py @@ -150,11 +150,11 @@ def __init__( self.COP = COP @property - def COP(self): + def COP(self): # noqa: N802 return self.conversion_factors[0][self.P_el.label] @COP.setter - def COP(self, value): + def COP(self, value): # noqa: N802 check_bounds(value, 'COP', self.label_full, 1, 20) self.conversion_factors[0][self.P_el.label] = value @@ -325,11 +325,11 @@ def __init__( self.Q_th = Q_th @property - def COP(self): + def COP(self): # noqa: N802 return self.conversion_factors[0][self.Q_th.label] @COP.setter - def COP(self, value): + def COP(self, value): # noqa: N802 check_bounds(value, 'COP', self.label_full, 1, 20) self.conversion_factors[0][self.Q_th.label] = value self.conversion_factors[1][self.Q_th.label] = value / (value - 1) diff --git a/flixOpt/structure.py b/flixOpt/structure.py index 997aa47a7..2e875740d 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -142,7 +142,7 @@ def to_dict(self) -> Dict: # Get the constructor parameters init_params = inspect.signature(self.__init__).parameters - for name, param in init_params.items(): + for name in init_params: if name == 'self': continue From 76415872cd017d9ec568eebc4ec5d2f5f904e46d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 4 Mar 2025 14:56:45 +0100 Subject: [PATCH 283/507] Update example to use labels --- examples/01_Simple/simple_example.py | 2 +- examples/02_Complex/complex_example.py | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/examples/01_Simple/simple_example.py b/examples/01_Simple/simple_example.py index 42752f6a7..e806f5c6d 100644 --- a/examples/01_Simple/simple_example.py +++ b/examples/01_Simple/simple_example.py @@ -83,7 +83,7 @@ # Gas Source: Gas tariff source with associated costs and CO2 emissions gas_source = fx.Source( label='Gastarif', - source=fx.Flow(label='Q_Gas', bus='Gas', size=1000, effects_per_flow_hour={costs: 0.04, CO2: 0.3}), + source=fx.Flow(label='Q_Gas', bus='Gas', size=1000, effects_per_flow_hour={costs.label: 0.04, CO2.label: 0.3}), ) # Power Sink: Represents the export of electricity to the grid diff --git a/examples/02_Complex/complex_example.py b/examples/02_Complex/complex_example.py index 81951fe46..0379f1846 100644 --- a/examples/02_Complex/complex_example.py +++ b/examples/02_Complex/complex_example.py @@ -50,7 +50,7 @@ Gaskessel = fx.linear_converters.Boiler( 'Kessel', eta=0.5, # Efficiency ratio - on_off_parameters=fx.OnOffParameters(effects_per_running_hour={Costs: 0, CO2: 1000}), # CO2 emissions per hour + on_off_parameters=fx.OnOffParameters(effects_per_running_hour={Costs.label: 0, CO2.label: 1000}), # CO2 emissions per hour Q_th=fx.Flow( label='Q_th', # Thermal output bus='Fernwärme', # Linked bus @@ -58,7 +58,7 @@ fix_effects=1000, # Fixed investment costs fixed_size=50, # Fixed size optional=False, # Forced investment - specific_effects={Costs: 10, PE: 2}, # Specific costs + specific_effects={Costs.label: 10, PE.label: 2}, # Specific costs ), load_factor_max=1.0, # Maximum load factor (50 kW) load_factor_min=0.1, # Minimum load factor (5 kW) @@ -98,9 +98,9 @@ Q_th = fx.Flow('Q_th', bus='Fernwärme') Q_fu = fx.Flow('Q_fu', bus='Gas') segmented_conversion_factors = { - P_el: [(5, 30), (40, 60)], # Similar to eta_th, each factor here can be an array - Q_th: [(6, 35), (45, 100)], - Q_fu: [(12, 70), (90, 200)], + P_el.label: [(5, 30), (40, 60)], # Similar to eta_th, each factor here can be an array + Q_th.label: [(6, 35), (45, 100)], + Q_fu.label: [(12, 70), (90, 200)], } bhkw_2 = fx.LinearConverter( @@ -116,8 +116,8 @@ segmented_investment_effects = ( [(5, 25), (25, 100)], # Investment size { - Costs: [(50, 250), (250, 800)], # Investment costs - PE: [(5, 25), (25, 100)], # Primary energy costs + Costs.label: [(50, 250), (250, 800)], # Investment costs + PE.label: [(5, 25), (25, 100)], # Primary energy costs }, ) @@ -158,7 +158,7 @@ 'Q_Gas', bus='Gas', # Gas source size=1000, # Nominal size - effects_per_flow_hour={Costs: 0.04, CO2: 0.3}, + effects_per_flow_hour={Costs.label: 0.04, CO2.label: 0.3}, ), ) From 47e2f70bef1bd11e7e5e314395c405599543576a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 4 Mar 2025 15:40:35 +0100 Subject: [PATCH 284/507] Handle numpy scalars in DataConverter --- flixOpt/core.py | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/flixOpt/core.py b/flixOpt/core.py index 9f0b7a693..ce4e69d43 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -51,7 +51,7 @@ def as_dataarray(data: NumericData, time: pd.DatetimeIndex, period: Optional[pd. coords = [time] dims = ['time'] - if isinstance(data, (int, float)): + if isinstance(data, (int, float, np.integer, np.floating)): return DataConverter._handle_scalar(data, coords, dims) elif isinstance(data, pd.DataFrame): return DataConverter._handle_dataframe(data, coords, dims) @@ -447,7 +447,6 @@ def create_time_series( ) -> TimeSeries: """ Creates a TimeSeries from the given data and adds it to the time_series_data. - If the data already is a TimeSeries, nothing happens. Parameters ---------- @@ -464,12 +463,6 @@ def create_time_series( The created TimeSeries. """ - if isinstance(data, TimeSeries): - if data not in self.time_series_data: - self._add_time_series(data, extra_timestep) - data.restore_data() - return data - time_series = TimeSeries.from_datasource( name=name, data=data if not isinstance(data, TimeSeriesData) else data.data, @@ -482,7 +475,7 @@ def create_time_series( if isinstance(data, TimeSeriesData): data.label = time_series.name # Connecting User_time_series to TimeSeries - self._add_time_series(time_series, extra_timestep) + self.add_time_series(time_series, extra_timestep) return time_series def calculate_aggregation_weights(self) -> Dict[str, float]: @@ -632,7 +625,7 @@ def align_dimensions( return timesteps, timesteps_extra, hours_per_step, hours_of_previous_timesteps, periods - def _add_time_series(self, time_series: TimeSeries, extra_timestep: bool): + def add_time_series(self, time_series: TimeSeries, extra_timestep: bool): self.time_series_data.append(time_series) if extra_timestep: self._time_series_data_with_extra_step.append(time_series) @@ -657,9 +650,11 @@ def to_dataframe(self, filtered: Literal['all', 'constant', 'non_constant'] = 'n else: raise ValueError('Not supported argument for "filtered".') - def to_dataset(self) -> xr.Dataset: + def to_dataset(self, include_constants: bool = True) -> xr.Dataset: """Combine all stored DataArrays into a single Dataset.""" - ds = xr.Dataset({time_series.name: time_series.active_data for time_series in self.time_series_data}) + ds = xr.Dataset({time_series.name: time_series.active_data + for time_series in self.time_series_data + if not time_series.all_equal or (time_series.all_equal and include_constants)}) ds.attrs.update({ "timesteps": f"{self.all_timesteps[0]} ... {self.all_timesteps[-1]} | len={len(self.timesteps)}", From 05915123581f7c89d0cb2effd3060c48db884cd9 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 4 Mar 2025 15:40:59 +0100 Subject: [PATCH 285/507] Improve dataset creation for export, minimizing filesize --- flixOpt/flow_system.py | 6 +++--- flixOpt/io.py | 8 ++++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/flixOpt/flow_system.py b/flixOpt/flow_system.py index 4b01459d6..84d8010db 100644 --- a/flixOpt/flow_system.py +++ b/flixOpt/flow_system.py @@ -266,13 +266,13 @@ def to_dict(self, data_mode: Literal['data', 'name', 'stats'] = 'data') -> Dict: "hours_of_previous_timesteps": self.time_series_collection.hours_of_previous_timesteps, } if data_mode == 'data': - return data + return io.replace_timeseries(data, 'data') elif data_mode == 'stats': return io.remove_none_and_empty(io.replace_timeseries(data, data_mode)) return io.replace_timeseries(data, data_mode) - def as_dataset(self) -> xr.Dataset: - ds = self.time_series_collection.to_dataset() + def as_dataset(self, constants_in_dataset: bool = False) -> xr.Dataset: + ds = self.time_series_collection.to_dataset(include_constants=constants_in_dataset) ds.attrs = self.to_dict(data_mode='name') return ds diff --git a/flixOpt/io.py b/flixOpt/io.py index 4e939eb5d..1cd1f6651 100644 --- a/flixOpt/io.py +++ b/flixOpt/io.py @@ -36,17 +36,21 @@ def structure_to_json(flow_system: FlowSystem, path: Union[str, pathlib.Path] = json.dump(_results_structure(flow_system), f, indent=4, ensure_ascii=False) -def replace_timeseries(obj, mode: Literal['name', 'stats'] = 'name'): +def replace_timeseries(obj, mode: Literal['name', 'stats', 'data'] = 'name'): """Recursively replaces TimeSeries objects with their names prefixed by '::::'.""" if isinstance(obj, dict): return {k: replace_timeseries(v, mode) for k, v in obj.items()} elif isinstance(obj, list): return [replace_timeseries(v, mode) for v in obj] elif isinstance(obj, TimeSeries): # Adjust this based on the actual class - if mode == 'name': + if obj.all_equal: + return obj.active_data.values[0] + elif mode == 'name': return f"::::{obj.name}" elif mode == 'stats': return obj.stats + elif mode == 'data': + return obj else: raise ValueError(f"Invalid mode {mode}") else: From fcc966a58e493d6ef90b008203138f343bcec89b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 4 Mar 2025 15:55:12 +0100 Subject: [PATCH 286/507] Reorder classes in FlowSystem --- flixOpt/flow_system.py | 377 +++++++++++++++++++---------------------- 1 file changed, 179 insertions(+), 198 deletions(-) diff --git a/flixOpt/flow_system.py b/flixOpt/flow_system.py index 84d8010db..9c87a1328 100644 --- a/flixOpt/flow_system.py +++ b/flixOpt/flow_system.py @@ -68,6 +68,54 @@ def __init__( self.effects: EffectCollection = EffectCollection() self.model: Optional[SystemModel] = None + @classmethod + def from_dataset(cls, ds: xr.Dataset): + timesteps_extra = pd.DatetimeIndex(ds.attrs['timesteps_extra'], name='time') + hours_of_last_timestep = TimeSeriesCollection.create_hours_per_timestep( + timesteps_extra, None + ).isel(time=-1).item() + + flow_system = FlowSystem(timesteps=timesteps_extra[:-1], + hours_of_last_timestep=hours_of_last_timestep, + hours_of_previous_timesteps=ds.attrs['hours_of_previous_timesteps'], + periods=pd.Index(ds.attrs['periods'], name='period') if ds.attrs.get('periods') is not None else None) + + structure = io.insert_dataarray({key: ds.attrs[key] for key in ['components', 'buses', 'effects']}, ds) + flow_system.add_elements( + * [Bus.from_dict(bus) for bus in structure['buses'].values()] + + [Effect.from_dict(effect) for effect in structure['effects'].values()] + + [CLASS_REGISTRY[comp['__class__']].from_dict(comp) for comp in structure['components'].values()] + ) + return flow_system + + @classmethod + def from_dict(cls, data: Dict) -> 'FlowSystem': + timesteps_extra = pd.DatetimeIndex(data['timesteps_extra'], name='time') + hours_of_last_timestep = TimeSeriesCollection.create_hours_per_timestep( + timesteps_extra, None + ).isel(time=-1).item() + + flow_system = FlowSystem(timesteps=timesteps_extra[:-1], + hours_of_last_timestep=hours_of_last_timestep, + hours_of_previous_timesteps=data['hours_of_previous_timesteps'], + periods=pd.Index(data['periods'], name='period') if data.get('periods') is not None else None) + + flow_system.add_elements( + *[Bus.from_dict(bus) for bus in data['buses'].values()] + ) + + flow_system.add_elements( + *[Effect.from_dict(effect) for effect in data['effects'].values()] + ) + + flow_system.add_elements( + *[CLASS_REGISTRY[comp['__class__']].from_dict(comp) for comp in data['components'].values()] + ) + + flow_system.transform_data() + + return flow_system + def add_elements(self, *elements: Element) -> None: """ add all modeling elements, like storages, boilers, heatpumps, buses, ... @@ -88,155 +136,11 @@ def add_elements(self, *elements: Element) -> None: else: raise Exception('argument is not instance of a modeling Element (Element)') - def _add_effects(self, *args: Effect) -> None: - self.effects.add_effects(*args) - - def _add_components(self, *components: Component) -> None: - for new_component in list(components): - logger.info(f'Registered new Component: {new_component.label}') - self._check_if_element_is_unique(new_component) # check if already exists: - self.components[new_component.label] = new_component # Add to existing components - - def _add_buses(self, *buses: Bus): - for new_bus in list(buses): - logger.info(f'Registered new Bus: {new_bus.label}') - self._check_if_element_is_unique(new_bus) # check if already exists: - self.buses[new_bus.label] = new_bus # Add to existing components - - def _connect_network(self): - """Connects the network of components and buses. Can be rerun without changes if no elements were added""" - for component in self.components.values(): - for flow in component.inputs + component.outputs: - flow.component = component.label_full - flow.is_input_in_component = True if flow in component.inputs else False - - # Add Bus if not already added (deprecated) - if flow._bus_object is not None and flow._bus_object not in self.buses.values(): - self._add_buses(flow._bus_object) - warnings.warn( - f'The Bus {flow._bus_object.label} was added to the FlowSystem from {flow.label_full}.' - f'This is deprecated and will be removed in the future. ' - f'Please pass the Bus.label to the Flow and the Bus to the FlowSystem instead.', - UserWarning, - stacklevel=1) - - # Connect Buses - bus = self.buses.get(flow.bus) - if bus is None: - raise KeyError(f'Bus {flow.bus} not found in the FlowSystem, but used by "{flow.label_full}". ' - f'Please add it first.') - if flow.is_input_in_component and flow not in bus.outputs: - bus.outputs.append(flow) - elif not flow.is_input_in_component and flow not in bus.inputs: - bus.inputs.append(flow) - - def transform_data(self): - self._connect_network() - for element in self.all_elements.values(): - element.transform_data(self) - - def create_time_series( - self, - name: str, - data: Optional[Union[NumericData, TimeSeriesData, TimeSeries]], - extra_timestep: bool = False, - ) -> Optional[TimeSeries]: - """ - Tries to create a TimeSeries from NumericData Data and adds it to the time_series_collection - If the data already is a TimeSeries, nothing happens and the TimeSeries gets reset and returned - If the data is a TimeSeriesData, it is converted to a TimeSeries, and the aggregation weights are applied. - If the data is None, nothing happens. - """ - - if data is None: - return None - elif isinstance(data, TimeSeries): - data.restore_data() - if data in self.time_series_collection: - return data - return self.time_series_collection.create_time_series( - data=data.active_data, - name=name, - extra_timestep=extra_timestep - ) - return self.time_series_collection.create_time_series( - data=data, - name=name, - extra_timestep=extra_timestep - ) - - def create_effect_time_series(self, - label_prefix: Optional[str], - effect_values: EffectValuesUser, - label_suffix: Optional[str] = None, - ) -> Optional[EffectTimeSeries]: - """ - Transform EffectValues to EffectTimeSeries. - Creates a TimeSeries for each key in the nested_values dictionary, using the value as the data. - - The resulting label of the TimeSeries is the label of the parent_element, - followed by the label of the Effect in the nested_values and the label_suffix. - If the key in the EffectValues is None, the alias 'Standard_Effect' is used - """ - effect_values: Optional[EffectValuesDict] = self.effects.create_effect_values_dict(effect_values) - if effect_values is None: - return None - - return { - effect: self.create_time_series( - '|'.join(filter(None, [label_prefix, effect, label_suffix])), - value - ) - for effect, value in effect_values.items() - } - - def network_infos(self) -> Tuple[Dict[str, Dict[str, str]], Dict[str, Dict[str, str]]]: - self._connect_network() - nodes = { - node.label_full: { - 'label': node.label, - 'class': 'Bus' if isinstance(node, Bus) else 'Component', - 'infos': node.__str__(), - } - for node in list(self.components.values()) + list(self.buses.values()) - } - - edges = { - flow.label_full: { - 'label': flow.label, - 'start': flow.bus if flow.is_input_in_component else flow.component, - 'end': flow.component if flow.is_input_in_component else flow.bus, - 'infos': flow.__str__(), - } - for flow in self.flows.values() - } - - return nodes, edges - - def infos(self, use_numpy=True, use_element_label=False) -> Dict: - infos = { - 'Components': { - comp.label: comp.infos(use_numpy, use_element_label) - for comp in sorted(self.components.values(), key=lambda component: component.label.upper()) - }, - 'Buses': { - bus.label: bus.infos(use_numpy, use_element_label) - for bus in sorted(self.buses.values(), key=lambda bus: bus.label.upper()) - }, - 'Effects': { - effect.label: effect.infos(use_numpy, use_element_label) - for effect in sorted(self.effects, key=lambda effect: effect.label.upper()) - }, - } - return infos - - def infos_compact(self): - return get_compact_representation(self.infos(use_numpy=True, use_element_label=True)), - def to_json(self, path: Union[str, pathlib.Path]): """ Saves the flow system to a json file. - This not meant to be reloaded and recreate the object, but rather used to document or compare the object. + This not meant to be reloaded and recreate the object, + but rather used to document or compare the flow_system to others. Parameters: ----------- @@ -244,9 +148,9 @@ def to_json(self, path: Union[str, pathlib.Path]): The path to the json file. """ with open(path, 'w', encoding='utf-8') as f: - json.dump(self.infos_compact(), f, indent=4, ensure_ascii=False) + json.dump(self.as_dict('stats'), f, indent=4, ensure_ascii=False) - def to_dict(self, data_mode: Literal['data', 'name', 'stats'] = 'data') -> Dict: + def as_dict(self, data_mode: Literal['data', 'name', 'stats'] = 'data') -> Dict: """Convert the object to a dictionary representation.""" data = { "components": { @@ -273,57 +177,9 @@ def to_dict(self, data_mode: Literal['data', 'name', 'stats'] = 'data') -> Dict: def as_dataset(self, constants_in_dataset: bool = False) -> xr.Dataset: ds = self.time_series_collection.to_dataset(include_constants=constants_in_dataset) - ds.attrs = self.to_dict(data_mode='name') + ds.attrs = self.as_dict(data_mode='name') return ds - @classmethod - def from_dataset(cls, ds: xr.Dataset): - timesteps_extra = pd.DatetimeIndex(ds.attrs['timesteps_extra'], name='time') - hours_of_last_timestep = TimeSeriesCollection.create_hours_per_timestep( - timesteps_extra, None - ).isel(time=-1).item() - - flow_system = FlowSystem(timesteps=timesteps_extra[:-1], - hours_of_last_timestep=hours_of_last_timestep, - hours_of_previous_timesteps=ds.attrs['hours_of_previous_timesteps'], - periods=pd.Index(ds.attrs['periods'], name='period') if ds.attrs.get('periods') is not None else None) - - structure = io.insert_dataarray({key: ds.attrs[key] for key in ['components', 'buses', 'effects']}, ds) - flow_system.add_elements( - * [Bus.from_dict(bus) for bus in structure['buses'].values()] - + [Effect.from_dict(effect) for effect in structure['effects'].values()] - + [CLASS_REGISTRY[comp['__class__']].from_dict(comp) for comp in structure['components'].values()] - ) - return flow_system - - @classmethod - def from_dict(cls, data: Dict) -> 'FlowSystem': - timesteps_extra = pd.DatetimeIndex(data['timesteps_extra'], name='time') - hours_of_last_timestep = TimeSeriesCollection.create_hours_per_timestep( - timesteps_extra, None - ).isel(time=-1).item() - - flow_system = FlowSystem(timesteps=timesteps_extra[:-1], - hours_of_last_timestep=hours_of_last_timestep, - hours_of_previous_timesteps=data['hours_of_previous_timesteps'], - periods=pd.Index(data['periods'], name='period') if data.get('periods') is not None else None) - - flow_system.add_elements( - *[Bus.from_dict(bus) for bus in data['buses'].values()] - ) - - flow_system.add_elements( - *[Effect.from_dict(effect) for effect in data['effects'].values()] - ) - - flow_system.add_elements( - *[CLASS_REGISTRY[comp['__class__']].from_dict(comp) for comp in data['components'].values()] - ) - - flow_system.transform_data() - - return flow_system - def plot_network( self, path: Union[bool, str, pathlib.Path] = 'flow_system.html', @@ -375,6 +231,89 @@ def plot_network( node_infos, edge_infos = self.network_infos() return plotting.plot_network(node_infos, edge_infos, path, controls, show) + def network_infos(self) -> Tuple[Dict[str, Dict[str, str]], Dict[str, Dict[str, str]]]: + self._connect_network() + nodes = { + node.label_full: { + 'label': node.label, + 'class': 'Bus' if isinstance(node, Bus) else 'Component', + 'infos': node.__str__(), + } + for node in list(self.components.values()) + list(self.buses.values()) + } + + edges = { + flow.label_full: { + 'label': flow.label, + 'start': flow.bus if flow.is_input_in_component else flow.component, + 'end': flow.component if flow.is_input_in_component else flow.bus, + 'infos': flow.__str__(), + } + for flow in self.flows.values() + } + + return nodes, edges + + def transform_data(self): + self._connect_network() + for element in self.all_elements.values(): + element.transform_data(self) + + def create_time_series( + self, + name: str, + data: Optional[Union[NumericData, TimeSeriesData, TimeSeries]], + extra_timestep: bool = False, + ) -> Optional[TimeSeries]: + """ + Tries to create a TimeSeries from NumericData Data and adds it to the time_series_collection + If the data already is a TimeSeries, nothing happens and the TimeSeries gets reset and returned + If the data is a TimeSeriesData, it is converted to a TimeSeries, and the aggregation weights are applied. + If the data is None, nothing happens. + """ + + if data is None: + return None + elif isinstance(data, TimeSeries): + data.restore_data() + if data in self.time_series_collection: + return data + return self.time_series_collection.create_time_series( + data=data.active_data, + name=name, + extra_timestep=extra_timestep + ) + return self.time_series_collection.create_time_series( + data=data, + name=name, + extra_timestep=extra_timestep + ) + + def create_effect_time_series(self, + label_prefix: Optional[str], + effect_values: EffectValuesUser, + label_suffix: Optional[str] = None, + ) -> Optional[EffectTimeSeries]: + """ + Transform EffectValues to EffectTimeSeries. + Creates a TimeSeries for each key in the nested_values dictionary, using the value as the data. + + The resulting label of the TimeSeries is the label of the parent_element, + followed by the label of the Effect in the nested_values and the label_suffix. + If the key in the EffectValues is None, the alias 'Standard_Effect' is used + """ + effect_values: Optional[EffectValuesDict] = self.effects.create_effect_values_dict(effect_values) + if effect_values is None: + return None + + return { + effect: self.create_time_series( + '|'.join(filter(None, [label_prefix, effect, label_suffix])), + value + ) + for effect, value in effect_values.items() + } + def create_model(self) -> SystemModel: self.model = SystemModel(self) return self.model @@ -394,13 +333,55 @@ def _check_if_element_is_unique(self, element: Element) -> None: if element.label_full in self.all_elements: raise Exception(f'Label of Element {element.label} already used in another element!') + def _add_effects(self, *args: Effect) -> None: + self.effects.add_effects(*args) + + def _add_components(self, *components: Component) -> None: + for new_component in list(components): + logger.info(f'Registered new Component: {new_component.label}') + self._check_if_element_is_unique(new_component) # check if already exists: + self.components[new_component.label] = new_component # Add to existing components + + def _add_buses(self, *buses: Bus): + for new_bus in list(buses): + logger.info(f'Registered new Bus: {new_bus.label}') + self._check_if_element_is_unique(new_bus) # check if already exists: + self.buses[new_bus.label] = new_bus # Add to existing components + + def _connect_network(self): + """Connects the network of components and buses. Can be rerun without changes if no elements were added""" + for component in self.components.values(): + for flow in component.inputs + component.outputs: + flow.component = component.label_full + flow.is_input_in_component = True if flow in component.inputs else False + + # Add Bus if not already added (deprecated) + if flow._bus_object is not None and flow._bus_object not in self.buses.values(): + self._add_buses(flow._bus_object) + warnings.warn( + f'The Bus {flow._bus_object.label} was added to the FlowSystem from {flow.label_full}.' + f'This is deprecated and will be removed in the future. ' + f'Please pass the Bus.label to the Flow and the Bus to the FlowSystem instead.', + UserWarning, + stacklevel=1) + + # Connect Buses + bus = self.buses.get(flow.bus) + if bus is None: + raise KeyError(f'Bus {flow.bus} not found in the FlowSystem, but used by "{flow.label_full}". ' + f'Please add it first.') + if flow.is_input_in_component and flow not in bus.outputs: + bus.outputs.append(flow) + elif not flow.is_input_in_component and flow not in bus.inputs: + bus.inputs.append(flow) + def __repr__(self): return f'<{self.__class__.__name__} with {len(self.components)} components and {len(self.effects)} effects>' def __str__(self): with StringIO() as output_buffer: console = Console(file=output_buffer, width=1000) # Adjust width as needed - console.print(Pretty(io.remove_none_and_empty(self.to_dict('stats')), expand_all=True, indent_guides=True)) + console.print(Pretty(self.as_dict('stats'), expand_all=True, indent_guides=True)) value = output_buffer.getvalue() return value From 9ee616383ed3dbf102fb38907f6b0ee6683210c8 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 4 Mar 2025 15:55:20 +0100 Subject: [PATCH 287/507] Bugfix in io --- flixOpt/io.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixOpt/io.py b/flixOpt/io.py index 1cd1f6651..619c71182 100644 --- a/flixOpt/io.py +++ b/flixOpt/io.py @@ -44,7 +44,7 @@ def replace_timeseries(obj, mode: Literal['name', 'stats', 'data'] = 'name'): return [replace_timeseries(v, mode) for v in obj] elif isinstance(obj, TimeSeries): # Adjust this based on the actual class if obj.all_equal: - return obj.active_data.values[0] + return obj.active_data.values[0].item() elif mode == 'name': return f"::::{obj.name}" elif mode == 'stats': From 1eb6f3ee6c1efa14693bc843732752bcaf5286d8 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 4 Mar 2025 16:12:59 +0100 Subject: [PATCH 288/507] Add to and from netcdf to the FlowSystem --- flixOpt/calculation.py | 6 ++++++ flixOpt/flow_system.py | 16 ++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py index ce28d87b3..cd25a7e8b 100644 --- a/flixOpt/calculation.py +++ b/flixOpt/calculation.py @@ -75,6 +75,12 @@ def __init__( except FileNotFoundError as e: raise FileNotFoundError(f'Folder {self.folder} and its parent do not exist. Please create them first.') from e + def flow_system_to_netcdf(self): + """ + Saves the flow_system to .netcdf file. + """ + self.flow_system.to_netcdf(self.folder / f'{self.name}_flowsystem.nc') + @property def main_results(self) -> Dict[str, Union[Scalar, Dict]]: from flixOpt.features import InvestmentModel diff --git a/flixOpt/flow_system.py b/flixOpt/flow_system.py index 9c87a1328..9f6f98383 100644 --- a/flixOpt/flow_system.py +++ b/flixOpt/flow_system.py @@ -116,6 +116,16 @@ def from_dict(cls, data: Dict) -> 'FlowSystem': return flow_system + @classmethod + def from_netcdf(cls, path: Union[str, pathlib.Path]): + """ + Load a FlowSystem from a netcdf file + """ + with xr.open_dataset(path) as ds: + ds = ds.load() + ds.attrs = json.loads(ds.attrs['flow_system']) + return cls.from_dataset(ds) + def add_elements(self, *elements: Element) -> None: """ add all modeling elements, like storages, boilers, heatpumps, buses, ... @@ -180,6 +190,12 @@ def as_dataset(self, constants_in_dataset: bool = False) -> xr.Dataset: ds.attrs = self.as_dict(data_mode='name') return ds + def to_netcdf(self, path: Union[str, pathlib.Path]): + ds = self.as_dataset() + ds.attrs = {'flow_system': json.dumps(ds.attrs)} + ds.to_netcdf(path) + logger.info(f'Saved FlowSystem to {path}') + def plot_network( self, path: Union[bool, str, pathlib.Path] = 'flow_system.html', From cf6de0b69e4647b89412a2b0103778d3b2c6c688 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 4 Mar 2025 16:52:17 +0100 Subject: [PATCH 289/507] First step in updating test_integration.py to pytest --- tests/test_integration.py | 896 +++++++++----------------------------- 1 file changed, 205 insertions(+), 691 deletions(-) diff --git a/tests/test_integration.py b/tests/test_integration.py index 83acea143..ae8385480 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -1,747 +1,237 @@ import datetime import os -import unittest -from typing import Literal +from typing import Any, Dict, List, Literal +import flixOpt as fx import numpy as np import pandas as pd import pytest -import flixOpt as fx - -np.random.seed(45) - -class BaseTest(unittest.TestCase): - def setUp(self): - fx.change_logging_level('DEBUG') - - def get_solver(self): - return fx.solvers.HighsSolver(mip_gap=0, time_limit_seconds=300) - - def assert_almost_equal_numeric( - self, actual, desired, err_msg, relative_error_range_in_percent=0.011, absolute_tolerance=1e-9 - ): # error_range etwas höher als mip_gap, weil unterschiedl. Bezugswerte +# Utility function to get solver +def get_solver(): + return fx.solvers.HighsSolver(mip_gap=0, time_limit_seconds=300) + +# Custom assertion function +def assert_almost_equal_numeric( + actual, + desired, + err_msg, + relative_error_range_in_percent=0.011, + absolute_tolerance=1e-9 +): + """ + Custom assertion function for comparing numeric values with relative and absolute tolerances + """ + relative_tol = relative_error_range_in_percent / 100 + + if isinstance(desired, (int, float)): + delta = abs(relative_tol * desired) if desired != 0 else absolute_tolerance + assert np.isclose(actual, desired, atol=delta), err_msg + else: + np.testing.assert_allclose( + actual, + desired, + rtol=relative_tol, + atol=absolute_tolerance, + err_msg=err_msg + ) + +@pytest.fixture +def base_timesteps(): + """Fixture for creating base timesteps""" + return pd.date_range('2020-01-01', periods=9, freq='h', name='time') + +@pytest.fixture +def base_thermal_load(): + """Fixture for creating base thermal load profile""" + return np.array([30.0, 0.0, 90.0, 110, 110, 20, 20, 20, 20]) + +@pytest.fixture +def base_electrical_load(): + """Fixture for creating base electrical load profile""" + return np.array([40.0, 40.0, 40.0, 40, 40, 40, 40, 40, 40]) + +@pytest.fixture +def base_electrical_price(): + """Fixture for creating base electrical price profile""" + return 1 / 1000 * np.array([80.0, 80.0, 80.0, 80, 80, 80, 80, 80, 80]) + +class TestFlowSystem: + @pytest.fixture + def simple_flow_system(self, base_timesteps, base_thermal_load, base_electrical_price): """ - Asserts that actual is almost equal to desired. - Designed for comparing float and ndarrays. Whith respect to tolerances + Create a simple energy system for testing """ - relative_tol = relative_error_range_in_percent / 100 - if isinstance(desired, (int, float)): - delta = abs(relative_tol * desired) if desired != 0 else absolute_tolerance - self.assertAlmostEqual(actual, desired, msg=err_msg, delta=delta) - else: - np.testing.assert_allclose(actual, desired, rtol=relative_tol, atol=absolute_tolerance) - - -class TestSimple(BaseTest): - def setUp(self): - super().setUp() - - self.Q_th_Last = np.array([30.0, 0.0, 90.0, 110, 110, 20, 20, 20, 20]) - self.p_el = 1 / 1000 * np.array([80.0, 80.0, 80.0, 80, 80, 80, 80, 80, 80]) - self.timesteps = pd.date_range('2020-01-01', periods=len(self.Q_th_Last), freq='h', name='time') - - def test_model(self): - calculation = self.model() - effects = calculation.flow_system.effects - comps = calculation.flow_system.components - - # Compare expected values with actual values - self.assert_almost_equal_numeric( - effects['costs'].model.total.solution.item(), 81.88394666666667, 'costs doesnt match expected value' - ) - self.assert_almost_equal_numeric( - effects['CO2'].model.total.solution.item(), 255.09184, 'CO2 doesnt match expected value' - ) - self.assert_almost_equal_numeric( - comps['Boiler'].Q_th.model.flow_rate.solution.values, - [0, 0, 0, 28.4864, 35, 0, 0, 0, 0], - 'Q_th doesnt match expected value', - ) - self.assert_almost_equal_numeric( - comps['CHP_unit'].Q_th.model.flow_rate.solution.values, - [30.0, 26.66666667, 75.0, 75.0, 75.0, 20.0, 20.0, 20.0, 20.0], - 'Q_th doesnt match expected value', - ) - - def test_from_results(self): - calculation = self.model() - calculation.results.to_file() - - results = fx.results.CalculationResults.from_file(calculation.folder, calculation.name) - # test effect results - self.assert_almost_equal_numeric( - results.model.variables['costs|total'].solution.values, - 81.88394666666667, - 'costs doesnt match expected value', - ) - self.assert_almost_equal_numeric( - results.model.variables['CO2|total'].solution.values, 255.09184, 'CO2 doesnt match expected value' - ) - self.assert_almost_equal_numeric( - results.model.variables['Boiler(Q_th)|flow_rate'].solution.values, - [0, 0, 0, 28.4864, 35, 0, 0, 0, 0], - 'Q_th doesnt match expected value', - ) - self.assert_almost_equal_numeric( - results.model.variables['CHP_unit(Q_th)|flow_rate'].solution.values, - [30.0, 26.66666667, 75.0, 75.0, 75.0, 20.0, 20.0, 20.0, 20.0], - 'Q_th doesnt match expected value', - ) - - df = results['Fernwärme'].flow_rates() - self.assert_almost_equal_numeric( - calculation.flow_system.components['Wärmelast'].sink.model.flow_rate.solution.values, - df['Wärmelast(Q_th_Last)|flow_rate'].values, - 'Loaded Results and directly used results dont match, or loading didnt work properly', - ) - - def model(self) -> fx.FullCalculation: - # Define the components and flow_system + # Define effects costs = fx.Effect('costs', '€', 'Kosten', is_standard=True, is_objective=True) CO2 = fx.Effect( 'CO2', 'kg', 'CO2_e-Emissionen', - specific_share_to_other_effects_operation={costs: 0.2}, + specific_share_to_other_effects_operation={costs.label: 0.2}, maximum_operation_per_hour=1000, ) - aBoiler = fx.linear_converters.Boiler( + # Create components + boiler = fx.linear_converters.Boiler( 'Boiler', eta=0.5, Q_th=fx.Flow( 'Q_th', bus='Fernwärme', size=50, - relative_minimum=5 / 50, + relative_minimum=5/50, relative_maximum=1, on_off_parameters=fx.OnOffParameters(), ), Q_fu=fx.Flow('Q_fu', bus='Gas'), ) - aKWK = fx.linear_converters.CHP( + + chp = fx.linear_converters.CHP( 'CHP_unit', eta_th=0.5, eta_el=0.4, - P_el=fx.Flow('P_el', bus='Strom', size=60, relative_minimum=5 / 60, on_off_parameters=fx.OnOffParameters()), + P_el=fx.Flow('P_el', bus='Strom', size=60, relative_minimum=5/60, on_off_parameters=fx.OnOffParameters()), Q_th=fx.Flow('Q_th', bus='Fernwärme'), Q_fu=fx.Flow('Q_fu', bus='Gas'), ) - aSpeicher = fx.Storage( + + storage = fx.Storage( 'Speicher', charging=fx.Flow('Q_th_load', bus='Fernwärme', size=1e4), discharging=fx.Flow('Q_th_unload', bus='Fernwärme', size=1e4), capacity_in_flow_hours=fx.InvestParameters(fix_effects=20, fixed_size=30, optional=False), initial_charge_state=0, - relative_maximum_charge_state=1 / 100 * np.array([80.0, 70.0, 80.0, 80, 80, 80, 80, 80, 80, 80]), + relative_maximum_charge_state=1/100 * np.array([80.0, 70.0, 80.0, 80, 80, 80, 80, 80, 80, 80]), eta_charge=0.9, eta_discharge=1, relative_loss_per_hour=0.08, prevent_simultaneous_charge_and_discharge=True, ) - aWaermeLast = fx.Sink( - 'Wärmelast', sink=fx.Flow('Q_th_Last', bus='Fernwärme', size=1, fixed_relative_profile=self.Q_th_Last) - ) - aGasTarif = fx.Source( - 'Gastarif', source=fx.Flow('Q_Gas', bus='Gas', size=1000, effects_per_flow_hour={costs: 0.04, CO2: 0.3}) - ) - aStromEinspeisung = fx.Sink( - 'Einspeisung', sink=fx.Flow('P_el', bus='Strom', effects_per_flow_hour=-1 * self.p_el) - ) - - es = fx.FlowSystem(self.timesteps) - es.add_elements(fx.Bus('Strom'), fx.Bus('Fernwärme'), fx.Bus('Gas')) - es.add_elements(aSpeicher) - es.add_elements(costs, CO2) - es.add_elements(aBoiler, aWaermeLast, aGasTarif) - es.add_elements(aStromEinspeisung) - es.add_elements(aKWK) - - print(es) - es.plot_network() - - aCalc = fx.FullCalculation('Test_Sim', es) - aCalc.do_modeling() - - aCalc.solve(self.get_solver()) - - return aCalc - - -class TestComponents(BaseTest): - def setUp(self): - super().setUp() - self.Q_th_Last = np.array([np.random.random() for _ in range(10)]) * 180 - self.p_el = (np.array([np.random.random() for _ in range(10)]) + 0.5) / 1.5 * 50 - self.timesteps = pd.date_range('2020-01-01', periods=len(self.Q_th_Last), freq='h', name='time') - - def create_basic_elements(self): - self.busses = {label: fx.Bus(label) for label in ['Strom', 'Fernwärme', 'Gas']} - self.effects = {'Costs': fx.Effect('Costs', '€', 'Kosten', is_standard=True, is_objective=True)} - self.components = { - 'Wärmelast': fx.Sink( - 'Wärmelast', - sink=fx.Flow('Q_th_Last', bus=self.busses['Fernwärme'], size=1, fixed_relative_profile=self.Q_th_Last), - ), - 'Gastarif': fx.Source( - 'Gastarif', source=fx.Flow('Q_Gas', bus=self.busses['Gas'], size=1000, effects_per_flow_hour=0.04) - ), - 'Einspeisung': fx.Sink( - 'Einspeisung', sink=fx.Flow('P_el', bus=self.busses['Strom'], effects_per_flow_hour=-1 * self.p_el) - ), - } - - def test_transmission_basic(self): - self.create_basic_elements() - flow_system = fx.FlowSystem(self.timesteps) - flow_system.add_elements(*(list(self.effects.values()) + list(self.components.values()))) - extra_bus = fx.Bus('Wärme lokal') - boiler = fx.linear_converters.Boiler( - 'Boiler', eta=0.5, Q_th=fx.Flow('Q_th', bus=extra_bus), Q_fu=fx.Flow('Q_fu', bus=self.busses['Gas']) - ) - - transmission = fx.Transmission( - 'Rohr', - relative_losses=0.2, - absolute_losses=20, - in1=fx.Flow('Rohr1', extra_bus, size=fx.InvestParameters(specific_effects=5, maximum_size=1e6)), - out1=fx.Flow('Rohr2', self.busses['Fernwärme'], size=1000), - ) - - flow_system.add_elements(transmission, boiler) - calculation = fx.FullCalculation('Test_Sim', flow_system) - calculation.do_modeling() - calculation.solve(self.get_solver()) - print(calculation.results) - self.assert_almost_equal_numeric( - transmission.in1.model.on_off.on.solution.values, np.array([1, 1, 1, 1, 1, 1, 1, 1, 1, 1]), 'On does not work properly' - ) - - self.assert_almost_equal_numeric( - transmission.in1.model.flow_rate.solution.values * 0.8 - 20, - transmission.out1.model.flow_rate.solution.values, - 'Losses are not computed correctly', - ) - def test_transmission_advanced(self): - self.create_basic_elements() - flow_system = fx.FlowSystem(self.timesteps) - flow_system.add_elements(*(list(self.effects.values()) + list(self.components.values()))) - flow_system.add_elements(fx.Bus('Wärme lokal')) - - boiler = fx.linear_converters.Boiler( - 'Boiler_Standard', - eta=0.9, - Q_th=fx.Flow( - 'Q_th', bus='Fernwärme', relative_maximum=np.array([0, 0, 0, 1, 1, 1, 1, 1, 1, 1]) - ), - Q_fu=fx.Flow('Q_fu', bus='Gas'), + heat_load = fx.Sink( + 'Wärmelast', + sink=fx.Flow('Q_th_Last', bus='Fernwärme', size=1, fixed_relative_profile=base_thermal_load) ) - boiler2 = fx.linear_converters.Boiler( - 'Boiler_backup', eta=0.4, Q_th=fx.Flow('Q_th', bus='Wärme lokal'), Q_fu=fx.Flow('Q_fu', bus='Gas') + gas_tariff = fx.Source( + 'Gastarif', + source=fx.Flow('Q_Gas', bus='Gas', size=1000, effects_per_flow_hour={costs: 0.04, CO2: 0.3}) ) - last2 = fx.Sink( - 'Wärmelast2', - sink=fx.Flow( - 'Q_th_Last', - bus='Wärme lokal', - size=1, - fixed_relative_profile=self.Q_th_Last * np.array([0, 0, 0, 0, 0, 1, 1, 1, 1, 1]), - ), + electricity_feed_in = fx.Sink( + 'Einspeisung', + sink=fx.Flow('P_el', bus='Strom', effects_per_flow_hour=-1 * base_electrical_price) ) - transmission = fx.Transmission( - 'Rohr', - relative_losses=0.2, - absolute_losses=20, - in1=fx.Flow('Rohr1a', bus='Wärme lokal', size=fx.InvestParameters(specific_effects=5, maximum_size=1000)), - out1=fx.Flow('Rohr1b', 'Fernwärme', size=1000), - in2=fx.Flow('Rohr2a', 'Fernwärme', size=1000), - out2=fx.Flow('Rohr2b', bus='Wärme lokal', size=1000), + # Create flow system + flow_system = fx.FlowSystem(base_timesteps) + flow_system.add_elements( + fx.Bus('Strom'), + fx.Bus('Fernwärme'), + fx.Bus('Gas') ) + flow_system.add_elements(storage, costs, CO2, boiler, heat_load, gas_tariff, electricity_feed_in, chp) - flow_system.add_elements(transmission, boiler, boiler2, last2) - calculation = fx.FullCalculation('Test_Transmission', flow_system) + # Create and solve calculation + calculation = fx.FullCalculation('Test_Sim', flow_system) calculation.do_modeling() - calculation.solve(self.get_solver()) - - self.assert_almost_equal_numeric( - transmission.in1.model.on_off.on.solution.values, np.array([1, 1, 1, 0, 0, 0, 0, 0, 0, 0]), 'On does not work properly' - ) - - self.assert_almost_equal_numeric( - calculation.results.model.variables['Rohr(Rohr1b)|flow_rate'].solution.values, - transmission.out1.model.flow_rate.solution.values, - 'Flow rate of Rohr__Rohr1b is not correct', - ) - - self.assert_almost_equal_numeric( - transmission.in1.model.flow_rate.solution.values * 0.8 - - np.array([20 if val > 0.1 else 0 for val in transmission.in1.model.flow_rate.solution.values]), - transmission.out1.model.flow_rate.solution.values, - 'Losses are not computed correctly', - ) - - self.assert_almost_equal_numeric( - transmission.in1.model._investment.size.solution.item(), - transmission.in2.model._investment.size.solution.item(), - 'THe Investments are not equated correctly', - ) - - def tearDown(self): - self.busses = None - self.effects = None - self.components = None - self.timesteps = None - self.Q_th_Last = None - self.p_el = None - self.timesteps = None - - -class TestComplex(BaseTest): - def setUp(self): - super().setUp() - self.Q_th_Last = np.array([30.0, 0.0, 90.0, 110, 110, 20, 20, 20, 20]) - self.P_el_Last = np.array([40.0, 40.0, 40.0, 40, 40, 40, 40, 40, 40]) - self.timesteps = pd.date_range('2020-01-01', periods=len(self.Q_th_Last), freq='h', name='time') - self.excessCosts = None - self.useCHPwithLinearSegments = False - - def test_basic(self): - calculation = self.basic_model() - effects = calculation.flow_system.effects - comps = calculation.flow_system.components - - # Compare expected values with actual values - self.assert_almost_equal_numeric( - effects['costs'].model.total.solution.item(), -11597.873624489237, 'costs doesnt match expected value' - ) - self.assert_almost_equal_numeric( - effects['costs'].model.operation.total_per_timestep.solution.values, - [ - -2.38500000e03, - -2.21681333e03, - -2.38500000e03, - -2.17599000e03, - -2.35107029e03, - -2.38500000e03, - 0.00000000e00, - -1.68897826e-10, - -2.16914486e-12, - ], - 'costs doesnt match expected value', - ) - - self.assert_almost_equal_numeric( - sum(effects['costs'].model.operation.shares['CO2(operation)'].solution.values), - 258.63729669618675, - 'costs doesnt match expected value', - ) - self.assert_almost_equal_numeric( - sum(effects['costs'].model.operation.shares['Kessel(Q_th)'].solution.values), - 0.01, - 'costs doesnt match expected value', - ) - self.assert_almost_equal_numeric( - sum(effects['costs'].model.operation.shares['Kessel'].solution.values), - -0.0, - 'costs doesnt match expected value', - ) - self.assert_almost_equal_numeric( - sum(effects['costs'].model.operation.shares['Gastarif(Q_Gas)'].solution.values), - 39.09153113079115, - 'costs doesnt match expected value', - ) - self.assert_almost_equal_numeric( - sum(effects['costs'].model.operation.shares['Einspeisung(P_el)'].solution.values), - -14196.61245231646, - 'costs doesnt match expected value', - ) - self.assert_almost_equal_numeric( - sum(effects['costs'].model.operation.shares['KWK'].solution.values), - 0.0, - 'costs doesnt match expected value', - ) - - self.assert_almost_equal_numeric( - effects['costs'].model.invest.shares['Kessel(Q_th)'].solution.values, - 1000 + 500, - 'costs doesnt match expected value', - ) - - self.assert_almost_equal_numeric( - effects['costs'].model.invest.shares['Speicher'].solution.values, - 800 + 1, - 'costs doesnt match expected value', - ) - - self.assert_almost_equal_numeric( - effects['CO2'].model.operation.total.solution.values, 1293.1864834809337, 'CO2 doesnt match expected value' - ) - self.assert_almost_equal_numeric( - effects['CO2'].model.invest.total.solution.values, 0.9999999999999994, 'CO2 doesnt match expected value' - ) - self.assert_almost_equal_numeric( - comps['Kessel'].Q_th.model.flow_rate.solution.values, - [0, 0, 0, 45, 0, 0, 0, 0, 0], - 'Kessel doesnt match expected value', - ) - - self.assert_almost_equal_numeric( - comps['KWK'].Q_th.model.flow_rate.solution.values, - [ - 7.50000000e01, - 6.97111111e01, - 7.50000000e01, - 7.50000000e01, - 7.39330280e01, - 7.50000000e01, - 0.00000000e00, - 3.12638804e-14, - 3.83693077e-14, - ], - 'KWK Q_th doesnt match expected value', - ) - self.assert_almost_equal_numeric( - comps['KWK'].P_el.model.flow_rate.solution.values, - [ - 6.00000000e01, - 5.57688889e01, - 6.00000000e01, - 6.00000000e01, - 5.91464224e01, - 6.00000000e01, - 0.00000000e00, - 2.50111043e-14, - 3.06954462e-14, - ], - 'KWK P_el doesnt match expected value', - ) - - self.assert_almost_equal_numeric( - comps['Speicher'].model.netto_discharge.solution.values, - [-45.0, -69.71111111, 15.0, -10.0, 36.06697198, -55.0, 20.0, 20.0, 20.0], - 'Speicher nettoFlow doesnt match expected value', - ) - self.assert_almost_equal_numeric( - comps['Speicher'].model.charge_state.solution.values, - [0.0, 40.5, 100.0, 77.0, 79.84, 37.38582802, 83.89496178, 57.18336484, 32.60869565, 10.0], - 'Speicher nettoFlow doesnt match expected value', - ) - - self.assert_almost_equal_numeric( - comps['Speicher'].model.variables['Speicher|SegmentedShares|costs'].solution.values, - 800, - 'Speicher investCosts_segmented_costs doesnt match expected value', - ) - - def test_segments_of_flows(self): - calculation = self.segments_of_flows_model() - effects = calculation.flow_system.effects - comps = calculation.flow_system.components - - # Compare expected values with actual values - self.assert_almost_equal_numeric( - effects['costs'].model.total.solution.item(), -10710.997365760755, 'costs doesnt match expected value' - ) - self.assert_almost_equal_numeric( - effects['CO2'].model.total.solution.item(), 1278.7939026086956, 'CO2 doesnt match expected value' - ) - self.assert_almost_equal_numeric( - comps['Kessel'].Q_th.model.flow_rate.solution.values, - [0, 0, 0, 45, 0, 0, 0, 0, 0], - 'Kessel doesnt match expected value', - ) - kwk_flows = {flow.label: flow for flow in comps['KWK'].inputs + comps['KWK'].outputs} - self.assert_almost_equal_numeric( - kwk_flows['Q_th'].model.flow_rate.solution.values, - [45.0, 45.0, 64.5962087, 100.0, 61.3136, 45.0, 45.0, 12.86469565, 0.0], - 'KWK Q_th doesnt match expected value', - ) - self.assert_almost_equal_numeric( - kwk_flows['P_el'].model.flow_rate.solution.values, - [40.0, 40.0, 47.12589407, 60.0, 45.93221818, 40.0, 40.0, 10.91784108, -0.0], - 'KWK P_el doesnt match expected value', - ) - - self.assert_almost_equal_numeric( - comps['Speicher'].model.netto_discharge.solution.values, - [-15.0, -45.0, 25.4037913, -35.0, 48.6864, -25.0, -25.0, 7.13530435, 20.0], - 'Speicher nettoFlow doesnt match expected value', - ) - - self.assert_almost_equal_numeric( - comps['Speicher'].model.variables['Speicher|SegmentedShares|costs'].solution.values, - 454.74666666666667, - 'Speicher investCosts_segmented_costs doesnt match expected value', - ) - - def basic_model(self) -> fx.FullCalculation: - # Define the components and flow_system - costs = fx.Effect('costs', '€', 'Kosten', is_standard=True, is_objective=True) - CO2 = fx.Effect('CO2', 'kg', 'CO2_e-Emissionen', specific_share_to_other_effects_operation={costs: 0.2}) - PE = fx.Effect('PE', 'kWh_PE', 'Primärenergie', maximum_total=3.5e3) - - aGaskessel = fx.linear_converters.Boiler( - 'Kessel', - eta=0.5, - on_off_parameters=fx.OnOffParameters(effects_per_running_hour={costs: 0, CO2: 1000}), - Q_th=fx.Flow( - 'Q_th', - bus='Fernwärme', - load_factor_max=1.0, - load_factor_min=0.1, - relative_minimum=5 / 50, - relative_maximum=1, - previous_flow_rate=50, - size=fx.InvestParameters( - fix_effects=1000, fixed_size=50, optional=False, specific_effects={costs: 10, PE: 2} - ), - on_off_parameters=fx.OnOffParameters( - on_hours_total_min=0, - on_hours_total_max=1000, - consecutive_on_hours_max=10, - consecutive_on_hours_min=1, - consecutive_off_hours_max=10, - effects_per_switch_on=0.01, - switch_on_total_max=1000, - ), - flow_hours_total_max=1e6, - ), - Q_fu=fx.Flow('Q_fu', bus='Gas', size=200, relative_minimum=0, relative_maximum=1), - ) + calculation.solve(get_solver()) - aKWK = fx.linear_converters.CHP( - 'KWK', - eta_th=0.5, - eta_el=0.4, - on_off_parameters=fx.OnOffParameters(effects_per_switch_on=0.01), - P_el=fx.Flow('P_el', bus='Strom', size=60, relative_minimum=5 / 60, previous_flow_rate=10), - Q_th=fx.Flow('Q_th', bus='Fernwärme', size=1e3), - Q_fu=fx.Flow('Q_fu', bus='Gas', size=1e3), - ) + return calculation - costsInvestsizeSegments = ([(5, 25), (25, 100)], {costs: [(50, 250), (250, 800)], PE: [(5, 25), (25, 100)]}) - invest_Speicher = fx.InvestParameters( - fix_effects=0, - effects_in_segments=costsInvestsizeSegments, - optional=False, - specific_effects={costs: 0.01, CO2: 0.01}, - minimum_size=0, - maximum_size=1000, - ) - aSpeicher = fx.Storage( - 'Speicher', - charging=fx.Flow('Q_th_load', bus='Fernwärme', size=1e4), - discharging=fx.Flow('Q_th_unload', bus='Fernwärme', size=1e4), - capacity_in_flow_hours=invest_Speicher, - initial_charge_state=0, - maximal_final_charge_state=10, - eta_charge=0.9, - eta_discharge=1, - relative_loss_per_hour=0.08, - prevent_simultaneous_charge_and_discharge=True, - ) + def test_simple_flow_system(self, simple_flow_system): + """ + Test the effects of the simple energy system model + """ + effects = simple_flow_system.flow_system.effects - aWaermeLast = fx.Sink( - 'Wärmelast', - sink=fx.Flow( - 'Q_th_Last', bus='Fernwärme', size=1, relative_minimum=0, fixed_relative_profile=self.Q_th_Last - ), - ) - aGasTarif = fx.Source( - 'Gastarif', source=fx.Flow('Q_Gas', bus='Gas', size=1000, effects_per_flow_hour={costs: 0.04, CO2: 0.3}) - ) - aStromEinspeisung = fx.Sink( - 'Einspeisung', sink=fx.Flow('P_el', bus='Strom', effects_per_flow_hour=-1 * np.array(self.P_el_Last)) + # Cost assertions + assert_almost_equal_numeric( + effects['costs'].model.total.solution.item(), + 81.88394666666667, + 'costs doesnt match expected value' ) - es = fx.FlowSystem(self.timesteps) - es.add_elements(costs, CO2, PE) - es.add_elements(aGaskessel, aWaermeLast, aGasTarif, aStromEinspeisung, aKWK, aSpeicher) - es.add_elements(fx.Bus('Strom', excess_penalty_per_flow_hour=self.excessCosts), - fx.Bus('Fernwärme', excess_penalty_per_flow_hour=self.excessCosts), - fx.Bus('Gas', excess_penalty_per_flow_hour=self.excessCosts) - ) - print(es) - es.plot_network() - - aCalc = fx.FullCalculation('Sim1', es) - aCalc.do_modeling() - - aCalc.solve(self.get_solver()) - - return aCalc - - def segments_of_flows_model(self): - # Define the components and flow_system - costs = fx.Effect('costs', '€', 'Kosten', is_standard=True, is_objective=True) - CO2 = fx.Effect('CO2', 'kg', 'CO2_e-Emissionen', specific_share_to_other_effects_operation={costs: 0.2}) - PE = fx.Effect('PE', 'kWh_PE', 'Primärenergie', maximum_total=3.5e3) - - invest_Gaskessel = fx.InvestParameters( - fix_effects=1000, fixed_size=50, optional=False, specific_effects={costs: 10, PE: 2} - ) - aGaskessel = fx.linear_converters.Boiler( - 'Kessel', - eta=0.5, - on_off_parameters=fx.OnOffParameters(effects_per_running_hour={costs: 0, CO2: 1000}), - Q_th=fx.Flow( - 'Q_th', - bus='Fernwärme', - size=invest_Gaskessel, - load_factor_max=1.0, - load_factor_min=0.1, - relative_minimum=5 / 50, - relative_maximum=1, - on_off_parameters=fx.OnOffParameters( - on_hours_total_min=0, - on_hours_total_max=1000, - consecutive_on_hours_max=10, - consecutive_off_hours_max=10, - effects_per_switch_on=0.01, - switch_on_total_max=1000, - ), - previous_flow_rate=50, - flow_hours_total_max=1e6, - ), - Q_fu=fx.Flow('Q_fu', bus='Gas', size=200, relative_minimum=0, relative_maximum=1), + # CO2 assertions + assert_almost_equal_numeric( + effects['CO2'].model.total.solution.item(), + 255.09184, + 'CO2 doesnt match expected value' ) - P_el = fx.Flow('P_el', bus='Strom', size=60, relative_maximum=55, previous_flow_rate=10) - Q_th = fx.Flow('Q_th', bus='Fernwärme') - Q_fu = fx.Flow('Q_fu', bus='Gas') - segmented_conversion_factors = { - P_el: [(5, 30), (40, 60)], - Q_th: [(6, 35), (45, 100)], - Q_fu: [(12, 70), (90, 200)], - } - aKWK = fx.LinearConverter( - 'KWK', - inputs=[Q_fu], - outputs=[P_el, Q_th], - segmented_conversion_factors=segmented_conversion_factors, - on_off_parameters=fx.OnOffParameters(effects_per_switch_on=0.01), - ) + def test_model_components(self, simple_flow_system): + """ + Test the component flows of the simple energy system model + """ + comps = simple_flow_system.flow_system.components - costsInvestsizeSegments = ([(5, 25), (25, 100)], {costs: [(50, 250), (250, 800)], PE: [(5, 25), (25, 100)]}) - invest_Speicher = fx.InvestParameters( - fix_effects=0, - effects_in_segments=costsInvestsizeSegments, - optional=False, - specific_effects={costs: 0.01, CO2: 0.01}, - minimum_size=0, - maximum_size=1000, - ) - aSpeicher = fx.Storage( - 'Speicher', - charging=fx.Flow('Q_th_load', bus='Fernwärme', size=1e4), - discharging=fx.Flow('Q_th_unload', bus='Fernwärme', size=1e4), - capacity_in_flow_hours=invest_Speicher, - initial_charge_state=0, - maximal_final_charge_state=10, - eta_charge=0.9, - eta_discharge=1, - relative_loss_per_hour=0.08, - prevent_simultaneous_charge_and_discharge=True, + # Boiler assertions + assert_almost_equal_numeric( + comps['Boiler'].Q_th.model.flow_rate.solution.values, + [0, 0, 0, 28.4864, 35, 0, 0, 0, 0], + 'Q_th doesnt match expected value', ) - aWaermeLast = fx.Sink( - 'Wärmelast', - sink=fx.Flow( - 'Q_th_Last', bus='Fernwärme', size=1, relative_minimum=0, fixed_relative_profile=self.Q_th_Last - ), - ) - aGasTarif = fx.Source( - 'Gastarif', source=fx.Flow('Q_Gas', bus='Gas', size=1000, effects_per_flow_hour={costs: 0.04, CO2: 0.3}) - ) - aStromEinspeisung = fx.Sink( - 'Einspeisung', sink=fx.Flow('P_el', bus='Strom', effects_per_flow_hour=-1 * np.array(self.P_el_Last)) + # CHP unit assertions + assert_almost_equal_numeric( + comps['CHP_unit'].Q_th.model.flow_rate.solution.values, + [30.0, 26.66666667, 75.0, 75.0, 75.0, 20.0, 20.0, 20.0, 20.0], + 'Q_th doesnt match expected value', ) - es = fx.FlowSystem(self.timesteps) - es.add_elements(costs, CO2, PE) - es.add_elements(aGaskessel, aWaermeLast, aGasTarif, aStromEinspeisung, aKWK) - es.add_elements(aSpeicher) - es.add_elements(fx.Bus('Strom', excess_penalty_per_flow_hour=self.excessCosts), - fx.Bus('Fernwärme', excess_penalty_per_flow_hour=self.excessCosts), - fx.Bus('Gas', excess_penalty_per_flow_hour=self.excessCosts) - ) - - print(es) - es.plot_network() - - aCalc = fx.FullCalculation('Sim1', es) - aCalc.do_modeling() - - aCalc.solve(self.get_solver()) - - return aCalc - - -class TestModelingTypes(BaseTest): - def setUp(self): - super().setUp() - self.Q_th_Last = np.array([30.0, 0.0, 90.0, 110, 110, 20, 20, 20, 20]) - self.p_el = 1 / 1000 * np.array([80.0, 80.0, 80.0, 80, 80, 80, 80, 80, 80]) - self.timesteps = ( - datetime.datetime(2020, 1, 1) + np.arange(len(self.Q_th_Last)) * datetime.timedelta(hours=1) - ).astype('datetime64') - self.max_emissions_per_hour = 1000 - - def test_full(self): - calculation = self.calculate('full') - effects = calculation.flow_system.effects - self.assert_almost_equal_numeric( - effects['costs'].model.total.solution.item(), 343613, 'costs doesnt match expected value' - ) + def test_results_persistence(self, simple_flow_system): + """ + Test saving and loading results + """ + # Save results to file + simple_flow_system.results.to_file() - def test_aggregated(self): - calculation = self.calculate('aggregated') - effects = calculation.flow_system.effects - self.assert_almost_equal_numeric( - effects['costs'].model.total.solution.item(), 342967.0, 'costs doesnt match expected value' + # Load results from file + results = fx.results.CalculationResults.from_file( + simple_flow_system.folder, + simple_flow_system.name ) - def test_segmented(self): - calculation = self.calculate('segmented') - self.assert_almost_equal_numeric( - sum(calculation.results.solution_without_overlap('costs(operation)|total_per_timestep')), - 343613, + # Verify key variables from loaded results + assert_almost_equal_numeric( + results.model.variables['costs|total'].solution.values, + 81.88394666666667, 'costs doesnt match expected value', ) - - def calculate(self, modeling_type: Literal['full', 'segmented', 'aggregated']): - doFullCalc, doSegmentedCalc, doAggregatedCalc = ( - modeling_type == 'full', - modeling_type == 'segmented', - modeling_type == 'aggregated', + assert_almost_equal_numeric( + results.model.variables['CO2|total'].solution.values, + 255.09184, + 'CO2 doesnt match expected value' ) - if not any([doFullCalc, doSegmentedCalc, doAggregatedCalc]): - raise Exception('Unknown modeling type') +# Advanced Modeling Types Test +class TestModelingTypes: + @pytest.fixture(params=['full', 'segmented', 'aggregated']) + def modeling_calculation(self, request): + """ + Fixture to run calculations with different modeling types + """ + # Load data filename = os.path.join(os.path.dirname(__file__), 'ressources', 'Zeitreihen2020.csv') ts_raw = pd.read_csv(filename, index_col=0).sort_index() data = ts_raw['2020-01-01 00:00:00':'2020-12-31 23:45:00']['2020-01-01':'2020-01-03 23:45:00'] - P_el_Last, Q_th_Last, p_el, gP = ( - data['P_Netz/MW'].values, - data['Q_Netz/MW'].values, - data['Strompr.€/MWh'].values, - data['Gaspr.€/MWh'].values, - ) + + # Extract data columns + P_el_Last = data['P_Netz/MW'].values + Q_th_Last = data['Q_Netz/MW'].values + p_el = data['Strompr.€/MWh'].values + gP = data['Gaspr.€/MWh'].values timesteps = pd.DatetimeIndex(data.index) - costs, CO2, PE = ( - fx.Effect('costs', '€', 'Kosten', is_standard=True, is_objective=True), - fx.Effect('CO2', 'kg', 'CO2_e-Emissionen'), - fx.Effect('PE', 'kWh_PE', 'Primärenergie'), - ) + # Create effects + costs = fx.Effect('costs', '€', 'Kosten', is_standard=True, is_objective=True) + CO2 = fx.Effect('CO2', 'kg', 'CO2_e-Emissionen') + PE = fx.Effect('PE', 'kWh_PE', 'Primärenergie') - aGaskessel = fx.linear_converters.Boiler( + # Create components (similar to original implementation) + boiler = fx.linear_converters.Boiler( 'Kessel', eta=0.85, Q_th=fx.Flow(label='Q_th', bus='Fernwärme'), @@ -754,7 +244,7 @@ def calculate(self, modeling_type: Literal['full', 'segmented', 'aggregated']): on_off_parameters=fx.OnOffParameters(effects_per_switch_on=1000), ), ) - aKWK = fx.linear_converters.CHP( + chp = fx.linear_converters.CHP( 'BHKW2', eta_th=0.58, eta_el=0.22, @@ -763,7 +253,7 @@ def calculate(self, modeling_type: Literal['full', 'segmented', 'aggregated']): Q_th=fx.Flow('Q_th', bus='Fernwärme'), Q_fu=fx.Flow('Q_fu', bus='Kohle', size=288, relative_minimum=87 / 288), ) - aSpeicher = fx.Storage( + storage = fx.Storage( 'Speicher', charging=fx.Flow('Q_th_load', size=137, bus='Fernwärme'), discharging=fx.Flow('Q_th_unload', size=158, bus='Fernwärme'), @@ -778,13 +268,13 @@ def calculate(self, modeling_type: Literal['full', 'segmented', 'aggregated']): ) TS_Q_th_Last, TS_P_el_Last = fx.TimeSeriesData(Q_th_Last), fx.TimeSeriesData(P_el_Last, agg_weight=0.7) - aWaermeLast, aStromLast = ( + heat_load, electricity_load = ( fx.Sink( 'Wärmelast', sink=fx.Flow('Q_th_Last', bus='Fernwärme', size=1, fixed_relative_profile=TS_Q_th_Last) ), fx.Sink('Stromlast', sink=fx.Flow('P_el_Last', bus='Strom', size=1, fixed_relative_profile=TS_P_el_Last)), ) - aKohleTarif, aGasTarif = ( + coal_tariff, gas_tariff = ( fx.Source( 'Kohletarif', source=fx.Flow('Q_Kohle', bus='Kohle', size=1000, effects_per_flow_hour={costs: 4.6, CO2: 0.3}), @@ -798,7 +288,7 @@ def calculate(self, modeling_type: Literal['full', 'segmented', 'aggregated']): fx.TimeSeriesData(-(p_el - 0.5), agg_group='p_el'), fx.TimeSeriesData(p_el + 0.5, agg_group='p_el'), ) - aStromEinspeisung, aStromTarif = ( + electricity_feed_in, electricity_tariff = ( fx.Sink('Einspeisung', sink=fx.Flow('P_el', bus='Strom', size=1000, effects_per_flow_hour=p_feed_in)), fx.Source( 'Stromtarif', @@ -806,27 +296,31 @@ def calculate(self, modeling_type: Literal['full', 'segmented', 'aggregated']): ), ) - es = fx.FlowSystem(timesteps) - es.add_elements(costs, CO2, PE) - es.add_elements( - aGaskessel, aWaermeLast, aStromLast, aGasTarif, aKohleTarif, aStromEinspeisung, aStromTarif, aKWK, aSpeicher + # Create flow system + flow_system = fx.FlowSystem(timesteps) + flow_system.add_elements(costs, CO2, PE) + flow_system.add_elements( + boiler, heat_load, electricity_load, gas_tariff, coal_tariff, electricity_feed_in, electricity_tariff, + chp, storage + ) + flow_system.add_elements( + fx.Bus('Strom'), fx.Bus('Fernwärme'), + fx.Bus('Gas'), fx.Bus('Kohle') ) - es.add_elements(fx.Bus('Strom'), fx.Bus('Fernwärme'), fx.Bus('Gas'), fx.Bus('Kohle')) - - print(es) - es.plot_network() - if doFullCalc: - calc = fx.FullCalculation('fullModel', es) + # Create calculation based on modeling type + modeling_type = request.param + if modeling_type == 'full': + calc = fx.FullCalculation('fullModel', flow_system) calc.do_modeling() - calc.solve(self.get_solver()) - elif doSegmentedCalc: - calc = fx.SegmentedCalculation('segModel', es, timesteps_per_segment=96, overlap_timesteps=1) - calc.do_modeling_and_solve(self.get_solver()) - elif doAggregatedCalc: + calc.solve(get_solver()) + elif modeling_type == 'segmented': + calc = fx.SegmentedCalculation('segModel', flow_system, timesteps_per_segment=96, overlap_timesteps=1) + calc.do_modeling_and_solve(get_solver()) + elif modeling_type == 'aggregated': calc = fx.AggregatedCalculation( 'aggModel', - es, + flow_system, fx.AggregationParameters( hours_per_period=6, nr_of_periods=4, @@ -839,14 +333,34 @@ def calculate(self, modeling_type: Literal['full', 'segmented', 'aggregated']): ), ) calc.do_modeling() - print(es) - es.plot_network() - calc.solve(self.get_solver()) - else: - raise Exception('Wrong Modeling Type') + calc.solve(get_solver()) + + return calc, modeling_type - return calc + def test_modeling_types_costs(self, modeling_calculation): + """ + Test total costs for different modeling types + """ + calc, modeling_type = modeling_calculation + + expected_costs = { + 'full': 343613, + 'segmented': 343613, # Approximate value + 'aggregated': 342967.0 + } + if modeling_type in ['full', 'aggregated']: + assert_almost_equal_numeric( + calc.results.model['costs|total'].solution.item(), + expected_costs[modeling_type], + f'Costs do not match for {modeling_type} modeling type' + ) + else: + assert_almost_equal_numeric( + sum(calc.results.solution_without_overlap('costs(operation)|total_per_timestep')), + expected_costs[modeling_type], + f'Costs do not match for {modeling_type} modeling type' + ) if __name__ == '__main__': pytest.main(['-v', '--disable-warnings']) From deb09c87c169bfff7fe2d9b4866a61dbf4267552 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 4 Mar 2025 17:09:02 +0100 Subject: [PATCH 290/507] Connect the Network before storing start values in Segmented Calculation --- flixOpt/calculation.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py index cd25a7e8b..2d5f3f191 100644 --- a/flixOpt/calculation.py +++ b/flixOpt/calculation.py @@ -345,11 +345,12 @@ def __init__( f'{self.timesteps_per_segment_with_overlap=} cant be greater than the total length {len(self.all_timesteps)}' ) + self.flow_system._connect_network() # Connect network to ensure that all FLows know their Component # Storing all original start values self._original_start_values = { - **{flow: flow.previous_flow_rate for flow in self.flow_system.flows.values()}, + **{flow.label_full: flow.previous_flow_rate for flow in self.flow_system.flows.values()}, **{ - comp: comp.initial_charge_state + comp.label_full: comp.initial_charge_state for comp in self.flow_system.components.values() if isinstance(comp, Storage) }, @@ -423,10 +424,10 @@ def _transfer_start_values(self, segment_index: int): def _reset_start_values(self): """This resets the start values of all Elements to its original state""" for flow in self.flow_system.flows.values(): - flow.previous_flow_rate = self._original_start_values[flow] + flow.previous_flow_rate = self._original_start_values[flow.label_full] for comp in self.flow_system.components.values(): if isinstance(comp, Storage): - comp.initial_charge_state = self._original_start_values[comp] + comp.initial_charge_state = self._original_start_values[comp.label_full] def _calculate_timesteps_of_segment(self) -> List[pd.DatetimeIndex]: active_timesteps_per_segment = [] From 415b5479b5be1ef5a1db7789ed4bb04a1327655d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 4 Mar 2025 17:10:51 +0100 Subject: [PATCH 291/507] Prevent warnings in tests --- tests/test_integration.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_integration.py b/tests/test_integration.py index ae8385480..d79478a51 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -277,10 +277,10 @@ def modeling_calculation(self, request): coal_tariff, gas_tariff = ( fx.Source( 'Kohletarif', - source=fx.Flow('Q_Kohle', bus='Kohle', size=1000, effects_per_flow_hour={costs: 4.6, CO2: 0.3}), + source=fx.Flow('Q_Kohle', bus='Kohle', size=1000, effects_per_flow_hour={costs.label: 4.6, CO2.label: 0.3}), ), fx.Source( - 'Gastarif', source=fx.Flow('Q_Gas', bus='Gas', size=1000, effects_per_flow_hour={costs: gP, CO2: 0.3}) + 'Gastarif', source=fx.Flow('Q_Gas', bus='Gas', size=1000, effects_per_flow_hour={costs.label: gP, CO2.label: 0.3}) ), ) @@ -292,7 +292,7 @@ def modeling_calculation(self, request): fx.Sink('Einspeisung', sink=fx.Flow('P_el', bus='Strom', size=1000, effects_per_flow_hour=p_feed_in)), fx.Source( 'Stromtarif', - source=fx.Flow('P_el', bus='Strom', size=1000, effects_per_flow_hour={costs: p_sell, CO2: 0.3}), + source=fx.Flow('P_el', bus='Strom', size=1000, effects_per_flow_hour={costs.label: p_sell, CO2.label: 0.3}), ), ) @@ -363,4 +363,4 @@ def test_modeling_types_costs(self, modeling_calculation): ) if __name__ == '__main__': - pytest.main(['-v', '--disable-warnings']) + pytest.main(['-v']) From c7f91fd37f7fed623953cc512cdd13ed8ad13f2d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 4 Mar 2025 17:21:42 +0100 Subject: [PATCH 292/507] Read TestComponents --- tests/test_integration.py | 127 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/tests/test_integration.py b/tests/test_integration.py index d79478a51..50db8e0d1 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -206,6 +206,133 @@ def test_results_persistence(self, simple_flow_system): 'CO2 doesnt match expected value' ) + +class TestComponents: + @pytest.fixture + def basic_flow_system(self) -> fx.FlowSystem: + """Create basic elements for component testing""" + flow_system = fx.FlowSystem(pd.date_range('2020-01-01', periods=10, freq='h', name='time')) + Q_th_Last = np.array([np.random.random() for _ in range(10)]) * 180 + p_el = (np.array([np.random.random() for _ in range(10)]) + 0.5) / 1.5 * 50 + + flow_system.add_elements( + fx.Bus('Strom'), + fx.Bus('Fernwärme'), + fx.Bus('Gas'), + fx.Effect('Costs', '€', 'Kosten', is_standard=True, is_objective=True), + fx.Sink('Wärmelast', sink=fx.Flow('Q_th_Last', 'Fernwärme', size=1, fixed_relative_profile=Q_th_Last)), + fx.Source('Gastarif', source=fx.Flow('Q_Gas', 'Gas', size=1000, effects_per_flow_hour=0.04)), + fx.Sink('Einspeisung', sink=fx.Flow('P_el', 'Strom', effects_per_flow_hour=-1 * p_el)), + ) + + return flow_system + + def test_transmission_basic(self, basic_flow_system): + """Test basic transmission functionality""" + flow_system = basic_flow_system + flow_system.add_elements(fx.Bus('Wärme lokal')) + + boiler = fx.linear_converters.Boiler( + 'Boiler', + eta=0.5, + Q_th=fx.Flow('Q_th', bus='Wärme lokal'), + Q_fu=fx.Flow('Q_fu', bus='Gas') + ) + + transmission = fx.Transmission( + 'Rohr', + relative_losses=0.2, + absolute_losses=20, + in1=fx.Flow('Rohr1', 'Wärme lokal', size=fx.InvestParameters(specific_effects=5, maximum_size=1e6)), + out1=fx.Flow('Rohr2', 'Fernwärme', size=1000), + ) + + flow_system.add_elements(transmission, boiler) + calculation = fx.FullCalculation('Test_Sim', flow_system) + calculation.do_modeling() + calculation.solve(get_solver()) + + # Assertions + assert_almost_equal_numeric( + transmission.in1.model.on_off.on.solution.values, + np.array([1, 1, 1, 1, 1, 1, 1, 1, 1, 1]), + 'On does not work properly' + ) + + assert_almost_equal_numeric( + transmission.in1.model.flow_rate.solution.values * 0.8 - 20, + transmission.out1.model.flow_rate.solution.values, + 'Losses are not computed correctly', + ) + + def test_transmission_advanced(self, basic_flow_system): + """Test advanced transmission functionality""" + flow_system = basic_flow_system + flow_system.add_elements(fx.Bus('Wärme lokal')) + + boiler = fx.linear_converters.Boiler( + 'Boiler_Standard', + eta=0.9, + Q_th=fx.Flow( + 'Q_th', bus='Fernwärme', relative_maximum=np.array([0, 0, 0, 1, 1, 1, 1, 1, 1, 1]) + ), + Q_fu=fx.Flow('Q_fu', bus='Gas'), + ) + + boiler2 = fx.linear_converters.Boiler( + 'Boiler_backup', eta=0.4, Q_th=fx.Flow('Q_th', bus='Wärme lokal'), Q_fu=fx.Flow('Q_fu', bus='Gas') + ) + + last2 = fx.Sink( + 'Wärmelast2', + sink=fx.Flow( + 'Q_th_Last', + bus='Wärme lokal', + size=1, + fixed_relative_profile=flow_system.components['Wärmelast'].sink.fixed_relative_profile * np.array([0, 0, 0, 0, 0, 1, 1, 1, 1, 1]), + ), + ) + + transmission = fx.Transmission( + 'Rohr', + relative_losses=0.2, + absolute_losses=20, + in1=fx.Flow('Rohr1a', bus='Wärme lokal', size=fx.InvestParameters(specific_effects=5, maximum_size=1000)), + out1=fx.Flow('Rohr1b', 'Fernwärme', size=1000), + in2=fx.Flow('Rohr2a', 'Fernwärme', size=1000), + out2=fx.Flow('Rohr2b', bus='Wärme lokal', size=1000), + ) + + flow_system.add_elements(transmission, boiler, boiler2, last2) + calculation = fx.FullCalculation('Test_Transmission', flow_system) + calculation.do_modeling() + calculation.solve(get_solver()) + + # Assertions + assert_almost_equal_numeric( + transmission.in1.model.on_off.on.solution.values, + np.array([1, 1, 1, 0, 0, 0, 0, 0, 0, 0]), + 'On does not work properly' + ) + + assert_almost_equal_numeric( + calculation.results.model.variables['Rohr(Rohr1b)|flow_rate'].solution.values, + transmission.out1.model.flow_rate.solution.values, + 'Flow rate of Rohr__Rohr1b is not correct', + ) + + assert_almost_equal_numeric( + transmission.in1.model.flow_rate.solution.values * 0.8 + - np.array([20 if val > 0.1 else 0 for val in transmission.in1.model.flow_rate.solution.values]), + transmission.out1.model.flow_rate.solution.values, + 'Losses are not computed correctly', + ) + + assert_almost_equal_numeric( + transmission.in1.model._investment.size.solution.item(), + transmission.in2.model._investment.size.solution.item(), + 'The Investments are not equated correctly', + ) # Advanced Modeling Types Test class TestModelingTypes: @pytest.fixture(params=['full', 'segmented', 'aggregated']) From c31e6880671afe34407053edb3c120a4c56ce47a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 4 Mar 2025 17:51:20 +0100 Subject: [PATCH 293/507] First try to add TestComplex back --- tests/test_integration.py | 352 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 352 insertions(+) diff --git a/tests/test_integration.py b/tests/test_integration.py index 50db8e0d1..021e9c88f 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -333,6 +333,358 @@ def test_transmission_advanced(self, basic_flow_system): transmission.in2.model._investment.size.solution.item(), 'The Investments are not equated correctly', ) + + +class TestComplex: + + @pytest.fixture + def flow_system_base(self): + """ + Helper method to create a base model with configurable parameters + """ + Q_th_Last = np.array([30, 0, 90, 110, 110, 20, 20, 20, 20]) + P_el_Last = np.array([40, 40, 40, 40, 40, 40, 40, 40, 40]) + flow_system = fx.FlowSystem(pd.date_range('2020-01-01', periods=9, freq='h', name='time')) + # Define the components and flow_system + flow_system.add_elements( + fx.Effect('costs', '€', 'Kosten', is_standard=True, is_objective=True), + fx.Effect('CO2', 'kg', 'CO2_e-Emissionen', specific_share_to_other_effects_operation={'costs': 0.2}), + fx.Effect('PE', 'kWh_PE', 'Primärenergie', maximum_total=3.5e3), + fx.Bus('Strom'), + fx.Bus('Fernwärme'), + fx.Bus('Gas'), + fx.Sink('Wärmelast', sink=fx.Flow('Q_th_Last', 'Fernwärme', size=1, fixed_relative_profile=Q_th_Last)), + fx.Source('Gastarif', source=fx.Flow('Q_Gas', 'Gas', size=1000, effects_per_flow_hour=0.04)), + fx.Sink('Einspeisung', sink=fx.Flow('P_el', 'Strom', effects_per_flow_hour=-1 * P_el_Last)), + ) + + aGaskessel = fx.linear_converters.Boiler( + 'Kessel', + eta=0.5, + on_off_parameters=fx.OnOffParameters(effects_per_running_hour={'costs': 0, 'CO2': 1000}), + Q_th=fx.Flow( + 'Q_th', + bus='Fernwärme', + load_factor_max=1.0, + load_factor_min=0.1, + relative_minimum=5 / 50, + relative_maximum=1, + previous_flow_rate=50, + size=fx.InvestParameters( + fix_effects=1000, fixed_size=50, optional=False, specific_effects={'costs': 10, 'PE': 2} + ), + on_off_parameters=fx.OnOffParameters( + on_hours_total_min=0, + on_hours_total_max=1000, + consecutive_on_hours_max=10, + consecutive_on_hours_min=1, + consecutive_off_hours_max=10, + effects_per_switch_on=0.01, + switch_on_total_max=1000, + ), + flow_hours_total_max=1e6, + ), + Q_fu=fx.Flow('Q_fu', bus='Gas', size=200, relative_minimum=0, relative_maximum=1), + ) + + aKWK = fx.linear_converters.CHP( + 'KWK', + eta_th=0.5, + eta_el=0.4, + on_off_parameters=fx.OnOffParameters(effects_per_switch_on=0.01), + P_el=fx.Flow('P_el', bus='Strom', size=60, relative_minimum=5 / 60, previous_flow_rate=10), + Q_th=fx.Flow('Q_th', bus='Fernwärme', size=1e3), + Q_fu=fx.Flow('Q_fu', bus='Gas', size=1e3), + ) + + invest_Speicher = fx.InvestParameters( + fix_effects=0, + effects_in_segments=([(5, 25), (25, 100)], + {'costs': [(50, 250), (250, 800)], 'PE': [(5, 25), (25, 100)]} + ), + optional=False, + specific_effects={'costs': 0.01, 'CO2': 0.01}, + minimum_size=0, + maximum_size=1000, + ) + aSpeicher = fx.Storage( + 'Speicher', + charging=fx.Flow('Q_th_load', bus='Fernwärme', size=1e4), + discharging=fx.Flow('Q_th_unload', bus='Fernwärme', size=1e4), + capacity_in_flow_hours=invest_Speicher, + initial_charge_state=0, + maximal_final_charge_state=10, + eta_charge=0.9, + eta_discharge=1, + relative_loss_per_hour=0.08, + prevent_simultaneous_charge_and_discharge=True, + ) + + flow_system.add_elements(aGaskessel, aKWK, aSpeicher) + + return flow_system + + @pytest.fixture + def flow_system_segments_of_flows(self): + Q_th_Last = np.array([30, 0, 90, 110, 110, 20, 20, 20, 20]) + P_el_Last = np.array([40, 40, 40, 40, 40, 40, 40, 40, 40]) + flow_system = fx.FlowSystem(pd.date_range('2020-01-01', periods=9, freq='h', name='time')) + # Define the components and flow_system + flow_system.add_elements( + fx.Effect('costs', '€', 'Kosten', is_standard=True, is_objective=True), + fx.Effect('CO2', 'kg', 'CO2_e-Emissionen', specific_share_to_other_effects_operation={'costs': 0.2}), + fx.Effect('PE', 'kWh_PE', 'Primärenergie', maximum_total=3.5e3), + fx.Bus('Strom'), + fx.Bus('Fernwärme'), + fx.Bus('Gas'), + fx.Sink('Wärmelast', sink=fx.Flow('Q_th_Last', 'Fernwärme', size=1, fixed_relative_profile=Q_th_Last)), + fx.Source('Gastarif', source=fx.Flow('Q_Gas', 'Gas', size=1000, effects_per_flow_hour=0.04)), + fx.Sink('Einspeisung', sink=fx.Flow('P_el', 'Strom', effects_per_flow_hour=-1 * P_el_Last)), + ) + aGaskessel = fx.linear_converters.Boiler( + 'Kessel', + eta=0.5, + on_off_parameters=fx.OnOffParameters(effects_per_running_hour={'costs': 0, 'CO2': 1000}), + Q_th=fx.Flow( + 'Q_th', + bus='Fernwärme', + size=fx.InvestParameters( + fix_effects=1000, fixed_size=50, optional=False, specific_effects={'costs': 10, 'PE': 2} + ), + load_factor_max=1.0, + load_factor_min=0.1, + relative_minimum=5 / 50, + relative_maximum=1, + on_off_parameters=fx.OnOffParameters( + on_hours_total_min=0, + on_hours_total_max=1000, + consecutive_on_hours_max=10, + consecutive_off_hours_max=10, + effects_per_switch_on=0.01, + switch_on_total_max=1000, + ), + previous_flow_rate=50, + flow_hours_total_max=1e6, + ), + Q_fu=fx.Flow('Q_fu', bus='Gas', size=200, relative_minimum=0, relative_maximum=1), + ) + aKWK = fx.LinearConverter( + 'KWK', + inputs=[fx.Flow('Q_fu', bus='Gas')], + outputs=[fx.Flow('P_el', bus='Strom', size=60, relative_maximum=55, previous_flow_rate=10), + fx.Flow('Q_th', bus='Fernwärme')], + segmented_conversion_factors={ + 'P_el': [(5, 30), (40, 60)], + 'Q_th': [(6, 35), (45, 100)], + 'Q_fu': [(12, 70), (90, 200)], + }, + on_off_parameters=fx.OnOffParameters(effects_per_switch_on=0.01), + ) + + costsInvestsizeSegments = ([(5, 25), (25, 100)], {'costs': [(50, 250), (250, 800)], 'PE': [(5, 25), (25, 100)]}) + invest_Speicher = fx.InvestParameters( + fix_effects=0, + effects_in_segments=costsInvestsizeSegments, + optional=False, + specific_effects={'costs': 0.01, 'CO2': 0.01}, + minimum_size=0, + maximum_size=1000, + ) + aSpeicher = fx.Storage( + 'Speicher', + charging=fx.Flow('Q_th_load', bus='Fernwärme', size=1e4), + discharging=fx.Flow('Q_th_unload', bus='Fernwärme', size=1e4), + capacity_in_flow_hours=invest_Speicher, + initial_charge_state=0, + maximal_final_charge_state=10, + eta_charge=0.9, + eta_discharge=1, + relative_loss_per_hour=0.08, + prevent_simultaneous_charge_and_discharge=True, + ) + + flow_system.add_elements(aGaskessel, aKWK, aSpeicher) + + return flow_system + + def test_basic_flow_system(self, flow_system_base): + flow_system = flow_system_base + calculation = fx.FullCalculation('Test_Complex-Basic', flow_system) + calculation.do_modeling() + calculation.solve(get_solver()) + + # Assertions + assert_almost_equal_numeric( + calculation.results.model['costs|total'].solution.item(), + -11597.873624489237, 'costs doesnt match expected value' + ) + + assert_almost_equal_numeric( + calculation.results.model['costs(operation)|total_per_timestep'].solution.values, + [ + -2.38500000e03, + -2.21681333e03, + -2.38500000e03, + -2.17599000e03, + -2.35107029e03, + -2.38500000e03, + 0.00000000e00, + -1.68897826e-10, + -2.16914486e-12, + ], + 'costs doesnt match expected value', + ) + + assert_almost_equal_numeric( + sum(calculation.results.model['CO2(operation)->costs(operation)'].solution.values), + 258.63729669618675, + 'costs doesnt match expected value', + ) + assert_almost_equal_numeric( + sum(calculation.results.model['Kessel(Q_th)->costs(operation)'].solution.values), + 0.01, + 'costs doesnt match expected value', + ) + assert_almost_equal_numeric( + sum(calculation.results.model['Kessel->costs(operation)']), + -0.0, + 'costs doesnt match expected value', + ) + assert_almost_equal_numeric( + sum(calculation.results.model['Gastarif(Q_Gas)->costs(operation)'].solution.values), + 39.09153113079115, + 'costs doesnt match expected value', + ) + assert_almost_equal_numeric( + sum(calculation.results.model['Einspeisung(P_el)->costs(operation)'].solution.values), + -14196.61245231646, + 'costs doesnt match expected value', + ) + assert_almost_equal_numeric( + sum(calculation.results.model['KWK->costs(operation)'].solution.values), + 0.0, + 'costs doesnt match expected value', + ) + + assert_almost_equal_numeric( + calculation.results.model['Kessel(Q_th)->costs(invest)'].solution.values, + 1000 + 500, + 'costs doesnt match expected value', + ) + + assert_almost_equal_numeric( + calculation.results.model['Speicher->costs(invest)'].solution.values, + 800 + 1, + 'costs doesnt match expected value', + ) + + assert_almost_equal_numeric( + calculation.results.model['CO2(operation)->costs(operation)'].solution.values, 1293.1864834809337, + 'CO2 doesnt match expected value' + ) + assert_almost_equal_numeric( + calculation.results.model['CO2(invest)|total'].solution.values, 0.9999999999999994, + 'CO2 doesnt match expected value' + ) + assert_almost_equal_numeric( + calculation.results.model['Kessel(Q_th)|flow_rate'].solution.values, + [0, 0, 0, 45, 0, 0, 0, 0, 0], + 'Kessel doesnt match expected value', + ) + + assert_almost_equal_numeric( + calculation.results.model['KWK(Q_th)|flow_rate'].solution.values, + [ + 7.50000000e01, + 6.97111111e01, + 7.50000000e01, + 7.50000000e01, + 7.39330280e01, + 7.50000000e01, + 0.00000000e00, + 3.12638804e-14, + 3.83693077e-14, + ], + 'KWK Q_th doesnt match expected value', + ) + assert_almost_equal_numeric( + calculation.results.model['KWK(P_el)|flow_rate'].solution.values, + [ + 6.00000000e01, + 5.57688889e01, + 6.00000000e01, + 6.00000000e01, + 5.91464224e01, + 6.00000000e01, + 0.00000000e00, + 2.50111043e-14, + 3.06954462e-14, + ], + 'KWK P_el doesnt match expected value', + ) + + assert_almost_equal_numeric( + calculation.results.model['Speicher|netto_discharge'].solution.values, + [-45.0, -69.71111111, 15.0, -10.0, 36.06697198, -55.0, 20.0, 20.0, 20.0], + 'Speicher nettoFlow doesnt match expected value', + ) + assert_almost_equal_numeric( + calculation.results.model['Speicher|charge_state'].solution.values, + [0.0, 40.5, 100.0, 77.0, 79.84, 37.38582802, 83.89496178, 57.18336484, 32.60869565, 10.0], + 'Speicher nettoFlow doesnt match expected value', + ) + + assert_almost_equal_numeric( + calculation.results.model['Speicher|SegmentedShares|costs'].solution.values, + 800, + 'Speicher investCosts_segmented_costs doesnt match expected value', + ) + + def test_segments_of_flows(self, flow_system_segments_of_flows): + flow_system = flow_system_segments_of_flows + calculation = fx.FullCalculation('Test_Complex-Segments', flow_system) + calculation.do_modeling() + calculation.solve(get_solver()) + effects = calculation.flow_system.effects + comps = calculation.flow_system.components + + # Compare expected values with actual values + assert_almost_equal_numeric( + effects['costs'].model.total.solution.item(), -10710.997365760755, 'costs doesnt match expected value' + ) + assert_almost_equal_numeric( + effects['CO2'].model.total.solution.item(), 1278.7939026086956, 'CO2 doesnt match expected value' + ) + assert_almost_equal_numeric( + comps['Kessel'].Q_th.model.flow_rate.solution.values, + [0, 0, 0, 45, 0, 0, 0, 0, 0], + 'Kessel doesnt match expected value', + ) + kwk_flows = {flow.label: flow for flow in comps['KWK'].inputs + comps['KWK'].outputs} + assert_almost_equal_numeric( + kwk_flows['Q_th'].model.flow_rate.solution.values, + [45.0, 45.0, 64.5962087, 100.0, 61.3136, 45.0, 45.0, 12.86469565, 0.0], + 'KWK Q_th doesnt match expected value', + ) + assert_almost_equal_numeric( + kwk_flows['P_el'].model.flow_rate.solution.values, + [40.0, 40.0, 47.12589407, 60.0, 45.93221818, 40.0, 40.0, 10.91784108, -0.0], + 'KWK P_el doesnt match expected value', + ) + + assert_almost_equal_numeric( + comps['Speicher'].model.netto_discharge.solution.values, + [-15.0, -45.0, 25.4037913, -35.0, 48.6864, -25.0, -25.0, 7.13530435, 20.0], + 'Speicher nettoFlow doesnt match expected value', + ) + + assert_almost_equal_numeric( + comps['Speicher'].model.variables['Speicher|SegmentedShares|costs'].solution.values, + 454.74666666666667, + 'Speicher investCosts_segmented_costs doesnt match expected value', + ) + + # Advanced Modeling Types Test class TestModelingTypes: @pytest.fixture(params=['full', 'segmented', 'aggregated']) From a7b43d0a803eba962feaff5567297c9d3cee46ee Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 5 Mar 2025 09:13:23 +0100 Subject: [PATCH 294/507] Bugfixes in test updates --- tests/test_integration.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_integration.py b/tests/test_integration.py index 021e9c88f..7ea7af2e7 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -354,7 +354,7 @@ def flow_system_base(self): fx.Bus('Fernwärme'), fx.Bus('Gas'), fx.Sink('Wärmelast', sink=fx.Flow('Q_th_Last', 'Fernwärme', size=1, fixed_relative_profile=Q_th_Last)), - fx.Source('Gastarif', source=fx.Flow('Q_Gas', 'Gas', size=1000, effects_per_flow_hour=0.04)), + fx.Source('Gastarif', source=fx.Flow('Q_Gas', 'Gas', size=1000, effects_per_flow_hour={'costs': 0.04, 'CO2': 0.3})), fx.Sink('Einspeisung', sink=fx.Flow('P_el', 'Strom', effects_per_flow_hour=-1 * P_el_Last)), ) @@ -438,7 +438,7 @@ def flow_system_segments_of_flows(self): fx.Bus('Fernwärme'), fx.Bus('Gas'), fx.Sink('Wärmelast', sink=fx.Flow('Q_th_Last', 'Fernwärme', size=1, fixed_relative_profile=Q_th_Last)), - fx.Source('Gastarif', source=fx.Flow('Q_Gas', 'Gas', size=1000, effects_per_flow_hour=0.04)), + fx.Source('Gastarif', source=fx.Flow('Q_Gas', 'Gas', size=1000, effects_per_flow_hour={'costs': 0.04, 'CO2': 0.3})), fx.Sink('Einspeisung', sink=fx.Flow('P_el', 'Strom', effects_per_flow_hour=-1 * P_el_Last)), ) aGaskessel = fx.linear_converters.Boiler( @@ -546,7 +546,7 @@ def test_basic_flow_system(self, flow_system_base): 'costs doesnt match expected value', ) assert_almost_equal_numeric( - sum(calculation.results.model['Kessel->costs(operation)']), + sum(calculation.results.model['Kessel->costs(operation)'].solution.values), -0.0, 'costs doesnt match expected value', ) @@ -579,7 +579,7 @@ def test_basic_flow_system(self, flow_system_base): ) assert_almost_equal_numeric( - calculation.results.model['CO2(operation)->costs(operation)'].solution.values, 1293.1864834809337, + calculation.results.model['CO2(operation)|total'].solution.values, 1293.1864834809337, 'CO2 doesnt match expected value' ) assert_almost_equal_numeric( From 788d9fb8fec4b525cc59a194edbe6d1406c3204e Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 5 Mar 2025 09:13:37 +0100 Subject: [PATCH 295/507] Updated type hint --- flixOpt/interface.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixOpt/interface.py b/flixOpt/interface.py index a82451d22..ec4462633 100644 --- a/flixOpt/interface.py +++ b/flixOpt/interface.py @@ -36,7 +36,7 @@ def __init__( fix_effects: Optional['EffectValuesUserScalar'] = None, specific_effects: Optional['EffectValuesUserScalar'] = None, # costs per Flow-Unit/Storage-Size/... effects_in_segments: Optional[ - Tuple[List[Tuple[Scalar, Scalar]], Dict['Effect', List[Tuple[Scalar, Scalar]]]] + Tuple[List[Tuple[Scalar, Scalar]], Dict['str', List[Tuple[Scalar, Scalar]]]] ] = None, divest_effects: Optional['EffectValuesUserScalar'] = None, ): From 56b48b8d848b0e9d1e433a43a1da06faa1209474 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 5 Mar 2025 09:41:10 +0100 Subject: [PATCH 296/507] Move flow_systems from Test to conftest.py --- tests/conftest.py | 321 ++++++++++++++++++++++++++++++++++++++ tests/test_integration.py | 321 +------------------------------------- 2 files changed, 322 insertions(+), 320 deletions(-) create mode 100644 tests/conftest.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..87ea7f8ea --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,321 @@ +import pytest +import numpy as np +import pandas as pd +import flixOpt as fx + + +def get_solver(): + return fx.solvers.HighsSolver(mip_gap=0, time_limit_seconds=300) + + +@pytest.fixture(params=['highs', 'gurobi']) +def solver_fixture(request): + return { + 'highs': fx.solvers.HighsSolver(0.0001, 60), + 'gurobi': fx.solvers.GurobiSolver(0.0001, 60), + }[request.param] + + +# Custom assertion function +def assert_almost_equal_numeric( + actual, + desired, + err_msg, + relative_error_range_in_percent=0.011, + absolute_tolerance=1e-9 +): + """ + Custom assertion function for comparing numeric values with relative and absolute tolerances + """ + relative_tol = relative_error_range_in_percent / 100 + + if isinstance(desired, (int, float)): + delta = abs(relative_tol * desired) if desired != 0 else absolute_tolerance + assert np.isclose(actual, desired, atol=delta), err_msg + else: + np.testing.assert_allclose( + actual, + desired, + rtol=relative_tol, + atol=absolute_tolerance, + err_msg=err_msg + ) + + +@pytest.fixture +def simple_flow_system() -> fx.FlowSystem: + """ + Create a simple energy system for testing + """ + base_thermal_load = np.array([30.0, 0.0, 90.0, 110, 110, 20, 20, 20, 20]) + base_electrical_price = 1 / 1000 * np.array([80.0, 80.0, 80.0, 80, 80, 80, 80, 80, 80]) + base_timesteps = pd.date_range('2020-01-01', periods=9, freq='h', name='time') + # Define effects + costs = fx.Effect('costs', '€', 'Kosten', is_standard=True, is_objective=True) + CO2 = fx.Effect( + 'CO2', + 'kg', + 'CO2_e-Emissionen', + specific_share_to_other_effects_operation={costs.label: 0.2}, + maximum_operation_per_hour=1000, + ) + + # Create components + boiler = fx.linear_converters.Boiler( + 'Boiler', + eta=0.5, + Q_th=fx.Flow( + 'Q_th', + bus='Fernwärme', + size=50, + relative_minimum=5 / 50, + relative_maximum=1, + on_off_parameters=fx.OnOffParameters(), + ), + Q_fu=fx.Flow('Q_fu', bus='Gas'), + ) + + chp = fx.linear_converters.CHP( + 'CHP_unit', + eta_th=0.5, + eta_el=0.4, + P_el=fx.Flow('P_el', bus='Strom', size=60, relative_minimum=5 / 60, on_off_parameters=fx.OnOffParameters()), + Q_th=fx.Flow('Q_th', bus='Fernwärme'), + Q_fu=fx.Flow('Q_fu', bus='Gas'), + ) + + storage = fx.Storage( + 'Speicher', + charging=fx.Flow('Q_th_load', bus='Fernwärme', size=1e4), + discharging=fx.Flow('Q_th_unload', bus='Fernwärme', size=1e4), + capacity_in_flow_hours=fx.InvestParameters(fix_effects=20, fixed_size=30, optional=False), + initial_charge_state=0, + relative_maximum_charge_state=1 / 100 * np.array([80.0, 70.0, 80.0, 80, 80, 80, 80, 80, 80, 80]), + eta_charge=0.9, + eta_discharge=1, + relative_loss_per_hour=0.08, + prevent_simultaneous_charge_and_discharge=True, + ) + + heat_load = fx.Sink( + 'Wärmelast', + sink=fx.Flow('Q_th_Last', bus='Fernwärme', size=1, fixed_relative_profile=base_thermal_load) + ) + + gas_tariff = fx.Source( + 'Gastarif', + source=fx.Flow('Q_Gas', bus='Gas', size=1000, effects_per_flow_hour={'costs': 0.04, 'CO2': 0.3}) + ) + + electricity_feed_in = fx.Sink( + 'Einspeisung', + sink=fx.Flow('P_el', bus='Strom', effects_per_flow_hour=-1 * base_electrical_price) + ) + + # Create flow system + flow_system = fx.FlowSystem(base_timesteps) + flow_system.add_elements( + fx.Bus('Strom'), + fx.Bus('Fernwärme'), + fx.Bus('Gas') + ) + flow_system.add_elements(storage, costs, CO2, boiler, heat_load, gas_tariff, electricity_feed_in, chp) + + # Create and solve calculation + calculation = fx.FullCalculation('Test_Sim', flow_system) + calculation.do_modeling() + calculation.solve(get_solver()) + + return calculation + + +@pytest.fixture +def basic_flow_system() -> fx.FlowSystem: + """Create basic elements for component testing""" + flow_system = fx.FlowSystem(pd.date_range('2020-01-01', periods=10, freq='h', name='time')) + Q_th_Last = np.array([np.random.random() for _ in range(10)]) * 180 + p_el = (np.array([np.random.random() for _ in range(10)]) + 0.5) / 1.5 * 50 + + flow_system.add_elements( + fx.Bus('Strom'), + fx.Bus('Fernwärme'), + fx.Bus('Gas'), + fx.Effect('Costs', '€', 'Kosten', is_standard=True, is_objective=True), + fx.Sink('Wärmelast', sink=fx.Flow('Q_th_Last', 'Fernwärme', size=1, fixed_relative_profile=Q_th_Last)), + fx.Source('Gastarif', source=fx.Flow('Q_Gas', 'Gas', size=1000, effects_per_flow_hour=0.04)), + fx.Sink('Einspeisung', sink=fx.Flow('P_el', 'Strom', effects_per_flow_hour=-1 * p_el)), + ) + + return flow_system + + +@pytest.fixture +def flow_system_base() -> fx.FlowSystem: + """ + Helper method to create a base model with configurable parameters + """ + Q_th_Last = np.array([30, 0, 90, 110, 110, 20, 20, 20, 20]) + P_el_Last = np.array([40, 40, 40, 40, 40, 40, 40, 40, 40]) + flow_system = fx.FlowSystem(pd.date_range('2020-01-01', periods=9, freq='h', name='time')) + # Define the components and flow_system + flow_system.add_elements( + fx.Effect('costs', '€', 'Kosten', is_standard=True, is_objective=True), + fx.Effect('CO2', 'kg', 'CO2_e-Emissionen', specific_share_to_other_effects_operation={'costs': 0.2}), + fx.Effect('PE', 'kWh_PE', 'Primärenergie', maximum_total=3.5e3), + fx.Bus('Strom'), + fx.Bus('Fernwärme'), + fx.Bus('Gas'), + fx.Sink('Wärmelast', sink=fx.Flow('Q_th_Last', 'Fernwärme', size=1, fixed_relative_profile=Q_th_Last)), + fx.Source('Gastarif', source=fx.Flow('Q_Gas', 'Gas', size=1000, effects_per_flow_hour={'costs': 0.04, 'CO2': 0.3})), + fx.Sink('Einspeisung', sink=fx.Flow('P_el', 'Strom', effects_per_flow_hour=-1 * P_el_Last)), + ) + + aGaskessel = fx.linear_converters.Boiler( + 'Kessel', + eta=0.5, + on_off_parameters=fx.OnOffParameters(effects_per_running_hour={'costs': 0, 'CO2': 1000}), + Q_th=fx.Flow( + 'Q_th', + bus='Fernwärme', + load_factor_max=1.0, + load_factor_min=0.1, + relative_minimum=5 / 50, + relative_maximum=1, + previous_flow_rate=50, + size=fx.InvestParameters( + fix_effects=1000, fixed_size=50, optional=False, specific_effects={'costs': 10, 'PE': 2} + ), + on_off_parameters=fx.OnOffParameters( + on_hours_total_min=0, + on_hours_total_max=1000, + consecutive_on_hours_max=10, + consecutive_on_hours_min=1, + consecutive_off_hours_max=10, + effects_per_switch_on=0.01, + switch_on_total_max=1000, + ), + flow_hours_total_max=1e6, + ), + Q_fu=fx.Flow('Q_fu', bus='Gas', size=200, relative_minimum=0, relative_maximum=1), + ) + + aKWK = fx.linear_converters.CHP( + 'KWK', + eta_th=0.5, + eta_el=0.4, + on_off_parameters=fx.OnOffParameters(effects_per_switch_on=0.01), + P_el=fx.Flow('P_el', bus='Strom', size=60, relative_minimum=5 / 60, previous_flow_rate=10), + Q_th=fx.Flow('Q_th', bus='Fernwärme', size=1e3), + Q_fu=fx.Flow('Q_fu', bus='Gas', size=1e3), + ) + + invest_Speicher = fx.InvestParameters( + fix_effects=0, + effects_in_segments=([(5, 25), (25, 100)], + {'costs': [(50, 250), (250, 800)], 'PE': [(5, 25), (25, 100)]} + ), + optional=False, + specific_effects={'costs': 0.01, 'CO2': 0.01}, + minimum_size=0, + maximum_size=1000, + ) + aSpeicher = fx.Storage( + 'Speicher', + charging=fx.Flow('Q_th_load', bus='Fernwärme', size=1e4), + discharging=fx.Flow('Q_th_unload', bus='Fernwärme', size=1e4), + capacity_in_flow_hours=invest_Speicher, + initial_charge_state=0, + maximal_final_charge_state=10, + eta_charge=0.9, + eta_discharge=1, + relative_loss_per_hour=0.08, + prevent_simultaneous_charge_and_discharge=True, + ) + + flow_system.add_elements(aGaskessel, aKWK, aSpeicher) + + return flow_system + + +@pytest.fixture +def flow_system_segments_of_flows() -> fx.FlowSystem: + Q_th_Last = np.array([30, 0, 90, 110, 110, 20, 20, 20, 20]) + P_el_Last = np.array([40, 40, 40, 40, 40, 40, 40, 40, 40]) + flow_system = fx.FlowSystem(pd.date_range('2020-01-01', periods=9, freq='h', name='time')) + # Define the components and flow_system + flow_system.add_elements( + fx.Effect('costs', '€', 'Kosten', is_standard=True, is_objective=True), + fx.Effect('CO2', 'kg', 'CO2_e-Emissionen', specific_share_to_other_effects_operation={'costs': 0.2}), + fx.Effect('PE', 'kWh_PE', 'Primärenergie', maximum_total=3.5e3), + fx.Bus('Strom'), + fx.Bus('Fernwärme'), + fx.Bus('Gas'), + fx.Sink('Wärmelast', sink=fx.Flow('Q_th_Last', 'Fernwärme', size=1, fixed_relative_profile=Q_th_Last)), + fx.Source('Gastarif', source=fx.Flow('Q_Gas', 'Gas', size=1000, effects_per_flow_hour={'costs': 0.04, 'CO2': 0.3})), + fx.Sink('Einspeisung', sink=fx.Flow('P_el', 'Strom', effects_per_flow_hour=-1 * P_el_Last)), + ) + aGaskessel = fx.linear_converters.Boiler( + 'Kessel', + eta=0.5, + on_off_parameters=fx.OnOffParameters(effects_per_running_hour={'costs': 0, 'CO2': 1000}), + Q_th=fx.Flow( + 'Q_th', + bus='Fernwärme', + size=fx.InvestParameters( + fix_effects=1000, fixed_size=50, optional=False, specific_effects={'costs': 10, 'PE': 2} + ), + load_factor_max=1.0, + load_factor_min=0.1, + relative_minimum=5 / 50, + relative_maximum=1, + on_off_parameters=fx.OnOffParameters( + on_hours_total_min=0, + on_hours_total_max=1000, + consecutive_on_hours_max=10, + consecutive_off_hours_max=10, + effects_per_switch_on=0.01, + switch_on_total_max=1000, + ), + previous_flow_rate=50, + flow_hours_total_max=1e6, + ), + Q_fu=fx.Flow('Q_fu', bus='Gas', size=200, relative_minimum=0, relative_maximum=1), + ) + aKWK = fx.LinearConverter( + 'KWK', + inputs=[fx.Flow('Q_fu', bus='Gas')], + outputs=[fx.Flow('P_el', bus='Strom', size=60, relative_maximum=55, previous_flow_rate=10), + fx.Flow('Q_th', bus='Fernwärme')], + segmented_conversion_factors={ + 'P_el': [(5, 30), (40, 60)], + 'Q_th': [(6, 35), (45, 100)], + 'Q_fu': [(12, 70), (90, 200)], + }, + on_off_parameters=fx.OnOffParameters(effects_per_switch_on=0.01), + ) + + costsInvestsizeSegments = ([(5, 25), (25, 100)], {'costs': [(50, 250), (250, 800)], 'PE': [(5, 25), (25, 100)]}) + invest_Speicher = fx.InvestParameters( + fix_effects=0, + effects_in_segments=costsInvestsizeSegments, + optional=False, + specific_effects={'costs': 0.01, 'CO2': 0.01}, + minimum_size=0, + maximum_size=1000, + ) + aSpeicher = fx.Storage( + 'Speicher', + charging=fx.Flow('Q_th_load', bus='Fernwärme', size=1e4), + discharging=fx.Flow('Q_th_unload', bus='Fernwärme', size=1e4), + capacity_in_flow_hours=invest_Speicher, + initial_charge_state=0, + maximal_final_charge_state=10, + eta_charge=0.9, + eta_discharge=1, + relative_loss_per_hour=0.08, + prevent_simultaneous_charge_and_discharge=True, + ) + + flow_system.add_elements(aGaskessel, aKWK, aSpeicher) + + return flow_system diff --git a/tests/test_integration.py b/tests/test_integration.py index 7ea7af2e7..948f41c99 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -7,140 +7,10 @@ import pandas as pd import pytest +from .conftest import assert_almost_equal_numeric, get_solver, basic_flow_system, simple_flow_system -# Utility function to get solver -def get_solver(): - return fx.solvers.HighsSolver(mip_gap=0, time_limit_seconds=300) - -# Custom assertion function -def assert_almost_equal_numeric( - actual, - desired, - err_msg, - relative_error_range_in_percent=0.011, - absolute_tolerance=1e-9 -): - """ - Custom assertion function for comparing numeric values with relative and absolute tolerances - """ - relative_tol = relative_error_range_in_percent / 100 - - if isinstance(desired, (int, float)): - delta = abs(relative_tol * desired) if desired != 0 else absolute_tolerance - assert np.isclose(actual, desired, atol=delta), err_msg - else: - np.testing.assert_allclose( - actual, - desired, - rtol=relative_tol, - atol=absolute_tolerance, - err_msg=err_msg - ) - -@pytest.fixture -def base_timesteps(): - """Fixture for creating base timesteps""" - return pd.date_range('2020-01-01', periods=9, freq='h', name='time') - -@pytest.fixture -def base_thermal_load(): - """Fixture for creating base thermal load profile""" - return np.array([30.0, 0.0, 90.0, 110, 110, 20, 20, 20, 20]) - -@pytest.fixture -def base_electrical_load(): - """Fixture for creating base electrical load profile""" - return np.array([40.0, 40.0, 40.0, 40, 40, 40, 40, 40, 40]) - -@pytest.fixture -def base_electrical_price(): - """Fixture for creating base electrical price profile""" - return 1 / 1000 * np.array([80.0, 80.0, 80.0, 80, 80, 80, 80, 80, 80]) class TestFlowSystem: - @pytest.fixture - def simple_flow_system(self, base_timesteps, base_thermal_load, base_electrical_price): - """ - Create a simple energy system for testing - """ - # Define effects - costs = fx.Effect('costs', '€', 'Kosten', is_standard=True, is_objective=True) - CO2 = fx.Effect( - 'CO2', - 'kg', - 'CO2_e-Emissionen', - specific_share_to_other_effects_operation={costs.label: 0.2}, - maximum_operation_per_hour=1000, - ) - - # Create components - boiler = fx.linear_converters.Boiler( - 'Boiler', - eta=0.5, - Q_th=fx.Flow( - 'Q_th', - bus='Fernwärme', - size=50, - relative_minimum=5/50, - relative_maximum=1, - on_off_parameters=fx.OnOffParameters(), - ), - Q_fu=fx.Flow('Q_fu', bus='Gas'), - ) - - chp = fx.linear_converters.CHP( - 'CHP_unit', - eta_th=0.5, - eta_el=0.4, - P_el=fx.Flow('P_el', bus='Strom', size=60, relative_minimum=5/60, on_off_parameters=fx.OnOffParameters()), - Q_th=fx.Flow('Q_th', bus='Fernwärme'), - Q_fu=fx.Flow('Q_fu', bus='Gas'), - ) - - storage = fx.Storage( - 'Speicher', - charging=fx.Flow('Q_th_load', bus='Fernwärme', size=1e4), - discharging=fx.Flow('Q_th_unload', bus='Fernwärme', size=1e4), - capacity_in_flow_hours=fx.InvestParameters(fix_effects=20, fixed_size=30, optional=False), - initial_charge_state=0, - relative_maximum_charge_state=1/100 * np.array([80.0, 70.0, 80.0, 80, 80, 80, 80, 80, 80, 80]), - eta_charge=0.9, - eta_discharge=1, - relative_loss_per_hour=0.08, - prevent_simultaneous_charge_and_discharge=True, - ) - - heat_load = fx.Sink( - 'Wärmelast', - sink=fx.Flow('Q_th_Last', bus='Fernwärme', size=1, fixed_relative_profile=base_thermal_load) - ) - - gas_tariff = fx.Source( - 'Gastarif', - source=fx.Flow('Q_Gas', bus='Gas', size=1000, effects_per_flow_hour={costs: 0.04, CO2: 0.3}) - ) - - electricity_feed_in = fx.Sink( - 'Einspeisung', - sink=fx.Flow('P_el', bus='Strom', effects_per_flow_hour=-1 * base_electrical_price) - ) - - # Create flow system - flow_system = fx.FlowSystem(base_timesteps) - flow_system.add_elements( - fx.Bus('Strom'), - fx.Bus('Fernwärme'), - fx.Bus('Gas') - ) - flow_system.add_elements(storage, costs, CO2, boiler, heat_load, gas_tariff, electricity_feed_in, chp) - - # Create and solve calculation - calculation = fx.FullCalculation('Test_Sim', flow_system) - calculation.do_modeling() - calculation.solve(get_solver()) - - return calculation - def test_simple_flow_system(self, simple_flow_system): """ Test the effects of the simple energy system model @@ -208,25 +78,6 @@ def test_results_persistence(self, simple_flow_system): class TestComponents: - @pytest.fixture - def basic_flow_system(self) -> fx.FlowSystem: - """Create basic elements for component testing""" - flow_system = fx.FlowSystem(pd.date_range('2020-01-01', periods=10, freq='h', name='time')) - Q_th_Last = np.array([np.random.random() for _ in range(10)]) * 180 - p_el = (np.array([np.random.random() for _ in range(10)]) + 0.5) / 1.5 * 50 - - flow_system.add_elements( - fx.Bus('Strom'), - fx.Bus('Fernwärme'), - fx.Bus('Gas'), - fx.Effect('Costs', '€', 'Kosten', is_standard=True, is_objective=True), - fx.Sink('Wärmelast', sink=fx.Flow('Q_th_Last', 'Fernwärme', size=1, fixed_relative_profile=Q_th_Last)), - fx.Source('Gastarif', source=fx.Flow('Q_Gas', 'Gas', size=1000, effects_per_flow_hour=0.04)), - fx.Sink('Einspeisung', sink=fx.Flow('P_el', 'Strom', effects_per_flow_hour=-1 * p_el)), - ) - - return flow_system - def test_transmission_basic(self, basic_flow_system): """Test basic transmission functionality""" flow_system = basic_flow_system @@ -337,176 +188,6 @@ def test_transmission_advanced(self, basic_flow_system): class TestComplex: - @pytest.fixture - def flow_system_base(self): - """ - Helper method to create a base model with configurable parameters - """ - Q_th_Last = np.array([30, 0, 90, 110, 110, 20, 20, 20, 20]) - P_el_Last = np.array([40, 40, 40, 40, 40, 40, 40, 40, 40]) - flow_system = fx.FlowSystem(pd.date_range('2020-01-01', periods=9, freq='h', name='time')) - # Define the components and flow_system - flow_system.add_elements( - fx.Effect('costs', '€', 'Kosten', is_standard=True, is_objective=True), - fx.Effect('CO2', 'kg', 'CO2_e-Emissionen', specific_share_to_other_effects_operation={'costs': 0.2}), - fx.Effect('PE', 'kWh_PE', 'Primärenergie', maximum_total=3.5e3), - fx.Bus('Strom'), - fx.Bus('Fernwärme'), - fx.Bus('Gas'), - fx.Sink('Wärmelast', sink=fx.Flow('Q_th_Last', 'Fernwärme', size=1, fixed_relative_profile=Q_th_Last)), - fx.Source('Gastarif', source=fx.Flow('Q_Gas', 'Gas', size=1000, effects_per_flow_hour={'costs': 0.04, 'CO2': 0.3})), - fx.Sink('Einspeisung', sink=fx.Flow('P_el', 'Strom', effects_per_flow_hour=-1 * P_el_Last)), - ) - - aGaskessel = fx.linear_converters.Boiler( - 'Kessel', - eta=0.5, - on_off_parameters=fx.OnOffParameters(effects_per_running_hour={'costs': 0, 'CO2': 1000}), - Q_th=fx.Flow( - 'Q_th', - bus='Fernwärme', - load_factor_max=1.0, - load_factor_min=0.1, - relative_minimum=5 / 50, - relative_maximum=1, - previous_flow_rate=50, - size=fx.InvestParameters( - fix_effects=1000, fixed_size=50, optional=False, specific_effects={'costs': 10, 'PE': 2} - ), - on_off_parameters=fx.OnOffParameters( - on_hours_total_min=0, - on_hours_total_max=1000, - consecutive_on_hours_max=10, - consecutive_on_hours_min=1, - consecutive_off_hours_max=10, - effects_per_switch_on=0.01, - switch_on_total_max=1000, - ), - flow_hours_total_max=1e6, - ), - Q_fu=fx.Flow('Q_fu', bus='Gas', size=200, relative_minimum=0, relative_maximum=1), - ) - - aKWK = fx.linear_converters.CHP( - 'KWK', - eta_th=0.5, - eta_el=0.4, - on_off_parameters=fx.OnOffParameters(effects_per_switch_on=0.01), - P_el=fx.Flow('P_el', bus='Strom', size=60, relative_minimum=5 / 60, previous_flow_rate=10), - Q_th=fx.Flow('Q_th', bus='Fernwärme', size=1e3), - Q_fu=fx.Flow('Q_fu', bus='Gas', size=1e3), - ) - - invest_Speicher = fx.InvestParameters( - fix_effects=0, - effects_in_segments=([(5, 25), (25, 100)], - {'costs': [(50, 250), (250, 800)], 'PE': [(5, 25), (25, 100)]} - ), - optional=False, - specific_effects={'costs': 0.01, 'CO2': 0.01}, - minimum_size=0, - maximum_size=1000, - ) - aSpeicher = fx.Storage( - 'Speicher', - charging=fx.Flow('Q_th_load', bus='Fernwärme', size=1e4), - discharging=fx.Flow('Q_th_unload', bus='Fernwärme', size=1e4), - capacity_in_flow_hours=invest_Speicher, - initial_charge_state=0, - maximal_final_charge_state=10, - eta_charge=0.9, - eta_discharge=1, - relative_loss_per_hour=0.08, - prevent_simultaneous_charge_and_discharge=True, - ) - - flow_system.add_elements(aGaskessel, aKWK, aSpeicher) - - return flow_system - - @pytest.fixture - def flow_system_segments_of_flows(self): - Q_th_Last = np.array([30, 0, 90, 110, 110, 20, 20, 20, 20]) - P_el_Last = np.array([40, 40, 40, 40, 40, 40, 40, 40, 40]) - flow_system = fx.FlowSystem(pd.date_range('2020-01-01', periods=9, freq='h', name='time')) - # Define the components and flow_system - flow_system.add_elements( - fx.Effect('costs', '€', 'Kosten', is_standard=True, is_objective=True), - fx.Effect('CO2', 'kg', 'CO2_e-Emissionen', specific_share_to_other_effects_operation={'costs': 0.2}), - fx.Effect('PE', 'kWh_PE', 'Primärenergie', maximum_total=3.5e3), - fx.Bus('Strom'), - fx.Bus('Fernwärme'), - fx.Bus('Gas'), - fx.Sink('Wärmelast', sink=fx.Flow('Q_th_Last', 'Fernwärme', size=1, fixed_relative_profile=Q_th_Last)), - fx.Source('Gastarif', source=fx.Flow('Q_Gas', 'Gas', size=1000, effects_per_flow_hour={'costs': 0.04, 'CO2': 0.3})), - fx.Sink('Einspeisung', sink=fx.Flow('P_el', 'Strom', effects_per_flow_hour=-1 * P_el_Last)), - ) - aGaskessel = fx.linear_converters.Boiler( - 'Kessel', - eta=0.5, - on_off_parameters=fx.OnOffParameters(effects_per_running_hour={'costs': 0, 'CO2': 1000}), - Q_th=fx.Flow( - 'Q_th', - bus='Fernwärme', - size=fx.InvestParameters( - fix_effects=1000, fixed_size=50, optional=False, specific_effects={'costs': 10, 'PE': 2} - ), - load_factor_max=1.0, - load_factor_min=0.1, - relative_minimum=5 / 50, - relative_maximum=1, - on_off_parameters=fx.OnOffParameters( - on_hours_total_min=0, - on_hours_total_max=1000, - consecutive_on_hours_max=10, - consecutive_off_hours_max=10, - effects_per_switch_on=0.01, - switch_on_total_max=1000, - ), - previous_flow_rate=50, - flow_hours_total_max=1e6, - ), - Q_fu=fx.Flow('Q_fu', bus='Gas', size=200, relative_minimum=0, relative_maximum=1), - ) - aKWK = fx.LinearConverter( - 'KWK', - inputs=[fx.Flow('Q_fu', bus='Gas')], - outputs=[fx.Flow('P_el', bus='Strom', size=60, relative_maximum=55, previous_flow_rate=10), - fx.Flow('Q_th', bus='Fernwärme')], - segmented_conversion_factors={ - 'P_el': [(5, 30), (40, 60)], - 'Q_th': [(6, 35), (45, 100)], - 'Q_fu': [(12, 70), (90, 200)], - }, - on_off_parameters=fx.OnOffParameters(effects_per_switch_on=0.01), - ) - - costsInvestsizeSegments = ([(5, 25), (25, 100)], {'costs': [(50, 250), (250, 800)], 'PE': [(5, 25), (25, 100)]}) - invest_Speicher = fx.InvestParameters( - fix_effects=0, - effects_in_segments=costsInvestsizeSegments, - optional=False, - specific_effects={'costs': 0.01, 'CO2': 0.01}, - minimum_size=0, - maximum_size=1000, - ) - aSpeicher = fx.Storage( - 'Speicher', - charging=fx.Flow('Q_th_load', bus='Fernwärme', size=1e4), - discharging=fx.Flow('Q_th_unload', bus='Fernwärme', size=1e4), - capacity_in_flow_hours=invest_Speicher, - initial_charge_state=0, - maximal_final_charge_state=10, - eta_charge=0.9, - eta_discharge=1, - relative_loss_per_hour=0.08, - prevent_simultaneous_charge_and_discharge=True, - ) - - flow_system.add_elements(aGaskessel, aKWK, aSpeicher) - - return flow_system - def test_basic_flow_system(self, flow_system_base): flow_system = flow_system_base calculation = fx.FullCalculation('Test_Complex-Basic', flow_system) From d0ac83b1ceffab575a77f4e5416ac64f401862d5 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 5 Mar 2025 10:00:19 +0100 Subject: [PATCH 297/507] Improve conftest.py --- tests/conftest.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 87ea7f8ea..f1c208830 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -121,12 +121,7 @@ def simple_flow_system() -> fx.FlowSystem: ) flow_system.add_elements(storage, costs, CO2, boiler, heat_load, gas_tariff, electricity_feed_in, chp) - # Create and solve calculation - calculation = fx.FullCalculation('Test_Sim', flow_system) - calculation.do_modeling() - calculation.solve(get_solver()) - - return calculation + return flow_system @pytest.fixture @@ -319,3 +314,10 @@ def flow_system_segments_of_flows() -> fx.FlowSystem: flow_system.add_elements(aGaskessel, aKWK, aSpeicher) return flow_system + + +def create_calculation_and_solve(flow_system: fx.FlowSystem, solver, name: str) -> fx.FullCalculation: + calculation = fx.FullCalculation(name, flow_system) + calculation.do_modeling() + calculation.solve(solver) + return calculation \ No newline at end of file From c9214ecf6ef8696f08064cd7c2593614781d12ba Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 5 Mar 2025 10:00:52 +0100 Subject: [PATCH 298/507] Use common fucntion to create a nd solve a FlowSystem --- tests/test_integration.py | 38 ++++++++++++++++++-------------------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/tests/test_integration.py b/tests/test_integration.py index 948f41c99..646460434 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -7,7 +7,7 @@ import pandas as pd import pytest -from .conftest import assert_almost_equal_numeric, get_solver, basic_flow_system, simple_flow_system +from .conftest import assert_almost_equal_numeric, get_solver, basic_flow_system, simple_flow_system, create_calculation_and_solve class TestFlowSystem: @@ -15,7 +15,9 @@ def test_simple_flow_system(self, simple_flow_system): """ Test the effects of the simple energy system model """ - effects = simple_flow_system.flow_system.effects + calculation = create_calculation_and_solve(simple_flow_system, get_solver(), 'test_simple_flow_system') + + effects = calculation.flow_system.effects # Cost assertions assert_almost_equal_numeric( @@ -35,7 +37,8 @@ def test_model_components(self, simple_flow_system): """ Test the component flows of the simple energy system model """ - comps = simple_flow_system.flow_system.components + calculation = create_calculation_and_solve(simple_flow_system, get_solver(), 'test_model_components') + comps = calculation.flow_system.components # Boiler assertions assert_almost_equal_numeric( @@ -56,12 +59,14 @@ def test_results_persistence(self, simple_flow_system): Test saving and loading results """ # Save results to file - simple_flow_system.results.to_file() + calculation = create_calculation_and_solve(simple_flow_system, get_solver(), 'test_model_components') + + calculation.results.to_file() # Load results from file results = fx.results.CalculationResults.from_file( - simple_flow_system.folder, - simple_flow_system.name + calculation.folder, + calculation.name ) # Verify key variables from loaded results @@ -99,9 +104,8 @@ def test_transmission_basic(self, basic_flow_system): ) flow_system.add_elements(transmission, boiler) - calculation = fx.FullCalculation('Test_Sim', flow_system) - calculation.do_modeling() - calculation.solve(get_solver()) + + calculation = create_calculation_and_solve(flow_system, get_solver(), 'test_transmission_basic') # Assertions assert_almost_equal_numeric( @@ -155,9 +159,8 @@ def test_transmission_advanced(self, basic_flow_system): ) flow_system.add_elements(transmission, boiler, boiler2, last2) - calculation = fx.FullCalculation('Test_Transmission', flow_system) - calculation.do_modeling() - calculation.solve(get_solver()) + + calculation = create_calculation_and_solve(flow_system, get_solver(), 'test_transmission_advanced') # Assertions assert_almost_equal_numeric( @@ -189,10 +192,7 @@ def test_transmission_advanced(self, basic_flow_system): class TestComplex: def test_basic_flow_system(self, flow_system_base): - flow_system = flow_system_base - calculation = fx.FullCalculation('Test_Complex-Basic', flow_system) - calculation.do_modeling() - calculation.solve(get_solver()) + calculation = create_calculation_and_solve(flow_system_base, get_solver(), 'test_basic_flow_system') # Assertions assert_almost_equal_numeric( @@ -322,10 +322,8 @@ def test_basic_flow_system(self, flow_system_base): ) def test_segments_of_flows(self, flow_system_segments_of_flows): - flow_system = flow_system_segments_of_flows - calculation = fx.FullCalculation('Test_Complex-Segments', flow_system) - calculation.do_modeling() - calculation.solve(get_solver()) + calculation = create_calculation_and_solve(flow_system_segments_of_flows, get_solver(), 'test_segments_of_flows') + effects = calculation.flow_system.effects comps = calculation.flow_system.components From a3163f7c3dd620282c13f278315fe2cf954d5867 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 5 Mar 2025 16:43:28 +0100 Subject: [PATCH 299/507] Add tests for io --- tests/test_io.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 tests/test_io.py diff --git a/tests/test_io.py b/tests/test_io.py new file mode 100644 index 000000000..0fdb33161 --- /dev/null +++ b/tests/test_io.py @@ -0,0 +1,36 @@ +import pytest +from typing import Dict, List, Union, Optional + +import flixOpt as fx + +from conftest import flow_system_base, flow_system_segments_of_flows, simple_flow_system, assert_almost_equal_numeric + +@pytest.fixture(params=[flow_system_base, flow_system_segments_of_flows, simple_flow_system]) +def flow_system(request): + return request.getfixturevalue(request.param.__name__) + + +def test_flow_system_io(flow_system): + calculation_0 = fx.FullCalculation('IO', flow_system=flow_system) + calculation_0.do_modeling() + calculation_0.solve(fx.solvers.HighsSolver(mip_gap=0.001, time_limit_seconds=30)) + + calculation_0.flow_system_to_netcdf() + flow_system_1 = fx.FlowSystem.from_netcdf(f'results/{calculation_0.name}_flowsystem.nc') + + calculation_1 = fx.FullCalculation(f'Loaded_IO', flow_system=flow_system_1) + calculation_1.do_modeling() + calculation_1.solve(fx.solvers.HighsSolver(mip_gap=0.001, time_limit_seconds=30)) + + assert_almost_equal_numeric(calculation_0.results.model.objective.value, + calculation_1.results.model.objective.value, + 'objective of loaded flow_system doesnt match the original') + + assert_almost_equal_numeric( + calculation_0.results.model.variables['costs|total'].solution.values, + calculation_1.results.model.variables['costs|total'].solution.values, + 'costs doesnt match expected value', + ) + +if __name__ == '__main__': + pytest.main(['-v', '--disable-warnings']) \ No newline at end of file From cea6b0ab659181d28b76489e0c5fcf12cd961c11 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 6 Mar 2025 08:50:39 +0100 Subject: [PATCH 300/507] Unified test fixtures --- tests/conftest.py | 108 ++++++++++++---------------------------------- 1 file changed, 27 insertions(+), 81 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index f1c208830..9bfd36697 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -145,7 +145,7 @@ def basic_flow_system() -> fx.FlowSystem: @pytest.fixture -def flow_system_base() -> fx.FlowSystem: +def flow_system_complex() -> fx.FlowSystem: """ Helper method to create a base model with configurable parameters """ @@ -194,16 +194,6 @@ def flow_system_base() -> fx.FlowSystem: Q_fu=fx.Flow('Q_fu', bus='Gas', size=200, relative_minimum=0, relative_maximum=1), ) - aKWK = fx.linear_converters.CHP( - 'KWK', - eta_th=0.5, - eta_el=0.4, - on_off_parameters=fx.OnOffParameters(effects_per_switch_on=0.01), - P_el=fx.Flow('P_el', bus='Strom', size=60, relative_minimum=5 / 60, previous_flow_rate=10), - Q_th=fx.Flow('Q_th', bus='Fernwärme', size=1e3), - Q_fu=fx.Flow('Q_fu', bus='Gas', size=1e3), - ) - invest_Speicher = fx.InvestParameters( fix_effects=0, effects_in_segments=([(5, 25), (25, 100)], @@ -227,56 +217,36 @@ def flow_system_base() -> fx.FlowSystem: prevent_simultaneous_charge_and_discharge=True, ) - flow_system.add_elements(aGaskessel, aKWK, aSpeicher) + flow_system.add_elements(aGaskessel, aSpeicher) return flow_system @pytest.fixture -def flow_system_segments_of_flows() -> fx.FlowSystem: - Q_th_Last = np.array([30, 0, 90, 110, 110, 20, 20, 20, 20]) - P_el_Last = np.array([40, 40, 40, 40, 40, 40, 40, 40, 40]) - flow_system = fx.FlowSystem(pd.date_range('2020-01-01', periods=9, freq='h', name='time')) - # Define the components and flow_system - flow_system.add_elements( - fx.Effect('costs', '€', 'Kosten', is_standard=True, is_objective=True), - fx.Effect('CO2', 'kg', 'CO2_e-Emissionen', specific_share_to_other_effects_operation={'costs': 0.2}), - fx.Effect('PE', 'kWh_PE', 'Primärenergie', maximum_total=3.5e3), - fx.Bus('Strom'), - fx.Bus('Fernwärme'), - fx.Bus('Gas'), - fx.Sink('Wärmelast', sink=fx.Flow('Q_th_Last', 'Fernwärme', size=1, fixed_relative_profile=Q_th_Last)), - fx.Source('Gastarif', source=fx.Flow('Q_Gas', 'Gas', size=1000, effects_per_flow_hour={'costs': 0.04, 'CO2': 0.3})), - fx.Sink('Einspeisung', sink=fx.Flow('P_el', 'Strom', effects_per_flow_hour=-1 * P_el_Last)), - ) - aGaskessel = fx.linear_converters.Boiler( - 'Kessel', - eta=0.5, - on_off_parameters=fx.OnOffParameters(effects_per_running_hour={'costs': 0, 'CO2': 1000}), - Q_th=fx.Flow( - 'Q_th', - bus='Fernwärme', - size=fx.InvestParameters( - fix_effects=1000, fixed_size=50, optional=False, specific_effects={'costs': 10, 'PE': 2} - ), - load_factor_max=1.0, - load_factor_min=0.1, - relative_minimum=5 / 50, - relative_maximum=1, - on_off_parameters=fx.OnOffParameters( - on_hours_total_min=0, - on_hours_total_max=1000, - consecutive_on_hours_max=10, - consecutive_off_hours_max=10, - effects_per_switch_on=0.01, - switch_on_total_max=1000, - ), - previous_flow_rate=50, - flow_hours_total_max=1e6, - ), - Q_fu=fx.Flow('Q_fu', bus='Gas', size=200, relative_minimum=0, relative_maximum=1), - ) - aKWK = fx.LinearConverter( +def flow_system_base(flow_system_complex) -> fx.FlowSystem: + """ + Helper method to create a base model with configurable parameters + """ + flow_system = flow_system_complex + + flow_system.add_elements(fx.linear_converters.CHP( + 'KWK', + eta_th=0.5, + eta_el=0.4, + on_off_parameters=fx.OnOffParameters(effects_per_switch_on=0.01), + P_el=fx.Flow('P_el', bus='Strom', size=60, relative_minimum=5 / 60, previous_flow_rate=10), + Q_th=fx.Flow('Q_th', bus='Fernwärme', size=1e3), + Q_fu=fx.Flow('Q_fu', bus='Gas', size=1e3), + )) + + return flow_system + + +@pytest.fixture +def flow_system_segments_of_flows(flow_system_complex) -> fx.FlowSystem: + flow_system = flow_system_complex + + flow_system.add_elements(fx.LinearConverter( 'KWK', inputs=[fx.Flow('Q_fu', bus='Gas')], outputs=[fx.Flow('P_el', bus='Strom', size=60, relative_maximum=55, previous_flow_rate=10), @@ -287,31 +257,7 @@ def flow_system_segments_of_flows() -> fx.FlowSystem: 'Q_fu': [(12, 70), (90, 200)], }, on_off_parameters=fx.OnOffParameters(effects_per_switch_on=0.01), - ) - - costsInvestsizeSegments = ([(5, 25), (25, 100)], {'costs': [(50, 250), (250, 800)], 'PE': [(5, 25), (25, 100)]}) - invest_Speicher = fx.InvestParameters( - fix_effects=0, - effects_in_segments=costsInvestsizeSegments, - optional=False, - specific_effects={'costs': 0.01, 'CO2': 0.01}, - minimum_size=0, - maximum_size=1000, - ) - aSpeicher = fx.Storage( - 'Speicher', - charging=fx.Flow('Q_th_load', bus='Fernwärme', size=1e4), - discharging=fx.Flow('Q_th_unload', bus='Fernwärme', size=1e4), - capacity_in_flow_hours=invest_Speicher, - initial_charge_state=0, - maximal_final_charge_state=10, - eta_charge=0.9, - eta_discharge=1, - relative_loss_per_hour=0.08, - prevent_simultaneous_charge_and_discharge=True, - ) - - flow_system.add_elements(aGaskessel, aKWK, aSpeicher) + )) return flow_system From f4eaa1a3f4b15b756ac5fb7611a2132c79a35469 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 6 Mar 2025 08:56:26 +0100 Subject: [PATCH 301/507] ruff checks --- tests/conftest.py | 31 ++++++++++++++++--------------- tests/test_integration.py | 13 ++++++++----- tests/test_io.py | 9 +++++---- 3 files changed, 29 insertions(+), 24 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 9bfd36697..ea1b3a3c8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,7 @@ -import pytest import numpy as np import pandas as pd +import pytest + import flixOpt as fx @@ -52,7 +53,7 @@ def simple_flow_system() -> fx.FlowSystem: base_timesteps = pd.date_range('2020-01-01', periods=9, freq='h', name='time') # Define effects costs = fx.Effect('costs', '€', 'Kosten', is_standard=True, is_objective=True) - CO2 = fx.Effect( + co2 = fx.Effect( 'CO2', 'kg', 'CO2_e-Emissionen', @@ -119,7 +120,7 @@ def simple_flow_system() -> fx.FlowSystem: fx.Bus('Fernwärme'), fx.Bus('Gas') ) - flow_system.add_elements(storage, costs, CO2, boiler, heat_load, gas_tariff, electricity_feed_in, chp) + flow_system.add_elements(storage, costs, co2, boiler, heat_load, gas_tariff, electricity_feed_in, chp) return flow_system @@ -128,7 +129,7 @@ def simple_flow_system() -> fx.FlowSystem: def basic_flow_system() -> fx.FlowSystem: """Create basic elements for component testing""" flow_system = fx.FlowSystem(pd.date_range('2020-01-01', periods=10, freq='h', name='time')) - Q_th_Last = np.array([np.random.random() for _ in range(10)]) * 180 + thermal_load = np.array([np.random.random() for _ in range(10)]) * 180 p_el = (np.array([np.random.random() for _ in range(10)]) + 0.5) / 1.5 * 50 flow_system.add_elements( @@ -136,7 +137,7 @@ def basic_flow_system() -> fx.FlowSystem: fx.Bus('Fernwärme'), fx.Bus('Gas'), fx.Effect('Costs', '€', 'Kosten', is_standard=True, is_objective=True), - fx.Sink('Wärmelast', sink=fx.Flow('Q_th_Last', 'Fernwärme', size=1, fixed_relative_profile=Q_th_Last)), + fx.Sink('Wärmelast', sink=fx.Flow('Q_th_Last', 'Fernwärme', size=1, fixed_relative_profile=thermal_load)), fx.Source('Gastarif', source=fx.Flow('Q_Gas', 'Gas', size=1000, effects_per_flow_hour=0.04)), fx.Sink('Einspeisung', sink=fx.Flow('P_el', 'Strom', effects_per_flow_hour=-1 * p_el)), ) @@ -149,8 +150,8 @@ def flow_system_complex() -> fx.FlowSystem: """ Helper method to create a base model with configurable parameters """ - Q_th_Last = np.array([30, 0, 90, 110, 110, 20, 20, 20, 20]) - P_el_Last = np.array([40, 40, 40, 40, 40, 40, 40, 40, 40]) + thermal_load = np.array([30, 0, 90, 110, 110, 20, 20, 20, 20]) + electrical_load = np.array([40, 40, 40, 40, 40, 40, 40, 40, 40]) flow_system = fx.FlowSystem(pd.date_range('2020-01-01', periods=9, freq='h', name='time')) # Define the components and flow_system flow_system.add_elements( @@ -160,12 +161,12 @@ def flow_system_complex() -> fx.FlowSystem: fx.Bus('Strom'), fx.Bus('Fernwärme'), fx.Bus('Gas'), - fx.Sink('Wärmelast', sink=fx.Flow('Q_th_Last', 'Fernwärme', size=1, fixed_relative_profile=Q_th_Last)), + fx.Sink('Wärmelast', sink=fx.Flow('Q_th_Last', 'Fernwärme', size=1, fixed_relative_profile=thermal_load)), fx.Source('Gastarif', source=fx.Flow('Q_Gas', 'Gas', size=1000, effects_per_flow_hour={'costs': 0.04, 'CO2': 0.3})), - fx.Sink('Einspeisung', sink=fx.Flow('P_el', 'Strom', effects_per_flow_hour=-1 * P_el_Last)), + fx.Sink('Einspeisung', sink=fx.Flow('P_el', 'Strom', effects_per_flow_hour=-1 * electrical_load)), ) - aGaskessel = fx.linear_converters.Boiler( + boiler = fx.linear_converters.Boiler( 'Kessel', eta=0.5, on_off_parameters=fx.OnOffParameters(effects_per_running_hour={'costs': 0, 'CO2': 1000}), @@ -194,7 +195,7 @@ def flow_system_complex() -> fx.FlowSystem: Q_fu=fx.Flow('Q_fu', bus='Gas', size=200, relative_minimum=0, relative_maximum=1), ) - invest_Speicher = fx.InvestParameters( + invest_speicher = fx.InvestParameters( fix_effects=0, effects_in_segments=([(5, 25), (25, 100)], {'costs': [(50, 250), (250, 800)], 'PE': [(5, 25), (25, 100)]} @@ -204,11 +205,11 @@ def flow_system_complex() -> fx.FlowSystem: minimum_size=0, maximum_size=1000, ) - aSpeicher = fx.Storage( + speicher = fx.Storage( 'Speicher', charging=fx.Flow('Q_th_load', bus='Fernwärme', size=1e4), discharging=fx.Flow('Q_th_unload', bus='Fernwärme', size=1e4), - capacity_in_flow_hours=invest_Speicher, + capacity_in_flow_hours=invest_speicher, initial_charge_state=0, maximal_final_charge_state=10, eta_charge=0.9, @@ -217,7 +218,7 @@ def flow_system_complex() -> fx.FlowSystem: prevent_simultaneous_charge_and_discharge=True, ) - flow_system.add_elements(aGaskessel, aSpeicher) + flow_system.add_elements(boiler, speicher) return flow_system @@ -266,4 +267,4 @@ def create_calculation_and_solve(flow_system: fx.FlowSystem, solver, name: str) calculation = fx.FullCalculation(name, flow_system) calculation.do_modeling() calculation.solve(solver) - return calculation \ No newline at end of file + return calculation diff --git a/tests/test_integration.py b/tests/test_integration.py index 646460434..6dc959a64 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -1,13 +1,16 @@ -import datetime import os -from typing import Any, Dict, List, Literal -import flixOpt as fx import numpy as np import pandas as pd import pytest -from .conftest import assert_almost_equal_numeric, get_solver, basic_flow_system, simple_flow_system, create_calculation_and_solve +import flixOpt as fx + +from .conftest import ( + assert_almost_equal_numeric, + create_calculation_and_solve, + get_solver, +) class TestFlowSystem: @@ -105,7 +108,7 @@ def test_transmission_basic(self, basic_flow_system): flow_system.add_elements(transmission, boiler) - calculation = create_calculation_and_solve(flow_system, get_solver(), 'test_transmission_basic') + _ = create_calculation_and_solve(flow_system, get_solver(), 'test_transmission_basic') # Assertions assert_almost_equal_numeric( diff --git a/tests/test_io.py b/tests/test_io.py index 0fdb33161..94cb98247 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -1,9 +1,10 @@ +from typing import Dict, List, Optional, Union + import pytest -from typing import Dict, List, Union, Optional +from conftest import assert_almost_equal_numeric, flow_system_base, flow_system_segments_of_flows, simple_flow_system import flixOpt as fx -from conftest import flow_system_base, flow_system_segments_of_flows, simple_flow_system, assert_almost_equal_numeric @pytest.fixture(params=[flow_system_base, flow_system_segments_of_flows, simple_flow_system]) def flow_system(request): @@ -18,7 +19,7 @@ def test_flow_system_io(flow_system): calculation_0.flow_system_to_netcdf() flow_system_1 = fx.FlowSystem.from_netcdf(f'results/{calculation_0.name}_flowsystem.nc') - calculation_1 = fx.FullCalculation(f'Loaded_IO', flow_system=flow_system_1) + calculation_1 = fx.FullCalculation('Loaded_IO', flow_system=flow_system_1) calculation_1.do_modeling() calculation_1.solve(fx.solvers.HighsSolver(mip_gap=0.001, time_limit_seconds=30)) @@ -33,4 +34,4 @@ def test_flow_system_io(flow_system): ) if __name__ == '__main__': - pytest.main(['-v', '--disable-warnings']) \ No newline at end of file + pytest.main(['-v', '--disable-warnings']) From fcb5656749d680b4041d1e82a74f36ff853e5afe Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 6 Mar 2025 08:58:57 +0100 Subject: [PATCH 302/507] Add description for conftest.py --- tests/conftest.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index ea1b3a3c8..d2dd0acfe 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,9 @@ +""" +The conftest.py file is used by pytest to define shared fixtures, hooks, and configuration +that apply to multiple test files without needing explicit imports. +It helps avoid redundancy and centralizes reusable test logic. +""" + import numpy as np import pandas as pd import pytest From 36e5edd2829736e7d87fc0bc8766c652e65617e2 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 7 Mar 2025 08:55:42 +0100 Subject: [PATCH 303/507] Improve how to save the FlowSystem --- flixOpt/calculation.py | 19 ++++++++++++------- flixOpt/flow_system.py | 7 +++++-- flixOpt/results.py | 2 +- tests/test_io.py | 2 +- 4 files changed, 19 insertions(+), 11 deletions(-) diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py index 2d5f3f191..9e8733d78 100644 --- a/flixOpt/calculation.py +++ b/flixOpt/calculation.py @@ -75,12 +75,6 @@ def __init__( except FileNotFoundError as e: raise FileNotFoundError(f'Folder {self.folder} and its parent do not exist. Please create them first.') from e - def flow_system_to_netcdf(self): - """ - Saves the flow_system to .netcdf file. - """ - self.flow_system.to_netcdf(self.folder / f'{self.name}_flowsystem.nc') - @property def main_results(self) -> Dict[str, Union[Scalar, Dict]]: from flixOpt.features import InvestmentModel @@ -173,17 +167,28 @@ def solve(self, self.results = CalculationResults.from_calculation(self) - def save_results(self): + def save_results(self, save_flow_system: bool = False, compression: int = 0): """ Saves the results of the calculation to a folder with the name of the calculation. The folder is created if it does not exist. The CalculationResults are saved as a .nc and a .json file. The calculation infos are saved as a .yaml file. + Optionally, the flow_system is saved as a .nc file. + + Parameters + ---------- + save_flow_system : bool, optional + Whether to save the flow_system, by default False + compression : int, optional + Compression level for the netCDF file, by default 0 wich leads to no compression. + Currently, only the Flow System file can be compressed. """ with open(self.folder / f'{self.name}_infos.yaml', 'w', encoding='utf-8') as f: yaml.dump(self.infos, f, allow_unicode=True, sort_keys=False, indent=4) self.results.to_file(self.folder, self.name) + if save_flow_system: + self.flow_system.to_netcdf(self.folder / f'{self.name}_flowsystem.nc', compression) def _activate_time_series(self): self.flow_system.transform_data() diff --git a/flixOpt/flow_system.py b/flixOpt/flow_system.py index 9f6f98383..7df74338b 100644 --- a/flixOpt/flow_system.py +++ b/flixOpt/flow_system.py @@ -8,6 +8,7 @@ import warnings from io import StringIO from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Tuple, Union +import importlib.util import numpy as np import pandas as pd @@ -190,10 +191,12 @@ def as_dataset(self, constants_in_dataset: bool = False) -> xr.Dataset: ds.attrs = self.as_dict(data_mode='name') return ds - def to_netcdf(self, path: Union[str, pathlib.Path]): + def to_netcdf(self, path: Union[str, pathlib.Path], compression: int = 0): + if compression != 0 and importlib.util.find_spec('netCDF4') is None: + raise ModuleNotFoundError('Encoding is only supported with netCDF4. Install netcdf4 via pip install netcdf4.') ds = self.as_dataset() ds.attrs = {'flow_system': json.dumps(ds.attrs)} - ds.to_netcdf(path) + ds.to_netcdf(path, encoding=None if compression == 0 else {k: dict(zlib=True, complevel=compression).copy() for k in ds.data_vars}) logger.info(f'Saved FlowSystem to {path}') def plot_network( diff --git a/flixOpt/results.py b/flixOpt/results.py index 256639015..830c04b2b 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -132,7 +132,7 @@ def to_file(self, folder: Optional[Union[str, pathlib.Path]] = None, name: Optio self.model.to_netcdf(path.with_suffix('.nc'), *args, **kwargs) with open(path.with_suffix('.json'), 'w', encoding='utf-8') as f: json.dump(self._get_meta_data(), f, indent=4, ensure_ascii=False) - logger.info(f'Saved calculation "{name}" to {path}') + logger.info(f'Saved calculation results "{name}" to {path}') def _get_meta_data(self) -> Dict: return { diff --git a/tests/test_io.py b/tests/test_io.py index 94cb98247..3c5d585b9 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -16,7 +16,7 @@ def test_flow_system_io(flow_system): calculation_0.do_modeling() calculation_0.solve(fx.solvers.HighsSolver(mip_gap=0.001, time_limit_seconds=30)) - calculation_0.flow_system_to_netcdf() + calculation_0.save_results(save_flow_system=True) flow_system_1 = fx.FlowSystem.from_netcdf(f'results/{calculation_0.name}_flowsystem.nc') calculation_1 = fx.FullCalculation('Loaded_IO', flow_system=flow_system_1) From 4787535cea4257732846031bdbd573f01990c5fb Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 7 Mar 2025 09:16:32 +0100 Subject: [PATCH 304/507] create another fixture --- tests/conftest.py | 108 ++++++++++++++++++++++++++++++++++++++ tests/test_integration.py | 102 +++-------------------------------- tests/test_io.py | 2 +- 3 files changed, 115 insertions(+), 97 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index d2dd0acfe..c1ce39d1c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,6 +7,7 @@ import numpy as np import pandas as pd import pytest +import os import flixOpt as fx @@ -269,6 +270,113 @@ def flow_system_segments_of_flows(flow_system_complex) -> fx.FlowSystem: return flow_system +@pytest.fixture +def flow_system_long(): + """ + Fixture to create and return the flow system with loaded data + """ + # Load data + filename = os.path.join(os.path.dirname(__file__), 'ressources', 'Zeitreihen2020.csv') + ts_raw = pd.read_csv(filename, index_col=0).sort_index() + data = ts_raw['2020-01-01 00:00:00':'2020-12-31 23:45:00']['2020-01-01':'2020-01-03 23:45:00'] + + # Extract data columns + P_el_Last = data['P_Netz/MW'].values + Q_th_Last = data['Q_Netz/MW'].values + p_el = data['Strompr.€/MWh'].values + gP = data['Gaspr.€/MWh'].values + timesteps = pd.DatetimeIndex(data.index) + + # Create effects + costs = fx.Effect('costs', '€', 'Kosten', is_standard=True, is_objective=True) + CO2 = fx.Effect('CO2', 'kg', 'CO2_e-Emissionen') + PE = fx.Effect('PE', 'kWh_PE', 'Primärenergie') + + # Create components (similar to original implementation) + boiler = fx.linear_converters.Boiler( + 'Kessel', + eta=0.85, + Q_th=fx.Flow(label='Q_th', bus='Fernwärme'), + Q_fu=fx.Flow( + label='Q_fu', + bus='Gas', + size=95, + relative_minimum=12 / 95, + previous_flow_rate=0, + on_off_parameters=fx.OnOffParameters(effects_per_switch_on=1000), + ), + ) + chp = fx.linear_converters.CHP( + 'BHKW2', + eta_th=0.58, + eta_el=0.22, + on_off_parameters=fx.OnOffParameters(effects_per_switch_on=24000), + P_el=fx.Flow('P_el', bus='Strom'), + Q_th=fx.Flow('Q_th', bus='Fernwärme'), + Q_fu=fx.Flow('Q_fu', bus='Kohle', size=288, relative_minimum=87 / 288), + ) + storage = fx.Storage( + 'Speicher', + charging=fx.Flow('Q_th_load', size=137, bus='Fernwärme'), + discharging=fx.Flow('Q_th_unload', size=158, bus='Fernwärme'), + capacity_in_flow_hours=684, + initial_charge_state=137, + minimal_final_charge_state=137, + maximal_final_charge_state=158, + eta_charge=1, + eta_discharge=1, + relative_loss_per_hour=0.001, + prevent_simultaneous_charge_and_discharge=True, + ) + + TS_Q_th_Last, TS_P_el_Last = fx.TimeSeriesData(Q_th_Last), fx.TimeSeriesData(P_el_Last, agg_weight=0.7) + heat_load, electricity_load = ( + fx.Sink( + 'Wärmelast', sink=fx.Flow('Q_th_Last', bus='Fernwärme', size=1, fixed_relative_profile=TS_Q_th_Last) + ), + fx.Sink('Stromlast', sink=fx.Flow('P_el_Last', bus='Strom', size=1, fixed_relative_profile=TS_P_el_Last)), + ) + coal_tariff, gas_tariff = ( + fx.Source( + 'Kohletarif', + source=fx.Flow('Q_Kohle', bus='Kohle', size=1000, effects_per_flow_hour={costs.label: 4.6, CO2.label: 0.3}), + ), + fx.Source( + 'Gastarif', source=fx.Flow('Q_Gas', bus='Gas', size=1000, effects_per_flow_hour={costs.label: gP, CO2.label: 0.3}) + ), + ) + + p_feed_in, p_sell = ( + fx.TimeSeriesData(-(p_el - 0.5), agg_group='p_el'), + fx.TimeSeriesData(p_el + 0.5, agg_group='p_el'), + ) + electricity_feed_in, electricity_tariff = ( + fx.Sink('Einspeisung', sink=fx.Flow('P_el', bus='Strom', size=1000, effects_per_flow_hour=p_feed_in)), + fx.Source( + 'Stromtarif', + source=fx.Flow('P_el', bus='Strom', size=1000, effects_per_flow_hour={costs.label: p_sell, CO2.label: 0.3}), + ), + ) + + # Create flow system + flow_system = fx.FlowSystem(timesteps) + flow_system.add_elements(costs, CO2, PE) + flow_system.add_elements( + boiler, heat_load, electricity_load, gas_tariff, coal_tariff, electricity_feed_in, electricity_tariff, + chp, storage + ) + flow_system.add_elements( + fx.Bus('Strom'), fx.Bus('Fernwärme'), + fx.Bus('Gas'), fx.Bus('Kohle') + ) + + # Return all the necessary data + return flow_system, { + 'TS_Q_th_Last': TS_Q_th_Last, + 'TS_P_el_Last': TS_P_el_Last, + } + + def create_calculation_and_solve(flow_system: fx.FlowSystem, solver, name: str) -> fx.FullCalculation: calculation = fx.FullCalculation(name, flow_system) calculation.do_modeling() diff --git a/tests/test_integration.py b/tests/test_integration.py index 6dc959a64..ed5f5c0d5 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -367,107 +367,17 @@ def test_segments_of_flows(self, flow_system_segments_of_flows): ) -# Advanced Modeling Types Test class TestModelingTypes: + @pytest.fixture(params=['full', 'segmented', 'aggregated']) - def modeling_calculation(self, request): + def modeling_calculation(self, request, flow_system_long): """ Fixture to run calculations with different modeling types """ - # Load data - filename = os.path.join(os.path.dirname(__file__), 'ressources', 'Zeitreihen2020.csv') - ts_raw = pd.read_csv(filename, index_col=0).sort_index() - data = ts_raw['2020-01-01 00:00:00':'2020-12-31 23:45:00']['2020-01-01':'2020-01-03 23:45:00'] - - # Extract data columns - P_el_Last = data['P_Netz/MW'].values - Q_th_Last = data['Q_Netz/MW'].values - p_el = data['Strompr.€/MWh'].values - gP = data['Gaspr.€/MWh'].values - timesteps = pd.DatetimeIndex(data.index) - - # Create effects - costs = fx.Effect('costs', '€', 'Kosten', is_standard=True, is_objective=True) - CO2 = fx.Effect('CO2', 'kg', 'CO2_e-Emissionen') - PE = fx.Effect('PE', 'kWh_PE', 'Primärenergie') - - # Create components (similar to original implementation) - boiler = fx.linear_converters.Boiler( - 'Kessel', - eta=0.85, - Q_th=fx.Flow(label='Q_th', bus='Fernwärme'), - Q_fu=fx.Flow( - label='Q_fu', - bus='Gas', - size=95, - relative_minimum=12 / 95, - previous_flow_rate=0, - on_off_parameters=fx.OnOffParameters(effects_per_switch_on=1000), - ), - ) - chp = fx.linear_converters.CHP( - 'BHKW2', - eta_th=0.58, - eta_el=0.22, - on_off_parameters=fx.OnOffParameters(effects_per_switch_on=24000), - P_el=fx.Flow('P_el', bus='Strom'), - Q_th=fx.Flow('Q_th', bus='Fernwärme'), - Q_fu=fx.Flow('Q_fu', bus='Kohle', size=288, relative_minimum=87 / 288), - ) - storage = fx.Storage( - 'Speicher', - charging=fx.Flow('Q_th_load', size=137, bus='Fernwärme'), - discharging=fx.Flow('Q_th_unload', size=158, bus='Fernwärme'), - capacity_in_flow_hours=684, - initial_charge_state=137, - minimal_final_charge_state=137, - maximal_final_charge_state=158, - eta_charge=1, - eta_discharge=1, - relative_loss_per_hour=0.001, - prevent_simultaneous_charge_and_discharge=True, - ) - - TS_Q_th_Last, TS_P_el_Last = fx.TimeSeriesData(Q_th_Last), fx.TimeSeriesData(P_el_Last, agg_weight=0.7) - heat_load, electricity_load = ( - fx.Sink( - 'Wärmelast', sink=fx.Flow('Q_th_Last', bus='Fernwärme', size=1, fixed_relative_profile=TS_Q_th_Last) - ), - fx.Sink('Stromlast', sink=fx.Flow('P_el_Last', bus='Strom', size=1, fixed_relative_profile=TS_P_el_Last)), - ) - coal_tariff, gas_tariff = ( - fx.Source( - 'Kohletarif', - source=fx.Flow('Q_Kohle', bus='Kohle', size=1000, effects_per_flow_hour={costs.label: 4.6, CO2.label: 0.3}), - ), - fx.Source( - 'Gastarif', source=fx.Flow('Q_Gas', bus='Gas', size=1000, effects_per_flow_hour={costs.label: gP, CO2.label: 0.3}) - ), - ) - - p_feed_in, p_sell = ( - fx.TimeSeriesData(-(p_el - 0.5), agg_group='p_el'), - fx.TimeSeriesData(p_el + 0.5, agg_group='p_el'), - ) - electricity_feed_in, electricity_tariff = ( - fx.Sink('Einspeisung', sink=fx.Flow('P_el', bus='Strom', size=1000, effects_per_flow_hour=p_feed_in)), - fx.Source( - 'Stromtarif', - source=fx.Flow('P_el', bus='Strom', size=1000, effects_per_flow_hour={costs.label: p_sell, CO2.label: 0.3}), - ), - ) - - # Create flow system - flow_system = fx.FlowSystem(timesteps) - flow_system.add_elements(costs, CO2, PE) - flow_system.add_elements( - boiler, heat_load, electricity_load, gas_tariff, coal_tariff, electricity_feed_in, electricity_tariff, - chp, storage - ) - flow_system.add_elements( - fx.Bus('Strom'), fx.Bus('Fernwärme'), - fx.Bus('Gas'), fx.Bus('Kohle') - ) + # Extract flow system and data from the fixture + flow_system = flow_system_long[0] + TS_Q_th_Last = flow_system_long[1]['TS_Q_th_Last'] + TS_P_el_Last = flow_system_long[1]['TS_P_el_Last'] # Create calculation based on modeling type modeling_type = request.param diff --git a/tests/test_io.py b/tests/test_io.py index 3c5d585b9..83fda3707 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -16,7 +16,7 @@ def test_flow_system_io(flow_system): calculation_0.do_modeling() calculation_0.solve(fx.solvers.HighsSolver(mip_gap=0.001, time_limit_seconds=30)) - calculation_0.save_results(save_flow_system=True) + calculation_0.save_results(save_flow_system=True, compression=5) flow_system_1 = fx.FlowSystem.from_netcdf(f'results/{calculation_0.name}_flowsystem.nc') calculation_1 = fx.FullCalculation('Loaded_IO', flow_system=flow_system_1) From d0a8a55ada9f91472464a128625765ef6b196c02 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 7 Mar 2025 09:17:25 +0100 Subject: [PATCH 305/507] Update tests --- examples/01_Simple/simple_example.py | 3 +- examples/02_Complex/complex_example.py | 2 +- .../example_calculation_types.py | 33 +++++++++++++++++-- 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/examples/01_Simple/simple_example.py b/examples/01_Simple/simple_example.py index e806f5c6d..b550aa9b6 100644 --- a/examples/01_Simple/simple_example.py +++ b/examples/01_Simple/simple_example.py @@ -37,7 +37,7 @@ label='CO2', unit='kg', description='CO2_e-Emissionen', - specific_share_to_other_effects_operation={costs: 0.2}, + specific_share_to_other_effects_operation={'costs': 0.2}, maximum_operation_per_hour=1000, # Max CO2 emissions per hour ) @@ -114,3 +114,4 @@ # Convert the results for the storage component to a dataframe and display df = calculation.results['Storage'].charge_state_and_flow_rates() print(df) + calculation.save_results(save_flow_system=True) diff --git a/examples/02_Complex/complex_example.py b/examples/02_Complex/complex_example.py index 0379f1846..8d5900261 100644 --- a/examples/02_Complex/complex_example.py +++ b/examples/02_Complex/complex_example.py @@ -41,7 +41,7 @@ # --- Define Effects --- # Specify effects related to costs, CO2 emissions, and primary energy consumption Costs = fx.Effect('costs', '€', 'Kosten', is_standard=True, is_objective=True) - CO2 = fx.Effect('CO2', 'kg', 'CO2_e-Emissionen', specific_share_to_other_effects_operation={Costs: 0.2}) + CO2 = fx.Effect('CO2', 'kg', 'CO2_e-Emissionen', specific_share_to_other_effects_operation={Costs.label: 0.2}) PE = fx.Effect('PE', 'kWh_PE', 'Primärenergie', maximum_total=3.5e3) # --- Define Components --- diff --git a/examples/03_Calculation_types/example_calculation_types.py b/examples/03_Calculation_types/example_calculation_types.py index 54a856371..d906f978b 100644 --- a/examples/03_Calculation_types/example_calculation_types.py +++ b/examples/03_Calculation_types/example_calculation_types.py @@ -121,12 +121,12 @@ # Gas Tariff a_gas_tarif = fx.Source( - 'Gastarif', source=fx.Flow('Q_Gas', bus='Gas', size=1000, effects_per_flow_hour={costs: gas_price, CO2: 0.3}) + 'Gastarif', source=fx.Flow('Q_Gas', bus='Gas', size=1000, effects_per_flow_hour={costs.label: gas_price, CO2.label: 0.3}) ) # Coal Tariff a_kohle_tarif = fx.Source( - 'Kohletarif', source=fx.Flow('Q_Kohle', bus='Kohle', size=1000, effects_per_flow_hour={costs: 4.6, CO2: 0.3}) + 'Kohletarif', source=fx.Flow('Q_Kohle', bus='Kohle', size=1000, effects_per_flow_hour={costs.label: 4.6, CO2.label: 0.3}) ) # Electricity Tariff and Feed-in @@ -136,7 +136,7 @@ a_strom_tarif = fx.Source( 'Stromtarif', - source=fx.Flow('P_el', bus='Strom', size=1000, effects_per_flow_hour={costs: TS_electricity_price_buy, CO2: 0.3}), + source=fx.Flow('P_el', bus='Strom', size=1000, effects_per_flow_hour={costs.label: TS_electricity_price_buy, CO2: 0.3}), ) # Flow System Setup @@ -157,9 +157,36 @@ # Calculations calculations: List[Union[fx.FullCalculation, fx.AggregatedCalculation, fx.SegmentedCalculation]] = [] + import timeit + import os + + + def benchmark_flow_system_io(fs: fx.FlowSystem, compression, filename: str): + """Benchmark saving an xarray Dataset with NetCDF compression. + Returns: + dict: Contains execution time and file size in MB. + """ + # Measure execution time + start_time = timeit.default_timer() + fs.to_netcdf(filename, compression=compression) + elapsed_time = timeit.default_timer() - start_time + file_size = os.path.getsize(filename) / (1024 * 1024) + + # Print results + print(f"Compression Level: {compression}") + print(f"Execution Time: {elapsed_time:.3f} sec") + print(f"File Size: {file_size:.2f} MB") + + return {"execution_time": elapsed_time, "file_size_mb": file_size} + + if full: calculation = fx.FullCalculation('Full', flow_system) calculation.do_modeling() + benchmark = {compression: benchmark_flow_system_io(flow_system, compression, f'results/benchmark_fs_io_{compression}.nc') + for compression in [1, 5, 9]} + flow_system.to_netcdf('flowsystem_comp.nc') + flow_system.to_netcdf('flowsystem.nc') calculation.solve(fx.solvers.HighsSolver(0, 60)) calculations.append(calculation) From 6fe0b273a63dc6ca93d7c64edf76476ee6ef3975 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 7 Mar 2025 11:54:57 +0100 Subject: [PATCH 306/507] Update examples --- examples/01_Simple/simple_example.py | 2 +- .../example_calculation_types.py | 27 ------------------- 2 files changed, 1 insertion(+), 28 deletions(-) diff --git a/examples/01_Simple/simple_example.py b/examples/01_Simple/simple_example.py index b550aa9b6..2c05601bf 100644 --- a/examples/01_Simple/simple_example.py +++ b/examples/01_Simple/simple_example.py @@ -37,7 +37,7 @@ label='CO2', unit='kg', description='CO2_e-Emissionen', - specific_share_to_other_effects_operation={'costs': 0.2}, + specific_share_to_other_effects_operation={costs.label: 0.2}, maximum_operation_per_hour=1000, # Max CO2 emissions per hour ) diff --git a/examples/03_Calculation_types/example_calculation_types.py b/examples/03_Calculation_types/example_calculation_types.py index d906f978b..673b22f34 100644 --- a/examples/03_Calculation_types/example_calculation_types.py +++ b/examples/03_Calculation_types/example_calculation_types.py @@ -157,36 +157,9 @@ # Calculations calculations: List[Union[fx.FullCalculation, fx.AggregatedCalculation, fx.SegmentedCalculation]] = [] - import timeit - import os - - - def benchmark_flow_system_io(fs: fx.FlowSystem, compression, filename: str): - """Benchmark saving an xarray Dataset with NetCDF compression. - Returns: - dict: Contains execution time and file size in MB. - """ - # Measure execution time - start_time = timeit.default_timer() - fs.to_netcdf(filename, compression=compression) - elapsed_time = timeit.default_timer() - start_time - file_size = os.path.getsize(filename) / (1024 * 1024) - - # Print results - print(f"Compression Level: {compression}") - print(f"Execution Time: {elapsed_time:.3f} sec") - print(f"File Size: {file_size:.2f} MB") - - return {"execution_time": elapsed_time, "file_size_mb": file_size} - - if full: calculation = fx.FullCalculation('Full', flow_system) calculation.do_modeling() - benchmark = {compression: benchmark_flow_system_io(flow_system, compression, f'results/benchmark_fs_io_{compression}.nc') - for compression in [1, 5, 9]} - flow_system.to_netcdf('flowsystem_comp.nc') - flow_system.to_netcdf('flowsystem.nc') calculation.solve(fx.solvers.HighsSolver(0, 60)) calculations.append(calculation) From 5f3b454490a6586e2ef3fa772440c48dbdc2caf7 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 7 Mar 2025 11:58:07 +0100 Subject: [PATCH 307/507] Add long flow_system to test_io.py --- tests/test_io.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/test_io.py b/tests/test_io.py index 83fda3707..bdb3131a8 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -1,14 +1,18 @@ from typing import Dict, List, Optional, Union import pytest -from conftest import assert_almost_equal_numeric, flow_system_base, flow_system_segments_of_flows, simple_flow_system +from conftest import assert_almost_equal_numeric, flow_system_base, flow_system_segments_of_flows, simple_flow_system, flow_system_long import flixOpt as fx -@pytest.fixture(params=[flow_system_base, flow_system_segments_of_flows, simple_flow_system]) +@pytest.fixture(params=[flow_system_base, flow_system_segments_of_flows, simple_flow_system, flow_system_long]) def flow_system(request): - return request.getfixturevalue(request.param.__name__) + fs = request.getfixturevalue(request.param.__name__) + if isinstance(fs, fx.FlowSystem): + return fs + else: + return fs[0] def test_flow_system_io(flow_system): From 980ed30764874a3f7b1f06548a9d280185e7c753 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 7 Mar 2025 12:07:49 +0100 Subject: [PATCH 308/507] ruff check --- flixOpt/flow_system.py | 2 +- tests/conftest.py | 138 ++++++++++++++++---------------------- tests/test_integration.py | 8 +-- tests/test_io.py | 8 ++- 4 files changed, 70 insertions(+), 86 deletions(-) diff --git a/flixOpt/flow_system.py b/flixOpt/flow_system.py index 7df74338b..5f7db4f1f 100644 --- a/flixOpt/flow_system.py +++ b/flixOpt/flow_system.py @@ -2,13 +2,13 @@ This module contains the FlowSystem class, which is used to collect instances of many other classes by the end User. """ +import importlib.util import json import logging import pathlib import warnings from io import StringIO from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Tuple, Union -import importlib.util import numpy as np import pandas as pd diff --git a/tests/conftest.py b/tests/conftest.py index c1ce39d1c..f9b940c83 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,10 +4,11 @@ It helps avoid redundancy and centralizes reusable test logic. """ +import os + import numpy as np import pandas as pd import pytest -import os import flixOpt as fx @@ -281,99 +282,76 @@ def flow_system_long(): data = ts_raw['2020-01-01 00:00:00':'2020-12-31 23:45:00']['2020-01-01':'2020-01-03 23:45:00'] # Extract data columns - P_el_Last = data['P_Netz/MW'].values - Q_th_Last = data['Q_Netz/MW'].values + electrical_load = data['P_Netz/MW'].values + thermal_load = data['Q_Netz/MW'].values p_el = data['Strompr.€/MWh'].values - gP = data['Gaspr.€/MWh'].values - timesteps = pd.DatetimeIndex(data.index) - - # Create effects - costs = fx.Effect('costs', '€', 'Kosten', is_standard=True, is_objective=True) - CO2 = fx.Effect('CO2', 'kg', 'CO2_e-Emissionen') - PE = fx.Effect('PE', 'kWh_PE', 'Primärenergie') - - # Create components (similar to original implementation) - boiler = fx.linear_converters.Boiler( - 'Kessel', - eta=0.85, - Q_th=fx.Flow(label='Q_th', bus='Fernwärme'), - Q_fu=fx.Flow( - label='Q_fu', - bus='Gas', - size=95, - relative_minimum=12 / 95, - previous_flow_rate=0, - on_off_parameters=fx.OnOffParameters(effects_per_switch_on=1000), - ), - ) - chp = fx.linear_converters.CHP( - 'BHKW2', - eta_th=0.58, - eta_el=0.22, - on_off_parameters=fx.OnOffParameters(effects_per_switch_on=24000), - P_el=fx.Flow('P_el', bus='Strom'), - Q_th=fx.Flow('Q_th', bus='Fernwärme'), - Q_fu=fx.Flow('Q_fu', bus='Kohle', size=288, relative_minimum=87 / 288), - ) - storage = fx.Storage( - 'Speicher', - charging=fx.Flow('Q_th_load', size=137, bus='Fernwärme'), - discharging=fx.Flow('Q_th_unload', size=158, bus='Fernwärme'), - capacity_in_flow_hours=684, - initial_charge_state=137, - minimal_final_charge_state=137, - maximal_final_charge_state=158, - eta_charge=1, - eta_discharge=1, - relative_loss_per_hour=0.001, - prevent_simultaneous_charge_and_discharge=True, - ) - - TS_Q_th_Last, TS_P_el_Last = fx.TimeSeriesData(Q_th_Last), fx.TimeSeriesData(P_el_Last, agg_weight=0.7) - heat_load, electricity_load = ( - fx.Sink( - 'Wärmelast', sink=fx.Flow('Q_th_Last', bus='Fernwärme', size=1, fixed_relative_profile=TS_Q_th_Last) - ), - fx.Sink('Stromlast', sink=fx.Flow('P_el_Last', bus='Strom', size=1, fixed_relative_profile=TS_P_el_Last)), - ) - coal_tariff, gas_tariff = ( - fx.Source( - 'Kohletarif', - source=fx.Flow('Q_Kohle', bus='Kohle', size=1000, effects_per_flow_hour={costs.label: 4.6, CO2.label: 0.3}), - ), - fx.Source( - 'Gastarif', source=fx.Flow('Q_Gas', bus='Gas', size=1000, effects_per_flow_hour={costs.label: gP, CO2.label: 0.3}) - ), - ) + gas_price = data['Gaspr.€/MWh'].values + thermal_load_ts, electrical_load_ts = fx.TimeSeriesData(thermal_load), fx.TimeSeriesData(electrical_load, + agg_weight=0.7) p_feed_in, p_sell = ( fx.TimeSeriesData(-(p_el - 0.5), agg_group='p_el'), fx.TimeSeriesData(p_el + 0.5, agg_group='p_el'), ) - electricity_feed_in, electricity_tariff = ( - fx.Sink('Einspeisung', sink=fx.Flow('P_el', bus='Strom', size=1000, effects_per_flow_hour=p_feed_in)), - fx.Source( - 'Stromtarif', - source=fx.Flow('P_el', bus='Strom', size=1000, effects_per_flow_hour={costs.label: p_sell, CO2.label: 0.3}), - ), - ) - # Create flow system - flow_system = fx.FlowSystem(timesteps) - flow_system.add_elements(costs, CO2, PE) + flow_system = fx.FlowSystem(pd.DatetimeIndex(data.index)) flow_system.add_elements( - boiler, heat_load, electricity_load, gas_tariff, coal_tariff, electricity_feed_in, electricity_tariff, - chp, storage + fx.Bus('Strom'), fx.Bus('Fernwärme'), fx.Bus('Gas'), fx.Bus('Kohle'), + + fx.Effect('costs', '€', 'Kosten', is_standard=True, is_objective=True), + fx.Effect('CO2', 'kg', 'CO2_e-Emissionen'), + fx.Effect('PE', 'kWh_PE', 'Primärenergie'), + + fx.Sink('Wärmelast', sink=fx.Flow('Q_th_Last', bus='Fernwärme', size=1, fixed_relative_profile=thermal_load_ts)), + fx.Sink('Stromlast', sink=fx.Flow('P_el_Last', bus='Strom', size=1, fixed_relative_profile=electrical_load_ts)), + fx.Source('Kohletarif',source=fx.Flow('Q_Kohle', bus='Kohle', size=1000, effects_per_flow_hour={'costs': 4.6, 'CO2': 0.3})), + fx.Source('Gastarif', source=fx.Flow('Q_Gas', bus='Gas', size=1000, effects_per_flow_hour={'costs': gas_price, 'CO2': 0.3})), + fx.Sink('Einspeisung', sink=fx.Flow('P_el', bus='Strom', size=1000, effects_per_flow_hour=p_feed_in)), + fx.Source('Stromtarif', source=fx.Flow('P_el', bus='Strom', size=1000, effects_per_flow_hour={'costs': p_sell, 'CO2': 0.3})) ) + flow_system.add_elements( - fx.Bus('Strom'), fx.Bus('Fernwärme'), - fx.Bus('Gas'), fx.Bus('Kohle') + fx.linear_converters.Boiler( + 'Kessel', + eta=0.85, + Q_th=fx.Flow(label='Q_th', bus='Fernwärme'), + Q_fu=fx.Flow( + label='Q_fu', + bus='Gas', + size=95, + relative_minimum=12 / 95, + previous_flow_rate=0, + on_off_parameters=fx.OnOffParameters(effects_per_switch_on=1000), + ), + ), + fx.linear_converters.CHP( + 'BHKW2', + eta_th=0.58, + eta_el=0.22, + on_off_parameters=fx.OnOffParameters(effects_per_switch_on=24000), + P_el=fx.Flow('P_el', bus='Strom'), + Q_th=fx.Flow('Q_th', bus='Fernwärme'), + Q_fu=fx.Flow('Q_fu', bus='Kohle', size=288, relative_minimum=87 / 288), + ), + fx.Storage( + 'Speicher', + charging=fx.Flow('Q_th_load', size=137, bus='Fernwärme'), + discharging=fx.Flow('Q_th_unload', size=158, bus='Fernwärme'), + capacity_in_flow_hours=684, + initial_charge_state=137, + minimal_final_charge_state=137, + maximal_final_charge_state=158, + eta_charge=1, + eta_discharge=1, + relative_loss_per_hour=0.001, + prevent_simultaneous_charge_and_discharge=True, + ), ) # Return all the necessary data return flow_system, { - 'TS_Q_th_Last': TS_Q_th_Last, - 'TS_P_el_Last': TS_P_el_Last, + 'thermal_load_ts': thermal_load_ts, + 'electrical_load_ts': electrical_load_ts, } diff --git a/tests/test_integration.py b/tests/test_integration.py index ed5f5c0d5..15636f37c 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -376,8 +376,8 @@ def modeling_calculation(self, request, flow_system_long): """ # Extract flow system and data from the fixture flow_system = flow_system_long[0] - TS_Q_th_Last = flow_system_long[1]['TS_Q_th_Last'] - TS_P_el_Last = flow_system_long[1]['TS_P_el_Last'] + thermal_load_ts = flow_system_long[1]['thermal_load_ts'] + electrical_load_ts = flow_system_long[1]['electrical_load_ts'] # Create calculation based on modeling type modeling_type = request.param @@ -399,8 +399,8 @@ def modeling_calculation(self, request, flow_system_long): aggregate_data_and_fix_non_binary_vars=True, percentage_of_period_freedom=0, penalty_of_period_freedom=0, - time_series_for_low_peaks=[TS_P_el_Last, TS_Q_th_Last], - time_series_for_high_peaks=[TS_Q_th_Last], + time_series_for_low_peaks=[electrical_load_ts, thermal_load_ts], + time_series_for_high_peaks=[thermal_load_ts], ), ) calc.do_modeling() diff --git a/tests/test_io.py b/tests/test_io.py index bdb3131a8..9c9b3dec4 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -1,7 +1,13 @@ from typing import Dict, List, Optional, Union import pytest -from conftest import assert_almost_equal_numeric, flow_system_base, flow_system_segments_of_flows, simple_flow_system, flow_system_long +from conftest import ( + assert_almost_equal_numeric, + flow_system_base, + flow_system_long, + flow_system_segments_of_flows, + simple_flow_system, +) import flixOpt as fx From 7c5159ac87381056adcf64e67d34d308f21e730d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 8 Mar 2025 17:56:06 +0100 Subject: [PATCH 309/507] Remove periods --- flixOpt/calculation.py | 7 +------ flixOpt/core.py | 4 ++-- flixOpt/flow_system.py | 10 ++-------- flixOpt/results.py | 1 - 4 files changed, 5 insertions(+), 17 deletions(-) diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py index 9e8733d78..447312054 100644 --- a/flixOpt/calculation.py +++ b/flixOpt/calculation.py @@ -44,7 +44,6 @@ def __init__( name: str, flow_system: FlowSystem, active_timesteps: Optional[pd.DatetimeIndex] = None, - active_periods: Optional[pd.Index] = None, folder: Optional[pathlib.Path] = None, ): """ @@ -63,7 +62,6 @@ def __init__( self.flow_system = flow_system self.model: Optional[SystemModel] = None self.active_timesteps = active_timesteps - self.active_periods = active_periods self.durations = {'modeling': 0.0, 'solving': 0.0, 'saving': 0.0} self.folder = pathlib.Path.cwd() / 'results' if folder is None else pathlib.Path(folder) @@ -120,7 +118,6 @@ def infos(self): return { 'Name': self.name, 'Number of timesteps': len(self.flow_system.time_series_collection.timesteps), - 'Periods': self.flow_system.time_series_collection.periods, 'Calculation Type': self.__class__.__name__, 'Constraints': self.model.constraints.ncons, 'Variables': self.model.variables.nvars, @@ -193,7 +190,7 @@ def save_results(self, save_flow_system: bool = False, compression: int = 0): def _activate_time_series(self): self.flow_system.transform_data() self.flow_system.time_series_collection.activate_indices( - active_timesteps=self.active_timesteps, active_periods=self.active_periods + active_timesteps=self.active_timesteps, ) @@ -232,8 +229,6 @@ def __init__( folder : pathlib.Path or None folder where results should be saved. If None, then the current working directory is used. """ - if flow_system.time_series_collection.periods is not None: - raise NotImplementedError('Multiple Periods are currently not supported in AggregatedCalculation') super().__init__(name, flow_system, active_timesteps, folder=folder) self.aggregation_parameters = aggregation_parameters self.components_to_clusterize = components_to_clusterize diff --git a/flixOpt/core.py b/flixOpt/core.py index ce4e69d43..129fb1194 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -416,7 +416,7 @@ def __init__( timesteps: pd.DatetimeIndex, hours_of_last_timestep: Optional[float], hours_of_previous_timesteps: Optional[Union[int, float, np.ndarray]], - periods: Optional[List[int]] + periods: Optional[List[int]] = None, ): ( self.all_timesteps, @@ -690,7 +690,7 @@ def _calculate_hours_of_previous_timesteps( @staticmethod def create_hours_per_timestep( timesteps_extra: pd.DatetimeIndex, - periods: Optional[pd.Index] + periods: Optional[pd.Index] = None ) -> xr.DataArray: """Creates a DataArray representing the duration of each timestep in hours.""" hours_per_step = timesteps_extra.to_series().diff()[1:].values / pd.to_timedelta(1, 'h') diff --git a/flixOpt/flow_system.py b/flixOpt/flow_system.py index 5f7db4f1f..9c6a297e2 100644 --- a/flixOpt/flow_system.py +++ b/flixOpt/flow_system.py @@ -38,7 +38,6 @@ def __init__( timesteps: pd.DatetimeIndex, hours_of_last_timestep: Optional[float] = None, hours_of_previous_timesteps: Optional[Union[int, float, np.ndarray]] = None, - periods: Optional[List[int]] = None, ): """ Parameters @@ -52,15 +51,11 @@ def __init__( 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 : Optional[List[int]], optional - The periods of the model. Every period has the same timesteps. - Usually years are used as periods. """ self.time_series_collection = TimeSeriesCollection( timesteps=timesteps, hours_of_last_timestep=hours_of_last_timestep, hours_of_previous_timesteps=hours_of_previous_timesteps, - periods=periods ) # defaults: @@ -79,7 +74,7 @@ def from_dataset(cls, ds: xr.Dataset): flow_system = FlowSystem(timesteps=timesteps_extra[:-1], hours_of_last_timestep=hours_of_last_timestep, hours_of_previous_timesteps=ds.attrs['hours_of_previous_timesteps'], - periods=pd.Index(ds.attrs['periods'], name='period') if ds.attrs.get('periods') is not None else None) + ) structure = io.insert_dataarray({key: ds.attrs[key] for key in ['components', 'buses', 'effects']}, ds) flow_system.add_elements( @@ -99,7 +94,7 @@ def from_dict(cls, data: Dict) -> 'FlowSystem': flow_system = FlowSystem(timesteps=timesteps_extra[:-1], hours_of_last_timestep=hours_of_last_timestep, hours_of_previous_timesteps=data['hours_of_previous_timesteps'], - periods=pd.Index(data['periods'], name='period') if data.get('periods') is not None else None) + ) flow_system.add_elements( *[Bus.from_dict(bus) for bus in data['buses'].values()] @@ -177,7 +172,6 @@ def as_dict(self, data_mode: Literal['data', 'name', 'stats'] = 'data') -> Dict: for effect in sorted(self.effects, key=lambda effect: effect.label.upper()) }, "timesteps_extra": [date.isoformat() for date in self.time_series_collection.timesteps_extra], - "periods": self.time_series_collection.periods, "hours_of_previous_timesteps": self.time_series_collection.hours_of_previous_timesteps, } if data_mode == 'data': diff --git a/flixOpt/results.py b/flixOpt/results.py index 830c04b2b..07fff5c3f 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -106,7 +106,6 @@ def __init__(self, for label, infos in results_structure['Effects'].items()} self.timesteps_extra = pd.DatetimeIndex([datetime.datetime.fromisoformat(date) for date in results_structure['Time']], name='time') - self.periods = pd.Index(results_structure['Periods'], name = 'period') if results_structure['Periods'] is not None else None self.hours_per_timestep = TimeSeriesCollection.create_hours_per_timestep(self.timesteps_extra, self.periods) def __getitem__(self, key: str) -> Union['ComponentResults', 'BusResults', 'EffectResults']: From 1f42844948f79ae890d000a7542195bf1fcd566a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 8 Mar 2025 18:26:23 +0100 Subject: [PATCH 310/507] Simplify the DataConverter to only use timesteps, not periods --- flixOpt/core.py | 147 +++++++++++++++++------------------------------- 1 file changed, 52 insertions(+), 95 deletions(-) diff --git a/flixOpt/core.py b/flixOpt/core.py index 129fb1194..34e681ff1 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -20,104 +20,62 @@ NumericData = Union[int, float, np.ndarray, pd.Series, pd.DataFrame, xr.DataArray] NumericDataTS = Union[NumericData, 'TimeSeriesData'] -class DataConverter: - """ - A utility class for converting various data types into an xarray.DataArray - with specified time and optional period indexes. - - Supported input types: - - int or float: Generates a DataArray filled with the given scalar value. - - pd.Series: Index should be time steps; expands over periods if provided. - - pd.DataFrame: Columns represent periods, and the index represents time steps. - If a single column is passed but periods exist, the data is expanded over periods. - - np.ndarray: - - If 1D, attempts to reshape based on time steps and periods. - - If 2D, ensures dimensions match time steps and periods, transposing if necessary. - - Logs a warning if periods and time steps have the same length to prevent confusion. - - Raises: - - TypeError if an unsupported data type is provided. - - ValueError if data dimensions do not match expected time and period indexes. - """ - @staticmethod - def as_dataarray(data: NumericData, time: pd.DatetimeIndex, period: Optional[pd.Index] = None) -> xr.DataArray: - """ - Converts the given data to an xarray.DataArray with the specified time and period indexes. - """ - if period is not None: - coords = [period, time] - dims = ['period', 'time'] - else: - coords = [time] - dims = ['time'] - - if isinstance(data, (int, float, np.integer, np.floating)): - return DataConverter._handle_scalar(data, coords, dims) - elif isinstance(data, pd.DataFrame): - return DataConverter._handle_dataframe(data, coords, dims) - elif isinstance(data, pd.Series): - return DataConverter._handle_series(data, coords, dims) - elif isinstance(data, np.ndarray): - return DataConverter._handle_array(data, coords, dims) - elif isinstance(data, xr.DataArray): - return data - - raise TypeError(f"Unsupported data type. Must be scalar, np.ndarray, pd.Series, or pd.DataFrame." - f"Got {type(data)=}") - - @staticmethod - def _handle_scalar(data: Scalar, coords: list, dims: list) -> xr.DataArray: - """Handles scalar input by filling the array with the value.""" - return xr.DataArray(data, coords=coords, dims=dims) - - @staticmethod - def _handle_dataframe(data: pd.DataFrame, coords: list, dims: list) -> xr.DataArray: - """Handles pandas DataFrame input.""" - if len(coords) == 2: - if data.shape[1] == 1: - return DataConverter._handle_series(data.iloc[:, 0], coords, dims) - elif data.shape != (len(coords[1]), len(coords[0])): - raise ValueError("DataFrame shape does not match provided indexes") - return xr.DataArray(data.T, coords=coords, dims=dims) - @staticmethod - def _handle_series(data: pd.Series, coords: list, dims: list) -> xr.DataArray: - """Handles pandas Series input.""" - if len(coords) == 2: - if data.shape[0] != len(coords[1]): - raise ValueError(f"Series index does not match the shape of the provided timsteps: {data.shape[0]= } != {len(coords[1])=}") - return xr.DataArray(np.tile(data.values, (len(coords[0]), 1)), coords=coords, dims=dims) - return xr.DataArray(data.values, coords=coords, dims=dims) +class ConversionError(Exception): + """Base exception for data conversion errors.""" + pass - @staticmethod - def _handle_array(data: np.ndarray, coords: list, dims: list) -> xr.DataArray: - """Handles NumPy array input.""" - expected_shape = tuple(len(coord) for coord in coords) - - if data.ndim == 1 and len(coords) == 2: - if data.shape[0] == len(coords[0]): - data = np.tile(data[:, np.newaxis], (1, len(coords[1]))) - elif data.shape[0] == len(coords[1]): - data = np.tile(data[np.newaxis, :], (len(coords[0]), 1)) - else: - raise ValueError("1D array length does not match either dimension in coords") - if data.shape != expected_shape: - raise ValueError(f"Shape of data {data.shape} does not match expected shape {expected_shape}") +class DataConverter: + """ + Converts various data types into xarray.DataArray with a timesteps index. - return xr.DataArray(data, coords=coords, dims=dims) + Supports: scalars, arrays, Series, DataFrames, and DataArrays. + """ @staticmethod - def _handle_xr_dataarray(data: xr.DataArray, coords: list, dims: list) -> xr.DataArray: - """Handles xr.DataArray input.""" - if data.ndim != len(coords): - raise ValueError(f"DataArray must have {len(coords)} dimensions, got {data.ndim}") - if data.dims != dims: - raise ValueError(f"DataArray dimensions {data.dims} do not match expected dimensions {dims}") - if data.shape != tuple(coord.size for coord in coords): - raise ValueError(f"DataArray shape {data.shape} does not match expected shape {tuple(coord.size for coord in coords)}") - # TODO: This is not really thought through or tested - return data + def as_dataarray(data: NumericData, timesteps: pd.DatetimeIndex) -> xr.DataArray: + """Convert data to xarray.DataArray with specified timesteps index.""" + if not isinstance(timesteps, pd.DatetimeIndex) or len(timesteps) == 0: + raise ValueError(f"Timesteps must be a non-empty DatetimeIndex, got {type(timesteps).__name__}") + if not timesteps.name == 'time': + raise ConversionError(f'DatetimeIndex is not named correctly. Must be named "time", got {timesteps.name=}') + + coords = [timesteps] + dims = ['time'] + expected_shape = (len(timesteps),) + + try: + if isinstance(data, (int, float, np.integer, np.floating)): + return xr.DataArray(data, coords=coords, dims=dims) + elif isinstance(data, pd.DataFrame): + if not data.index.equals(timesteps): + raise ConversionError("DataFrame index doesn't match timesteps index") + if not len(data.columns) == 1: + raise ConversionError('DataFrame must have exactly one column') + return xr.DataArray(data.values, coords=coords, dims=dims) + elif isinstance(data, pd.Series): + if not data.index.equals(timesteps): + raise ConversionError("Series index doesn't match timesteps index") + return xr.DataArray(data.values, coords=coords, dims=dims) + elif isinstance(data, np.ndarray): + if data.ndim != 1: + raise ConversionError(f"Array must be 1-dimensional, got {data.ndim}") + elif data.shape[0] != expected_shape[0]: + raise ConversionError(f"Array shape {data.shape} doesn't match expected {expected_shape}") + return xr.DataArray(data, coords=coords, dims=dims) + elif isinstance(data, xr.DataArray): + if data.dims != tuple(dims): + raise ConversionError(f"DataArray dimensions {data.dims} don't match expected {dims}") + if data.sizes[dims[0]] != len(coords[0]): + raise ConversionError(f"DataArray length {data.sizes[dims[0]]} doesn't match expected {len(coords[0])}") + return data.copy(deep=True) + else: + raise ConversionError(f"Unsupported type: {type(data).__name__}") + except Exception as e: + if isinstance(e, ConversionError): + raise + raise ConversionError(f"Conversion error: {str(e)}") from e class TimeSeriesData: @@ -225,8 +183,8 @@ def __init__(self, """ if 'time' not in data.indexes: raise ValueError(f'DataArray must have a "time" index. Got {data.indexes}') - if 'period' not in data.indexes and data.ndim > 1: - raise ValueError(f'Second index of DataArray must be "period". Got {data.indexes}') + if data.ndim > 1: + raise ValueError(f'Number of dimensions of DataArray must be 1. Got {data.ndim}') self.name = name self.aggregation_weight = aggregation_weight @@ -237,7 +195,6 @@ def __init__(self, self._active_data = None self._active_timesteps = self.stored_data.indexes['time'] - self._active_periods = self.stored_data.indexes['period'] if 'period' in self.stored_data.indexes else None self._update_active_data() def reset(self): From 3658eedb42c26c80326184ddb6d018ebfcbb03ff Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 8 Mar 2025 18:32:31 +0100 Subject: [PATCH 311/507] Remove periods from TimeSeries --- flixOpt/core.py | 47 +++++++++++------------------------------------ 1 file changed, 11 insertions(+), 36 deletions(-) diff --git a/flixOpt/core.py b/flixOpt/core.py index 34e681ff1..fc065c406 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -131,24 +131,26 @@ class TimeSeries: @classmethod def from_datasource(cls, - data: Union[int, float, np.ndarray, pd.Series, pd.DataFrame, xr.DataArray], + data: Union[Scalar, np.integer, np.floating, np.ndarray, pd.Series, pd.DataFrame, xr.DataArray], name: str, timesteps: pd.DatetimeIndex = None, - periods: Optional[pd.Index] = None, aggregation_weight: Optional[float] = None, aggregation_group: Optional[str] = None ) -> 'TimeSeries': """ - Initialize the TimeSeries from multiple datasources. + Initialize the TimeSeries from multiple data sources. Parameters: - data (pd.Series): A Series with a DatetimeIndex and possibly a MultiIndex. - - dims (Tuple[pd.Index, ...]): The dimensions of the TimeSeries. + - name (str): The name of the TimeSeries. + - timesteps (pd.DatetimeIndex): The timesteps of the TimeSeries. - aggregation_weight (float, optional): The weight of the data in the aggregation. Defaults to None. - aggregation_group (str, optional): The group this TimeSeries is a part of. agg_weight is split between members of a group. Default is None. """ - data = cls(DataConverter.as_dataarray(data, timesteps, periods), name, aggregation_weight, aggregation_group) - return data + return cls(DataConverter.as_dataarray(data, timesteps), + name, + aggregation_weight, + aggregation_group) @classmethod def from_json(cls, data: Optional[Dict[str, Any]] = None, path: Optional[str] = None) -> 'TimeSeries': @@ -198,9 +200,8 @@ def __init__(self, self._update_active_data() def reset(self): - """Reset active timesteps and periods.""" + """Reset active timesteps.""" self.active_timesteps = None - self.active_periods = None def restore_data(self): """Restore stored_data from the backup.""" @@ -230,10 +231,7 @@ def stats(self) -> str: def _update_active_data(self): """Update the active data.""" - if 'period' in self._stored_data.indexes: - self._active_data = self._stored_data.sel(time=self.active_timesteps, period=self.active_periods) - else: - self._active_data = self._stored_data.sel(time=self.active_timesteps) + self._active_data = self._stored_data.sel(time=self.active_timesteps) @property def all_equal(self) -> bool: @@ -257,33 +255,11 @@ def active_timesteps(self, timesteps: Optional[pd.DatetimeIndex]): self._update_active_data() # Refresh view - @property - def active_periods(self) -> Optional[pd.Index]: - """Return the current active index.""" - return self._active_periods - - @active_periods.setter - def active_periods(self, periods: Optional[pd.Index]): - """Set new active periods and refresh active_data.""" - if periods is None: - self._active_periods = self.stored_data.indexes['period'] if 'period' in self.stored_data.indexes else None - elif isinstance(periods, pd.Index): - self._active_periods = periods - else: - raise TypeError("periods must be a pd.Index or None") - - self._update_active_data() # Refresh view - @property def active_data(self) -> xr.DataArray: """Return a view of stored_data based on active_index.""" return self._active_data - @active_data.setter - def active_data(self, value): - """Prevent direct modification of active_data.""" - raise AttributeError("active_data cannot be directly modified. Modify stored_data instead.") - @property def stored_data(self) -> xr.DataArray: """Return a copy of stored_data. Prevents modification of stored data""" @@ -299,12 +275,11 @@ def stored_data(self, value: Union[pd.Series, pd.DataFrame, xr.DataArray]): value: Union[int, float, np.ndarray, pd.Series, pd.DataFrame, xr.DataArray] Data to update stored_data with. """ - new_data = DataConverter.as_dataarray(value, time=self.active_timesteps, period=self.active_periods) + new_data = DataConverter.as_dataarray(value, timesteps=self.active_timesteps) if new_data.equals(self._stored_data): return # No change in stored_data. Do nothing. This prevents pushing out the backup self._stored_data = new_data self.active_timesteps = None - self.active_periods = None @property def sel(self): From 99f7ff5c51efcab0ca89c6b74ed9de3b085b851e Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 8 Mar 2025 18:39:16 +0100 Subject: [PATCH 312/507] Remove periods everywere --- flixOpt/core.py | 70 +++++++++++----------------------------------- flixOpt/io.py | 1 - flixOpt/results.py | 4 +-- 3 files changed, 18 insertions(+), 57 deletions(-) diff --git a/flixOpt/core.py b/flixOpt/core.py index fc065c406..07daf8a92 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -226,7 +226,7 @@ def to_json(self, path: Optional[pathlib.Path] = None) -> Dict[str, Any]: @property def stats(self) -> str: - """Return a statistical summary of the active data, considering periods if available.""" + """Return a statistical summary of the active data.""" return get_numeric_stats(self.active_data, padd=0) def _update_active_data(self): @@ -348,22 +348,18 @@ def __init__( timesteps: pd.DatetimeIndex, hours_of_last_timestep: Optional[float], hours_of_previous_timesteps: Optional[Union[int, float, np.ndarray]], - periods: Optional[List[int]] = None, ): ( self.all_timesteps, self.all_timesteps_extra, self.all_hours_per_timestep, self.hours_of_previous_timesteps, - self.all_periods ) = TimeSeriesCollection.align_dimensions(timesteps, - periods, hours_of_last_timestep, hours_of_previous_timesteps) self._active_timesteps = None self._active_timesteps_extra = None - self._active_periods = None self._active_hours_per_timestep = None self.group_weights: Dict[str, float] = {} @@ -399,7 +395,6 @@ def create_time_series( name=name, data=data if not isinstance(data, TimeSeriesData) else data.data, timesteps=self.timesteps if not extra_timestep else self.timesteps_extra, - periods=self.periods, aggregation_weight=data.agg_weight if isinstance(data, TimeSeriesData) else None, aggregation_group=data.agg_group if isinstance(data, TimeSeriesData) else None ) @@ -419,52 +414,42 @@ def calculate_aggregation_weights(self) -> Dict[str, float]: return self.weights - def activate_indices(self, - active_timesteps: Optional[pd.DatetimeIndex] = None, - active_periods: Optional[pd.Index] = None): + def activate_indices(self, active_timesteps: Optional[pd.DatetimeIndex] = None): """ - Update active timesteps, periods, and data of the TimeSeriesCollection. - If no arguments are provided, the active timesteps and periods are reset. + Update active timesteps and data of the TimeSeriesCollection. + If no arguments are provided, the active timesteps are reset. Parameters ---------- active_timesteps : Optional[pd.DatetimeIndex] The active timesteps of the model. If None, the all timesteps of the TimeSeriesCollection are taken. - active_periods : Optional[pd.Index] - The active periods of the model. - If None, all periods from the TimeSeriesCollection are taken. """ - if active_timesteps is None and active_periods is None: + if active_timesteps is None: return self.reset() active_timesteps = active_timesteps if active_timesteps is not None else self.all_timesteps - active_periods = active_periods if active_periods is not None else self.all_periods if not np.all(active_timesteps.isin(self.all_timesteps)): raise ValueError('active_timesteps must be a subset of the timesteps of the TimeSeriesCollection') - if active_periods is not None and not np.all(active_periods.isin(self.all_periods)): - raise ValueError('active_periods must be a subset of the periods of the TimeSeriesCollection') ( self._active_timesteps, self._active_timesteps_extra, self._active_hours_per_timestep, _, - self._active_periods ) = TimeSeriesCollection.align_dimensions( - active_timesteps, active_periods, self.hours_of_last_timestep, self.hours_of_previous_timesteps + active_timesteps, self.hours_of_last_timestep, self.hours_of_previous_timesteps ) self._activate_timeserieses() def reset(self): - """Reset active timesteps and periods of all TimeSeries.""" + """Reset active timesteps of all TimeSeries.""" self._active_timesteps = None self._active_timesteps_extra = None self._active_hours_per_timestep = None - self._active_periods = None for time_series in self.time_series_data: time_series.reset() @@ -502,11 +487,10 @@ def insert_new_data(self, data: pd.DataFrame): @staticmethod def align_dimensions( timesteps: pd.DatetimeIndex, - periods: Optional[pd.Index] = None, hours_of_last_timestep: Optional[float] = None, hours_of_previous_timesteps: Optional[Union[int, float, np.ndarray]] = None - ) -> Tuple[pd.DatetimeIndex, pd.DatetimeIndex, xr.DataArray, Union[int, float, np.ndarray], Optional[pd.Index]]: - """Converts the given timesteps, periods and hours_of_last_timestep to the right format + ) -> Tuple[pd.DatetimeIndex, pd.DatetimeIndex, xr.DataArray, Union[int, float, np.ndarray]]: + """Converts the given timesteps and hours_of_last_timestep to the right format Parameters ---------- @@ -515,14 +499,11 @@ def align_dimensions( hours_of_last_timestep : Optional[float], optional The duration of the last time step. Uses the last time interval if not specified hours_of_previous_timesteps: Un - periods : Optional[pd.Index], optional - The periods of the model. Every period has the same timesteps. - Usually years are used as periods. Returns ------- Tuple[pd.DatetimeIndex, pd.DatetimeIndex, xr.DataArray, Optional[pd.Index]] - The timesteps, timesteps_extra, hours_per_timestep and periods + The timesteps, timesteps_extra and hours_per_timestep - timesteps: pd.DatetimeIndex The timesteps of the model. @@ -532,9 +513,6 @@ def align_dimensions( The duration of each timestep in hours. - hours_of_previous_timesteps: Optional[Union[int, float, np.ndarray]] The duration of the previous timesteps in hours. - - periods: Optional[pd.Index] - The periods of the model. Every period has the same timesteps. - Usually years are used as periods. """ if not isinstance(timesteps, pd.DatetimeIndex): @@ -543,19 +521,13 @@ def align_dimensions( logger.warning('timesteps must be a pandas DatetimeIndex with name "time". Renamed it to "time".') timesteps.name = 'time' - if periods is not None and not isinstance(periods, pd.Index): - raise TypeError('periods must be a pandas Index or None') - if periods is not None and periods.name != 'period': - logger.warning('periods must be a pandas Index with name "period". Renamed it.') - periods.name = 'period' - timesteps_extra = TimeSeriesCollection._create_extra_timestep(timesteps, hours_of_last_timestep) hours_of_previous_timesteps = TimeSeriesCollection._calculate_hours_of_previous_timesteps( timesteps, hours_of_previous_timesteps ) - hours_per_step = TimeSeriesCollection.create_hours_per_timestep(timesteps_extra, periods) + hours_per_step = TimeSeriesCollection.create_hours_per_timestep(timesteps_extra) - return timesteps, timesteps_extra, hours_per_step, hours_of_previous_timesteps, periods + return timesteps, timesteps_extra, hours_per_step, hours_of_previous_timesteps def add_time_series(self, time_series: TimeSeries, extra_timestep: bool): self.time_series_data.append(time_series) @@ -565,7 +537,6 @@ def add_time_series(self, time_series: TimeSeries, extra_timestep: bool): def _activate_timeserieses(self): for time_series in self.time_series_data: - time_series.active_periods = self.periods if time_series in self._time_series_data_with_extra_step: time_series.active_timesteps = self.timesteps_extra else: @@ -591,7 +562,6 @@ def to_dataset(self, include_constants: bool = True) -> xr.Dataset: ds.attrs.update({ "timesteps": f"{self.all_timesteps[0]} ... {self.all_timesteps[-1]} | len={len(self.timesteps)}", "hours_per_timestep": get_numeric_stats(self.hours_per_timestep), - "periods": f"{self.periods[0]} ... {self.periods[-1]} | len={len(self.periods)}" if self.periods is not None else None, }) return ds @@ -622,14 +592,13 @@ def _calculate_hours_of_previous_timesteps( @staticmethod def create_hours_per_timestep( timesteps_extra: pd.DatetimeIndex, - periods: Optional[pd.Index] = None ) -> xr.DataArray: """Creates a DataArray representing the duration of each timestep in hours.""" hours_per_step = timesteps_extra.to_series().diff()[1:].values / pd.to_timedelta(1, 'h') return xr.DataArray( - data=np.tile(hours_per_step, (len(periods), 1)) if periods is not None else hours_per_step, - coords=(periods, timesteps_extra[:-1]) if periods is not None else (timesteps_extra[:-1],), - dims=('period', 'time') if periods is not None else ('time',), + data=hours_per_step, + coords=(timesteps_extra[:-1],), + dims=('time',), name='hours_per_step' ) @@ -694,11 +663,11 @@ def constants(self) -> List[TimeSeries]: @property def coords(self) -> Union[Tuple[pd.Index, pd.DatetimeIndex], Tuple[pd.DatetimeIndex]]: - return (self.periods, self.timesteps) if self.periods is not None else (self.timesteps,) + return (self.timesteps,) @property def coords_extra(self) -> Union[Tuple[pd.Index, pd.DatetimeIndex], Tuple[pd.DatetimeIndex]]: - return (self.periods, self.timesteps_extra) if self.periods is not None else (self.timesteps_extra,) + return (self.timesteps_extra,) @property def timesteps(self) -> pd.DatetimeIndex: @@ -708,10 +677,6 @@ def timesteps(self) -> pd.DatetimeIndex: def timesteps_extra(self) -> pd.DatetimeIndex: return self.all_timesteps_extra if self._active_timesteps_extra is None else self._active_timesteps_extra - @property - def periods(self) -> pd.Index: - return self.all_periods if self._active_periods is None else self._active_periods - @property def hours_per_timestep(self) -> xr.DataArray: return self.all_hours_per_timestep if self._active_hours_per_timestep is None else self._active_hours_per_timestep @@ -736,7 +701,6 @@ def __str__(self): f" Time Range: {self.timesteps[0]} -> {self.timesteps[-1]}\n" f" No. of timesteps: {len(self.timesteps)}\n" f" Hours per timestep: {get_numeric_stats(self.hours_per_timestep)}" - f" Periods: {list(self.periods) if self.periods is not None else 'None'}\n" f" TimeSeriesData:\n" f"{stats_summary}" ) diff --git a/flixOpt/io.py b/flixOpt/io.py index 619c71182..c52a0167a 100644 --- a/flixOpt/io.py +++ b/flixOpt/io.py @@ -27,7 +27,6 @@ def _results_structure(flow_system: FlowSystem) -> Dict[str, Dict]: for effect in sorted(flow_system.effects, key=lambda effect: effect.label_full.upper()) }, 'Time': [datetime.datetime.isoformat(date) for date in flow_system.time_series_collection.timesteps_extra], - 'Periods': flow_system.time_series_collection.periods.tolist() if flow_system.time_series_collection.periods is not None else None } diff --git a/flixOpt/results.py b/flixOpt/results.py index 07fff5c3f..7f82fca47 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -48,8 +48,6 @@ class CalculationResults: A dictionary of EffectResults for each effect in the flow_system. timesteps_extra : pd.DatetimeIndex The extra timesteps of the flow_system. - periods : pd.Index - The periods of the flow_system. hours_per_timestep : xr.DataArray The duration of each timestep in hours. @@ -106,7 +104,7 @@ def __init__(self, for label, infos in results_structure['Effects'].items()} self.timesteps_extra = pd.DatetimeIndex([datetime.datetime.fromisoformat(date) for date in results_structure['Time']], name='time') - self.hours_per_timestep = TimeSeriesCollection.create_hours_per_timestep(self.timesteps_extra, self.periods) + self.hours_per_timestep = TimeSeriesCollection.create_hours_per_timestep(self.timesteps_extra) def __getitem__(self, key: str) -> Union['ComponentResults', 'BusResults', 'EffectResults']: if key in self.components: From 71c179828858df34caf010ecf048894a3be67205 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 9 Mar 2025 21:14:04 +0100 Subject: [PATCH 313/507] Bugfix in DataConverter --- flixOpt/core.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flixOpt/core.py b/flixOpt/core.py index 07daf8a92..165a2afa5 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -53,7 +53,7 @@ def as_dataarray(data: NumericData, timesteps: pd.DatetimeIndex) -> xr.DataArray raise ConversionError("DataFrame index doesn't match timesteps index") if not len(data.columns) == 1: raise ConversionError('DataFrame must have exactly one column') - return xr.DataArray(data.values, coords=coords, dims=dims) + return xr.DataArray(data.values.flatten(), coords=coords, dims=dims) elif isinstance(data, pd.Series): if not data.index.equals(timesteps): raise ConversionError("Series index doesn't match timesteps index") @@ -75,7 +75,7 @@ def as_dataarray(data: NumericData, timesteps: pd.DatetimeIndex) -> xr.DataArray except Exception as e: if isinstance(e, ConversionError): raise - raise ConversionError(f"Conversion error: {str(e)}") from e + raise ConversionError(f"Converting data {type(data)} to xarray.Dataset raised an error: {str(e)}") from e class TimeSeriesData: From 42c2a883b6c90890a3ca4c1ecd0cfed602dba2cf Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 9 Mar 2025 21:16:42 +0100 Subject: [PATCH 314/507] Update DataCOnverter tests --- tests/test_dataconverter.py | 131 +++++++++++++++++++----------------- 1 file changed, 71 insertions(+), 60 deletions(-) diff --git a/tests/test_dataconverter.py b/tests/test_dataconverter.py index b2edf2668..b5f876abb 100644 --- a/tests/test_dataconverter.py +++ b/tests/test_dataconverter.py @@ -3,109 +3,120 @@ import pytest import xarray as xr -from flixOpt.core import DataConverter # Adjust this import to match your project structure +from flixOpt.core import DataConverter, ConversionError # Adjust this import to match your project structure @pytest.fixture def sample_time_index(request): - return pd.date_range("2024-01-01", periods=5, freq="D") - -@pytest.fixture -def sample_period_index(request): - return pd.Index(["A", "B", "C"]) + index = pd.date_range("2024-01-01", periods=5, freq="D", name='time') + return index -def test_scalar_conversion(sample_time_index, sample_period_index): - # Test scalar conversion without periods +def test_scalar_conversion(sample_time_index): + # Test scalar conversion result = DataConverter.as_dataarray(42, sample_time_index) assert isinstance(result, xr.DataArray) assert result.shape == (len(sample_time_index),) - assert np.all(result.values == 42) - - # Test scalar conversion with periods - result = DataConverter.as_dataarray(42, sample_time_index, sample_period_index) - assert result.shape == (len(sample_period_index), len(sample_time_index)) + assert result.dims == ('time',) assert np.all(result.values == 42) -def test_series_conversion(sample_time_index, sample_period_index): +def test_series_conversion(sample_time_index): series = pd.Series([1, 2, 3, 4, 5], index=sample_time_index) - # Test Series conversion without periods + # Test Series conversion result = DataConverter.as_dataarray(series, sample_time_index) assert isinstance(result, xr.DataArray) assert result.shape == (5,) + assert result.dims == ('time',) assert np.array_equal(result.values, series.values) - # Test Series conversion with periods (should expand) - result = DataConverter.as_dataarray(series, sample_time_index, sample_period_index) - assert result.shape == (3, 5) - assert np.all(result.values[:, 0] == 1) # Ensure expansion - assert np.all(result.isel(period=0).values == series.values) - -def test_dataframe_conversion(sample_time_index, sample_period_index): +def test_dataframe_conversion(sample_time_index): + # Create a single-column DataFrame df = pd.DataFrame( - np.arange(15).reshape(5, 3), - index=sample_time_index, - columns=sample_period_index, + {"A": [1, 2, 3, 4, 5]}, + index=sample_time_index ) # Test DataFrame conversion - result = DataConverter.as_dataarray(df, sample_time_index, sample_period_index) + result = DataConverter.as_dataarray(df, sample_time_index) assert isinstance(result, xr.DataArray) - assert result.shape == (3, 5) - assert np.array_equal(result.values.T, df.values) - + assert result.shape == (5,) + assert result.dims == ('time',) + assert np.array_equal(result.values.flatten(), df['A'].values) -def test_dataframe_single_column_expansion(sample_time_index, sample_period_index): - df = pd.DataFrame( - {"A": [1, 2, 3, 4, 5]}, - index=sample_time_index - ) - # Test expansion - result = DataConverter.as_dataarray(df, sample_time_index, sample_period_index) - assert result.shape == (3, 5) - assert np.all(result.values[:, 0] == 1) - assert np.all(result.isel(period=0).values == df.values.flatten()) +def test_ndarray_conversion(sample_time_index): + # Test 1D array conversion + arr_1d = np.array([1, 2, 3, 4, 5]) + result = DataConverter.as_dataarray(arr_1d, sample_time_index) + assert result.shape == (5,) + assert result.dims == ('time',) + assert np.array_equal(result.values, arr_1d) -def test_ndarray_conversion(sample_time_index, sample_period_index): - # Test 1D array conversion (should expand into each period) - arr_1d = np.array([1, 2, 3, 4, 5]) - result = DataConverter.as_dataarray(arr_1d, sample_time_index, sample_period_index) - assert result.shape == (3, 5) +def test_dataarray_conversion(sample_time_index): + # Create a DataArray + original = xr.DataArray( + data=np.array([1, 2, 3, 4, 5]), + coords={'time': sample_time_index}, + dims=['time'] + ) - # Test 1D array conversion (should expand into each timestep) - arr_1d_period = np.array([1, 2, 3]) - result = DataConverter.as_dataarray(arr_1d_period, sample_time_index, sample_period_index) - assert result.shape == (3, 5) + # Test DataArray conversion + result = DataConverter.as_dataarray(original, sample_time_index) + assert result.shape == (5,) + assert result.dims == ('time',) + assert np.array_equal(result.values, original.values) - # Test 2D array conversion - arr_2d = np.random.rand(3, 5) - result = DataConverter.as_dataarray(arr_2d, sample_time_index, sample_period_index) - assert result.shape == (3, 5) + # Ensure it's a copy + result[0] = 999 + assert original[0].item() == 1 # Original should be unchanged -def test_invalid_inputs(sample_time_index, sample_period_index): +def test_invalid_inputs(sample_time_index): # Test invalid input type - with pytest.raises(TypeError): + with pytest.raises(ConversionError): DataConverter.as_dataarray("invalid_string", sample_time_index) # Test mismatched Series index mismatched_series = pd.Series([1, 2, 3, 4, 5, 6], index=pd.date_range("2025-01-01", periods=6, freq="D")) - with pytest.raises(ValueError): + with pytest.raises(ConversionError): DataConverter.as_dataarray(mismatched_series, sample_time_index) - # Test mismatched DataFrame shape - df_invalid = pd.DataFrame(np.random.rand(4, 2), index=sample_time_index[:4], columns=sample_period_index[:2]) + # Test DataFrame with multiple columns + df_multi_col = pd.DataFrame({ + "A": [1, 2, 3, 4, 5], + "B": [6, 7, 8, 9, 10] + }, index=sample_time_index) + with pytest.raises(ConversionError): + DataConverter.as_dataarray(df_multi_col, sample_time_index) + + # Test mismatched array shape + with pytest.raises(ConversionError): + DataConverter.as_dataarray(np.array([1, 2, 3]), sample_time_index) # Wrong length + + # Test multi-dimensional array + with pytest.raises(ConversionError): + DataConverter.as_dataarray(np.array([[1, 2], [3, 4]]), sample_time_index) # 2D array not allowed + + +def test_time_index_validation(): + # Test with unnamed index + unnamed_index = pd.date_range("2024-01-01", periods=5, freq="D") + with pytest.raises(ConversionError): + DataConverter.as_dataarray(42, unnamed_index) + + # Test with empty index + empty_index = pd.DatetimeIndex([], name='time') with pytest.raises(ValueError): - DataConverter.as_dataarray(df_invalid, sample_time_index, sample_period_index) + DataConverter.as_dataarray(42, empty_index) + # Test with non-DatetimeIndex + wrong_type_index = pd.Index([1, 2, 3, 4, 5], name='time') with pytest.raises(ValueError): - # Test mismatched Shape. Array should be (3, 5) - DataConverter.as_dataarray(np.random.rand(5, 3), sample_time_index, sample_period_index) + DataConverter.as_dataarray(42, wrong_type_index) if __name__ == "__main__": From f9759d5267c339fc1354be594d6af9dbb2a74603 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 9 Mar 2025 21:23:12 +0100 Subject: [PATCH 315/507] Remove period artifacts --- flixOpt/calculation.py | 2 -- flixOpt/flow_system.py | 8 ++------ flixOpt/results.py | 2 +- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py index 447312054..746a98022 100644 --- a/flixOpt/calculation.py +++ b/flixOpt/calculation.py @@ -327,8 +327,6 @@ def __init__( folder where results should be saved. If None, then the current working directory is used. """ super().__init__(name, flow_system, folder=folder) - if flow_system.time_series_collection.periods is not None: - raise NotImplementedError('Multiple Periods are currently not supported in SegmentedCalculation') self.timesteps_per_segment = timesteps_per_segment self.overlap_timesteps = overlap_timesteps self.nr_of_previous_values = nr_of_previous_values diff --git a/flixOpt/flow_system.py b/flixOpt/flow_system.py index 9c6a297e2..992e055e2 100644 --- a/flixOpt/flow_system.py +++ b/flixOpt/flow_system.py @@ -67,9 +67,7 @@ def __init__( @classmethod def from_dataset(cls, ds: xr.Dataset): timesteps_extra = pd.DatetimeIndex(ds.attrs['timesteps_extra'], name='time') - hours_of_last_timestep = TimeSeriesCollection.create_hours_per_timestep( - timesteps_extra, None - ).isel(time=-1).item() + hours_of_last_timestep = TimeSeriesCollection.create_hours_per_timestep(timesteps_extra).isel(time=-1).item() flow_system = FlowSystem(timesteps=timesteps_extra[:-1], hours_of_last_timestep=hours_of_last_timestep, @@ -87,9 +85,7 @@ def from_dataset(cls, ds: xr.Dataset): @classmethod def from_dict(cls, data: Dict) -> 'FlowSystem': timesteps_extra = pd.DatetimeIndex(data['timesteps_extra'], name='time') - hours_of_last_timestep = TimeSeriesCollection.create_hours_per_timestep( - timesteps_extra, None - ).isel(time=-1).item() + hours_of_last_timestep = TimeSeriesCollection.create_hours_per_timestep(timesteps_extra).isel(time=-1).item() flow_system = FlowSystem(timesteps=timesteps_extra[:-1], hours_of_last_timestep=hours_of_last_timestep, diff --git a/flixOpt/results.py b/flixOpt/results.py index 7f82fca47..370dc5507 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -351,7 +351,7 @@ def __init__(self, self.overlap_timesteps = overlap_timesteps self.name = name self.folder = pathlib.Path(folder) if folder is not None else pathlib.Path.cwd() / 'results' - self.hours_per_timestep = TimeSeriesCollection.create_hours_per_timestep(self.all_timesteps, None) + self.hours_per_timestep = TimeSeriesCollection.create_hours_per_timestep(self.all_timesteps) def solution_without_overlap(self, variable: str) -> xr.DataArray: """Returns the solution of a variable without overlap""" From 9f64ebe9201091fd02b6059008aca6dac6775e11 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 9 Mar 2025 21:41:07 +0100 Subject: [PATCH 316/507] Update TImeSeries tests and bugfix in TimeSeries --- flixOpt/core.py | 10 +- tests/test_timeseries.py | 488 +++++++++++++++++++++++++++------------ 2 files changed, 351 insertions(+), 147 deletions(-) diff --git a/flixOpt/core.py b/flixOpt/core.py index 165a2afa5..47b07cc73 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -321,19 +321,19 @@ def __rtruediv__(self, other): return other / self.active_data # Unary operations. Not sure if this is the best way... - def __neg__(self): + def __neg__(self) -> xr.DataArray: return -self.active_data - def __pos__(self): + def __pos__(self) -> xr.DataArray: return +self.active_data - def __abs__(self): + def __abs__(self) -> xr.DataArray: return abs(self.active_data) - def __gt__(self, other): + def __gt__(self, other) -> bool: """Compare two TimeSeries instances based on their xarray.DataArray.""" if isinstance(other, TimeSeries): - return (self.active_data > other.active_data).all() + return (self.active_data > other.active_data).all().item() return NotImplemented # In case the comparison is with something else def __array_ufunc__(self, ufunc, method, *inputs, **kwargs): diff --git a/tests/test_timeseries.py b/tests/test_timeseries.py index 8fe4bd113..57efc5cfe 100644 --- a/tests/test_timeseries.py +++ b/tests/test_timeseries.py @@ -1,146 +1,350 @@ -import linopy +import json +import tempfile +from pathlib import Path + +import numpy as np import pandas as pd import pytest import xarray as xr -from flixOpt.core import TimeSeries # Adjust import based on your module structure - - -# Helper function to create a test TimeSeries object -def create_test_timeseries(): - data = xr.DataArray([10, 20, 30], coords={'time': pd.date_range('2023-01-01', periods=3)}) - return TimeSeries(data, 'Name') - -# Test initialization -def test_initialization(): - ts = create_test_timeseries() - assert isinstance(ts, TimeSeries) - assert isinstance(ts.stored_data, xr.DataArray) - assert ts.stored_data.equals(xr.DataArray([10, 20, 30], coords={'time': pd.date_range('2023-01-01', periods=3)})) - -# Test active_timesteps property setter and getter -def test_active_timesteps_setter_getter(): - ts = create_test_timeseries() - new_index = pd.date_range('2023-01-02', periods=2) - ts.active_timesteps = new_index - assert ts.active_timesteps.equals(new_index) - assert ts.active_data.equals(ts.stored_data.sel(time=new_index)) - -# Test invalid active_timesteps assignment -def test_invalid_active_timesteps(): - ts = create_test_timeseries() - with pytest.raises(TypeError): - ts.active_timesteps = "invalid_index" - -# Test restoring data -def test_restore_data(): - ts = create_test_timeseries() - ts.active_timesteps = pd.date_range('2023-01-02', periods=2) - ts.restore_data() - assert ts.active_timesteps.equals(ts.stored_data.indexes['time']) - assert ts.active_data.equals(ts.stored_data) - - ts = create_test_timeseries() - old_data = ts.stored_data - new_data = xr.DataArray([1,2], coords=(pd.date_range("2023-01-02", periods=2, name='time'),)) - ts.stored_data = new_data - assert ts.active_data.equals(new_data) - - ts.restore_data() # Restore original data - - assert ts.active_timesteps.equals(old_data.indexes['time']) # Ensure active_timesteps is reset to full index - - assert ts.active_data.equals(old_data) # Ensure active_data matches stored_data - -# Test arithmetic operations -def test_arithmetic_operations(): - ts1 = create_test_timeseries() - ts2 = create_test_timeseries() - - # Test addition - result = ts1 + ts2 - expected = ts1.active_data + ts2.active_data - xr.testing.assert_equal(result, expected) - - # Test subtraction - result = ts1 - ts2 - expected = ts1.active_data - ts2.active_data - xr.testing.assert_equal(result, expected) - - # Test multiplication - result = ts1 * ts2 - expected = ts1.active_data * ts2.active_data - xr.testing.assert_equal(result, expected) - - # Test division - result = ts1 / ts2 - expected = ts1.active_data / ts2.active_data - xr.testing.assert_equal(result, expected) - - -# Test setting stored_data -def test_stored_data_setter(): - ts = create_test_timeseries() - old_data = ts.stored_data - new_data = xr.DataArray([40, 50, 60], coords={'time': pd.date_range('2023-01-01', periods=3)}) - ts.stored_data = new_data - assert ts.stored_data.equals(new_data) - assert ts.active_data.equals(new_data) - assert ts._backup.equals(old_data) - -# Test active_data direct modification prevention -def test_prevent_active_data_modification(): - ts = create_test_timeseries() - with pytest.raises(AttributeError): - ts.active_data = object() - -# Test active_data default behavior -def test_active_data_default(): - ts = create_test_timeseries() - ts.active_timesteps = None # Should default to the full stored_data - assert ts.active_data.equals(ts.stored_data) - - -# Test arithmetic operations with xarray.DataArray -def test_arithmetic_operations_xarray(): - time_idx = pd.date_range('2020-01-01', periods=3, freq='d', name='time') - periods = pd.Index([2020, 2030], name='period') - - arithmetric_operations( - xr.DataArray([10, 20, 30], coords=(time_idx,)), - TimeSeries(xr.DataArray([10, 20, 30], coords=(time_idx,)), 'Name') - ) - - arithmetric_operations( - xr.DataArray([[10, 20, 30], [1,2,3]], coords={'period': periods, 'time': time_idx}), - TimeSeries(xr.DataArray([[10, 20, 30], [1,2,3]], coords={'period': periods, 'time': time_idx}),'Name') - ) - -def arithmetric_operations(data1: xr.DataArray, ts1: TimeSeries): - xr.testing.assert_equal(ts1 + data1, data1 + ts1, check_dim_order=True) - xr.testing.assert_equal(ts1 - data1, data1 - ts1, check_dim_order=True) - xr.testing.assert_equal(ts1 * data1, data1 * ts1, check_dim_order=True) - xr.testing.assert_equal(ts1 / data1, data1 / ts1, check_dim_order=True) - xr.testing.assert_equal(data1 + ts1.active_data, data1 + ts1, check_dim_order=True) - xr.testing.assert_equal(data1 - ts1.active_data, data1 - ts1, check_dim_order=True) - xr.testing.assert_equal(data1 * ts1.active_data, data1 * ts1, check_dim_order=True) - xr.testing.assert_equal(data1 / ts1.active_data, data1 / ts1, check_dim_order=True) - - -def test_operations_with_linopy(): - index = pd.date_range("2023-01-01", periods=3, name="time") - period = pd.Index([2020, 2030], name="period") - - m = linopy.Model() - var1 = m.add_variables(coords=(period, index)) - timeseries1 = TimeSeries(xr.DataArray([[10, 20, 30], [1,2,3]], coords={'period': period, 'time':index}),'Name') - expr = timeseries1 * var1 - expr + timeseries1 - (expr + timeseries1) / timeseries1 - expr = var1 * timeseries1 - - m.add_constraints((expr * timeseries1) <= 10) - - -if __name__ == "__main__": - pytest.main() +from flixOpt.core import ConversionError, DataConverter, TimeSeries # Adjust import path as needed + + +@pytest.fixture +def sample_time_index(): + """Create a sample time index with the required 'time' name.""" + return pd.date_range('2023-01-01', periods=5, freq='D', name='time') + + +@pytest.fixture +def simple_dataarray(sample_time_index): + """Create a simple DataArray with time dimension.""" + return xr.DataArray([10, 20, 30, 40, 50], coords={'time': sample_time_index}, dims=['time']) + + +@pytest.fixture +def sample_timeseries(simple_dataarray): + """Create a sample TimeSeries object.""" + return TimeSeries(simple_dataarray, name='Test Series') + + +class TestTimeSeries: + """Test suite for TimeSeries class.""" + + def test_initialization(self, simple_dataarray): + """Test basic initialization of TimeSeries.""" + ts = TimeSeries(simple_dataarray, name='Test Series') + + # Check basic properties + assert ts.name == 'Test Series' + assert ts.aggregation_weight is None + assert ts.aggregation_group is None + + # Check data initialization + assert isinstance(ts.stored_data, xr.DataArray) + assert ts.stored_data.equals(simple_dataarray) + assert ts.active_data.equals(simple_dataarray) + + # Check backup was created + assert ts._backup.equals(simple_dataarray) + + # Check active timesteps + assert ts.active_timesteps.equals(simple_dataarray.indexes['time']) + + def test_initialization_with_aggregation_params(self, simple_dataarray): + """Test initialization with aggregation parameters.""" + ts = TimeSeries( + simple_dataarray, + name='Weighted Series', + aggregation_weight=0.5, + aggregation_group='test_group' + ) + + assert ts.name == 'Weighted Series' + assert ts.aggregation_weight == 0.5 + assert ts.aggregation_group == 'test_group' + + def test_initialization_validation(self, sample_time_index): + """Test validation during initialization.""" + # Test missing time dimension + invalid_data = xr.DataArray([1, 2, 3], dims=['invalid_dim']) + with pytest.raises(ValueError, match='must have a "time" index'): + TimeSeries(invalid_data, name='Invalid Series') + + # Test multi-dimensional data + multi_dim_data = xr.DataArray( + [[1, 2, 3], [4, 5, 6]], + coords={'dim1': [0, 1], 'time': sample_time_index[:3]}, + dims=['dim1', 'time'] + ) + with pytest.raises(ValueError, match='dimensions of DataArray must be 1'): + TimeSeries(multi_dim_data, name='Multi-dim Series') + + def test_active_timesteps_getter_setter(self, sample_timeseries, sample_time_index): + """Test active_timesteps getter and setter.""" + # Initial state should use all timesteps + assert sample_timeseries.active_timesteps.equals(sample_time_index) + + # Set to a subset + subset_index = sample_time_index[1:3] + sample_timeseries.active_timesteps = subset_index + assert sample_timeseries.active_timesteps.equals(subset_index) + + # Active data should reflect the subset + assert sample_timeseries.active_data.equals( + sample_timeseries.stored_data.sel(time=subset_index) + ) + + # Reset to full index + sample_timeseries.active_timesteps = None + assert sample_timeseries.active_timesteps.equals(sample_time_index) + + # Test invalid type + with pytest.raises(TypeError, match="must be a pandas Index"): + sample_timeseries.active_timesteps = "invalid" + + def test_reset(self, sample_timeseries, sample_time_index): + """Test reset method.""" + # Set to subset first + subset_index = sample_time_index[1:3] + sample_timeseries.active_timesteps = subset_index + + # Reset + sample_timeseries.reset() + + # Should be back to full index + assert sample_timeseries.active_timesteps.equals(sample_time_index) + assert sample_timeseries.active_data.equals(sample_timeseries.stored_data) + + def test_restore_data(self, sample_timeseries, simple_dataarray): + """Test restore_data method.""" + # Modify the stored data + new_data = xr.DataArray( + [1, 2, 3, 4, 5], + coords={'time': sample_timeseries.active_timesteps}, + dims=['time'] + ) + + # Store original data for comparison + original_data = sample_timeseries.stored_data + + # Set new data + sample_timeseries.stored_data = new_data + assert sample_timeseries.stored_data.equals(new_data) + + # Restore from backup + sample_timeseries.restore_data() + + # Should be back to original data + assert sample_timeseries.stored_data.equals(original_data) + assert sample_timeseries.active_data.equals(original_data) + + def test_stored_data_setter(self, sample_timeseries, sample_time_index): + """Test stored_data setter with different data types.""" + # Test with a Series + series_data = pd.Series([5, 6, 7, 8, 9], index=sample_time_index) + sample_timeseries.stored_data = series_data + assert np.array_equal( + sample_timeseries.stored_data.values, + series_data.values + ) + + # Test with a single-column DataFrame + df_data = pd.DataFrame({'col1': [15, 16, 17, 18, 19]}, index=sample_time_index) + sample_timeseries.stored_data = df_data + assert np.array_equal( + sample_timeseries.stored_data.values, + df_data['col1'].values + ) + + # Test with a NumPy array + array_data = np.array([25, 26, 27, 28, 29]) + sample_timeseries.stored_data = array_data + assert np.array_equal( + sample_timeseries.stored_data.values, + array_data + ) + + # Test with a scalar + sample_timeseries.stored_data = 42 + assert np.all(sample_timeseries.stored_data.values == 42) + + # Test with another DataArray + another_dataarray = xr.DataArray( + [30, 31, 32, 33, 34], + coords={'time': sample_time_index}, + dims=['time'] + ) + sample_timeseries.stored_data = another_dataarray + assert sample_timeseries.stored_data.equals(another_dataarray) + + def test_stored_data_setter_no_change(self, sample_timeseries): + """Test stored_data setter when data doesn't change.""" + # Get current data + current_data = sample_timeseries.stored_data + current_backup = sample_timeseries._backup + + # Set the same data + sample_timeseries.stored_data = current_data + + # Backup shouldn't change + assert sample_timeseries._backup is current_backup # Should be the same object + + def test_from_datasource(self, sample_time_index): + """Test from_datasource class method.""" + # Test with scalar + ts_scalar = TimeSeries.from_datasource(42, 'Scalar Series', sample_time_index) + assert np.all(ts_scalar.stored_data.values == 42) + + # Test with Series + series_data = pd.Series([1, 2, 3, 4, 5], index=sample_time_index) + ts_series = TimeSeries.from_datasource(series_data, 'Series Data', sample_time_index) + assert np.array_equal(ts_series.stored_data.values, series_data.values) + + # Test with aggregation parameters + ts_with_agg = TimeSeries.from_datasource( + series_data, + 'Aggregated Series', + sample_time_index, + aggregation_weight=0.7, + aggregation_group='group1' + ) + assert ts_with_agg.aggregation_weight == 0.7 + assert ts_with_agg.aggregation_group == 'group1' + + def test_to_json_from_json(self, sample_timeseries): + """Test to_json and from_json methods.""" + # Test to_json (dictionary only) + json_dict = sample_timeseries.to_json() + assert json_dict['name'] == sample_timeseries.name + assert 'data' in json_dict + assert 'coords' in json_dict['data'] + assert 'time' in json_dict['data']['coords'] + + # Test to_json with file saving + with tempfile.TemporaryDirectory() as tmpdirname: + filepath = Path(tmpdirname) / 'timeseries.json' + sample_timeseries.to_json(filepath) + assert filepath.exists() + + # Test from_json with file loading + loaded_ts = TimeSeries.from_json(path=filepath) + assert loaded_ts.name == sample_timeseries.name + assert np.array_equal( + loaded_ts.stored_data.values, + sample_timeseries.stored_data.values + ) + + # Test from_json with dictionary + loaded_ts_dict = TimeSeries.from_json(data=json_dict) + assert loaded_ts_dict.name == sample_timeseries.name + assert np.array_equal( + loaded_ts_dict.stored_data.values, + sample_timeseries.stored_data.values + ) + + # Test validation in from_json + with pytest.raises(ValueError, match="Only one of path and data"): + TimeSeries.from_json(data=json_dict, path='dummy.json') + + def test_all_equal(self, sample_time_index): + """Test all_equal property.""" + # All equal values + equal_data = xr.DataArray( + [5, 5, 5, 5, 5], + coords={'time': sample_time_index}, + dims=['time'] + ) + ts_equal = TimeSeries(equal_data, 'Equal Series') + assert ts_equal.all_equal is True + + # Not all equal + unequal_data = xr.DataArray( + [5, 5, 6, 5, 5], + coords={'time': sample_time_index}, + dims=['time'] + ) + ts_unequal = TimeSeries(unequal_data, 'Unequal Series') + assert ts_unequal.all_equal is False + + def test_arithmetic_operations(self, sample_timeseries): + """Test arithmetic operations.""" + # Create a second TimeSeries for testing + data2 = xr.DataArray( + [1, 2, 3, 4, 5], + coords={'time': sample_timeseries.active_timesteps}, + dims=['time'] + ) + ts2 = TimeSeries(data2, 'Second Series') + + # Test operations between two TimeSeries objects + assert np.array_equal((sample_timeseries + ts2).values, sample_timeseries.active_data.values + ts2.active_data.values) + assert np.array_equal((sample_timeseries - ts2).values, sample_timeseries.active_data.values - ts2.active_data.values) + assert np.array_equal((sample_timeseries * ts2).values, sample_timeseries.active_data.values * ts2.active_data.values) + assert np.array_equal((sample_timeseries / ts2).values, sample_timeseries.active_data.values / ts2.active_data.values) + + # Test operations with DataArrays + assert np.array_equal((sample_timeseries + data2).values, sample_timeseries.active_data.values + data2.values) + assert np.array_equal((data2 + sample_timeseries).values, data2.values + sample_timeseries.active_data.values) + + # Test operations with scalars + assert np.array_equal((sample_timeseries + 5).values, sample_timeseries.active_data.values + 5) + assert np.array_equal((5 + sample_timeseries).values, 5 + sample_timeseries.active_data.values) + + # Test unary operations + assert np.array_equal((-sample_timeseries).values, -sample_timeseries.active_data.values) + assert np.array_equal((+sample_timeseries).values, +sample_timeseries.active_data.values) + assert np.array_equal((abs(sample_timeseries)).values, abs(sample_timeseries.active_data.values)) + + def test_comparison_operations(self, sample_time_index): + """Test comparison operations.""" + data1 = xr.DataArray([10, 20, 30, 40, 50], coords={'time': sample_time_index}, dims=['time']) + data2 = xr.DataArray([5, 10, 15, 20, 25], coords={'time': sample_time_index}, dims=['time']) + + ts1 = TimeSeries(data1, 'Series 1') + ts2 = TimeSeries(data2, 'Series 2') + + # Test __gt__ method + assert (ts1 > ts2) is True # All values in ts1 are greater than ts2 + + # Test with mixed values + data3 = xr.DataArray([5, 25, 15, 45, 25], coords={'time': sample_time_index}, dims=['time']) + ts3 = TimeSeries(data3, 'Series 3') + + assert (ts1 > ts3) is False # Not all values in ts1 are greater than ts3 + + def test_numpy_ufunc(self, sample_timeseries): + """Test numpy ufunc compatibility.""" + # Test basic numpy functions + assert np.array_equal( + np.add(sample_timeseries, 5).values, + np.add(sample_timeseries.active_data, 5).values + ) + + assert np.array_equal( + np.multiply(sample_timeseries, 2).values, + np.multiply(sample_timeseries.active_data, 2).values + ) + + # Test with two TimeSeries objects + data2 = xr.DataArray( + [1, 2, 3, 4, 5], + coords={'time': sample_timeseries.active_timesteps}, + dims=['time'] + ) + ts2 = TimeSeries(data2, 'Second Series') + + assert np.array_equal( + np.add(sample_timeseries, ts2).values, + np.add(sample_timeseries.active_data, ts2.active_data).values + ) + + def test_sel_and_isel_properties(self, sample_timeseries): + """Test sel and isel properties.""" + # Test that sel property works + selected = sample_timeseries.sel(time=sample_timeseries.active_timesteps[0]) + assert selected.item() == sample_timeseries.active_data.values[0] + + # Test that isel property works + indexed = sample_timeseries.isel(time=0) + assert indexed.item() == sample_timeseries.active_data.values[0] From 0a03c329a22a9dd5dac4c29606acec8095e6983a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 9 Mar 2025 22:06:59 +0100 Subject: [PATCH 317/507] Improve TimeSeries class --- flixOpt/core.py | 224 ++++++++++++++++++++++++++++++++++++------------ 1 file changed, 170 insertions(+), 54 deletions(-) diff --git a/flixOpt/core.py b/flixOpt/core.py index 47b07cc73..0e63674eb 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -17,7 +17,7 @@ logger = logging.getLogger('flixOpt') Scalar = Union[int, float] # Datatype -NumericData = Union[int, float, np.ndarray, pd.Series, pd.DataFrame, xr.DataArray] +NumericData = Union[int, float, np.integer, np.floating, np.ndarray, pd.Series, pd.DataFrame, xr.DataArray] NumericDataTS = Union[NumericData, 'TimeSeriesData'] @@ -128,60 +128,102 @@ def __str__(self): class TimeSeries: + """ + A class representing time series data with active and stored states. + + TimeSeries provides a way to store time-indexed data and work with temporal subsets. + It supports arithmetic operations, aggregation, and JSON serialization. + + Attributes: + name (str): The name of the time series + aggregation_weight (Optional[float]): Weight used for aggregation + aggregation_group (Optional[str]): Group name for shared aggregation weighting + needs_extra_timestep (bool): Whether this series needs an extra timestep + """ @classmethod def from_datasource(cls, - data: Union[Scalar, np.integer, np.floating, np.ndarray, pd.Series, pd.DataFrame, xr.DataArray], + data: NumericData, name: str, - timesteps: pd.DatetimeIndex = None, + timesteps: pd.DatetimeIndex, aggregation_weight: Optional[float] = None, - aggregation_group: Optional[str] = None + aggregation_group: Optional[str] = None, + needs_extra_timestep: bool = False ) -> 'TimeSeries': """ Initialize the TimeSeries from multiple data sources. Parameters: - - data (pd.Series): A Series with a DatetimeIndex and possibly a MultiIndex. - - name (str): The name of the TimeSeries. - - timesteps (pd.DatetimeIndex): The timesteps of the TimeSeries. - - aggregation_weight (float, optional): The weight of the data in the aggregation. Defaults to None. - - aggregation_group (str, optional): The group this TimeSeries is a part of. agg_weight is split between members of a group. Default is None. + data: The time series data + name: The name of the TimeSeries + timesteps: The timesteps of the TimeSeries + aggregation_weight: The weight in aggregation calculations + aggregation_group: Group this TimeSeries belongs to for aggregation weight sharing + needs_extra_timestep: Whether this series requires an extra timestep + + Returns: + A new TimeSeries instance """ - return cls(DataConverter.as_dataarray(data, timesteps), - name, - aggregation_weight, - aggregation_group) + return cls( + DataConverter.as_dataarray(data, timesteps), + name, + aggregation_weight, + aggregation_group, + needs_extra_timestep + ) @classmethod def from_json(cls, data: Optional[Dict[str, Any]] = None, path: Optional[str] = None) -> 'TimeSeries': """ - Load a TimeSeries from a dictionary or json file + Load a TimeSeries from a dictionary or json file. + + Parameters: + data: Dictionary containing TimeSeries data + path: Path to a JSON file containing TimeSeries data + + Returns: + A new TimeSeries instance + + Raises: + ValueError: If both path and data are provided or neither is provided """ - if path is not None and data is not None: - raise ValueError("Only one of path and data can be provided") + if (path is None and data is None) or (path is not None and data is not None): + raise ValueError("Exactly one of 'path' or 'data' must be provided") + if path is not None: with open(path, 'r') as f: data = json.load(f) + + # Convert ISO date strings to datetime objects data["data"]["coords"]["time"]["data"] = pd.to_datetime(data["data"]["coords"]["time"]["data"]) + + # Create the TimeSeries instance return cls( data=xr.DataArray.from_dict(data["data"]), name=data["name"], aggregation_weight=data["aggregation_weight"], aggregation_group=data["aggregation_group"], + needs_extra_timestep=data["needs_extra_timestep"] ) def __init__(self, data: xr.DataArray, name: str, aggregation_weight: Optional[float] = None, - aggregation_group: Optional[str] = None): + aggregation_group: Optional[str] = None, + needs_extra_timestep: bool = False): """ Initialize a TimeSeries with a DataArray. Parameters: - - data (xr.DataArray): A Series with a DatetimeIndex and possibly a MultiIndex. - - aggregation_weight (float, optional): The weight of the data in the aggregation. Defaults to None. - - aggregation_group (str, optional): The group this TimeSeries is a part of. agg_weight is split between members of a group. Default is None. + data: The DataArray containing time series data + name: The name of the TimeSeries + aggregation_weight: The weight in aggregation calculations + aggregation_group: Group this TimeSeries belongs to for weight sharing + needs_extra_timestep: Whether this series requires an extra timestep + + Raises: + ValueError: If data doesn't have a 'time' index or has more than 1 dimension """ if 'time' not in data.indexes: raise ValueError(f'DataArray must have a "time" index. Got {data.indexes}') @@ -191,95 +233,131 @@ def __init__(self, self.name = name self.aggregation_weight = aggregation_weight self.aggregation_group = aggregation_group + self.needs_extra_timestep = needs_extra_timestep - self._stored_data = data.copy() - self._backup: xr.DataArray = self.stored_data # Single backup instance. Enables to temporarily overwrite the data. + # Data management + self._stored_data = data.copy(deep=True) + self._backup = self._stored_data.copy(deep=True) + self._active_timesteps = self._stored_data.indexes['time'] self._active_data = None - - self._active_timesteps = self.stored_data.indexes['time'] self._update_active_data() def reset(self): - """Reset active timesteps.""" + """ + Reset active timesteps to the full set of stored timesteps. + """ self.active_timesteps = None def restore_data(self): - """Restore stored_data from the backup.""" - self._stored_data = self._backup.copy() + """ + Restore stored_data from the backup and reset active timesteps. + """ + self._stored_data = self._backup.copy(deep=True) self.reset() def to_json(self, path: Optional[pathlib.Path] = None) -> Dict[str, Any]: """ - Save the TimeSeries to a dictionary or json file + Save the TimeSeries to a dictionary or JSON file. + + Parameters: + path: Optional path to save JSON file + + Returns: + Dictionary representation of the TimeSeries """ data = { "name": self.name, "aggregation_weight": self.aggregation_weight, "aggregation_group": self.aggregation_group, + "needs_extra_timestep": self.needs_extra_timestep, "data": self.active_data.to_dict(), } - data["data"]["coords"]["time"]["data"] = [date.isoformat() for date in data["data"]["coords"]["time"]["data"]] + + # Convert datetime objects to ISO strings + data["data"]["coords"]["time"]["data"] = [ + date.isoformat() for date in data["data"]["coords"]["time"]["data"] + ] + + # Save to file if path is provided if path is not None: + indent = 4 if len(self.active_timesteps) <= 480 else None with open(path, 'w', encoding='utf-8') as f: - json.dump(data, f, indent=4 if len(self.active_timesteps) <= 480 else None, ensure_ascii=False) + json.dump(data, f, indent=indent, ensure_ascii=False) + return data @property def stats(self) -> str: - """Return a statistical summary of the active data.""" + """ + Return a statistical summary of the active data. + + Returns: + String representation of data statistics + """ return get_numeric_stats(self.active_data, padd=0) def _update_active_data(self): - """Update the active data.""" + """ + Update the active data based on active_timesteps. + """ self._active_data = self._stored_data.sel(time=self.active_timesteps) @property def all_equal(self) -> bool: - """ Checks for all values in the being equal""" + """Check if all values in the series are equal.""" return np.unique(self.active_data.values).size == 1 @property def active_timesteps(self) -> pd.DatetimeIndex: - """Return the current active index.""" + """Get the current active timesteps.""" return self._active_timesteps @active_timesteps.setter def active_timesteps(self, timesteps: Optional[pd.DatetimeIndex]): - """Set active_timesteps and refresh active_data.""" + """ + Set active_timesteps and refresh active_data. + + Parameters: + timesteps: New timesteps to activate, or None to use all stored timesteps + + Raises: + TypeError: If timesteps is not a pandas DatetimeIndex or None + """ if timesteps is None: self._active_timesteps = self.stored_data.indexes['time'] elif isinstance(timesteps, pd.DatetimeIndex): self._active_timesteps = timesteps else: - raise TypeError("active_index must be a pandas Index or MultiIndex or None") + raise TypeError("active_timesteps must be a pandas DatetimeIndex or None") - self._update_active_data() # Refresh view + self._update_active_data() @property def active_data(self) -> xr.DataArray: - """Return a view of stored_data based on active_index.""" + """Get a view of stored_data based on active_timesteps.""" return self._active_data @property def stored_data(self) -> xr.DataArray: - """Return a copy of stored_data. Prevents modification of stored data""" + """Get a copy of the full stored data.""" return self._stored_data.copy() @stored_data.setter - def stored_data(self, value: Union[pd.Series, pd.DataFrame, xr.DataArray]): + def stored_data(self, value: NumericData): """ - Update stored_data and refresh active_index and active_data. + Update stored_data and refresh active_data. - Parameters - ---------- - value: Union[int, float, np.ndarray, pd.Series, pd.DataFrame, xr.DataArray] - Data to update stored_data with. + Parameters: + value: New data to store """ new_data = DataConverter.as_dataarray(value, timesteps=self.active_timesteps) + + # Skip if data is unchanged to avoid overwriting backup if new_data.equals(self._stored_data): - return # No change in stored_data. Do nothing. This prevents pushing out the backup + return + self._stored_data = new_data - self.active_timesteps = None + self.active_timesteps = None # Reset to full timeline @property def sel(self): @@ -290,7 +368,7 @@ def isel(self): return self.active_data.isel def _apply_operation(self, other, op): - # Enable arithmetic operations using active_data + """Apply an operation between this TimeSeries and another object.""" if isinstance(other, TimeSeries): other = other.active_data return op(self.active_data, other) @@ -307,7 +385,6 @@ def __mul__(self, other): def __truediv__(self, other): return self._apply_operation(other, lambda x, y: x / y) - # Reflected arithmetic operations (to handle cases like `some_xarray + ts1`) def __radd__(self, other): return other + self.active_data @@ -320,7 +397,6 @@ def __rmul__(self, other): def __rtruediv__(self, other): return other / self.active_data - # Unary operations. Not sure if this is the best way... def __neg__(self) -> xr.DataArray: return -self.active_data @@ -330,17 +406,57 @@ def __pos__(self) -> xr.DataArray: def __abs__(self) -> xr.DataArray: return abs(self.active_data) - def __gt__(self, other) -> bool: - """Compare two TimeSeries instances based on their xarray.DataArray.""" + def __gt__(self, other): + """ + Compare if this TimeSeries is greater than another. + + Parameters: + other: Another TimeSeries to compare with + + Returns: + True if all values in this TimeSeries are greater than other + """ if isinstance(other, TimeSeries): return (self.active_data > other.active_data).all().item() - return NotImplemented # In case the comparison is with something else + return NotImplemented def __array_ufunc__(self, ufunc, method, *inputs, **kwargs): - """Ensures NumPy functions like np.add(TimeSeries, xarray) work correctly.""" + """ + Handle NumPy universal functions. + + This allows NumPy functions to work with TimeSeries objects. + """ + # Convert any TimeSeries inputs to their active_data inputs = [x.active_data if isinstance(x, TimeSeries) else x for x in inputs] return getattr(ufunc, method)(*inputs, **kwargs) + def __repr__(self): + """ + Get a string representation of the TimeSeries. + + Returns: + String showing TimeSeries details + """ + attrs = { + 'name': self.name, + 'aggregation_weight': self.aggregation_weight, + 'aggregation_group': self.aggregation_group, + 'needs_extra_timestep': self.needs_extra_timestep, + 'shape': self.active_data.shape, + 'time_range': f"{self.active_timesteps[0]} to {self.active_timesteps[-1]}" + } + attr_str = ', '.join(f"{k}={repr(v)}" for k, v in attrs.items()) + return f"TimeSeries({attr_str})" + + def __str__(self): + """ + Get a human-readable string representation. + + Returns: + Descriptive string with statistics + """ + return f"TimeSeries '{self.name}': {self.stats}" + class TimeSeriesCollection: def __init__( From be90859b5429091a10178bc05731fc0fe64a665f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 9 Mar 2025 22:10:38 +0100 Subject: [PATCH 318/507] Update tests --- tests/test_timeseries.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_timeseries.py b/tests/test_timeseries.py index 57efc5cfe..f940fd46d 100644 --- a/tests/test_timeseries.py +++ b/tests/test_timeseries.py @@ -100,7 +100,7 @@ def test_active_timesteps_getter_setter(self, sample_timeseries, sample_time_ind assert sample_timeseries.active_timesteps.equals(sample_time_index) # Test invalid type - with pytest.raises(TypeError, match="must be a pandas Index"): + with pytest.raises(TypeError, match="must be a pandas DatetimeIndex"): sample_timeseries.active_timesteps = "invalid" def test_reset(self, sample_timeseries, sample_time_index): @@ -244,7 +244,7 @@ def test_to_json_from_json(self, sample_timeseries): ) # Test validation in from_json - with pytest.raises(ValueError, match="Only one of path and data"): + with pytest.raises(ValueError, match="one of 'path' or 'data'"): TimeSeries.from_json(data=json_dict, path='dummy.json') def test_all_equal(self, sample_time_index): From 0c42e3f1e118a2b85441bdb3bb54ae049d709496 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 9 Mar 2025 22:34:58 +0100 Subject: [PATCH 319/507] Update TimeSeriesCollection --- flixOpt/core.py | 471 +++++++++++++++++++++++++++--------------------- 1 file changed, 266 insertions(+), 205 deletions(-) diff --git a/flixOpt/core.py b/flixOpt/core.py index 0e63674eb..bb007905b 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -459,38 +459,68 @@ def __str__(self): class TimeSeriesCollection: + """ + Collection of TimeSeries objects with shared timestep management. + + TimeSeriesCollection handles multiple TimeSeries objects with synchronized + timesteps, provides operations on collections, and manages extra timesteps. + """ + def __init__( self, timesteps: pd.DatetimeIndex, - hours_of_last_timestep: Optional[float], - hours_of_previous_timesteps: Optional[Union[int, float, np.ndarray]], + hours_of_last_timestep: Optional[float] = None, + hours_of_previous_timesteps: Optional[Union[float, np.ndarray]] = None ): - ( - self.all_timesteps, - self.all_timesteps_extra, - self.all_hours_per_timestep, - self.hours_of_previous_timesteps, - ) = TimeSeriesCollection.align_dimensions(timesteps, - hours_of_last_timestep, - hours_of_previous_timesteps) + """Initialize with timesteps and optional duration settings.""" + # Prepare and validate timesteps + self._validate_timesteps(timesteps) + self.hours_of_previous_timesteps = self._calculate_hours_of_previous_timesteps( + timesteps, hours_of_previous_timesteps + ) + + # Set up timesteps and hours + self.all_timesteps = timesteps + self.all_timesteps_extra = self._create_timesteps_with_extra(timesteps, hours_of_last_timestep) + self.all_hours_per_timestep = self._calculate_hours_per_timestep(self.all_timesteps_extra) + # Active timestep tracking self._active_timesteps = None self._active_timesteps_extra = None self._active_hours_per_timestep = None + # Dictionary of time series by name + self.time_series_data: Dict[str, TimeSeries] = {} + + # Aggregation self.group_weights: Dict[str, float] = {} self.weights: Dict[str, float] = {} - self.time_series_data: List[TimeSeries] = [] - self._time_series_data_with_extra_step: List[TimeSeries] = [] # All part of self.time_series_data, but with extra timestep + + # Caches for performance + self._cache_constants: Optional[List[TimeSeries]] = None + self._cache_non_constants: Optional[List[TimeSeries]] = None + self._cache_invalidated = False + + @classmethod + def with_uniform_timesteps( + cls, + start_time: pd.Timestamp, + periods: int, + freq: str, + hours_per_step: Optional[float] = None + ) -> 'TimeSeriesCollection': + """Create a collection with uniform timesteps.""" + timesteps = pd.date_range(start_time, periods=periods, freq=freq, name='time') + return cls(timesteps, hours_of_previous_timesteps=hours_per_step) def create_time_series( - self, - data: Union[NumericData, TimeSeriesData], - name: str, - extra_timestep: bool=False + self, + data: Union[NumericData, TimeSeriesData], + name: str, + needs_extra_timestep: bool = False ) -> TimeSeries: """ - Creates a TimeSeries from the given data and adds it to the time_series_data. + Creates a TimeSeries from the given data and adds it to the collection. Parameters ---------- @@ -498,7 +528,7 @@ def create_time_series( The data to create the TimeSeries from. name: str The name of the TimeSeries. - extra_timestep: bool, optional + needs_extra_timestep: bool, optional Whether to create an additional timestep at the end of the timesteps. Returns @@ -507,299 +537,330 @@ def create_time_series( The created TimeSeries. """ - time_series = TimeSeries.from_datasource( - name=name, - data=data if not isinstance(data, TimeSeriesData) else data.data, - timesteps=self.timesteps if not extra_timestep else self.timesteps_extra, - aggregation_weight=data.agg_weight if isinstance(data, TimeSeriesData) else None, - aggregation_group=data.agg_group if isinstance(data, TimeSeriesData) else None - ) + # Check for duplicate name + if name in self.time_series_data: + raise ValueError(f"TimeSeries '{name}' already exists in this collection") + + # Determine which timesteps to use + timesteps_to_use = self.timesteps_extra if needs_extra_timestep else self.timesteps + # Create the time series if isinstance(data, TimeSeriesData): - data.label = time_series.name # Connecting User_time_series to TimeSeries + time_series = TimeSeries.from_datasource( + name=name, + data=data.data, + timesteps=timesteps_to_use, + aggregation_weight=data.agg_weight, + aggregation_group=data.agg_group, + needs_extra_timestep=needs_extra_timestep + ) + # Connect the user time series to the created TimeSeries + data.label = name + else: + time_series = TimeSeries.from_datasource( + name=name, + data=data, + timesteps=timesteps_to_use, + needs_extra_timestep=needs_extra_timestep + ) + + # Add to the collection + self.add_time_series(time_series) + self._invalidate_cache() - self.add_time_series(time_series, extra_timestep) return time_series def calculate_aggregation_weights(self) -> Dict[str, float]: + """Calculate and return aggregation weights for all time series.""" self.group_weights = self._calculate_group_weights() - self.weights = self._calculate_aggregation_weights() + self.weights = self._calculate_weights() if np.all(np.isclose(list(self.weights.values()), 1, atol=1e-6)): logger.info('All Aggregation weights were set to 1') return self.weights - def activate_indices(self, active_timesteps: Optional[pd.DatetimeIndex] = None): + def activate_timesteps(self, active_timesteps: Optional[pd.DatetimeIndex] = None): """ - Update active timesteps and data of the TimeSeriesCollection. + Update active timesteps for the collection and all time series. If no arguments are provided, the active timesteps are reset. Parameters ---------- active_timesteps : Optional[pd.DatetimeIndex] The active timesteps of the model. - If None, the all timesteps of the TimeSeriesCollection are taken. - """ - + If None, the all timesteps of the TimeSeriesCollection are taken.""" if active_timesteps is None: return self.reset() - active_timesteps = active_timesteps if active_timesteps is not None else self.all_timesteps - - if not np.all(active_timesteps.isin(self.all_timesteps)): + if not np.all(np.isin(active_timesteps, self.all_timesteps)): raise ValueError('active_timesteps must be a subset of the timesteps of the TimeSeriesCollection') - ( - self._active_timesteps, - self._active_timesteps_extra, - self._active_hours_per_timestep, - _, - ) = TimeSeriesCollection.align_dimensions( - active_timesteps, self.hours_of_last_timestep, self.hours_of_previous_timesteps - ) + # Calculate derived timesteps + self._active_timesteps = active_timesteps + last_ts_idx = np.where(self.all_timesteps == active_timesteps[-1])[0][0] + self._active_timesteps_extra = self.all_timesteps_extra[:last_ts_idx + 2] + self._active_hours_per_timestep = self.all_hours_per_timestep.sel(time=active_timesteps) - self._activate_timeserieses() + # Update all time series + self._update_time_series_timesteps() def reset(self): - """Reset active timesteps of all TimeSeries.""" + """Reset active timesteps to defaults for all time series.""" self._active_timesteps = None self._active_timesteps_extra = None self._active_hours_per_timestep = None - for time_series in self.time_series_data: + + for time_series in self.time_series_data.values(): time_series.reset() def restore_data(self): - """Restore stored_data from the backup.""" - for time_series in self.time_series_data: + """Restore original data for all time series.""" + for time_series in self.time_series_data.values(): time_series.restore_data() + self._invalidate_cache() def insert_new_data(self, data: pd.DataFrame): - """Insert new data into the TimeSeriesCollection. - - Parameters - ---------- - data : pd.DataFrame - The new data to insert. - Must have the same columns as the TimeSeries in the TimeSeriesCollection. - Must have the same index as the timesteps of the TimeSeriesCollection. - """ + """Update time series with new data from a DataFrame.""" if not isinstance(data, pd.DataFrame): - raise TypeError(f"data must be a pandas DataFrame. Got {type(data)=}") - - for time_series in self.time_series_data: - if time_series.name in data.columns: - if time_series in self._time_series_data_with_extra_step: - extra_step_value = data[time_series.name].iloc[-1] - time_series.stored_data = pd.concat( - [data[time_series.name], pd.Series( - extra_step_value, index=[data.index[-1] + pd.Timedelta(hours=self.hours_of_last_timestep)]) - ] - ) - else: - time_series.stored_data = data[time_series.name] - logger.debug(f'Inserted data for {time_series.name}') - - @staticmethod - def align_dimensions( - timesteps: pd.DatetimeIndex, - hours_of_last_timestep: Optional[float] = None, - hours_of_previous_timesteps: Optional[Union[int, float, np.ndarray]] = None - ) -> Tuple[pd.DatetimeIndex, pd.DatetimeIndex, xr.DataArray, Union[int, float, np.ndarray]]: - """Converts the given timesteps and hours_of_last_timestep to the right format + raise TypeError(f"data must be a pandas DataFrame, got {type(data).__name__}") - Parameters - ---------- - timesteps : pd.DatetimeIndex - The timesteps of the model. - hours_of_last_timestep : Optional[float], optional - The duration of the last time step. Uses the last time interval if not specified - hours_of_previous_timesteps: Un + if not data.index.equals(self.timesteps): + raise ValueError("DataFrame index must match collection timesteps") - Returns - ------- - Tuple[pd.DatetimeIndex, pd.DatetimeIndex, xr.DataArray, Optional[pd.Index]] - The timesteps, timesteps_extra and hours_per_timestep + for name, ts in self.time_series_data.items(): + if name in data.columns: + if not ts.needs_extra_timestep: + ts.stored_data = data[name] + else: + # For time series with extra timestep, add the extra value + extra_step_value = data[name].iloc[-1] + extra_step_index = pd.DatetimeIndex([self.timesteps_extra[-1]], name='time') + extra_step_series = pd.Series([extra_step_value], index=extra_step_index) - - timesteps: pd.DatetimeIndex - The timesteps of the model. - - timesteps_extra: pd.DatetimeIndex - The timesteps of the model, including an extra timestep at the end. - - hours_per_timestep: xr.DataArray - The duration of each timestep in hours. - - hours_of_previous_timesteps: Optional[Union[int, float, np.ndarray]] - The duration of the previous timesteps in hours. - """ + # Combine the regular data with the extra timestep + combined_series = pd.concat([data[name], extra_step_series]) + ts.stored_data = combined_series - if not isinstance(timesteps, pd.DatetimeIndex): - raise TypeError('timesteps must be a pandas DatetimeIndex') - if not timesteps.name == 'time': - logger.warning('timesteps must be a pandas DatetimeIndex with name "time". Renamed it to "time".') - timesteps.name = 'time' + logger.debug(f'Updated data for {name}') - timesteps_extra = TimeSeriesCollection._create_extra_timestep(timesteps, hours_of_last_timestep) - hours_of_previous_timesteps = TimeSeriesCollection._calculate_hours_of_previous_timesteps( - timesteps, hours_of_previous_timesteps - ) - hours_per_step = TimeSeriesCollection.create_hours_per_timestep(timesteps_extra) + self._invalidate_cache() - return timesteps, timesteps_extra, hours_per_step, hours_of_previous_timesteps + def add_time_series(self, time_series: TimeSeries): + """Add an existing TimeSeries to the collection.""" + if time_series.name in self.time_series_data: + raise ValueError(f"TimeSeries '{time_series.name}' already exists in this collection") - def add_time_series(self, time_series: TimeSeries, extra_timestep: bool): - self.time_series_data.append(time_series) - if extra_timestep: - self._time_series_data_with_extra_step.append(time_series) - self._check_unique_labels() + self.time_series_data[time_series.name] = time_series + self._invalidate_cache() - def _activate_timeserieses(self): - for time_series in self.time_series_data: - if time_series in self._time_series_data_with_extra_step: - time_series.active_timesteps = self.timesteps_extra - else: - time_series.active_timesteps = self.timesteps + def to_dataframe(self, filtered: Literal['all', 'constant', 'non_constant'] = 'non_constant') -> pd.DataFrame: + """Convert collection to DataFrame with optional filtering.""" + # Convert to Dataset first + include_constants = filtered != 'non_constant' + ds = self.to_dataset(include_constants=include_constants) + df = ds.to_dataframe() - def to_dataframe(self, filtered: Literal['all', 'constant', 'non_constant'] = 'non_constant'): - df = self.to_dataset().to_dataframe() - if filtered == 'all': # Return all time series + # Apply filtering + if filtered == 'all': return df - elif filtered == 'constant': # Return only constant time series - return df.loc[:, df.nunique() ==1] - elif filtered == 'non_constant': # Return only non-constant time series + elif filtered == 'constant': + return df.loc[:, df.nunique() == 1] + elif filtered == 'non_constant': return df.loc[:, df.nunique() > 1] else: - raise ValueError('Not supported argument for "filtered".') + raise ValueError("filtered must be one of: 'all', 'constant', 'non_constant'") def to_dataset(self, include_constants: bool = True) -> xr.Dataset: - """Combine all stored DataArrays into a single Dataset.""" - ds = xr.Dataset({time_series.name: time_series.active_data - for time_series in self.time_series_data - if not time_series.all_equal or (time_series.all_equal and include_constants)}) + """Combine all time series into a single Dataset.""" + # Determine which series to include + if include_constants: + series_to_include = self.time_series_data.values() + else: + series_to_include = self.non_constants + + ds = xr.Dataset({ts.name: ts.active_data for ts in series_to_include}) ds.attrs.update({ - "timesteps": f"{self.all_timesteps[0]} ... {self.all_timesteps[-1]} | len={len(self.timesteps)}", - "hours_per_timestep": get_numeric_stats(self.hours_per_timestep), + "timesteps": f"{self.timesteps[0]} ... {self.timesteps[-1]} | len={len(self.timesteps)}", + "hours_per_timestep": self._format_stats(self.hours_per_timestep), }) return ds + def _update_time_series_timesteps(self): + """Update active timesteps for all time series.""" + for ts in self.time_series_data.values(): + if ts.needs_extra_timestep: + ts.active_timesteps = self.timesteps_extra + else: + ts.active_timesteps = self.timesteps + + def _invalidate_cache(self): + """Mark caches as invalid.""" + self._cache_invalidated = True + self._cache_constants = None + self._cache_non_constants = None + + @staticmethod + def _validate_timesteps(timesteps: pd.DatetimeIndex): + """Validate timesteps format and rename if needed.""" + if not isinstance(timesteps, pd.DatetimeIndex): + raise TypeError('timesteps must be a pandas DatetimeIndex') + + if len(timesteps) < 2: + raise ValueError('timesteps must contain at least 2 timestamps') + + # Ensure timesteps has the required name + if timesteps.name != 'time': + logger.warning('Renamed timesteps to "time" (was "%s")', timesteps.name) + timesteps.name = 'time' + @staticmethod - def _create_extra_timestep(timesteps: pd.DatetimeIndex, - hours_of_last_timestep: Optional[float]) -> pd.DatetimeIndex: - """Creates an extra timestep at the end of the timesteps.""" - if hours_of_last_timestep: - last_date = pd.DatetimeIndex( - [timesteps[-1] + pd.to_timedelta(hours_of_last_timestep, 'h')]) + def _create_timesteps_with_extra( + timesteps: pd.DatetimeIndex, + hours_of_last_timestep: Optional[float] + ) -> pd.DatetimeIndex: + """Create timesteps with an extra step at the end.""" + if hours_of_last_timestep is not None: + # Create the extra timestep using the specified duration + last_date = pd.DatetimeIndex([timesteps[-1] + pd.Timedelta(hours=hours_of_last_timestep)],name='time') else: - last_date = pd.DatetimeIndex([timesteps[-1] + (timesteps[-1] - timesteps[-2])]) + # Use the last interval as the extra timestep duration + last_date = pd.DatetimeIndex([timesteps[-1] + (timesteps[-1] - timesteps[-2])], name='time') + # Combine with original timesteps return pd.DatetimeIndex(timesteps.append(last_date), name='time') @staticmethod def _calculate_hours_of_previous_timesteps( timesteps: pd.DatetimeIndex, - hours_of_previous_timesteps: Optional[Union[int, float, np.ndarray]] - ) -> Union[int, float, np.ndarray]: - """Calculates the duration of the previous timesteps in hours.""" - return ( - ((timesteps[1] - timesteps[0]) / np.timedelta64(1, 'h')) - if hours_of_previous_timesteps is None - else hours_of_previous_timesteps - ) + hours_of_previous_timesteps: Optional[Union[float, np.ndarray]] + ) -> Union[float, np.ndarray]: + """Calculate duration of regular timesteps.""" + if hours_of_previous_timesteps is not None: + return hours_of_previous_timesteps + + # Calculate from the first interval + first_interval = timesteps[1] - timesteps[0] + return first_interval.total_seconds() / 3600 # Convert to hours @staticmethod - def create_hours_per_timestep( - timesteps_extra: pd.DatetimeIndex, - ) -> xr.DataArray: - """Creates a DataArray representing the duration of each timestep in hours.""" - hours_per_step = timesteps_extra.to_series().diff()[1:].values / pd.to_timedelta(1, 'h') + def _calculate_hours_per_timestep(timesteps_extra: pd.DatetimeIndex) -> xr.DataArray: + """Calculate duration of each timestep.""" + # Calculate differences between consecutive timestamps + hours_per_step = np.diff(timesteps_extra) / pd.Timedelta(hours=1) + return xr.DataArray( data=hours_per_step, - coords=(timesteps_extra[:-1],), + coords={'time': timesteps_extra[:-1]}, dims=('time',), name='hours_per_step' ) def _calculate_group_weights(self) -> Dict[str, float]: - """Calculates the aggregation weights of each group""" + """Calculate weights for aggregation groups.""" + # Count series in each group groups = [ - time_series.aggregation_group - for time_series in self.time_series_data - if time_series.aggregation_group is not None + ts.aggregation_group + for ts in self.time_series_data.values() + if ts.aggregation_group is not None ] - group_size = dict(Counter(groups)) - group_weights = {group: 1 / size for group, size in group_size.items()} - return group_weights - - def _calculate_aggregation_weights(self) -> Dict[str, float]: - """Calculates the aggregation weight for each TimeSeries. Default is 1""" - return { - time_series.name: self.group_weights.get(time_series.aggregation_group, time_series.aggregation_weight or 1) - for time_series in self.time_series_data - } + group_counts = Counter(groups) + + # Calculate weight for each group (1/count) + return {group: 1 / count for group, count in group_counts.items()} + + def _calculate_weights(self) -> Dict[str, float]: + """Calculate weights for all time series.""" + # Calculate weight for each time series + weights = {} + for name, ts in self.time_series_data.items(): + if ts.aggregation_group is not None: + # Use group weight + weights[name] = self.group_weights.get(ts.aggregation_group, 1) + else: + # Use individual weight or default to 1 + weights[name] = ts.aggregation_weight or 1 - def _check_unique_labels(self): - """Makes sure every label of the TimeSeries in time_series_list is unique""" - label_counts = Counter([time_series.name for time_series in self.time_series_data]) - duplicates = [label for label, count in label_counts.items() if count > 1] - assert duplicates == [], 'Duplicate TimeSeries labels found: {}.'.format(', '.join(duplicates)) + return weights - def __getitem__(self, name: str) -> 'TimeSeries': - """ - Get a time_series by label + def _format_stats(self, data) -> str: + """Format statistics for a data array.""" + if hasattr(data, 'values'): + values = data.values + else: + values = np.asarray(data) - Raises: - KeyError: If no time_series with the given label is found. - """ - #TODO: This is not efficient! Use a dict instead - for time_series in self.time_series_data: # TODO: This is not efficient! Use a dict instead - if time_series.name == name: - return time_series - raise KeyError(f'TimeSeries "{name}" not found!') + mean_val = np.mean(values) + min_val = np.min(values) + max_val = np.max(values) + + return f"mean: {mean_val:.2f}, min: {min_val:.2f}, max: {max_val:.2f}" + + def __getitem__(self, name: str) -> TimeSeries: + """Get a TimeSeries by name.""" + try: + return self.time_series_data[name] + except KeyError: + raise KeyError(f'TimeSeries "{name}" not found') def __iter__(self) -> Iterator[TimeSeries]: - return iter(self.time_series_data) + """Iterate through all TimeSeries in the collection.""" + return iter(self.time_series_data.values()) def __len__(self) -> int: + """Get the number of TimeSeries in the collection.""" return len(self.time_series_data) def __contains__(self, item: Union[str, TimeSeries]) -> bool: - """Check if the effect exists. Checks for label or object""" + """Check if a TimeSeries exists in the collection.""" if isinstance(item, str): - return item in [ts.name for ts in self.time_series_data] # Check if the label exists + return item in self.time_series_data elif isinstance(item, TimeSeries): - return item in self.time_series_data # Check if the object exists + return item in self.time_series_data.values() return False @property def non_constants(self) -> List[TimeSeries]: - return [time_series for time_series in self.time_series_data if not time_series.all_equal] + """Get time series with varying values.""" + if self._cache_non_constants is None or self._cache_invalidated: + self._cache_non_constants = [ + ts for ts in self.time_series_data.values() + if not ts.all_equal + ] + self._cache_invalidated = False + return self._cache_non_constants @property def constants(self) -> List[TimeSeries]: - return [time_series for time_series in self.time_series_data if time_series.all_equal] - - @property - def coords(self) -> Union[Tuple[pd.Index, pd.DatetimeIndex], Tuple[pd.DatetimeIndex]]: - return (self.timesteps,) - - @property - def coords_extra(self) -> Union[Tuple[pd.Index, pd.DatetimeIndex], Tuple[pd.DatetimeIndex]]: - return (self.timesteps_extra,) + """Get time series with constant values.""" + if self._cache_constants is None or self._cache_invalidated: + self._cache_constants = [ + ts for ts in self.time_series_data.values() + if ts.all_equal + ] + self._cache_invalidated = False + return self._cache_constants @property def timesteps(self) -> pd.DatetimeIndex: + """Get the active timesteps.""" return self.all_timesteps if self._active_timesteps is None else self._active_timesteps @property def timesteps_extra(self) -> pd.DatetimeIndex: + """Get the active timesteps with extra step.""" return self.all_timesteps_extra if self._active_timesteps_extra is None else self._active_timesteps_extra @property def hours_per_timestep(self) -> xr.DataArray: + """Get the duration of each active timestep.""" return self.all_hours_per_timestep if self._active_hours_per_timestep is None else self._active_hours_per_timestep @property def hours_of_last_timestep(self) -> float: - return self.hours_per_timestep[-1].item() + """Get the duration of the last timestep.""" + return float(self.hours_per_timestep[-1].item()) def __repr__(self): return f"TimeSeriesCollection:\n{self.to_dataset()}" @@ -814,10 +875,10 @@ def __str__(self): return ( f"TimeSeriesCollection with {len(self.time_series_data)} series\n" - f" Time Range: {self.timesteps[0]} -> {self.timesteps[-1]}\n" - f" No. of timesteps: {len(self.timesteps)}\n" - f" Hours per timestep: {get_numeric_stats(self.hours_per_timestep)}" - f" TimeSeriesData:\n" + f" Time Range: {self.timesteps[0]} → {self.timesteps[-1]}\n" + f" No. of timesteps: {len(self.timesteps)} + 1 extra\n" + f" Hours per timestep: {get_numeric_stats(self.hours_per_timestep)}\n" + f" Time Series Data:\n" f"{stats_summary}" ) From 21dfbe70ba150e6cd4b7bc73d3c1566054475c29 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 9 Mar 2025 22:45:31 +0100 Subject: [PATCH 320/507] Updated names in other places --- flixOpt/calculation.py | 2 +- flixOpt/components.py | 4 ++-- flixOpt/core.py | 12 ++++++++++-- flixOpt/flow_system.py | 10 +++++----- flixOpt/results.py | 4 ++-- 5 files changed, 20 insertions(+), 12 deletions(-) diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py index 746a98022..c88c36220 100644 --- a/flixOpt/calculation.py +++ b/flixOpt/calculation.py @@ -189,7 +189,7 @@ def save_results(self, save_flow_system: bool = False, compression: int = 0): def _activate_time_series(self): self.flow_system.transform_data() - self.flow_system.time_series_collection.activate_indices( + self.flow_system.time_series_collection.activate_timesteps( active_timesteps=self.active_timesteps, ) diff --git a/flixOpt/components.py b/flixOpt/components.py index dc9456c9e..a42fa57b3 100644 --- a/flixOpt/components.py +++ b/flixOpt/components.py @@ -226,10 +226,10 @@ def create_model(self, model: SystemModel) -> 'StorageModel': def transform_data(self, flow_system: 'FlowSystem') -> None: super().transform_data(flow_system) self.relative_minimum_charge_state = flow_system.create_time_series( - f'{self.label_full}|relative_minimum_charge_state', self.relative_minimum_charge_state, extra_timestep=True + f'{self.label_full}|relative_minimum_charge_state', self.relative_minimum_charge_state, needs_extra_timestep=True ) self.relative_maximum_charge_state = flow_system.create_time_series( - f'{self.label_full}|relative_maximum_charge_state', self.relative_maximum_charge_state, extra_timestep=True + f'{self.label_full}|relative_maximum_charge_state', self.relative_maximum_charge_state, needs_extra_timestep=True ) self.eta_charge = flow_system.create_time_series(f'{self.label_full}|eta_charge', self.eta_charge) self.eta_discharge = flow_system.create_time_series(f'{self.label_full}|eta_discharge', self.eta_discharge) diff --git a/flixOpt/core.py b/flixOpt/core.py index bb007905b..486a13221 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -482,7 +482,7 @@ def __init__( # Set up timesteps and hours self.all_timesteps = timesteps self.all_timesteps_extra = self._create_timesteps_with_extra(timesteps, hours_of_last_timestep) - self.all_hours_per_timestep = self._calculate_hours_per_timestep(self.all_timesteps_extra) + self.all_hours_per_timestep = self.calculate_hours_per_timestep(self.all_timesteps_extra) # Active timestep tracking self._active_timesteps = None @@ -745,7 +745,7 @@ def _calculate_hours_of_previous_timesteps( return first_interval.total_seconds() / 3600 # Convert to hours @staticmethod - def _calculate_hours_per_timestep(timesteps_extra: pd.DatetimeIndex) -> xr.DataArray: + def calculate_hours_per_timestep(timesteps_extra: pd.DatetimeIndex) -> xr.DataArray: """Calculate duration of each timestep.""" # Calculate differences between consecutive timestamps hours_per_step = np.diff(timesteps_extra) / pd.Timedelta(hours=1) @@ -852,6 +852,14 @@ def timesteps_extra(self) -> pd.DatetimeIndex: """Get the active timesteps with extra step.""" return self.all_timesteps_extra if self._active_timesteps_extra is None else self._active_timesteps_extra + @property + def coords(self) -> Union[Tuple[pd.Index, pd.DatetimeIndex], Tuple[pd.DatetimeIndex]]: + return (self.timesteps,) + + @property + def coords_extra(self) -> Union[Tuple[pd.Index, pd.DatetimeIndex], Tuple[pd.DatetimeIndex]]: + return (self.timesteps_extra,) + @property def hours_per_timestep(self) -> xr.DataArray: """Get the duration of each active timestep.""" diff --git a/flixOpt/flow_system.py b/flixOpt/flow_system.py index 992e055e2..51f4fb14f 100644 --- a/flixOpt/flow_system.py +++ b/flixOpt/flow_system.py @@ -67,7 +67,7 @@ def __init__( @classmethod def from_dataset(cls, ds: xr.Dataset): timesteps_extra = pd.DatetimeIndex(ds.attrs['timesteps_extra'], name='time') - hours_of_last_timestep = TimeSeriesCollection.create_hours_per_timestep(timesteps_extra).isel(time=-1).item() + hours_of_last_timestep = TimeSeriesCollection.calculate_hours_per_timestep(timesteps_extra).isel(time=-1).item() flow_system = FlowSystem(timesteps=timesteps_extra[:-1], hours_of_last_timestep=hours_of_last_timestep, @@ -85,7 +85,7 @@ def from_dataset(cls, ds: xr.Dataset): @classmethod def from_dict(cls, data: Dict) -> 'FlowSystem': timesteps_extra = pd.DatetimeIndex(data['timesteps_extra'], name='time') - hours_of_last_timestep = TimeSeriesCollection.create_hours_per_timestep(timesteps_extra).isel(time=-1).item() + hours_of_last_timestep = TimeSeriesCollection.calculate_hours_per_timestep(timesteps_extra).isel(time=-1).item() flow_system = FlowSystem(timesteps=timesteps_extra[:-1], hours_of_last_timestep=hours_of_last_timestep, @@ -272,7 +272,7 @@ def create_time_series( self, name: str, data: Optional[Union[NumericData, TimeSeriesData, TimeSeries]], - extra_timestep: bool = False, + needs_extra_timestep: bool = False, ) -> Optional[TimeSeries]: """ Tries to create a TimeSeries from NumericData Data and adds it to the time_series_collection @@ -290,12 +290,12 @@ def create_time_series( return self.time_series_collection.create_time_series( data=data.active_data, name=name, - extra_timestep=extra_timestep + needs_extra_timestep=needs_extra_timestep ) return self.time_series_collection.create_time_series( data=data, name=name, - extra_timestep=extra_timestep + needs_extra_timestep=needs_extra_timestep ) def create_effect_time_series(self, diff --git a/flixOpt/results.py b/flixOpt/results.py index 370dc5507..042239f7f 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -104,7 +104,7 @@ def __init__(self, for label, infos in results_structure['Effects'].items()} self.timesteps_extra = pd.DatetimeIndex([datetime.datetime.fromisoformat(date) for date in results_structure['Time']], name='time') - self.hours_per_timestep = TimeSeriesCollection.create_hours_per_timestep(self.timesteps_extra) + self.hours_per_timestep = TimeSeriesCollection.calculate_hours_per_timestep(self.timesteps_extra) def __getitem__(self, key: str) -> Union['ComponentResults', 'BusResults', 'EffectResults']: if key in self.components: @@ -351,7 +351,7 @@ def __init__(self, self.overlap_timesteps = overlap_timesteps self.name = name self.folder = pathlib.Path(folder) if folder is not None else pathlib.Path.cwd() / 'results' - self.hours_per_timestep = TimeSeriesCollection.create_hours_per_timestep(self.all_timesteps) + self.hours_per_timestep = TimeSeriesCollection.calculate_hours_per_timestep(self.all_timesteps) def solution_without_overlap(self, variable: str) -> xr.DataArray: """Returns the solution of a variable without overlap""" From 3b31895179159d698988bbe487080cbc592e7aa0 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 9 Mar 2025 22:47:56 +0100 Subject: [PATCH 321/507] Removed coords from TimeSeriesCollection --- flixOpt/core.py | 7 ------- flixOpt/structure.py | 8 ++++---- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/flixOpt/core.py b/flixOpt/core.py index 486a13221..0312f2190 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -852,13 +852,6 @@ def timesteps_extra(self) -> pd.DatetimeIndex: """Get the active timesteps with extra step.""" return self.all_timesteps_extra if self._active_timesteps_extra is None else self._active_timesteps_extra - @property - def coords(self) -> Union[Tuple[pd.Index, pd.DatetimeIndex], Tuple[pd.DatetimeIndex]]: - return (self.timesteps,) - - @property - def coords_extra(self) -> Union[Tuple[pd.Index, pd.DatetimeIndex], Tuple[pd.DatetimeIndex]]: - return (self.timesteps_extra,) @property def hours_per_timestep(self) -> xr.DataArray: diff --git a/flixOpt/structure.py b/flixOpt/structure.py index 2e875740d..fa8765f1f 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -67,12 +67,12 @@ def hours_of_previous_timesteps(self): return self.time_series_collection.hours_of_previous_timesteps @property - def coords(self): - return self.time_series_collection.coords + def coords(self) -> Tuple[pd.DatetimeIndex]: + return (self.time_series_collection.timesteps,) @property - def coords_extra(self): - return self.time_series_collection.coords_extra + def coords_extra(self) -> Tuple[pd.DatetimeIndex]: + return (self.time_series_collection.timesteps_extra,) class Interface: From e2f390b047d6cd8b1bfddb8bbab22d3cd294c46a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 10 Mar 2025 21:39:07 +0100 Subject: [PATCH 322/507] Remove caching from TimeSeriesCollection --- flixOpt/core.py | 39 ++++++++------------------------------- 1 file changed, 8 insertions(+), 31 deletions(-) diff --git a/flixOpt/core.py b/flixOpt/core.py index 0312f2190..de72cb29d 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -496,11 +496,6 @@ def __init__( self.group_weights: Dict[str, float] = {} self.weights: Dict[str, float] = {} - # Caches for performance - self._cache_constants: Optional[List[TimeSeries]] = None - self._cache_non_constants: Optional[List[TimeSeries]] = None - self._cache_invalidated = False - @classmethod def with_uniform_timesteps( cls, @@ -566,7 +561,6 @@ def create_time_series( # Add to the collection self.add_time_series(time_series) - self._invalidate_cache() return time_series @@ -618,7 +612,6 @@ def restore_data(self): """Restore original data for all time series.""" for time_series in self.time_series_data.values(): time_series.restore_data() - self._invalidate_cache() def insert_new_data(self, data: pd.DataFrame): """Update time series with new data from a DataFrame.""" @@ -644,15 +637,12 @@ def insert_new_data(self, data: pd.DataFrame): logger.debug(f'Updated data for {name}') - self._invalidate_cache() - def add_time_series(self, time_series: TimeSeries): """Add an existing TimeSeries to the collection.""" if time_series.name in self.time_series_data: raise ValueError(f"TimeSeries '{time_series.name}' already exists in this collection") self.time_series_data[time_series.name] = time_series - self._invalidate_cache() def to_dataframe(self, filtered: Literal['all', 'constant', 'non_constant'] = 'non_constant') -> pd.DataFrame: """Convert collection to DataFrame with optional filtering.""" @@ -695,12 +685,6 @@ def _update_time_series_timesteps(self): else: ts.active_timesteps = self.timesteps - def _invalidate_cache(self): - """Mark caches as invalid.""" - self._cache_invalidated = True - self._cache_constants = None - self._cache_non_constants = None - @staticmethod def _validate_timesteps(timesteps: pd.DatetimeIndex): """Validate timesteps format and rename if needed.""" @@ -823,24 +807,18 @@ def __contains__(self, item: Union[str, TimeSeries]) -> bool: @property def non_constants(self) -> List[TimeSeries]: """Get time series with varying values.""" - if self._cache_non_constants is None or self._cache_invalidated: - self._cache_non_constants = [ - ts for ts in self.time_series_data.values() - if not ts.all_equal - ] - self._cache_invalidated = False - return self._cache_non_constants + return [ + ts for ts in self.time_series_data.values() + if not ts.all_equal + ] @property def constants(self) -> List[TimeSeries]: """Get time series with constant values.""" - if self._cache_constants is None or self._cache_invalidated: - self._cache_constants = [ - ts for ts in self.time_series_data.values() - if ts.all_equal - ] - self._cache_invalidated = False - return self._cache_constants + return [ + ts for ts in self.time_series_data.values() + if ts.all_equal + ] @property def timesteps(self) -> pd.DatetimeIndex: @@ -852,7 +830,6 @@ def timesteps_extra(self) -> pd.DatetimeIndex: """Get the active timesteps with extra step.""" return self.all_timesteps_extra if self._active_timesteps_extra is None else self._active_timesteps_extra - @property def hours_per_timestep(self) -> xr.DataArray: """Get the duration of each active timestep.""" From b37fafa3f56eab68bccf63e33cabaeaa80a63399 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 10 Mar 2025 21:39:40 +0100 Subject: [PATCH 323/507] First test for TimeSeriesCollection --- tests/test_timeseries.py | 378 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 349 insertions(+), 29 deletions(-) diff --git a/tests/test_timeseries.py b/tests/test_timeseries.py index f940fd46d..32e517a3a 100644 --- a/tests/test_timeseries.py +++ b/tests/test_timeseries.py @@ -1,25 +1,26 @@ import json import tempfile from pathlib import Path +from typing import Dict, List, Tuple import numpy as np import pandas as pd import pytest import xarray as xr -from flixOpt.core import ConversionError, DataConverter, TimeSeries # Adjust import path as needed +from flixOpt.core import ConversionError, DataConverter, TimeSeries, TimeSeriesCollection, TimeSeriesData @pytest.fixture -def sample_time_index(): +def sample_timesteps(): """Create a sample time index with the required 'time' name.""" return pd.date_range('2023-01-01', periods=5, freq='D', name='time') @pytest.fixture -def simple_dataarray(sample_time_index): +def simple_dataarray(sample_timesteps): """Create a simple DataArray with time dimension.""" - return xr.DataArray([10, 20, 30, 40, 50], coords={'time': sample_time_index}, dims=['time']) + return xr.DataArray([10, 20, 30, 40, 50], coords={'time': sample_timesteps}, dims=['time']) @pytest.fixture @@ -64,7 +65,7 @@ def test_initialization_with_aggregation_params(self, simple_dataarray): assert ts.aggregation_weight == 0.5 assert ts.aggregation_group == 'test_group' - def test_initialization_validation(self, sample_time_index): + def test_initialization_validation(self, sample_timesteps): """Test validation during initialization.""" # Test missing time dimension invalid_data = xr.DataArray([1, 2, 3], dims=['invalid_dim']) @@ -74,19 +75,19 @@ def test_initialization_validation(self, sample_time_index): # Test multi-dimensional data multi_dim_data = xr.DataArray( [[1, 2, 3], [4, 5, 6]], - coords={'dim1': [0, 1], 'time': sample_time_index[:3]}, + coords={'dim1': [0, 1], 'time': sample_timesteps[:3]}, dims=['dim1', 'time'] ) with pytest.raises(ValueError, match='dimensions of DataArray must be 1'): TimeSeries(multi_dim_data, name='Multi-dim Series') - def test_active_timesteps_getter_setter(self, sample_timeseries, sample_time_index): + def test_active_timesteps_getter_setter(self, sample_timeseries, sample_timesteps): """Test active_timesteps getter and setter.""" # Initial state should use all timesteps - assert sample_timeseries.active_timesteps.equals(sample_time_index) + assert sample_timeseries.active_timesteps.equals(sample_timesteps) # Set to a subset - subset_index = sample_time_index[1:3] + subset_index = sample_timesteps[1:3] sample_timeseries.active_timesteps = subset_index assert sample_timeseries.active_timesteps.equals(subset_index) @@ -97,23 +98,23 @@ def test_active_timesteps_getter_setter(self, sample_timeseries, sample_time_ind # Reset to full index sample_timeseries.active_timesteps = None - assert sample_timeseries.active_timesteps.equals(sample_time_index) + assert sample_timeseries.active_timesteps.equals(sample_timesteps) # Test invalid type with pytest.raises(TypeError, match="must be a pandas DatetimeIndex"): sample_timeseries.active_timesteps = "invalid" - def test_reset(self, sample_timeseries, sample_time_index): + def test_reset(self, sample_timeseries, sample_timesteps): """Test reset method.""" # Set to subset first - subset_index = sample_time_index[1:3] + subset_index = sample_timesteps[1:3] sample_timeseries.active_timesteps = subset_index # Reset sample_timeseries.reset() # Should be back to full index - assert sample_timeseries.active_timesteps.equals(sample_time_index) + assert sample_timeseries.active_timesteps.equals(sample_timesteps) assert sample_timeseries.active_data.equals(sample_timeseries.stored_data) def test_restore_data(self, sample_timeseries, simple_dataarray): @@ -139,10 +140,10 @@ def test_restore_data(self, sample_timeseries, simple_dataarray): assert sample_timeseries.stored_data.equals(original_data) assert sample_timeseries.active_data.equals(original_data) - def test_stored_data_setter(self, sample_timeseries, sample_time_index): + def test_stored_data_setter(self, sample_timeseries, sample_timesteps): """Test stored_data setter with different data types.""" # Test with a Series - series_data = pd.Series([5, 6, 7, 8, 9], index=sample_time_index) + series_data = pd.Series([5, 6, 7, 8, 9], index=sample_timesteps) sample_timeseries.stored_data = series_data assert np.array_equal( sample_timeseries.stored_data.values, @@ -150,7 +151,7 @@ def test_stored_data_setter(self, sample_timeseries, sample_time_index): ) # Test with a single-column DataFrame - df_data = pd.DataFrame({'col1': [15, 16, 17, 18, 19]}, index=sample_time_index) + df_data = pd.DataFrame({'col1': [15, 16, 17, 18, 19]}, index=sample_timesteps) sample_timeseries.stored_data = df_data assert np.array_equal( sample_timeseries.stored_data.values, @@ -172,7 +173,7 @@ def test_stored_data_setter(self, sample_timeseries, sample_time_index): # Test with another DataArray another_dataarray = xr.DataArray( [30, 31, 32, 33, 34], - coords={'time': sample_time_index}, + coords={'time': sample_timesteps}, dims=['time'] ) sample_timeseries.stored_data = another_dataarray @@ -190,22 +191,22 @@ def test_stored_data_setter_no_change(self, sample_timeseries): # Backup shouldn't change assert sample_timeseries._backup is current_backup # Should be the same object - def test_from_datasource(self, sample_time_index): + def test_from_datasource(self, sample_timesteps): """Test from_datasource class method.""" # Test with scalar - ts_scalar = TimeSeries.from_datasource(42, 'Scalar Series', sample_time_index) + ts_scalar = TimeSeries.from_datasource(42, 'Scalar Series', sample_timesteps) assert np.all(ts_scalar.stored_data.values == 42) # Test with Series - series_data = pd.Series([1, 2, 3, 4, 5], index=sample_time_index) - ts_series = TimeSeries.from_datasource(series_data, 'Series Data', sample_time_index) + series_data = pd.Series([1, 2, 3, 4, 5], index=sample_timesteps) + ts_series = TimeSeries.from_datasource(series_data, 'Series Data', sample_timesteps) assert np.array_equal(ts_series.stored_data.values, series_data.values) # Test with aggregation parameters ts_with_agg = TimeSeries.from_datasource( series_data, 'Aggregated Series', - sample_time_index, + sample_timesteps, aggregation_weight=0.7, aggregation_group='group1' ) @@ -247,12 +248,12 @@ def test_to_json_from_json(self, sample_timeseries): with pytest.raises(ValueError, match="one of 'path' or 'data'"): TimeSeries.from_json(data=json_dict, path='dummy.json') - def test_all_equal(self, sample_time_index): + def test_all_equal(self, sample_timesteps): """Test all_equal property.""" # All equal values equal_data = xr.DataArray( [5, 5, 5, 5, 5], - coords={'time': sample_time_index}, + coords={'time': sample_timesteps}, dims=['time'] ) ts_equal = TimeSeries(equal_data, 'Equal Series') @@ -261,7 +262,7 @@ def test_all_equal(self, sample_time_index): # Not all equal unequal_data = xr.DataArray( [5, 5, 6, 5, 5], - coords={'time': sample_time_index}, + coords={'time': sample_timesteps}, dims=['time'] ) ts_unequal = TimeSeries(unequal_data, 'Unequal Series') @@ -296,10 +297,10 @@ def test_arithmetic_operations(self, sample_timeseries): assert np.array_equal((+sample_timeseries).values, +sample_timeseries.active_data.values) assert np.array_equal((abs(sample_timeseries)).values, abs(sample_timeseries.active_data.values)) - def test_comparison_operations(self, sample_time_index): + def test_comparison_operations(self, sample_timesteps): """Test comparison operations.""" - data1 = xr.DataArray([10, 20, 30, 40, 50], coords={'time': sample_time_index}, dims=['time']) - data2 = xr.DataArray([5, 10, 15, 20, 25], coords={'time': sample_time_index}, dims=['time']) + data1 = xr.DataArray([10, 20, 30, 40, 50], coords={'time': sample_timesteps}, dims=['time']) + data2 = xr.DataArray([5, 10, 15, 20, 25], coords={'time': sample_timesteps}, dims=['time']) ts1 = TimeSeries(data1, 'Series 1') ts2 = TimeSeries(data2, 'Series 2') @@ -308,7 +309,7 @@ def test_comparison_operations(self, sample_time_index): assert (ts1 > ts2) is True # All values in ts1 are greater than ts2 # Test with mixed values - data3 = xr.DataArray([5, 25, 15, 45, 25], coords={'time': sample_time_index}, dims=['time']) + data3 = xr.DataArray([5, 25, 15, 45, 25], coords={'time': sample_timesteps}, dims=['time']) ts3 = TimeSeries(data3, 'Series 3') assert (ts1 > ts3) is False # Not all values in ts1 are greater than ts3 @@ -348,3 +349,322 @@ def test_sel_and_isel_properties(self, sample_timeseries): # Test that isel property works indexed = sample_timeseries.isel(time=0) assert indexed.item() == sample_timeseries.active_data.values[0] + + +@pytest.fixture +def sample_collection(sample_timesteps): + """Create a sample TimeSeriesCollection.""" + return TimeSeriesCollection(sample_timesteps) + + +@pytest.fixture +def populated_collection(sample_collection): + """Create a TimeSeriesCollection with test data.""" + # Add a constant time series + sample_collection.create_time_series(42, "constant_series") + + # Add a varying time series + varying_data = np.array([10, 20, 30, 40, 50]) + sample_collection.create_time_series(varying_data, "varying_series") + + # Add a time series with extra timestep + sample_collection.create_time_series( + np.array([1, 2, 3, 4, 5, 6]), + "extra_timestep_series", + needs_extra_timestep=True + ) + + # Add series with aggregation settings + sample_collection.create_time_series( + TimeSeriesData(np.array([5, 5, 5, 5, 5]), agg_group="group1"), + "group1_series1" + ) + sample_collection.create_time_series( + TimeSeriesData(np.array([6, 6, 6, 6, 6]), agg_group="group1"), + "group1_series2" + ) + sample_collection.create_time_series( + TimeSeriesData(np.array([10, 10, 10, 10, 10]), agg_weight=0.5), + "weighted_series" + ) + + return sample_collection + + +class TestTimeSeriesCollection: + """Test suite for TimeSeriesCollection.""" + + def test_initialization(self, sample_timesteps): + """Test basic initialization.""" + collection = TimeSeriesCollection(sample_timesteps) + + assert collection.all_timesteps.equals(sample_timesteps) + assert len(collection.all_timesteps_extra) == len(sample_timesteps) + 1 + assert isinstance(collection.all_hours_per_timestep, xr.DataArray) + assert len(collection) == 0 + + def test_initialization_with_custom_hours(self, sample_timesteps): + """Test initialization with custom hour settings.""" + # Test with last timestep duration + last_timestep_hours = 12 + collection = TimeSeriesCollection( + sample_timesteps, + hours_of_last_timestep=last_timestep_hours + ) + + # Verify the last timestep duration + extra_step_delta = collection.all_timesteps_extra[-1] - collection.all_timesteps_extra[-2] + assert extra_step_delta == pd.Timedelta(hours=last_timestep_hours) + + # Test with previous timestep duration + hours_per_step = 8 + collection2 = TimeSeriesCollection( + sample_timesteps, + hours_of_previous_timesteps=hours_per_step + ) + + assert collection2.hours_of_previous_timesteps == hours_per_step + + def test_create_time_series(self, sample_collection): + """Test creating time series.""" + # Test scalar + ts1 = sample_collection.create_time_series(42, "scalar_series") + assert ts1.name == "scalar_series" + assert np.all(ts1.active_data.values == 42) + + # Test numpy array + data = np.array([1, 2, 3, 4, 5]) + ts2 = sample_collection.create_time_series(data, "array_series") + assert np.array_equal(ts2.active_data.values, data) + + # Test with TimeSeriesData + ts3 = sample_collection.create_time_series( + TimeSeriesData(10, agg_weight=0.7), + "weighted_series" + ) + assert ts3.aggregation_weight == 0.7 + + # Test with extra timestep + ts4 = sample_collection.create_time_series(5, "extra_series", needs_extra_timestep=True) + assert ts4.needs_extra_timestep + assert len(ts4.active_data) == len(sample_collection.timesteps_extra) + + # Test duplicate name + with pytest.raises(ValueError, match="already exists"): + sample_collection.create_time_series(1, "scalar_series") + + def test_access_time_series(self, populated_collection): + """Test accessing time series.""" + # Test __getitem__ + ts = populated_collection["varying_series"] + assert ts.name == "varying_series" + + # Test __contains__ with string + assert "constant_series" in populated_collection + assert "nonexistent_series" not in populated_collection + + # Test __contains__ with TimeSeries object + assert populated_collection["varying_series"] in populated_collection + + # Test __iter__ + names = [ts.name for ts in populated_collection] + assert len(names) == 6 + assert "varying_series" in names + + # Test access to non-existent series + with pytest.raises(KeyError): + populated_collection["nonexistent_series"] + + def test_constants_and_non_constants(self, populated_collection): + """Test constants and non_constants properties.""" + # Test constants + constants = populated_collection.constants + assert len(constants) == 4 # constant_series, group1_series1, group1_series2, weighted_series + assert all(ts.all_equal for ts in constants) + + # Test non_constants + non_constants = populated_collection.non_constants + assert len(non_constants) == 2 # varying_series, extra_timestep_series + assert all(not ts.all_equal for ts in non_constants) + + # Test caching behavior + id_before = id(populated_collection.constants) + # Access again without changes + assert id(populated_collection.constants) == id_before + + # Modify a series to invalidate cache + populated_collection["constant_series"].stored_data = np.array([1, 2, 3, 4, 5]) + + # Cache should be rebuilt + assert id(populated_collection.constants) != id_before + assert len(populated_collection.constants) == 3 # One less constant now + + def test_timesteps_properties(self, populated_collection, sample_timesteps): + """Test timestep-related properties.""" + # Test default (all) timesteps + assert populated_collection.timesteps.equals(sample_timesteps) + assert len(populated_collection.timesteps_extra) == len(sample_timesteps) + 1 + + # Test activating a subset + subset = sample_timesteps[1:3] + populated_collection.activate_timesteps(subset) + + assert populated_collection.timesteps.equals(subset) + assert len(populated_collection.timesteps_extra) == len(subset) + 1 + + # Check that time series were updated + assert populated_collection["varying_series"].active_timesteps.equals(subset) + assert populated_collection["extra_timestep_series"].active_timesteps.equals( + populated_collection.timesteps_extra + ) + + # Test reset + populated_collection.reset() + assert populated_collection.timesteps.equals(sample_timesteps) + + def test_to_dataframe_and_dataset(self, populated_collection): + """Test conversion to DataFrame and Dataset.""" + # Test to_dataset + ds = populated_collection.to_dataset() + assert isinstance(ds, xr.Dataset) + assert len(ds.data_vars) == 6 + + # Test to_dataframe with different filters + df_all = populated_collection.to_dataframe(filtered='all') + assert len(df_all.columns) == 6 + + df_constant = populated_collection.to_dataframe(filtered='constant') + assert len(df_constant.columns) == 4 + + df_non_constant = populated_collection.to_dataframe(filtered='non_constant') + assert len(df_non_constant.columns) == 2 + + # Test invalid filter + with pytest.raises(ValueError): + populated_collection.to_dataframe(filtered='invalid') + + def test_calculate_aggregation_weights(self, populated_collection): + """Test aggregation weight calculation.""" + weights = populated_collection.calculate_aggregation_weights() + + # Group weights should be 0.5 each (1/2) + assert populated_collection.group_weights["group1"] == 0.5 + + # Series in group1 should have weight 0.5 + assert weights["group1_series1"] == 0.5 + assert weights["group1_series2"] == 0.5 + + # Series with explicit weight should have that weight + assert weights["weighted_series"] == 0.5 + + # Series without group or weight should have weight 1 + assert weights["constant_series"] == 1 + + def test_insert_new_data(self, populated_collection, sample_timesteps): + """Test inserting new data.""" + # Create new data + new_data = pd.DataFrame({ + "constant_series": [100, 100, 100, 100, 100], + "varying_series": [5, 10, 15, 20, 25], + # extra_timestep_series is omitted to test partial updates + }, index=sample_timesteps) + + # Insert data + populated_collection.insert_new_data(new_data) + + # Verify updates + assert np.all(populated_collection["constant_series"].active_data.values == 100) + assert np.array_equal( + populated_collection["varying_series"].active_data.values, + np.array([5, 10, 15, 20, 25]) + ) + + # Series not in the DataFrame should be unchanged + assert np.array_equal( + populated_collection["extra_timestep_series"].active_data.values[:-1], + np.array([1, 2, 3, 4, 5]) + ) + + # Test with mismatched index + bad_index = pd.date_range("2023-02-01", periods=5, freq="D", name="time") + bad_data = pd.DataFrame({"constant_series": [1, 1, 1, 1, 1]}, index=bad_index) + + with pytest.raises(ValueError, match="must match collection timesteps"): + populated_collection.insert_new_data(bad_data) + + def test_restore_data(self, populated_collection): + """Test restoring original data.""" + # Capture original data + original_values = {name: ts.stored_data.copy() for name, ts in populated_collection.time_series_data.items()} + + # Modify data + new_data = pd.DataFrame({ + name: np.ones(len(populated_collection.timesteps)) * 999 + for name in populated_collection.time_series_data + if not populated_collection[name].needs_extra_timestep + }, index=populated_collection.timesteps) + + populated_collection.insert_new_data(new_data) + + # Verify data was changed + assert np.all(populated_collection["constant_series"].active_data.values == 999) + + # Restore data + populated_collection.restore_data() + + # Verify data was restored + for name, original in original_values.items(): + restored = populated_collection[name].stored_data + assert np.array_equal(restored.values, original.values) + + def test_class_method_with_uniform_timesteps(self): + """Test the with_uniform_timesteps class method.""" + collection = TimeSeriesCollection.with_uniform_timesteps( + start_time=pd.Timestamp("2023-01-01"), + periods=24, + freq="H", + hours_per_step=1 + ) + + assert len(collection.timesteps) == 24 + assert collection.hours_of_previous_timesteps == 1 + assert (collection.timesteps[1] - collection.timesteps[0]) == pd.Timedelta(hours=1) + + def test_hours_per_timestep(self, populated_collection): + """Test hours_per_timestep calculation.""" + # Standard case - uniform timesteps + hours = populated_collection.hours_per_timestep.values + assert np.allclose(hours, 24) # Default is daily timesteps + + # Create non-uniform timesteps + non_uniform_times = pd.DatetimeIndex([ + pd.Timestamp("2023-01-01"), + pd.Timestamp("2023-01-02"), + pd.Timestamp("2023-01-03 12:00:00"), # 1.5 days from previous + pd.Timestamp("2023-01-04"), # 0.5 days from previous + pd.Timestamp("2023-01-06") # 2 days from previous + ], name="time") + + collection = TimeSeriesCollection(non_uniform_times) + hours = collection.hours_per_timestep.values + + # Expected hours between timestamps + expected = np.array([24, 36, 12, 48]) + assert np.allclose(hours, expected) + + def test_validation_and_errors(self, sample_timesteps): + """Test validation and error handling.""" + # Test non-DatetimeIndex + with pytest.raises(TypeError, match="must be a pandas DatetimeIndex"): + TimeSeriesCollection(pd.Index([1, 2, 3, 4, 5])) + + # Test too few timesteps + with pytest.raises(ValueError, match="must contain at least 2 timestamps"): + TimeSeriesCollection(pd.DatetimeIndex([pd.Timestamp("2023-01-01")], name="time")) + + # Test invalid active_timesteps + collection = TimeSeriesCollection(sample_timesteps) + invalid_timesteps = pd.date_range("2024-01-01", periods=3, freq="D", name="time") + + with pytest.raises(ValueError, match="must be a subset"): + collection.activate_timesteps(invalid_timesteps) From a8a5a987996bc6f9e4eebe927c620b5c5f05875f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 10 Mar 2025 21:40:36 +0100 Subject: [PATCH 324/507] Remove caching from tests --- tests/test_timeseries.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/tests/test_timeseries.py b/tests/test_timeseries.py index 32e517a3a..e48bea5b5 100644 --- a/tests/test_timeseries.py +++ b/tests/test_timeseries.py @@ -487,17 +487,11 @@ def test_constants_and_non_constants(self, populated_collection): assert len(non_constants) == 2 # varying_series, extra_timestep_series assert all(not ts.all_equal for ts in non_constants) - # Test caching behavior - id_before = id(populated_collection.constants) - # Access again without changes - assert id(populated_collection.constants) == id_before - - # Modify a series to invalidate cache + # Test modifying a series changes the results populated_collection["constant_series"].stored_data = np.array([1, 2, 3, 4, 5]) - - # Cache should be rebuilt - assert id(populated_collection.constants) != id_before - assert len(populated_collection.constants) == 3 # One less constant now + updated_constants = populated_collection.constants + assert len(updated_constants) == 3 # One less constant + assert "constant_series" not in [ts.name for ts in updated_constants] def test_timesteps_properties(self, populated_collection, sample_timesteps): """Test timestep-related properties.""" From 977a9fca138e4f7070195a0255724b8a5b49b41f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 10 Mar 2025 21:43:54 +0100 Subject: [PATCH 325/507] Bugfix test --- tests/test_timeseries.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_timeseries.py b/tests/test_timeseries.py index e48bea5b5..8c1e5ce20 100644 --- a/tests/test_timeseries.py +++ b/tests/test_timeseries.py @@ -643,7 +643,7 @@ def test_hours_per_timestep(self, populated_collection): hours = collection.hours_per_timestep.values # Expected hours between timestamps - expected = np.array([24, 36, 12, 48]) + expected = np.array([24, 36, 12, 48, 48]) assert np.allclose(hours, expected) def test_validation_and_errors(self, sample_timesteps): From 0fd49cdfadc2097e08f901a302212c0928125196 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 11 Mar 2025 08:31:13 +0100 Subject: [PATCH 326/507] Bugfix TimeSeriesCollection --- flixOpt/core.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/flixOpt/core.py b/flixOpt/core.py index de72cb29d..9052b8b68 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -592,9 +592,10 @@ def activate_timesteps(self, active_timesteps: Optional[pd.DatetimeIndex] = None # Calculate derived timesteps self._active_timesteps = active_timesteps + first_ts_index = np.where(self.all_timesteps == active_timesteps[0])[0][0] last_ts_idx = np.where(self.all_timesteps == active_timesteps[-1])[0][0] - self._active_timesteps_extra = self.all_timesteps_extra[:last_ts_idx + 2] - self._active_hours_per_timestep = self.all_hours_per_timestep.sel(time=active_timesteps) + self._active_timesteps_extra = self.all_timesteps_extra[first_ts_index:last_ts_idx + 2] + self._active_hours_per_timestep = self.all_hours_per_timestep.isel(time=slice(first_ts_index, last_ts_idx + 1)) # Update all time series self._update_time_series_timesteps() From 5dff805927b0d9316413bacdd33a4621ba78af9f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 11 Mar 2025 09:32:03 +0100 Subject: [PATCH 327/507] Simplify to_dataset and handle slicing in to_dataframe() Improve insert and export methods of TimeSeriesCollection --- flixOpt/calculation.py | 4 +- flixOpt/core.py | 103 +++++++++++++++++++++++++++++++---------- 2 files changed, 81 insertions(+), 26 deletions(-) diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py index c88c36220..6f03dc378 100644 --- a/flixOpt/calculation.py +++ b/flixOpt/calculation.py @@ -275,7 +275,7 @@ def _perform_aggregation(self): # Aggregation - creation of aggregated timeseries: self.aggregation = Aggregation( - original_data=self.flow_system.time_series_collection.to_dataframe().iloc[:-1,:], # Exclude last row (NaN) + original_data=self.flow_system.time_series_collection.to_dataframe(include_extra_timestep=False), # Exclude last row (NaN) hours_per_time_step=float(dt_min), hours_per_period=self.aggregation_parameters.hours_per_period, nr_of_periods=self.aggregation_parameters.nr_of_periods, @@ -287,7 +287,7 @@ def _perform_aggregation(self): self.aggregation.cluster() self.aggregation.plot() if self.aggregation_parameters.aggregate_data_and_fix_non_binary_vars: - self.flow_system.time_series_collection.insert_new_data(self.aggregation.aggregated_data) + self.flow_system.time_series_collection.insert_new_data(self.aggregation.aggregated_data, include_extra_timestep=False) self.durations['aggregation'] = round(timeit.default_timer() - t_start_agg, 2) diff --git a/flixOpt/core.py b/flixOpt/core.py index 9052b8b68..eb42df463 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -614,42 +614,83 @@ def restore_data(self): for time_series in self.time_series_data.values(): time_series.restore_data() - def insert_new_data(self, data: pd.DataFrame): - """Update time series with new data from a DataFrame.""" + def add_time_series(self, time_series: TimeSeries): + """Add an existing TimeSeries to the collection.""" + if time_series.name in self.time_series_data: + raise ValueError(f"TimeSeries '{time_series.name}' already exists in this collection") + + self.time_series_data[time_series.name] = time_series + + def insert_new_data(self, data: pd.DataFrame, include_extra_timestep: bool = False): + """ + Update time series with new data from a DataFrame. + + Parameters + ---------- + data : pd.DataFrame + DataFrame containing new data with timestamps as index + include_extra_timestep : bool, optional + Whether the provided data already includes the extra timestep, by default False + """ if not isinstance(data, pd.DataFrame): raise TypeError(f"data must be a pandas DataFrame, got {type(data).__name__}") - if not data.index.equals(self.timesteps): - raise ValueError("DataFrame index must match collection timesteps") + # Check if the DataFrame index matches the expected timesteps + expected_timesteps = self.timesteps_extra if include_extra_timestep else self.timesteps + if not data.index.equals(expected_timesteps): + raise ValueError( + f"DataFrame index must match {'collection timesteps with extra timestep' if include_extra_timestep else 'collection timesteps'}") for name, ts in self.time_series_data.items(): if name in data.columns: if not ts.needs_extra_timestep: - ts.stored_data = data[name] + # For time series without extra timestep + if include_extra_timestep: + # If data includes extra timestep but series doesn't need it, exclude the last point + ts.stored_data = data[name].iloc[:-1] + else: + # Use data as is + ts.stored_data = data[name] else: - # For time series with extra timestep, add the extra value - extra_step_value = data[name].iloc[-1] - extra_step_index = pd.DatetimeIndex([self.timesteps_extra[-1]], name='time') - extra_step_series = pd.Series([extra_step_value], index=extra_step_index) - - # Combine the regular data with the extra timestep - combined_series = pd.concat([data[name], extra_step_series]) - ts.stored_data = combined_series + # For time series with extra timestep + if include_extra_timestep: + # Data already includes extra timestep + ts.stored_data = data[name] + else: + # Need to add extra timestep - extrapolate from the last value + extra_step_value = data[name].iloc[-1] + extra_step_index = pd.DatetimeIndex([self.timesteps_extra[-1]], name='time') + extra_step_series = pd.Series([extra_step_value], index=extra_step_index) + + # Combine the regular data with the extra timestep + ts.stored_data = pd.concat([data[name], extra_step_series]) logger.debug(f'Updated data for {name}') - def add_time_series(self, time_series: TimeSeries): - """Add an existing TimeSeries to the collection.""" - if time_series.name in self.time_series_data: - raise ValueError(f"TimeSeries '{time_series.name}' already exists in this collection") + def to_dataframe(self, + filtered: Literal['all', 'constant', 'non_constant'] = 'non_constant', + include_extra_timestep: bool = True) -> pd.DataFrame: + """ + Convert collection to DataFrame with optional filtering and timestep control. - self.time_series_data[time_series.name] = time_series + Parameters + ---------- + filtered : Literal['all', 'constant', 'non_constant'], optional + Filter time series by variability, by default 'non_constant' + include_extra_timestep : bool, optional + Whether to include the extra timestep in the result, by default True - def to_dataframe(self, filtered: Literal['all', 'constant', 'non_constant'] = 'non_constant') -> pd.DataFrame: - """Convert collection to DataFrame with optional filtering.""" - # Convert to Dataset first + Returns + ------- + pd.DataFrame + DataFrame representation of the collection + """ include_constants = filtered != 'non_constant' ds = self.to_dataset(include_constants=include_constants) + + if not include_extra_timestep: + ds = ds.isel(time=slice(None, -1)) + df = ds.to_dataframe() # Apply filtering @@ -663,19 +704,33 @@ def to_dataframe(self, filtered: Literal['all', 'constant', 'non_constant'] = 'n raise ValueError("filtered must be one of: 'all', 'constant', 'non_constant'") def to_dataset(self, include_constants: bool = True) -> xr.Dataset: - """Combine all time series into a single Dataset.""" + """ + Combine all time series into a single Dataset with all timesteps. + + Parameters + ---------- + include_constants : bool, optional + Whether to include time series with constant values, by default True + + Returns + ------- + xr.Dataset + Dataset containing all selected time series with all timesteps + """ # Determine which series to include if include_constants: series_to_include = self.time_series_data.values() else: series_to_include = self.non_constants - ds = xr.Dataset({ts.name: ts.active_data for ts in series_to_include}) + # Create dataset with all time series (with all their timesteps) + ds = xr.Dataset({[ts.name]: ts.active_data for ts in series_to_include}) ds.attrs.update({ - "timesteps": f"{self.timesteps[0]} ... {self.timesteps[-1]} | len={len(self.timesteps)}", + "timesteps": f"{self.timesteps_extra[0]} ... {self.timesteps_extra[-1]} | len={len(self.timesteps_extra)}", "hours_per_timestep": self._format_stats(self.hours_per_timestep), }) + return ds def _update_time_series_timesteps(self): From 72fc6673aba91a7ce68ea91c2b7c0df6b57c04d2 Mon Sep 17 00:00:00 2001 From: fel15133 Date: Tue, 11 Mar 2025 14:05:35 +0100 Subject: [PATCH 328/507] bugfix prevent_simultaneous_flows in SourceAndSink --- flixOpt/components.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/flixOpt/components.py b/flixOpt/components.py index dc9456c9e..72d5f15d7 100644 --- a/flixOpt/components.py +++ b/flixOpt/components.py @@ -567,7 +567,7 @@ def __init__( label: str, source: Flow, sink: Flow, - prevent_simultaneous_flows: bool = True, + prevent_simultaneous_sink_and_source: bool = True, meta_data: Optional[Dict] = None, ): """ @@ -581,7 +581,7 @@ def __init__( output-flow of this component sink : Flow input-flow of this component - prevent_simultaneous_flows: boolean. Default ist True. + prevent_simultaneous_sink_and_source: boolean. Default ist True. True: inflow and outflow are not allowed to be both non-zero at same timestep. False: inflow and outflow are working independently. @@ -590,11 +590,12 @@ def __init__( label, inputs=[sink], outputs=[source], - prevent_simultaneous_flows=prevent_simultaneous_flows, + prevent_simultaneous_flows=[source, sink] if prevent_simultaneous_sink_and_source else None, meta_data=meta_data, ) self.source = source self.sink = sink + self.prevent_simultaneous_sink_and_source = prevent_simultaneous_sink_and_source @register_class_for_io From 88ccc7eb7a9d0c76b4a600aa74e9e6e4b75df468 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 11 Mar 2025 14:20:41 +0100 Subject: [PATCH 329/507] Improve to_dataset() --- flixOpt/core.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flixOpt/core.py b/flixOpt/core.py index eb42df463..624c9d823 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -723,11 +723,11 @@ def to_dataset(self, include_constants: bool = True) -> xr.Dataset: else: series_to_include = self.non_constants - # Create dataset with all time series (with all their timesteps) - ds = xr.Dataset({[ts.name]: ts.active_data for ts in series_to_include}) + ds = xr.Dataset({ts.name: ts.active_data for ts in series_to_include}, + coords={'time': self.timesteps_extra}) ds.attrs.update({ - "timesteps": f"{self.timesteps_extra[0]} ... {self.timesteps_extra[-1]} | len={len(self.timesteps_extra)}", + "timesteps_extra": f"{self.timesteps_extra[0]} ... {self.timesteps_extra[-1]} | len={len(self.timesteps_extra)}", "hours_per_timestep": self._format_stats(self.hours_per_timestep), }) From fdf85c27fb6cc8fe2680e1f92788dc50a0977308 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 11 Mar 2025 14:37:19 +0100 Subject: [PATCH 330/507] Add flag in FlowSystem to prevent multiple iterations of connecting the system --- flixOpt/flow_system.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/flixOpt/flow_system.py b/flixOpt/flow_system.py index 51f4fb14f..0207acd34 100644 --- a/flixOpt/flow_system.py +++ b/flixOpt/flow_system.py @@ -64,6 +64,8 @@ def __init__( self.effects: EffectCollection = EffectCollection() self.model: Optional[SystemModel] = None + self._connected = False + @classmethod def from_dataset(cls, ds: xr.Dataset): timesteps_extra = pd.DatetimeIndex(ds.attrs['timesteps_extra'], name='time') @@ -128,6 +130,9 @@ def add_elements(self, *elements: Element) -> None: modeling Elements """ + if self._connected: + warnings.warn('You are adding elements to an already connected FlowSystem. This is not recommended (But it works).') + self._connected = False for new_element in list(elements): if isinstance(new_element, Component): self._add_components(new_element) @@ -241,7 +246,8 @@ def plot_network( return plotting.plot_network(node_infos, edge_infos, path, controls, show) def network_infos(self) -> Tuple[Dict[str, Dict[str, str]], Dict[str, Dict[str, str]]]: - self._connect_network() + if not self._connected: + self._connect_network() nodes = { node.label_full: { 'label': node.label, @@ -264,7 +270,8 @@ def network_infos(self) -> Tuple[Dict[str, Dict[str, str]], Dict[str, Dict[str, return nodes, edges def transform_data(self): - self._connect_network() + if not self._connected: + self._connect_network() for element in self.all_elements.values(): element.transform_data(self) @@ -324,6 +331,8 @@ def create_effect_time_series(self, } def create_model(self) -> SystemModel: + if not self._connected: + raise RuntimeError('FlowSystem is not connected. Call FlowSystem.connect() first.') self.model = SystemModel(self) return self.model @@ -383,6 +392,9 @@ def _connect_network(self): bus.outputs.append(flow) elif not flow.is_input_in_component and flow not in bus.inputs: bus.inputs.append(flow) + logger.debug(f'Connected {len(self.buses)} Buses and {len(self.components)} ' + f'via {len(self.flows)} Flows inside the FlowSystem.') + self._connected = True def __repr__(self): return f'<{self.__class__.__name__} with {len(self.components)} components and {len(self.effects)} effects>' From 2839e116021b24d2db22a497997bc4d6dca7be71 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 11 Mar 2025 14:40:54 +0100 Subject: [PATCH 331/507] Add additional test for io of FlowSystem --- tests/test_io.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/tests/test_io.py b/tests/test_io.py index 9c9b3dec4..2176f92c7 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -21,7 +21,7 @@ def flow_system(request): return fs[0] -def test_flow_system_io(flow_system): +def test_flow_system_file_io(flow_system): calculation_0 = fx.FullCalculation('IO', flow_system=flow_system) calculation_0.do_modeling() calculation_0.solve(fx.solvers.HighsSolver(mip_gap=0.001, time_limit_seconds=30)) @@ -43,5 +43,18 @@ def test_flow_system_io(flow_system): 'costs doesnt match expected value', ) + +def test_flow_system_io(flow_system): + di = flow_system.as_dict() + _ = fx.FlowSystem.from_dict(di) + + ds = flow_system.as_dataset() + _ = fx.FlowSystem.from_dataset(ds) + + print(flow_system) + flow_system.__repr__() + flow_system.__str__() + + if __name__ == '__main__': pytest.main(['-v', '--disable-warnings']) From 7b9795ee2c724f7eb7d0fd3d0d5d333e0b8d446d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 11 Mar 2025 15:08:26 +0100 Subject: [PATCH 332/507] Remove fixed_relative_profile from the FlowModel --- flixOpt/elements.py | 2 -- flixOpt/features.py | 57 ++++++++++++++++++--------------------------- 2 files changed, 23 insertions(+), 36 deletions(-) diff --git a/flixOpt/elements.py b/flixOpt/elements.py index ddc15db0e..f1bf19b3f 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -341,7 +341,6 @@ def do_modeling(self): parameters=self.element.size, defining_variable=self.flow_rate, relative_bounds_of_defining_variable=self.relative_flow_rate_bounds, - fixed_relative_profile=self.fixed_relative_flow_rate, on_variable=self.on_off.on if self.on_off is not None else None, ), 'investment' @@ -433,7 +432,6 @@ def absolute_flow_rate_bounds(self) -> Tuple[NumericData, NumericData]: return rel_min * size.fixed_size, rel_max * size.fixed_size return rel_min * size.minimum_size, rel_max * size.maximum_size - @property def relative_flow_rate_bounds(self) -> Tuple[NumericData, NumericData]: """Returns relative flow rate bounds.""" diff --git a/flixOpt/features.py b/flixOpt/features.py index dffec513b..c1848a492 100644 --- a/flixOpt/features.py +++ b/flixOpt/features.py @@ -34,7 +34,6 @@ def __init__( parameters: InvestParameters, defining_variable: [linopy.Variable], relative_bounds_of_defining_variable: Tuple[NumericData, NumericData], - fixed_relative_profile: Optional[NumericData] = None, label: Optional[str] = None, on_variable: Optional[linopy.Variable] = None, ): @@ -50,7 +49,6 @@ def __init__( self._on_variable = on_variable self._defining_variable = defining_variable self._relative_bounds_of_defining_variable = relative_bounds_of_defining_variable - self._fixed_relative_profile = fixed_relative_profile self.parameters = parameters def do_modeling(self): @@ -143,41 +141,32 @@ def _create_bounds_for_optional_investment(self): def _create_bounds_for_defining_variable(self): variable = self._defining_variable - # fixed relative value - if self._fixed_relative_profile is not None: - # TODO: Allow Off? Currently not.. - self.add(self._model.add_constraints( - variable == self.size * self._fixed_relative_profile, - name=f'{self.label_full}|fixed_{variable.name}'), - f'fixed_{variable.name}') + lb_relative, ub_relative = self._relative_bounds_of_defining_variable + # eq: defining_variable(t) <= size * upper_bound(t) + self.add(self._model.add_constraints( + variable <= self.size * ub_relative, + name=f'{self.label_full}|ub_{variable.name}'), + f'ub_{variable.name}') + if self._on_variable is None: + # eq: defining_variable(t) >= investment_size * relative_minimum(t) + self.add(self._model.add_constraints( + variable >= self.size * lb_relative, + name=f'{self.label_full}|lb_{variable.name}'), + f'lb_{variable.name}') else: - lb_relative, ub_relative = self._relative_bounds_of_defining_variable - # eq: defining_variable(t) <= size * upper_bound(t) + ## 2. Gleichung: Minimum durch Investmentgröße und On + # eq: defining_variable(t) >= mega * (On(t)-1) + size * relative_minimum(t) + # ... mit mega = relative_maximum * maximum_size + # äquivalent zu:. + # eq: - defining_variable(t) + mega * On(t) + size * relative_minimum(t) <= + mega + mega = lb_relative * self.parameters.maximum_size + on = self._on_variable self.add(self._model.add_constraints( - variable <= self.size * ub_relative, - name=f'{self.label_full}|ub_{variable.name}'), - f'ub_{variable.name}') - - if self._on_variable is None: - # eq: defining_variable(t) >= investment_size * relative_minimum(t) - self.add(self._model.add_constraints( - variable >= self.size * lb_relative, - name=f'{self.label_full}|lb_{variable.name}'), - f'lb_{variable.name}') - else: - ## 2. Gleichung: Minimum durch Investmentgröße und On - # eq: defining_variable(t) >= mega * (On(t)-1) + size * relative_minimum(t) - # ... mit mega = relative_maximum * maximum_size - # äquivalent zu:. - # eq: - defining_variable(t) + mega * On(t) + size * relative_minimum(t) <= + mega - mega = lb_relative * self.parameters.maximum_size - on = self._on_variable - self.add(self._model.add_constraints( - variable >= mega * (on - 1) + self.size * lb_relative, - name=f'{self.label_full}|lb_{variable.name}'), - f'lb_{variable.name}') - # anmerkung: Glg bei Spezialfall relative_minimum = 0 redundant zu OnOff ?? + variable >= mega * (on - 1) + self.size * lb_relative, + name=f'{self.label_full}|lb_{variable.name}'), + f'lb_{variable.name}') + # anmerkung: Glg bei Spezialfall relative_minimum = 0 redundant zu OnOff ?? class OnOffModel(Model): From 8f1c9f25a50957ea4599e41c339a2511c53ab7dc Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 11 Mar 2025 15:20:03 +0100 Subject: [PATCH 333/507] Improve modeling of fixed flow_rate --- flixOpt/elements.py | 19 +++++++++---------- flixOpt/features.py | 13 ++++++++++--- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/flixOpt/elements.py b/flixOpt/elements.py index f1bf19b3f..e482d0394 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -190,9 +190,9 @@ def __init__( (if size is not const, maybe load_factor_min fits better for you!) fixed_relative_profile : scalar, array, TimeSeriesData, optional fixed relative values for flow (if given). - val(t) := fixed_relative_profile(t) * size(t) + flow_rate(t) := fixed_relative_profile(t) * size(t) With this value, the flow_rate is no opt-variable anymore; - (relative_minimum u. relative_maximum are making sense anymore) + (relative_minimum u. relative_maximum are iverwritten) used for fixed load profiles, i.g. heat demand, wind-power, solarthermal If the load-profile is just an upper limit, use relative_maximum instead. previous_flow_rate : scalar, array, optional @@ -272,6 +272,13 @@ def _plausibility_checks(self) -> None: f'the resulting flow_rate will be very high. To fix this, assign a size to the Flow {self}.' ) + if self.fixed_relative_profile is not None and self.on_off_parameters is not None: + raise ValueError( + f'Flow {self.label} has both a fixed_relative_profile and an on_off_parameters. This is not supported. ' + f'Use relative_minimum and relative_maximum instead, ' + f'if you want to allow flows to be switched on and off.' + ) + @property def label_full(self) -> str: return f'{self.component}({self.label})' @@ -308,14 +315,6 @@ def do_modeling(self): ), 'flow_rate' ) - if self.element.fixed_relative_profile is not None: - self.add( - self._model.add_constraints( - self.flow_rate == self.element.fixed_relative_profile.active_data, - name=f'{self.label_full}|fix_flow_rate' - ), - 'flow_rate (fix)' - ) # OnOff if self.element.on_off_parameters is not None: diff --git a/flixOpt/features.py b/flixOpt/features.py index c1848a492..36d27f6a5 100644 --- a/flixOpt/features.py +++ b/flixOpt/features.py @@ -37,9 +37,6 @@ def __init__( label: Optional[str] = None, on_variable: Optional[linopy.Variable] = None, ): - """ - If fixed relative profile is used, the relative bounds are ignored - """ super().__init__(model, label_of_element, label) self.size: Optional[Union[Scalar, linopy.Variable]] = None self.is_invested: Optional[linopy.Variable] = None @@ -142,6 +139,16 @@ def _create_bounds_for_optional_investment(self): def _create_bounds_for_defining_variable(self): variable = self._defining_variable lb_relative, ub_relative = self._relative_bounds_of_defining_variable + if np.all(lb_relative == ub_relative): + self.add(self._model.add_constraints( + variable == self.size * ub_relative, + name=f'{self.label_full}|fix_{variable.name}'), + f'fix_{variable.name}') + if self._on_variable is not None: + raise ValueError(f'Flow {self.label} has a fixed relative flow rate and an on_variable.' + f'This combination is currently not supported.') + return + # eq: defining_variable(t) <= size * upper_bound(t) self.add(self._model.add_constraints( variable <= self.size * ub_relative, From 065f7c0dc8551a6ec73b59251d5e6d9d454cb717 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 11 Mar 2025 15:25:59 +0100 Subject: [PATCH 334/507] Move plausibility checks to create_model() method --- flixOpt/components.py | 2 +- flixOpt/elements.py | 3 +-- flixOpt/features.py | 2 -- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/flixOpt/components.py b/flixOpt/components.py index 9b14a7410..becaabe73 100644 --- a/flixOpt/components.py +++ b/flixOpt/components.py @@ -61,9 +61,9 @@ def __init__( super().__init__(label, inputs, outputs, on_off_parameters, meta_data=meta_data) self.conversion_factors = conversion_factors or [] self.segmented_conversion_factors = segmented_conversion_factors or {} - self._plausibility_checks() def create_model(self, model: SystemModel) -> 'LinearConverterModel': + self._plausibility_checks() self.model = LinearConverterModel(model, self) return self.model diff --git a/flixOpt/elements.py b/flixOpt/elements.py index e482d0394..0cf80a2cc 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -229,9 +229,8 @@ def __init__( self.bus = bus self._bus_object = None - self._plausibility_checks() - def create_model(self, model: SystemModel) -> 'FlowModel': + self._plausibility_checks() self.model = FlowModel(model, self) return self.model diff --git a/flixOpt/features.py b/flixOpt/features.py index 36d27f6a5..3e79aed43 100644 --- a/flixOpt/features.py +++ b/flixOpt/features.py @@ -16,9 +16,7 @@ from .structure import Model, SystemModel if TYPE_CHECKING: # for type checking and preventing circular imports - from .components import Storage from .effects import Effect - from .elements import Flow logger = logging.getLogger('flixOpt') From f8a4ff95751f195306c7c46b200caf890ff76674 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 11 Mar 2025 15:31:55 +0100 Subject: [PATCH 335/507] Bugfix in parameters of Highs --- flixOpt/solvers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixOpt/solvers.py b/flixOpt/solvers.py index f84609d10..c9371b572 100644 --- a/flixOpt/solvers.py +++ b/flixOpt/solvers.py @@ -52,7 +52,7 @@ class HighsSolver(_Solver): @property def _options(self) -> Dict[str, Any]: return { - 'mip_gap': self.mip_gap, + 'mip_rel_gap': self.mip_gap, 'time_limit': self.time_limit_seconds, 'threads': self.threads, } From 3ed81e996c00efccc89aeda863db054d4b9d1182 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 11 Mar 2025 15:36:08 +0100 Subject: [PATCH 336/507] ruff check and bugfixes --- flixOpt/components.py | 2 +- flixOpt/core.py | 4 ++-- flixOpt/flow_system.py | 5 ++++- tests/test_dataconverter.py | 2 +- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/flixOpt/components.py b/flixOpt/components.py index becaabe73..39258f953 100644 --- a/flixOpt/components.py +++ b/flixOpt/components.py @@ -590,7 +590,7 @@ def __init__( label, inputs=[sink], outputs=[source], - prevent_simultaneous_flows=[sink, source] if prevent_simultaneous_flows is True else None, + prevent_simultaneous_flows=[sink, source] if prevent_simultaneous_sink_and_source is True else None, meta_data=meta_data, ) self.source = source diff --git a/flixOpt/core.py b/flixOpt/core.py index 624c9d823..9d89585e0 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -841,8 +841,8 @@ def __getitem__(self, name: str) -> TimeSeries: """Get a TimeSeries by name.""" try: return self.time_series_data[name] - except KeyError: - raise KeyError(f'TimeSeries "{name}" not found') + except KeyError as e: + raise KeyError(f'TimeSeries "{name}" not found in the TimeSeriesCollection') from e def __iter__(self) -> Iterator[TimeSeries]: """Iterate through all TimeSeries in the collection.""" diff --git a/flixOpt/flow_system.py b/flixOpt/flow_system.py index 0207acd34..39d3160fc 100644 --- a/flixOpt/flow_system.py +++ b/flixOpt/flow_system.py @@ -131,7 +131,10 @@ def add_elements(self, *elements: Element) -> None: """ if self._connected: - warnings.warn('You are adding elements to an already connected FlowSystem. This is not recommended (But it works).') + warnings.warn( + 'You are adding elements to an already connected FlowSystem. This is not recommended (But it works).', + stacklevel=2 + ) self._connected = False for new_element in list(elements): if isinstance(new_element, Component): diff --git a/tests/test_dataconverter.py b/tests/test_dataconverter.py index b5f876abb..bfa3bb9e8 100644 --- a/tests/test_dataconverter.py +++ b/tests/test_dataconverter.py @@ -3,7 +3,7 @@ import pytest import xarray as xr -from flixOpt.core import DataConverter, ConversionError # Adjust this import to match your project structure +from flixOpt.core import ConversionError, DataConverter # Adjust this import to match your project structure @pytest.fixture From 451005decfcf706cf176481a6812062878bd0847 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 11 Mar 2025 15:47:20 +0100 Subject: [PATCH 337/507] Change linopy dependency to use github instead of current latest release --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 6cc8ce869..5ec595569 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ classifiers = [ dependencies = [ "numpy >= 1.21.5, < 2", "PyYAML >= 6.0", - "linopy >= 0.4.4", + "linopy @ git+https://github.com/PyPSA/linopy.git", # Some features of linopy are not yet released "rich >= 13.0.1", "highspy >= 1.5.3", # Default solver "pandas >= 2, < 3", # Used in post-processing From 2a65b9be1ca5202afd4e59564564f83e9777192d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 11 Mar 2025 15:49:44 +0100 Subject: [PATCH 338/507] Bugfix i import --- tests/test_io.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_io.py b/tests/test_io.py index 2176f92c7..3a0c4a308 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -1,7 +1,7 @@ from typing import Dict, List, Optional, Union import pytest -from conftest import ( +from .conftest import ( assert_almost_equal_numeric, flow_system_base, flow_system_long, From 24a361fa7eccb1321aa2c54a37a40a4b85dd7222 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 11 Mar 2025 15:50:47 +0100 Subject: [PATCH 339/507] ruff check --- tests/test_io.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_io.py b/tests/test_io.py index 3a0c4a308..d91ba515e 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -1,6 +1,9 @@ from typing import Dict, List, Optional, Union import pytest + +import flixOpt as fx + from .conftest import ( assert_almost_equal_numeric, flow_system_base, @@ -9,8 +12,6 @@ simple_flow_system, ) -import flixOpt as fx - @pytest.fixture(params=[flow_system_base, flow_system_segments_of_flows, simple_flow_system, flow_system_long]) def flow_system(request): From 4b0e9f258ba13184dc0f37305064590759f86626 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 11 Mar 2025 16:00:16 +0100 Subject: [PATCH 340/507] Missing dev dependencies --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 5ec595569..2d8b8fd03 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,12 +48,15 @@ dev = [ "pyvis == 0.3.1", # Used for visualizing the FLowSystem "tsam >= 2.3.1", # Used for time series aggregation "scipy >= 1.15.1", # Used by tsam. Prior versions have conflict with highspy. See https://github.com/scipy/scipy/issues/22257 + "netcdf4 >= 1.6.1", # Used for saving and loading the FlowSystem with compression + "gurobipy >= 10.0", ] full = [ "pyvis == 0.3.1", # Used for visualizing the FLowSystem "tsam >= 2.3.1", # Used for time series aggregation "scipy >= 1.15.1", # Used by tsam. Prior versions have conflict with highspy. See https://github.com/scipy/scipy/issues/22257 + "netcdf4 >= 1.6.1", # Used for saving and loading the FlowSystem with compression ] [project.urls] From 2404da9edaf8e157a864f5c6ddd38243c5494f36 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 11 Mar 2025 17:45:15 +0100 Subject: [PATCH 341/507] Unify solver usage in tests --- tests/conftest.py | 15 +++++++++------ tests/test_functional.py | 10 ++-------- tests/test_integration.py | 38 +++++++++++++++++++------------------- tests/test_io.py | 7 ++++--- 4 files changed, 34 insertions(+), 36 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index f9b940c83..1505794cd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,16 +13,19 @@ import flixOpt as fx -def get_solver(): +@pytest.fixture() +def highs_solver(): return fx.solvers.HighsSolver(mip_gap=0, time_limit_seconds=300) -@pytest.fixture(params=['highs', 'gurobi']) +@pytest.fixture() +def gurobi_solver(): + return fx.solvers.GurobiSolver(mip_gap=0, time_limit_seconds=300) + + +@pytest.fixture(params=[highs_solver, gurobi_solver]) def solver_fixture(request): - return { - 'highs': fx.solvers.HighsSolver(0.0001, 60), - 'gurobi': fx.solvers.GurobiSolver(0.0001, 60), - }[request.param] + return request.getfixturevalue(request.param.__name__) # Custom assertion function diff --git a/tests/test_functional.py b/tests/test_functional.py index 074207e53..20756177b 100644 --- a/tests/test_functional.py +++ b/tests/test_functional.py @@ -24,6 +24,8 @@ import flixOpt as fx +from .conftest import solver_fixture + np.random.seed(45) @@ -102,14 +104,6 @@ def solve_and_load( return calculation.results -@pytest.fixture(params=['highs', 'gurobi']) -def solver_fixture(request): - return { - 'highs': fx.solvers.HighsSolver(0.01, 60), - 'gurobi': fx.solvers.GurobiSolver(0.01, 60), - }[request.param] - - @pytest.fixture def time_steps_fixture(request): return pd.date_range('2020-01-01', periods=5, freq='h') diff --git a/tests/test_integration.py b/tests/test_integration.py index 15636f37c..83cff1e46 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -9,16 +9,16 @@ from .conftest import ( assert_almost_equal_numeric, create_calculation_and_solve, - get_solver, + highs_solver, ) class TestFlowSystem: - def test_simple_flow_system(self, simple_flow_system): + def test_simple_flow_system(self, simple_flow_system, highs_solver): """ Test the effects of the simple energy system model """ - calculation = create_calculation_and_solve(simple_flow_system, get_solver(), 'test_simple_flow_system') + calculation = create_calculation_and_solve(simple_flow_system, highs_solver, 'test_simple_flow_system') effects = calculation.flow_system.effects @@ -36,11 +36,11 @@ def test_simple_flow_system(self, simple_flow_system): 'CO2 doesnt match expected value' ) - def test_model_components(self, simple_flow_system): + def test_model_components(self, simple_flow_system, highs_solver): """ Test the component flows of the simple energy system model """ - calculation = create_calculation_and_solve(simple_flow_system, get_solver(), 'test_model_components') + calculation = create_calculation_and_solve(simple_flow_system, highs_solver, 'test_model_components') comps = calculation.flow_system.components # Boiler assertions @@ -57,12 +57,12 @@ def test_model_components(self, simple_flow_system): 'Q_th doesnt match expected value', ) - def test_results_persistence(self, simple_flow_system): + def test_results_persistence(self, simple_flow_system, highs_solver): """ Test saving and loading results """ # Save results to file - calculation = create_calculation_and_solve(simple_flow_system, get_solver(), 'test_model_components') + calculation = create_calculation_and_solve(simple_flow_system, highs_solver, 'test_model_components') calculation.results.to_file() @@ -86,7 +86,7 @@ def test_results_persistence(self, simple_flow_system): class TestComponents: - def test_transmission_basic(self, basic_flow_system): + def test_transmission_basic(self, basic_flow_system, highs_solver): """Test basic transmission functionality""" flow_system = basic_flow_system flow_system.add_elements(fx.Bus('Wärme lokal')) @@ -108,7 +108,7 @@ def test_transmission_basic(self, basic_flow_system): flow_system.add_elements(transmission, boiler) - _ = create_calculation_and_solve(flow_system, get_solver(), 'test_transmission_basic') + _ = create_calculation_and_solve(flow_system, highs_solver, 'test_transmission_basic') # Assertions assert_almost_equal_numeric( @@ -123,7 +123,7 @@ def test_transmission_basic(self, basic_flow_system): 'Losses are not computed correctly', ) - def test_transmission_advanced(self, basic_flow_system): + def test_transmission_advanced(self, basic_flow_system, highs_solver): """Test advanced transmission functionality""" flow_system = basic_flow_system flow_system.add_elements(fx.Bus('Wärme lokal')) @@ -163,7 +163,7 @@ def test_transmission_advanced(self, basic_flow_system): flow_system.add_elements(transmission, boiler, boiler2, last2) - calculation = create_calculation_and_solve(flow_system, get_solver(), 'test_transmission_advanced') + calculation = create_calculation_and_solve(flow_system, highs_solver, 'test_transmission_advanced') # Assertions assert_almost_equal_numeric( @@ -194,8 +194,8 @@ def test_transmission_advanced(self, basic_flow_system): class TestComplex: - def test_basic_flow_system(self, flow_system_base): - calculation = create_calculation_and_solve(flow_system_base, get_solver(), 'test_basic_flow_system') + def test_basic_flow_system(self, flow_system_base, highs_solver): + calculation = create_calculation_and_solve(flow_system_base, highs_solver, 'test_basic_flow_system') # Assertions assert_almost_equal_numeric( @@ -324,8 +324,8 @@ def test_basic_flow_system(self, flow_system_base): 'Speicher investCosts_segmented_costs doesnt match expected value', ) - def test_segments_of_flows(self, flow_system_segments_of_flows): - calculation = create_calculation_and_solve(flow_system_segments_of_flows, get_solver(), 'test_segments_of_flows') + def test_segments_of_flows(self, flow_system_segments_of_flows, highs_solver): + calculation = create_calculation_and_solve(flow_system_segments_of_flows, highs_solver, 'test_segments_of_flows') effects = calculation.flow_system.effects comps = calculation.flow_system.components @@ -370,7 +370,7 @@ def test_segments_of_flows(self, flow_system_segments_of_flows): class TestModelingTypes: @pytest.fixture(params=['full', 'segmented', 'aggregated']) - def modeling_calculation(self, request, flow_system_long): + def modeling_calculation(self, request, flow_system_long, highs_solver): """ Fixture to run calculations with different modeling types """ @@ -384,10 +384,10 @@ def modeling_calculation(self, request, flow_system_long): if modeling_type == 'full': calc = fx.FullCalculation('fullModel', flow_system) calc.do_modeling() - calc.solve(get_solver()) + calc.solve(highs_solver) elif modeling_type == 'segmented': calc = fx.SegmentedCalculation('segModel', flow_system, timesteps_per_segment=96, overlap_timesteps=1) - calc.do_modeling_and_solve(get_solver()) + calc.do_modeling_and_solve(highs_solver) elif modeling_type == 'aggregated': calc = fx.AggregatedCalculation( 'aggModel', @@ -404,7 +404,7 @@ def modeling_calculation(self, request, flow_system_long): ), ) calc.do_modeling() - calc.solve(get_solver()) + calc.solve(highs_solver) return calc, modeling_type diff --git a/tests/test_io.py b/tests/test_io.py index d91ba515e..476fa7d98 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -10,6 +10,7 @@ flow_system_long, flow_system_segments_of_flows, simple_flow_system, + highs_solver ) @@ -22,17 +23,17 @@ def flow_system(request): return fs[0] -def test_flow_system_file_io(flow_system): +def test_flow_system_file_io(flow_system, highs_solver): calculation_0 = fx.FullCalculation('IO', flow_system=flow_system) calculation_0.do_modeling() - calculation_0.solve(fx.solvers.HighsSolver(mip_gap=0.001, time_limit_seconds=30)) + calculation_0.solve(highs_solver) calculation_0.save_results(save_flow_system=True, compression=5) flow_system_1 = fx.FlowSystem.from_netcdf(f'results/{calculation_0.name}_flowsystem.nc') calculation_1 = fx.FullCalculation('Loaded_IO', flow_system=flow_system_1) calculation_1.do_modeling() - calculation_1.solve(fx.solvers.HighsSolver(mip_gap=0.001, time_limit_seconds=30)) + calculation_1.solve(highs_solver) assert_almost_equal_numeric(calculation_0.results.model.objective.value, calculation_1.results.model.objective.value, From c5265e038d0946f7449cfefbf80c6c54abb7f704 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 11 Mar 2025 17:57:30 +0100 Subject: [PATCH 342/507] Remove fixture imports --- tests/test_functional.py | 2 -- tests/test_integration.py | 1 - tests/test_io.py | 1 - 3 files changed, 4 deletions(-) diff --git a/tests/test_functional.py b/tests/test_functional.py index 20756177b..4e2e09423 100644 --- a/tests/test_functional.py +++ b/tests/test_functional.py @@ -24,8 +24,6 @@ import flixOpt as fx -from .conftest import solver_fixture - np.random.seed(45) diff --git a/tests/test_integration.py b/tests/test_integration.py index 83cff1e46..b5d39bc1a 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -9,7 +9,6 @@ from .conftest import ( assert_almost_equal_numeric, create_calculation_and_solve, - highs_solver, ) diff --git a/tests/test_io.py b/tests/test_io.py index 476fa7d98..dff224dd3 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -10,7 +10,6 @@ flow_system_long, flow_system_segments_of_flows, simple_flow_system, - highs_solver ) From d1d268ba7192d5d2f80ff4d31a6e9b2accab6539 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 13 Mar 2025 09:00:09 +0100 Subject: [PATCH 343/507] Add timing to saving process of Calculation --- flixOpt/calculation.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py index 6f03dc378..66f6ef443 100644 --- a/flixOpt/calculation.py +++ b/flixOpt/calculation.py @@ -181,11 +181,13 @@ def save_results(self, save_flow_system: bool = False, compression: int = 0): Compression level for the netCDF file, by default 0 wich leads to no compression. Currently, only the Flow System file can be compressed. """ + t_start = timeit.default_timer() with open(self.folder / f'{self.name}_infos.yaml', 'w', encoding='utf-8') as f: yaml.dump(self.infos, f, allow_unicode=True, sort_keys=False, indent=4) self.results.to_file(self.folder, self.name) if save_flow_system: self.flow_system.to_netcdf(self.folder / f'{self.name}_flowsystem.nc', compression) + self.durations['saving'] = round(timeit.default_timer() - t_start, 2) def _activate_time_series(self): self.flow_system.transform_data() From 4f9d80f03be8f887b2fad1c1020931322940e65d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 13 Mar 2025 09:00:50 +0100 Subject: [PATCH 344/507] Improve code quality --- flixOpt/elements.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/flixOpt/elements.py b/flixOpt/elements.py index 0cf80a2cc..bba75d965 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -414,21 +414,16 @@ def _create_bounds_for_load_factor(self): name_short ) - @property - def with_investment(self) -> bool: - """Checks if the element's size is investment-driven.""" - return isinstance(self.element.size, InvestParameters) - @property def absolute_flow_rate_bounds(self) -> Tuple[NumericData, NumericData]: """Returns absolute flow rate bounds. Important for OnOffModel""" - rel_min, rel_max = self.relative_flow_rate_bounds + relative_minimum, relative_maximum = self.relative_flow_rate_bounds size = self.element.size - if not self.with_investment: - return rel_min * size, rel_max * size + if not isinstance(size, InvestParameters): + return relative_minimum * size, relative_maximum * size if size.fixed_size is not None: - return rel_min * size.fixed_size, rel_max * size.fixed_size - return rel_min * size.minimum_size, rel_max * size.maximum_size + return relative_minimum * size.fixed_size, relative_maximum * size.fixed_size + return relative_minimum * size.minimum_size, relative_maximum * size.maximum_size @property def relative_flow_rate_bounds(self) -> Tuple[NumericData, NumericData]: From ccd44fd11ef5ffad3c863efa91c716c2345a2ba5 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 13 Mar 2025 15:38:30 +0100 Subject: [PATCH 345/507] Rename flow_rates() to node_balance() --- examples/00_Minmal/minimal_example.py | 4 ++-- examples/01_Simple/simple_example.py | 4 ++-- examples/02_Complex/complex_example.py | 2 +- examples/02_Complex/complex_example_results.py | 2 +- flixOpt/results.py | 8 ++++---- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/examples/00_Minmal/minimal_example.py b/examples/00_Minmal/minimal_example.py index e242c497d..bbda64dab 100644 --- a/examples/00_Minmal/minimal_example.py +++ b/examples/00_Minmal/minimal_example.py @@ -61,10 +61,10 @@ df1 = calculation.results['costs'].variables_time.solution.to_dataframe() # Plot the results of a specific element - calculation.results['District Heating'].plot_flow_rates() + calculation.results['District Heating'].plot_node_balance() # Save results to a file - df2 = calculation.results['District Heating'].flow_rates().to_dataframe() + df2 = calculation.results['District Heating'].node_balance().to_dataframe() # df2.to_csv('results/District Heating.csv') # Save results to csv # Print infos to the console. diff --git a/examples/01_Simple/simple_example.py b/examples/01_Simple/simple_example.py index 2c05601bf..95b7137ec 100644 --- a/examples/01_Simple/simple_example.py +++ b/examples/01_Simple/simple_example.py @@ -107,8 +107,8 @@ calculation.solve(fx.solvers.HighsSolver(mip_gap=0, time_limit_seconds=30)) # --- Analyze Results --- - calculation.results['Fernwärme'].plot_flow_rates() - calculation.results['Storage'].plot_flow_rates() + calculation.results['Fernwärme'].plot_node_balance() + calculation.results['Storage'].plot_node_balance() calculation.results.plot_heatmap('CHP(Q_th)|flow_rate') # Convert the results for the storage component to a dataframe and display diff --git a/examples/02_Complex/complex_example.py b/examples/02_Complex/complex_example.py index 8d5900261..9429d4c45 100644 --- a/examples/02_Complex/complex_example.py +++ b/examples/02_Complex/complex_example.py @@ -191,5 +191,5 @@ # But let's plot some results anyway calculation.results.plot_heatmap('BHKW2(Q_th)|flow_rate') - calculation.results['BHKW2'].plot_flow_rates() + calculation.results['BHKW2'].plot_node_balance() calculation.results['Speicher'].plot_charge_state() diff --git a/examples/02_Complex/complex_example_results.py b/examples/02_Complex/complex_example_results.py index f551a862c..e52fb0920 100644 --- a/examples/02_Complex/complex_example_results.py +++ b/examples/02_Complex/complex_example_results.py @@ -20,7 +20,7 @@ # --- Basic overview --- fx.plotting.plot_network(*results.network_infos, show=True) - results['Fernwärme'].plot_flow_rates() + results['Fernwärme'].plot_node_balance() # --- Detailed Plots --- # In depth plot for individual flow rates ('__' is used as the delimiter between Component and Flow diff --git a/flixOpt/results.py b/flixOpt/results.py index 042239f7f..427a5d5bd 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -208,11 +208,11 @@ def __init__(self, self.inputs = inputs self.outputs = outputs - def plot_flow_rates(self, + def plot_node_balance(self, save: Union[bool, pathlib.Path] = False, show: bool = True): fig = plotting.with_plotly( - self.flow_rates(with_last_timestep=True).to_dataframe(), mode='area', title=f'Flow rates of {self.label}' + self.node_balance(with_last_timestep=True).to_dataframe(), mode='area', title=f'Flow rates of {self.label}' ) return plotly_save_and_show( fig, @@ -221,7 +221,7 @@ def plot_flow_rates(self, show=show, save=True if save else False) - def flow_rates(self, + def node_balance(self, negate_inputs: bool = True, negate_outputs: bool = False, threshold: Optional[float] = 1e-5, @@ -265,7 +265,7 @@ def plot_charge_state(self, show: bool = True) -> plotly.graph_objs._figure.Figure: if not self.is_storage: raise ValueError(f'Cant plot charge_state. "{self.label}" is not a storage') - fig = plotting.with_plotly(self.flow_rates(with_last_timestep=True).to_dataframe(), + fig = plotting.with_plotly(self.node_balance(with_last_timestep=True).to_dataframe(), mode='area', title=f'Operation Balance of {self.label}', show=False) From 7ac0c59a488c1aa7bf73c41b56e3580f2c8606e7 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 13 Mar 2025 15:40:11 +0100 Subject: [PATCH 346/507] Add property to access variables and constraints easier in CalculationResults --- flixOpt/results.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/flixOpt/results.py b/flixOpt/results.py index 427a5d5bd..1b04ded15 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -160,6 +160,14 @@ def plot_heatmap(self, def storages(self) -> List['ComponentResults']: return [comp for comp in self.components.values() if comp.is_storage] + @property + def variables(self) -> linopy.Variables: + return self.model.variables + + @property + def constraints(self) -> linopy.Constraints: + return self.model.constraints + class _ElementResults: @classmethod From 3136c63a1797fef81b4e7234dba8b27fbfb7c0f8 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 13 Mar 2025 15:51:07 +0100 Subject: [PATCH 347/507] Add Docstring to method --- flixOpt/results.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/flixOpt/results.py b/flixOpt/results.py index 1b04ded15..52db7ee83 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -184,15 +184,15 @@ def __init__(self, constraints: List[str]): self._calculation_results = calculation_results self.label = label - self._variables = variables - self._constraints = constraints + self._variable_names = variables + self._constraint_names = constraints - self.variables = self._calculation_results.model.variables[self._variables] - self.constraints = self._calculation_results.model.constraints[self._constraints] + self.variables = self._calculation_results.model.variables[self._variable_names] + self.constraints = self._calculation_results.model.constraints[self._constraint_names] @property def variables_time(self): - return self.variables[[name for name in self._variables if 'time' in self.variables[name].dims]] + return self.variables[[name for name in self._variable_names if 'time' in self.variables[name].dims]] class _NodeResults(_ElementResults): @@ -311,7 +311,8 @@ class EffectResults(_ElementResults): """Results for an Effect""" def get_shares_from(self, element: str): - return self.variables[[name for name in self._variables if name.startswith(f'{element}->')]] + """ Get the shares from an Element (without subelements) to the Effect""" + return self.variables[[name for name in self._variable_names if name.startswith(f'{element}->')]] class SegmentedCalculationResults: From 6c94930a9e9431c128a0f6f799123c039e75a662 Mon Sep 17 00:00:00 2001 From: fel15133 Date: Fri, 14 Mar 2025 11:53:54 +0100 Subject: [PATCH 348/507] changing Model._model to Model._sys_model --- flixOpt/aggregation.py | 18 ++--- flixOpt/components.py | 32 ++++---- flixOpt/effects.py | 14 ++-- flixOpt/elements.py | 48 ++++++------ flixOpt/features.py | 166 ++++++++++++++++++++--------------------- flixOpt/structure.py | 10 +-- 6 files changed, 144 insertions(+), 144 deletions(-) diff --git a/flixOpt/aggregation.py b/flixOpt/aggregation.py index 3d010e264..09e7b4b63 100644 --- a/flixOpt/aggregation.py +++ b/flixOpt/aggregation.py @@ -305,8 +305,8 @@ def do_modeling(self): indices = self.aggregation_data.get_equation_indices(skip_first_index_of_period=True) - time_variables: Set[str] = {k for k, v in self._model.variables.data.items() if 'time' in v.indexes} - binary_variables: Set[str] = {k for k, v in self._model.variables.data.items() if k in self._model.binaries} + time_variables: Set[str] = {k for k, v in self._sys_model.variables.data.items() if 'time' in v.indexes} + binary_variables: Set[str] = {k for k, v in self._sys_model.variables.data.items() if k in self._sys_model.binaries} binary_time_variables: Set[str] = time_variables & binary_variables for component in components: @@ -325,7 +325,7 @@ def do_modeling(self): penalty = self.aggregation_parameters.penalty_of_period_freedom if (self.aggregation_parameters.percentage_of_period_freedom > 0) and penalty != 0: for variable in self.variables_direct.values(): - self._model.effects.add_share_to_penalty( + self._sys_model.effects.add_share_to_penalty( 'Aggregation', variable * penalty ) @@ -336,19 +336,19 @@ def _equate_indices(self, variable: linopy.Variable, indices: Tuple[np.ndarray, # Gleichung: # eq1: x(p1,t) - x(p3,t) = 0 # wobei p1 und p3 im gleichen Cluster sind und t = 0..N_p - con = self.add(self._model.add_constraints( + con = self.add(self._sys_model.add_constraints( variable.isel(time=indices[0]) - variable.isel(time=indices[1]) == 0, name=f'{self.label_full}|equate_indices|{variable.name}'), f'equate_indices|{variable.name}') # Korrektur: (bisher nur für Binärvariablen:) - if variable.name in self._model.variables.binaries and self.aggregation_parameters.percentage_of_period_freedom > 0: - var_k1 = self.add(self._model.add_variables( + if variable.name in self._sys_model.variables.binaries and self.aggregation_parameters.percentage_of_period_freedom > 0: + var_k1 = self.add(self._sys_model.add_variables( binary=True, coords={'time': variable.isel(time=indices[0]).indexes['time']}, name=f'{self.label_full}|correction1|{variable.name}'), f'correction1|{variable.name}') - var_k0 = self.add(self._model.add_variables( + var_k0 = self.add(self._sys_model.add_variables( binary=True, coords={'time': variable.isel(time=indices[0]).indexes['time']}, name=f'{self.label_full}|correction0|{variable.name}'), f'correction0|{variable.name}') @@ -363,7 +363,7 @@ def _equate_indices(self, variable: linopy.Variable, indices: Tuple[np.ndarray, # interlock var_k1 and var_K2: # eq: var_k0(t)+var_k1(t) <= 1.1 - self.add(self._model.add_constraints( + self.add(self._sys_model.add_constraints( var_k0 + var_k1 <= 1.1, name=f'{self.label_full}|lock_k0_and_k1|{variable.name}'), f'lock_k0_and_k1|{variable.name}' @@ -371,7 +371,7 @@ def _equate_indices(self, variable: linopy.Variable, indices: Tuple[np.ndarray, # Begrenzung der Korrektur-Anzahl: # eq: sum(K) <= n_Corr_max - self.add(self._model.add_constraints( + self.add(self._sys_model.add_constraints( sum(var_k0) + sum(var_k1) <= round(self.aggregation_parameters.percentage_of_period_freedom / 100 * length), name=f'{self.label_full}|limit_corrections|{variable.name}'), f'limit_corrections|{variable.name}' diff --git a/flixOpt/components.py b/flixOpt/components.py index 39258f953..2fc733619 100644 --- a/flixOpt/components.py +++ b/flixOpt/components.py @@ -368,7 +368,7 @@ def do_modeling(self): # equate size of both directions if isinstance(self.element.in1.size, InvestParameters) and self.element.in2 is not None: # eq: in1.size = in2.size - self.add(self._model.add_constraints( + self.add(self._sys_model.add_constraints( self.element.in1.model._investment.size == self.element.in2.model._investment.size, name=f'{self.label_full}|same_size'), 'same_size' @@ -377,7 +377,7 @@ def do_modeling(self): def create_transmission_equation(self, name: str, in_flow: Flow, out_flow: Flow) -> linopy.Constraint: """Creates an Equation for the Transmission efficiency and adds it to the model""" # eq: out(t) + on(t)*loss_abs(t) = in(t)*(1 - loss_rel(t)) - con_transmission = self.add(self._model.add_constraints( + con_transmission = self.add(self._sys_model.add_constraints( out_flow.model.flow_rate == -in_flow.model.flow_rate * (self.element.relative_losses.active_data - 1), name=f'{self.label_full}|{name}'), name @@ -410,7 +410,7 @@ def do_modeling(self): used_outputs: Set = all_output_flows & used_flows self.add( - self._model.add_constraints( + self._sys_model.add_constraints( sum([flow.model.flow_rate * conv_factors[flow.label].active_data for flow in used_inputs]) == sum([flow.model.flow_rate * conv_factors[flow.label].active_data for flow in used_outputs]), @@ -428,7 +428,7 @@ def do_modeling(self): for flow in self.element.flows } linear_segments = MultipleSegmentsModel( - self._model, self.label_of_element, segments, self.on_off.on if self.on_off is not None else None + self._sys_model, self.label_of_element, segments, self.on_off.on if self.on_off is not None else None ) # TODO: Add Outside_segments Variable (On) linear_segments.do_modeling() self.sub_models.append(linear_segments) @@ -448,18 +448,18 @@ def do_modeling(self): super().do_modeling() lb, ub = self.absolute_charge_state_bounds - self.charge_state = self.add(self._model.add_variables( - lower=lb, upper=ub, coords=self._model.coords_extra, + self.charge_state = self.add(self._sys_model.add_variables( + lower=lb, upper=ub, coords=self._sys_model.coords_extra, name=f'{self.label_full}|charge_state'), 'charge_state' ) - self.netto_discharge = self.add(self._model.add_variables( - coords=self._model.coords, name=f'{self.label_full}|netto_discharge'), + self.netto_discharge = self.add(self._sys_model.add_variables( + coords=self._sys_model.coords, name=f'{self.label_full}|netto_discharge'), 'netto_discharge' ) # netto_discharge: # eq: nettoFlow(t) - discharging(t) + charging(t) = 0 - self.add(self._model.add_constraints( + self.add(self._sys_model.add_constraints( self.netto_discharge == self.element.discharging.model.flow_rate - self.element.charging.model.flow_rate, name=f'{self.label_full}|netto_discharge'), 'netto_discharge' @@ -467,13 +467,13 @@ def do_modeling(self): charge_state = self.charge_state rel_loss = self.element.relative_loss_per_hour.active_data - hours_per_step = self._model.hours_per_step + hours_per_step = self._sys_model.hours_per_step charge_rate = self.element.charging.model.flow_rate discharge_rate = self.element.discharging.model.flow_rate eff_charge = self.element.eta_charge.active_data eff_discharge = self.element.eta_discharge.active_data - self.add(self._model.add_constraints( + self.add(self._sys_model.add_constraints( charge_state.isel(time=slice(1, None)) == charge_state.isel(time=slice(None, -1)) * (1 - rel_loss * hours_per_step) @@ -485,7 +485,7 @@ def do_modeling(self): if isinstance(self.element.capacity_in_flow_hours, InvestParameters): self._investment = InvestmentModel( - model=self._model, + model=self._sys_model, label_of_element=self.label_of_element, parameters=self.element.capacity_in_flow_hours, defining_variable=self.charge_state, @@ -503,13 +503,13 @@ def _initial_and_final_charge_state(self): name = f'{self.label_full}|{name_short}' if utils.is_number(self.element.initial_charge_state): - self.add(self._model.add_constraints( + self.add(self._sys_model.add_constraints( self.charge_state.isel(time=0) == self.element.initial_charge_state, name=name), name_short ) elif self.element.initial_charge_state == 'lastValueOfSim': - self.add(self._model.add_constraints( + self.add(self._sys_model.add_constraints( self.charge_state.isel(time=0) == self.charge_state.isel(time=-1), name=name), name_short @@ -518,14 +518,14 @@ def _initial_and_final_charge_state(self): raise Exception(f'initial_charge_state has undefined value: {self.element.initial_charge_state}') if self.element.maximal_final_charge_state is not None: - self.add(self._model.add_constraints( + self.add(self._sys_model.add_constraints( self.charge_state.isel(time=-1) <= self.element.maximal_final_charge_state, name=f'{self.label_full}|final_charge_max'), 'final_charge_max' ) if self.element.minimal_final_charge_state is not None: - self.add(self._model.add_constraints( + self.add(self._sys_model.add_constraints( self.charge_state.isel(time=-1) >= self.element.minimal_final_charge_state, name=f'{self.label_full}|final_charge_min'), 'final_charge_min' diff --git a/flixOpt/effects.py b/flixOpt/effects.py index 4d695633c..63b70bb06 100644 --- a/flixOpt/effects.py +++ b/flixOpt/effects.py @@ -137,7 +137,7 @@ def __init__(self, model: SystemModel, element: Effect): self.total: Optional[linopy.Variable] = None self.invest: ShareAllocationModel = self.add( ShareAllocationModel( - self._model, + self._sys_model, False, self.label_of_element, 'invest', @@ -149,7 +149,7 @@ def __init__(self, model: SystemModel, element: Effect): self.operation: ShareAllocationModel = self.add( ShareAllocationModel( - self._model, + self._sys_model, True, self.label_of_element, 'operation', @@ -170,7 +170,7 @@ def do_modeling(self): model.do_modeling() self.total = self.add( - self._model.add_variables( + self._sys_model.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=None, @@ -180,7 +180,7 @@ def do_modeling(self): ) self.add( - self._model.add_constraints( + self._sys_model.add_constraints( self.total == self.operation.total.sum() + self.invest.total.sum(), name=f'{self.label_full}|total' ), @@ -374,14 +374,14 @@ def add_share_to_penalty(self, name: str, expression: linopy.LinearExpression) - def do_modeling(self): for effect in self.effects: - effect.create_model(self._model) - self.penalty = self.add(ShareAllocationModel(self._model, shares_are_time_series=False, label_of_element='Penalty')) + effect.create_model(self._sys_model) + self.penalty = self.add(ShareAllocationModel(self._sys_model, shares_are_time_series=False, label_of_element='Penalty')) for model in [effect.model for effect in self.effects] + [self.penalty]: model.do_modeling() self._add_share_between_effects() - self._model.add_objective( + self._sys_model.add_objective( self.effects.objective_effect.model.total + self.penalty.total ) diff --git a/flixOpt/elements.py b/flixOpt/elements.py index bba75d965..31ca92367 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -306,10 +306,10 @@ def __init__(self, model: SystemModel, element: Flow): def do_modeling(self): # eq relative_minimum(t) * size <= flow_rate(t) <= relative_maximum(t) * size self.flow_rate: linopy.Variable = self.add( - self._model.add_variables( + self._sys_model.add_variables( lower=self.absolute_flow_rate_bounds[0] if self.element.on_off_parameters is None else 0, upper=self.absolute_flow_rate_bounds[1], - coords=self._model.coords, + coords=self._sys_model.coords, name=f'{self.label_full}|flow_rate' ), 'flow_rate' @@ -319,7 +319,7 @@ def do_modeling(self): if self.element.on_off_parameters is not None: self.on_off: OnOffModel = self.add( OnOffModel( - model=self._model, + model=self._sys_model, label_of_element=self.label_of_element, on_off_parameters=self.element.on_off_parameters, defining_variables=[self.flow_rate], @@ -334,7 +334,7 @@ def do_modeling(self): if isinstance(self.element.size, InvestParameters): self._investment: InvestmentModel = self.add( InvestmentModel( - model=self._model, + model=self._sys_model, label_of_element=self.label_of_element, parameters=self.element.size, defining_variable=self.flow_rate, @@ -346,7 +346,7 @@ def do_modeling(self): self._investment.do_modeling() self.total_flow_hours = self.add( - self._model.add_variables( + self._sys_model.add_variables( lower=self.element.flow_hours_total_min if self.element.flow_hours_total_min is not None else -np.inf, upper=self.element.flow_hours_total_max if self.element.flow_hours_total_max is not None else np.inf, coords=None, @@ -356,8 +356,8 @@ def do_modeling(self): ) self.add( - self._model.add_constraints( - self.total_flow_hours == (self.flow_rate * self._model.hours_per_step).sum(), + self._sys_model.add_constraints( + self.total_flow_hours == (self.flow_rate * self._sys_model.hours_per_step).sum(), name=f'{self.label_full}|total_flow_hours' ), 'total_flow_hours' @@ -372,10 +372,10 @@ def do_modeling(self): def _create_shares(self): # Arbeitskosten: if self.element.effects_per_flow_hour != {}: - self._model.effects.add_share_to_effects( + self._sys_model.effects.add_share_to_effects( name=self.label_full, # Use the full label of the element expressions={ - effect: self.flow_rate * self._model.hours_per_step * factor.active_data + effect: self.flow_rate * self._sys_model.hours_per_step * factor.active_data for effect, factor in self.element.effects_per_flow_hour.items() }, target='operation', @@ -387,12 +387,12 @@ def _create_bounds_for_load_factor(self): # eq: var_sumFlowHours <= size * dt_tot * load_factor_max if self.element.load_factor_max is not None: name_short = 'load_factor_max' - flow_hours_per_size_max = self._model.hours_per_step.sum() * self.element.load_factor_max + flow_hours_per_size_max = self._sys_model.hours_per_step.sum() * self.element.load_factor_max size = self.element.size if self._investment is None else self._investment.size if self._investment is not None: self.add( - self._model.add_constraints( + self._sys_model.add_constraints( self.total_flow_hours <= size * flow_hours_per_size_max, name=f'{self.label_full}|{name_short}', ), @@ -402,12 +402,12 @@ def _create_bounds_for_load_factor(self): # eq: size * sum(dt)* load_factor_min <= var_sumFlowHours if self.element.load_factor_min is not None: name_short = 'load_factor_min' - flow_hours_per_size_min = self._model.hours_per_step.sum() * self.element.load_factor_min + flow_hours_per_size_min = self._sys_model.hours_per_step.sum() * self.element.load_factor_min size = self.element.size if self._investment is None else self._investment.size if self._investment is not None: self.add( - self._model.add_constraints( + self._sys_model.add_constraints( self.total_flow_hours >= size * flow_hours_per_size_min, name=f'{self.label_full}|{name_short}', ), @@ -447,7 +447,7 @@ def do_modeling(self) -> None: self.add(flow.model.flow_rate, flow.label_full) inputs = sum([flow.model.flow_rate for flow in self.element.inputs]) outputs = sum([flow.model.flow_rate for flow in self.element.outputs]) - eq_bus_balance = self.add(self._model.add_constraints( + eq_bus_balance = self.add(self._sys_model.add_constraints( inputs == outputs, name=f'{self.label_full}|balance' )) @@ -455,20 +455,20 @@ def do_modeling(self) -> None: # Fehlerplus/-minus: if self.element.with_excess: excess_penalty = np.multiply( - self._model.hours_per_step, self.element.excess_penalty_per_flow_hour.active_data + self._sys_model.hours_per_step, self.element.excess_penalty_per_flow_hour.active_data ) - self.excess_input = self.add(self._model.add_variables( - lower=0, coords=self._model.coords, name=f'{self.label_full}|excess_input'), + self.excess_input = self.add(self._sys_model.add_variables( + lower=0, coords=self._sys_model.coords, name=f'{self.label_full}|excess_input'), 'excess_input' ) - self.excess_output = self.add(self._model.add_variables( - lower=0, coords=self._model.coords, name=f'{self.label_full}|excess_output'), + self.excess_output = self.add(self._sys_model.add_variables( + lower=0, coords=self._sys_model.coords, name=f'{self.label_full}|excess_output'), 'excess_output' ) eq_bus_balance.lhs -= -self.excess_input + self.excess_output - self._model.effects.add_share_to_penalty(self.label_of_element, (self.excess_input * excess_penalty).sum()) - self._model.effects.add_share_to_penalty(self.label_of_element, (self.excess_output * excess_penalty).sum()) + self._sys_model.effects.add_share_to_penalty(self.label_of_element, (self.excess_input * excess_penalty).sum()) + self._sys_model.effects.add_share_to_penalty(self.label_of_element, (self.excess_output * excess_penalty).sum()) def results_structure(self): inputs = [flow.model.flow_rate.name for flow in self.element.inputs] @@ -500,14 +500,14 @@ def do_modeling(self): flow.on_off_parameters = OnOffParameters() for flow in all_flows: - self.add(flow.create_model(self._model), flow.label) + self.add(flow.create_model(self._sys_model), flow.label) for sub_model in self.sub_models: sub_model.do_modeling() if self.element.on_off_parameters: self.on_off = self.add(OnOffModel( - self._model, + self._sys_model, self.element.on_off_parameters, self.label_of_element, defining_variables=[flow.model.flow_rate for flow in all_flows], @@ -519,7 +519,7 @@ def do_modeling(self): if self.element.prevent_simultaneous_flows: # Simultanious Useage --> Only One FLow is On at a time, but needs a Binary for every flow on_variables = [flow.model.on_off.on for flow in self.element.prevent_simultaneous_flows] - simultaneous_use = self.add(PreventSimultaneousUsageModel(self._model, on_variables, self.label_full)) + simultaneous_use = self.add(PreventSimultaneousUsageModel(self._sys_model, on_variables, self.label_full)) simultaneous_use.do_modeling() def results_structure(self): diff --git a/flixOpt/features.py b/flixOpt/features.py index 3e79aed43..33fc52c26 100644 --- a/flixOpt/features.py +++ b/flixOpt/features.py @@ -48,13 +48,13 @@ def __init__( def do_modeling(self): if self.parameters.fixed_size and not self.parameters.optional: - self.size = self.add(self._model.add_variables( + self.size = self.add(self._sys_model.add_variables( lower=self.parameters.fixed_size, upper=self.parameters.fixed_size, name=f'{self.label_full}|size'), 'size') else: - self.size = self.add(self._model.add_variables( + self.size = self.add(self._sys_model.add_variables( lower=0 if self.parameters.optional else self.parameters.minimum_size, upper=self.parameters.maximum_size, name=f'{self.label_full}|size'), @@ -62,7 +62,7 @@ def do_modeling(self): # Optional if self.parameters.optional: - self.is_invested = self.add(self._model.add_variables( + self.is_invested = self.add(self._sys_model.add_variables( binary=True, name=f'{self.label_full}|is_invested'), 'is_invested') @@ -79,7 +79,7 @@ def _create_shares(self): # fix_effects: fix_effects = self.parameters.fix_effects if fix_effects != {}: - self._model.effects.add_share_to_effects( + self._sys_model.effects.add_share_to_effects( name=self.label_of_element, expressions={effect: self.is_invested * factor if self.is_invested is not None else factor for effect, factor in fix_effects.items()}, @@ -88,14 +88,14 @@ def _create_shares(self): if self.parameters.divest_effects != {} and self.parameters.optional: # share: divest_effects - isInvested * divest_effects - self._model.effects.add_share_to_effects( + self._sys_model.effects.add_share_to_effects( name=self.label_of_element, expressions={effect: -self.is_invested * factor + factor for effect, factor in fix_effects.items()}, target='invest', ) if self.parameters.specific_effects != {}: - self._model.effects.add_share_to_effects( + self._sys_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='invest', @@ -104,7 +104,7 @@ def _create_shares(self): if self.parameters.effects_in_segments: self._segments = self.add( SegmentedSharesModel( - model=self._model, + model=self._sys_model, label_of_element=self.label_of_element, variable_segments=(self.size, self.parameters.effects_in_segments[0]), share_segments=self.parameters.effects_in_segments[1], @@ -116,20 +116,20 @@ def _create_shares(self): def _create_bounds_for_optional_investment(self): if self.parameters.fixed_size: # eq: investment_size = isInvested * fixed_size - self.add(self._model.add_constraints( + self.add(self._sys_model.add_constraints( self.size == self.is_invested * self.parameters.fixed_size, name=f'{self.label_full}|is_invested'), 'is_invested') else: # eq1: P_invest <= isInvested * investSize_max - self.add(self._model.add_constraints( + self.add(self._sys_model.add_constraints( self.size <= self.is_invested * self.parameters.maximum_size, name=f'{self.label_full}|is_invested_ub'), 'is_invested_ub') # eq2: P_invest >= isInvested * max(epsilon, investSize_min) - self.add(self._model.add_constraints( + self.add(self._sys_model.add_constraints( self.size >= self.is_invested * np.maximum(CONFIG.modeling.EPSILON,self.parameters.minimum_size), name=f'{self.label_full}|is_invested_lb'), 'is_invested_lb') @@ -138,7 +138,7 @@ def _create_bounds_for_defining_variable(self): variable = self._defining_variable lb_relative, ub_relative = self._relative_bounds_of_defining_variable if np.all(lb_relative == ub_relative): - self.add(self._model.add_constraints( + self.add(self._sys_model.add_constraints( variable == self.size * ub_relative, name=f'{self.label_full}|fix_{variable.name}'), f'fix_{variable.name}') @@ -148,14 +148,14 @@ def _create_bounds_for_defining_variable(self): return # eq: defining_variable(t) <= size * upper_bound(t) - self.add(self._model.add_constraints( + self.add(self._sys_model.add_constraints( variable <= self.size * ub_relative, name=f'{self.label_full}|ub_{variable.name}'), f'ub_{variable.name}') if self._on_variable is None: # eq: defining_variable(t) >= investment_size * relative_minimum(t) - self.add(self._model.add_constraints( + self.add(self._sys_model.add_constraints( variable >= self.size * lb_relative, name=f'{self.label_full}|lb_{variable.name}'), f'lb_{variable.name}') @@ -167,7 +167,7 @@ def _create_bounds_for_defining_variable(self): # eq: - defining_variable(t) + mega * On(t) + size * relative_minimum(t) <= + mega mega = lb_relative * self.parameters.maximum_size on = self._on_variable - self.add(self._model.add_constraints( + self.add(self._sys_model.add_constraints( variable >= mega * (on - 1) + self.size * lb_relative, name=f'{self.label_full}|lb_{variable.name}'), f'lb_{variable.name}') @@ -232,16 +232,16 @@ def __init__( def do_modeling(self): self.on = self.add( - self._model.add_variables( + self._sys_model.add_variables( name=f'{self.label_full}|on', binary=True, - coords=self._model.coords, + coords=self._sys_model.coords, ), 'on', ) self.total_on_hours = self.add( - self._model.add_variables( + self._sys_model.add_variables( lower=self.parameters.on_hours_total_min if self.parameters.on_hours_total_min is not None else 0, upper=self.parameters.on_hours_total_max if self.parameters.on_hours_total_max is not None else np.inf, name=f'{self.label_full}|on_hours_total' @@ -250,8 +250,8 @@ def do_modeling(self): ) self.add( - self._model.add_constraints( - self.total_on_hours == (self.on * self._model.hours_per_step).sum(), + self._sys_model.add_constraints( + self.total_on_hours == (self.on * self._sys_model.hours_per_step).sum(), name=f'{self.label_full}|on_hours_total' ), 'on_hours_total' @@ -261,16 +261,16 @@ def do_modeling(self): if self.parameters.use_off: self.off = self.add( - self._model.add_variables( + self._sys_model.add_variables( name=f'{self.label_full}|off', binary=True, - coords=self._model.coords, + coords=self._sys_model.coords, ), 'off' ) # eq: var_on(t) + var_off(t) = 1 - self.add(self._model.add_constraints(self.on + self.off == 1, name=f'{self.label_full}|off'), 'off') + self.add(self._sys_model.add_constraints(self.on + self.off == 1, name=f'{self.label_full}|off'), 'off') if self.parameters.use_consecutive_on_hours: self.consecutive_on_hours = self._get_duration_in_hours( @@ -291,13 +291,13 @@ def do_modeling(self): ) if self.parameters.use_switch_on: - self.switch_on = self.add(self._model.add_variables( - binary=True, name=f'{self.label_full}|switch_on', coords=self._model.coords),'switch_on') + self.switch_on = self.add(self._sys_model.add_variables( + binary=True, name=f'{self.label_full}|switch_on', coords=self._sys_model.coords), 'switch_on') - self.switch_off = self.add(self._model.add_variables( - binary=True, name=f'{self.label_full}|switch_off', coords=self._model.coords), 'switch_off') + self.switch_off = self.add(self._sys_model.add_variables( + binary=True, name=f'{self.label_full}|switch_off', coords=self._sys_model.coords), 'switch_off') - self.switch_on_nr = self.add(self._model.add_variables( + self.switch_on_nr = self.add(self._sys_model.add_variables( upper=self.parameters.switch_on_total_max if self.parameters.switch_on_total_max is not None else np.inf, name=f'{self.label_full}|switch_on_nr'), 'switch_on_nr') @@ -323,7 +323,7 @@ def _add_on_constraints(self): # eq: On(t) * max(epsilon, lower_bound) <= Q_th(t) self.add( - self._model.add_constraints( + self._sys_model.add_constraints( self.on * np.maximum(CONFIG.modeling.EPSILON, lb) <= def_var, name=f'{self.label_full}|on_con1' ), @@ -332,7 +332,7 @@ def _add_on_constraints(self): # eq: Q_th(t) <= Q_th_max * On(t) self.add( - self._model.add_constraints( + self._sys_model.add_constraints( self.on * np.maximum(CONFIG.modeling.EPSILON, ub) >= def_var, name=f'{self.label_full}|on_con2' ), @@ -346,7 +346,7 @@ def _add_on_constraints(self): # When all defining variables are 0, On is 0 # eq: On(t) * Epsilon <= sum(alle Leistungen(t)) self.add( - self._model.add_constraints( + self._sys_model.add_constraints( self.on * lb <= sum(self._defining_variables), name=f'{self.label_full}|on_con1' ), @@ -358,7 +358,7 @@ def _add_on_constraints(self): # --> damit Gleichungswerte nicht zu groß werden, noch durch nr_of_flows geteilt: # eq: sum( Leistung(t,i) / nr_of_flows ) - sum(Leistung_max(i)) / nr_of_flows * On(t) <= 0 self.add( - self._model.add_constraints( + self._sys_model.add_constraints( self.on * ub >= sum([def_var / nr_of_def_vars for def_var in self._defining_variables]), name=f'{self.label_full}|on_con2' ), @@ -421,29 +421,29 @@ def _get_duration_in_hours( """ assert binary_variable is not None, f'Duration Variable of {self.label_full} must be defined to add constraints' - mega = self._model.hours_per_step.sum() + previous_duration + mega = self._sys_model.hours_per_step.sum() + previous_duration if maximum_duration is not None: first_step_max: Scalar = maximum_duration.isel(time=0) - if previous_duration + self._model.hours_per_step[0] > first_step_max: + if previous_duration + self._sys_model.hours_per_step[0] > first_step_max: logger.warning( f'The maximum duration of "{variable_name}" is set to {maximum_duration.active_data}h, ' f'but the consecutive_duration previous to this model is {previous_duration}h. ' f'This forces "{binary_variable.name} = 0" in the first time step ' - f'(dt={self._model.hours_per_step[0]}h)!' + f'(dt={self._sys_model.hours_per_step[0]}h)!' ) - duration_in_hours = self.add(self._model.add_variables( + duration_in_hours = self.add(self._sys_model.add_variables( lower=0, upper=maximum_duration.active_data if maximum_duration is not None else mega, - coords=self._model.coords, + coords=self._sys_model.coords, name=f'{self.label_full}|{variable_name}'), variable_name ) # 1) eq: duration(t) - On(t) * BIG <= 0 - self.add(self._model.add_constraints( + self.add(self._sys_model.add_constraints( duration_in_hours <= binary_variable * mega, name=f'{self.label_full}|{variable_name}_con1'), f'{variable_name}_con1' @@ -452,10 +452,10 @@ def _get_duration_in_hours( # 2a) eq: duration(t) - duration(t-1) <= dt(t) # on(t)=1 -> duration(t) - duration(t-1) <= dt(t) # on(t)=0 -> duration(t-1) >= negat. value - self.add(self._model.add_constraints( + self.add(self._sys_model.add_constraints( duration_in_hours.isel(time=slice(1, None)) <= - duration_in_hours.isel(time=slice(None, -1)) + self._model.hours_per_step.isel(time=slice(None, -1)), + duration_in_hours.isel(time=slice(None, -1)) + self._sys_model.hours_per_step.isel(time=slice(None, -1)), name=f'{self.label_full}|{variable_name}_con2a'), f'{variable_name}_con2a' ) @@ -466,10 +466,10 @@ def _get_duration_in_hours( # on(t)=1 -> duration(t)- duration(t-1) >= dt(t) # on(t)=0 -> duration(t)- duration(t-1) >= negat. value - self.add(self._model.add_constraints( + self.add(self._sys_model.add_constraints( duration_in_hours.isel(time=slice(1, None)) >= - duration_in_hours.isel(time=slice(None, -1)) + self._model.hours_per_step.isel(time=slice(None, -1)) + duration_in_hours.isel(time=slice(None, -1)) + self._sys_model.hours_per_step.isel(time=slice(None, -1)) + (binary_variable.isel(time=slice(1, None)) - 1) * mega, name=f'{self.label_full}|{variable_name}_con2b'), f'{variable_name}_con2b' @@ -483,7 +483,7 @@ def _get_duration_in_hours( # Note: (previous values before t=1 are not recognised!) # eq: duration(t) >= minimum_duration(t) * [On(t) - On(t+1)] for t=1..(n-1) # eq: -duration(t) + minimum_duration(t) * On(t) - minimum_duration(t) * On(t+1) <= 0 - self.add(self._model.add_constraints( + self.add(self._sys_model.add_constraints( duration_in_hours >= (binary_variable.isel(time=slice(None, -1)) - binary_variable.isel(time=slice(1, None))) @@ -497,7 +497,7 @@ def _get_duration_in_hours( # Note: Only if the previous consecutive_duration is smaller than the minimum duration # and the previous_duration is greater 0! # eq: On(t=0) = 1 - self.add(self._model.add_constraints( + self.add(self._sys_model.add_constraints( binary_variable.isel(time=0) == 1, name=f'{self.label_full}|{variable_name}_minimum_inital'), f'{variable_name}_minimum_inital' @@ -505,8 +505,8 @@ def _get_duration_in_hours( # 4) first index: # eq: duration(t=0)= dt(0) * On(0) - self.add(self._model.add_constraints( - duration_in_hours.isel(time=0) == self._model.hours_per_step.isel(time=0) * binary_variable.isel(time=0), + self.add(self._sys_model.add_constraints( + duration_in_hours.isel(time=0) == self._sys_model.hours_per_step.isel(time=0) * binary_variable.isel(time=0), name=f'{self.label_full}|{variable_name}_initial'), f'{variable_name}_initial' ) @@ -523,7 +523,7 @@ def _add_switch_constraints(self): # % Schaltänderung aus On-Variable # % SwitchOn(t)-SwitchOff(t) = On(t)-On(t-1) self.add( - self._model.add_constraints( + self._sys_model.add_constraints( self.switch_on.isel(time=slice(1, None)) - self.switch_off.isel(time=slice(1, None)) == self.on.isel(time=slice(1,None)) - self.on.isel(time=slice(None,-1)), @@ -534,7 +534,7 @@ def _add_switch_constraints(self): # Initital switch on # eq: SwitchOn(t=0)-SwitchOff(t=0) = On(t=0) - On(t=-1) self.add( - self._model.add_constraints( + self._sys_model.add_constraints( self.switch_on.isel(time=0) - self.switch_off.isel(time=0) == self.on.isel(time=0) - self.previous_on_values[-1], @@ -545,7 +545,7 @@ def _add_switch_constraints(self): ## Entweder SwitchOff oder SwitchOn # eq: SwitchOn(t) + SwitchOff(t) <= 1.1 self.add( - self._model.add_constraints( + self._sys_model.add_constraints( self.switch_on + self.switch_off <= 1.1, name=f'{self.label_full}|switch_on_or_off' ), @@ -555,7 +555,7 @@ def _add_switch_constraints(self): ## Anzahl Starts: # eq: nrSwitchOn = sum(SwitchOn(t)) self.add( - self._model.add_constraints( + self._sys_model.add_constraints( self.switch_on_nr == self.switch_on.sum(), name=f'{self.label_full}|switch_on_nr' ), @@ -566,7 +566,7 @@ def _create_shares(self): # Anfahrkosten: effects_per_switch_on = self.parameters.effects_per_switch_on if effects_per_switch_on != {}: - self._model.effects.add_share_to_effects( + self._sys_model.effects.add_share_to_effects( name=self.label_of_element, expressions={effect: self.switch_on * factor for effect, factor in effects_per_switch_on.items()}, target='operation', @@ -575,9 +575,9 @@ def _create_shares(self): # Betriebskosten: effects_per_running_hour = self.parameters.effects_per_running_hour if effects_per_running_hour != {}: - self._model.effects.add_share_to_effects( + self._sys_model.effects.add_share_to_effects( name=self.label_of_element, - expressions={effect: self.on * factor * self._model.hours_per_step + expressions={effect: self.on * factor * self._sys_model.hours_per_step for effect, factor in effects_per_running_hour.items()}, target='operation', ) @@ -592,11 +592,11 @@ def previous_off_values(self) -> np.ndarray: @property def previous_consecutive_on_hours(self) -> Scalar: - return self.compute_consecutive_duration(self.previous_on_values, self._model.hours_per_step) + return self.compute_consecutive_duration(self.previous_on_values, self._sys_model.hours_per_step) @property def previous_consecutive_off_hours(self) -> Scalar: - return self.compute_consecutive_duration(self.previous_off_values, self._model.hours_per_step) + return self.compute_consecutive_duration(self.previous_off_values, self._sys_model.hours_per_step) @staticmethod def compute_previous_on_states(previous_values: List[Optional[NumericData]], epsilon: float = 1e-5) -> np.ndarray: @@ -703,29 +703,29 @@ def __init__( self.sample_points = sample_points def do_modeling(self): - self.in_segment = self.add(self._model.add_variables( + self.in_segment = self.add(self._sys_model.add_variables( binary=True, name=f'{self.label_full}|in_segment', - coords=self._model.coords if self._as_time_series else None), + coords=self._sys_model.coords if self._as_time_series else None), 'in_segment' ) - self.lambda0 = self.add(self._model.add_variables( + self.lambda0 = self.add(self._sys_model.add_variables( lower=0, upper=1, name=f'{self.label_full}|lambda0', - coords=self._model.coords if self._as_time_series else None), + coords=self._sys_model.coords if self._as_time_series else None), 'lambda0' ) - self.lambda1 = self.add(self._model.add_variables( + self.lambda1 = self.add(self._sys_model.add_variables( lower=0, upper=1, name=f'{self.label_full}|lambda1', - coords=self._model.coords if self._as_time_series else None), + coords=self._sys_model.coords if self._as_time_series else None), 'lambda1' ) # eq: lambda0(t) + lambda1(t) = in_segment(t) - self.add(self._model.add_constraints( + self.add(self._sys_model.add_constraints( self.in_segment == self.lambda0 + self.lambda1, name=f'{self.label_full}|in_segment'), 'in_segment' @@ -774,7 +774,7 @@ def do_modeling(self): self._segment_models = [ self.add( SegmentModel( - self._model, + self._sys_model, label_of_element=self.label_of_element, segment_index=i, sample_points=sample_points, @@ -789,8 +789,8 @@ def do_modeling(self): # eq: - v(t) + (v_0_0 * lambda_0_0 + v_0_1 * lambda_0_1) + (v_1_0 * lambda_1_0 + v_1_1 * lambda_1_1) ... = 0 # -> v_0_0, v_0_1 = Stützstellen des Segments 0 for var_name in self._sample_points.keys(): - variable = self._model.variables[var_name] - self.add(self._model.add_constraints( + variable = self._sys_model.variables[var_name] + self.add(self._sys_model.add_constraints( variable == sum([segment.lambda0 * segment.sample_points[var_name][0] + segment.lambda1 * segment.sample_points[var_name][1] for segment in self._segment_models]), @@ -804,8 +804,8 @@ def do_modeling(self): self.outside_segments = self._can_be_outside_segments rhs = self.outside_segments elif self._can_be_outside_segments is True: - self.outside_segments = self.add(self._model.add_variables( - coords=self._model.coords, + self.outside_segments = self.add(self._sys_model.add_variables( + coords=self._sys_model.coords, binary=True, name=f'{self.label_full}|outside_segments'), 'outside_segments' @@ -814,7 +814,7 @@ def do_modeling(self): else: rhs = 1 - self.add(self._model.add_constraints( + self.add(self._sys_model.add_constraints( sum([segment.in_segment for segment in self._segment_models]) <= rhs, name=f'{self.label_full}|{variable.name}_single_segment'), 'single_segment' @@ -860,27 +860,27 @@ def __init__( def do_modeling(self): self.total = self.add( - self._model.add_variables( + self._sys_model.add_variables( lower=self._total_min, upper=self._total_max, coords=None, name=f'{self.label_full}|total' ), 'total' ) # eq: sum = sum(share_i) # skalar - self._eq_total = self.add(self._model.add_constraints(self.total == 0, name=f'{self.label_full}|total'), 'total') + self._eq_total = self.add(self._sys_model.add_constraints(self.total == 0, name=f'{self.label_full}|total'), 'total') if self._shares_are_time_series: self.total_per_timestep = self.add( - self._model.add_variables( - lower=-np.inf if (self._min_per_hour is None) else np.multiply(self._min_per_hour, self._model.hours_per_step), - upper=np.inf if (self._max_per_hour is None) else np.multiply(self._max_per_hour, self._model.hours_per_step), - coords=self._model.coords, + self._sys_model.add_variables( + lower=-np.inf if (self._min_per_hour is None) else np.multiply(self._min_per_hour, self._sys_model.hours_per_step), + upper=np.inf if (self._max_per_hour is None) else np.multiply(self._max_per_hour, self._sys_model.hours_per_step), + coords=self._sys_model.coords, name=f'{self.label_full}|total_per_timestep' ), 'total_per_timestep' ) self._eq_total_per_timestep = self.add( - self._model.add_constraints(self.total_per_timestep == 0, name=f'{self.label_full}|total_per_timestep'), + self._sys_model.add_constraints(self.total_per_timestep == 0, name=f'{self.label_full}|total_per_timestep'), 'total_per_timestep' ) @@ -911,14 +911,14 @@ def add_share( self.share_constraints[name].lhs -= expression else: self.shares[name] = self.add( - self._model.add_variables( - coords=None if isinstance(expression, linopy.LinearExpression) and expression.ndim == 0 or not isinstance(expression, linopy.LinearExpression) else self._model.coords, + self._sys_model.add_variables( + coords=None if isinstance(expression, linopy.LinearExpression) and expression.ndim == 0 or not isinstance(expression, linopy.LinearExpression) else self._sys_model.coords, name=f'{name}->{self.label_full}' ), name ) self.share_constraints[name] = self.add( - self._model.add_constraints( + self._sys_model.add_constraints( self.shares[name] == expression, name=f'{name}->{self.label_full}' ), name @@ -953,8 +953,8 @@ def __init__( def do_modeling(self): self._shares = { - effect: self.add(self._model.add_variables( - coords=self._model.coords if self._as_tme_series else None, + effect: self.add(self._sys_model.add_variables( + coords=self._sys_model.coords if self._as_tme_series else None, name=f'{self.label_full}|{effect}'), f'{effect}' ) for effect in self._share_segments @@ -968,7 +968,7 @@ def do_modeling(self): self._segments_model = self.add( MultipleSegmentsModel( - model=self._model, + model=self._sys_model, label_of_element=self.label_of_element, sample_points=segments, can_be_outside_segments=self._can_be_outside_segments, @@ -978,7 +978,7 @@ def do_modeling(self): self._segments_model.do_modeling() # Shares - self._model.effects.add_share_to_effects( + self._sys_model.effects.add_share_to_effects( name=self.label_of_element, expressions={effect: variable*1 for effect, variable in self._shares.items()}, target='operation' if self._as_tme_series else 'invest', @@ -1012,6 +1012,6 @@ def __init__(self, model: SystemModel, variables: List[linopy.Variable], label_o def do_modeling(self): # eq: sum(flow_i.on(t)) <= 1.1 (1 wird etwas größer gewählt wg. Binärvariablengenauigkeit) - self.add(self._model.add_constraints(sum(self._simultanious_use_variables) <= 1.1, - name=f'{self.label_full}|prevent_simultaneous_use'), + self.add(self._sys_model.add_constraints(sum(self._simultanious_use_variables) <= 1.1, + name=f'{self.label_full}|prevent_simultaneous_use'), 'prevent_simultaneous_use') diff --git a/flixOpt/structure.py b/flixOpt/structure.py index fa8765f1f..e6c32a44c 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -285,7 +285,7 @@ def __init__(self, model: SystemModel, label_of_element: str, label: Optional[st label_full : str The full label of the model. Can overwrite the full label constructed from the other labels. """ - self._model = model + self._sys_model = model self.label_of_element = label_of_element self._label = label self._label_full = label_full @@ -368,11 +368,11 @@ def label_full(self) -> str: @property def variables_direct(self) -> linopy.Variables: - return self._model.variables[self._variables_direct] + return self._sys_model.variables[self._variables_direct] @property def constraints_direct(self) -> linopy.Constraints: - return self._model.constraints[self._constraints_direct] + return self._sys_model.constraints[self._constraints_direct] @property def _variables(self) -> List[str]: @@ -398,11 +398,11 @@ def _constraints(self) -> List[str]: @property def variables(self) -> linopy.Variables: - return self._model.variables[self._variables] + return self._sys_model.variables[self._variables] @property def constraints(self) -> linopy.Constraints: - return self._model.constraints[self._constraints] + return self._sys_model.constraints[self._constraints] @property def all_sub_models(self) -> List['Model']: From 3993f6f75c12cff3c998bd634fa1cb3cedaddd27 Mon Sep 17 00:00:00 2001 From: fel15133 Date: Fri, 14 Mar 2025 11:59:30 +0100 Subject: [PATCH 349/507] Revert "changing Model._model to Model._sys_model" This reverts commit 6c94930a9e9431c128a0f6f799123c039e75a662. --- flixOpt/aggregation.py | 18 ++--- flixOpt/components.py | 32 ++++---- flixOpt/effects.py | 14 ++-- flixOpt/elements.py | 48 ++++++------ flixOpt/features.py | 166 ++++++++++++++++++++--------------------- flixOpt/structure.py | 10 +-- 6 files changed, 144 insertions(+), 144 deletions(-) diff --git a/flixOpt/aggregation.py b/flixOpt/aggregation.py index 09e7b4b63..3d010e264 100644 --- a/flixOpt/aggregation.py +++ b/flixOpt/aggregation.py @@ -305,8 +305,8 @@ def do_modeling(self): indices = self.aggregation_data.get_equation_indices(skip_first_index_of_period=True) - time_variables: Set[str] = {k for k, v in self._sys_model.variables.data.items() if 'time' in v.indexes} - binary_variables: Set[str] = {k for k, v in self._sys_model.variables.data.items() if k in self._sys_model.binaries} + time_variables: Set[str] = {k for k, v in self._model.variables.data.items() if 'time' in v.indexes} + binary_variables: Set[str] = {k for k, v in self._model.variables.data.items() if k in self._model.binaries} binary_time_variables: Set[str] = time_variables & binary_variables for component in components: @@ -325,7 +325,7 @@ def do_modeling(self): penalty = self.aggregation_parameters.penalty_of_period_freedom if (self.aggregation_parameters.percentage_of_period_freedom > 0) and penalty != 0: for variable in self.variables_direct.values(): - self._sys_model.effects.add_share_to_penalty( + self._model.effects.add_share_to_penalty( 'Aggregation', variable * penalty ) @@ -336,19 +336,19 @@ def _equate_indices(self, variable: linopy.Variable, indices: Tuple[np.ndarray, # Gleichung: # eq1: x(p1,t) - x(p3,t) = 0 # wobei p1 und p3 im gleichen Cluster sind und t = 0..N_p - con = self.add(self._sys_model.add_constraints( + con = self.add(self._model.add_constraints( variable.isel(time=indices[0]) - variable.isel(time=indices[1]) == 0, name=f'{self.label_full}|equate_indices|{variable.name}'), f'equate_indices|{variable.name}') # Korrektur: (bisher nur für Binärvariablen:) - if variable.name in self._sys_model.variables.binaries and self.aggregation_parameters.percentage_of_period_freedom > 0: - var_k1 = self.add(self._sys_model.add_variables( + if variable.name in self._model.variables.binaries and self.aggregation_parameters.percentage_of_period_freedom > 0: + var_k1 = self.add(self._model.add_variables( binary=True, coords={'time': variable.isel(time=indices[0]).indexes['time']}, name=f'{self.label_full}|correction1|{variable.name}'), f'correction1|{variable.name}') - var_k0 = self.add(self._sys_model.add_variables( + var_k0 = self.add(self._model.add_variables( binary=True, coords={'time': variable.isel(time=indices[0]).indexes['time']}, name=f'{self.label_full}|correction0|{variable.name}'), f'correction0|{variable.name}') @@ -363,7 +363,7 @@ def _equate_indices(self, variable: linopy.Variable, indices: Tuple[np.ndarray, # interlock var_k1 and var_K2: # eq: var_k0(t)+var_k1(t) <= 1.1 - self.add(self._sys_model.add_constraints( + self.add(self._model.add_constraints( var_k0 + var_k1 <= 1.1, name=f'{self.label_full}|lock_k0_and_k1|{variable.name}'), f'lock_k0_and_k1|{variable.name}' @@ -371,7 +371,7 @@ def _equate_indices(self, variable: linopy.Variable, indices: Tuple[np.ndarray, # Begrenzung der Korrektur-Anzahl: # eq: sum(K) <= n_Corr_max - self.add(self._sys_model.add_constraints( + self.add(self._model.add_constraints( sum(var_k0) + sum(var_k1) <= round(self.aggregation_parameters.percentage_of_period_freedom / 100 * length), name=f'{self.label_full}|limit_corrections|{variable.name}'), f'limit_corrections|{variable.name}' diff --git a/flixOpt/components.py b/flixOpt/components.py index 2fc733619..39258f953 100644 --- a/flixOpt/components.py +++ b/flixOpt/components.py @@ -368,7 +368,7 @@ def do_modeling(self): # equate size of both directions if isinstance(self.element.in1.size, InvestParameters) and self.element.in2 is not None: # eq: in1.size = in2.size - self.add(self._sys_model.add_constraints( + self.add(self._model.add_constraints( self.element.in1.model._investment.size == self.element.in2.model._investment.size, name=f'{self.label_full}|same_size'), 'same_size' @@ -377,7 +377,7 @@ def do_modeling(self): def create_transmission_equation(self, name: str, in_flow: Flow, out_flow: Flow) -> linopy.Constraint: """Creates an Equation for the Transmission efficiency and adds it to the model""" # eq: out(t) + on(t)*loss_abs(t) = in(t)*(1 - loss_rel(t)) - con_transmission = self.add(self._sys_model.add_constraints( + con_transmission = self.add(self._model.add_constraints( out_flow.model.flow_rate == -in_flow.model.flow_rate * (self.element.relative_losses.active_data - 1), name=f'{self.label_full}|{name}'), name @@ -410,7 +410,7 @@ def do_modeling(self): used_outputs: Set = all_output_flows & used_flows self.add( - self._sys_model.add_constraints( + self._model.add_constraints( sum([flow.model.flow_rate * conv_factors[flow.label].active_data for flow in used_inputs]) == sum([flow.model.flow_rate * conv_factors[flow.label].active_data for flow in used_outputs]), @@ -428,7 +428,7 @@ def do_modeling(self): for flow in self.element.flows } linear_segments = MultipleSegmentsModel( - self._sys_model, self.label_of_element, segments, self.on_off.on if self.on_off is not None else None + self._model, self.label_of_element, segments, self.on_off.on if self.on_off is not None else None ) # TODO: Add Outside_segments Variable (On) linear_segments.do_modeling() self.sub_models.append(linear_segments) @@ -448,18 +448,18 @@ def do_modeling(self): super().do_modeling() lb, ub = self.absolute_charge_state_bounds - self.charge_state = self.add(self._sys_model.add_variables( - lower=lb, upper=ub, coords=self._sys_model.coords_extra, + self.charge_state = self.add(self._model.add_variables( + lower=lb, upper=ub, coords=self._model.coords_extra, name=f'{self.label_full}|charge_state'), 'charge_state' ) - self.netto_discharge = self.add(self._sys_model.add_variables( - coords=self._sys_model.coords, name=f'{self.label_full}|netto_discharge'), + self.netto_discharge = self.add(self._model.add_variables( + coords=self._model.coords, name=f'{self.label_full}|netto_discharge'), 'netto_discharge' ) # netto_discharge: # eq: nettoFlow(t) - discharging(t) + charging(t) = 0 - self.add(self._sys_model.add_constraints( + self.add(self._model.add_constraints( self.netto_discharge == self.element.discharging.model.flow_rate - self.element.charging.model.flow_rate, name=f'{self.label_full}|netto_discharge'), 'netto_discharge' @@ -467,13 +467,13 @@ def do_modeling(self): charge_state = self.charge_state rel_loss = self.element.relative_loss_per_hour.active_data - hours_per_step = self._sys_model.hours_per_step + hours_per_step = self._model.hours_per_step charge_rate = self.element.charging.model.flow_rate discharge_rate = self.element.discharging.model.flow_rate eff_charge = self.element.eta_charge.active_data eff_discharge = self.element.eta_discharge.active_data - self.add(self._sys_model.add_constraints( + self.add(self._model.add_constraints( charge_state.isel(time=slice(1, None)) == charge_state.isel(time=slice(None, -1)) * (1 - rel_loss * hours_per_step) @@ -485,7 +485,7 @@ def do_modeling(self): if isinstance(self.element.capacity_in_flow_hours, InvestParameters): self._investment = InvestmentModel( - model=self._sys_model, + model=self._model, label_of_element=self.label_of_element, parameters=self.element.capacity_in_flow_hours, defining_variable=self.charge_state, @@ -503,13 +503,13 @@ def _initial_and_final_charge_state(self): name = f'{self.label_full}|{name_short}' if utils.is_number(self.element.initial_charge_state): - self.add(self._sys_model.add_constraints( + self.add(self._model.add_constraints( self.charge_state.isel(time=0) == self.element.initial_charge_state, name=name), name_short ) elif self.element.initial_charge_state == 'lastValueOfSim': - self.add(self._sys_model.add_constraints( + self.add(self._model.add_constraints( self.charge_state.isel(time=0) == self.charge_state.isel(time=-1), name=name), name_short @@ -518,14 +518,14 @@ def _initial_and_final_charge_state(self): raise Exception(f'initial_charge_state has undefined value: {self.element.initial_charge_state}') if self.element.maximal_final_charge_state is not None: - self.add(self._sys_model.add_constraints( + self.add(self._model.add_constraints( self.charge_state.isel(time=-1) <= self.element.maximal_final_charge_state, name=f'{self.label_full}|final_charge_max'), 'final_charge_max' ) if self.element.minimal_final_charge_state is not None: - self.add(self._sys_model.add_constraints( + self.add(self._model.add_constraints( self.charge_state.isel(time=-1) >= self.element.minimal_final_charge_state, name=f'{self.label_full}|final_charge_min'), 'final_charge_min' diff --git a/flixOpt/effects.py b/flixOpt/effects.py index 63b70bb06..4d695633c 100644 --- a/flixOpt/effects.py +++ b/flixOpt/effects.py @@ -137,7 +137,7 @@ def __init__(self, model: SystemModel, element: Effect): self.total: Optional[linopy.Variable] = None self.invest: ShareAllocationModel = self.add( ShareAllocationModel( - self._sys_model, + self._model, False, self.label_of_element, 'invest', @@ -149,7 +149,7 @@ def __init__(self, model: SystemModel, element: Effect): self.operation: ShareAllocationModel = self.add( ShareAllocationModel( - self._sys_model, + self._model, True, self.label_of_element, 'operation', @@ -170,7 +170,7 @@ def do_modeling(self): model.do_modeling() self.total = self.add( - self._sys_model.add_variables( + self._model.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=None, @@ -180,7 +180,7 @@ def do_modeling(self): ) self.add( - self._sys_model.add_constraints( + self._model.add_constraints( self.total == self.operation.total.sum() + self.invest.total.sum(), name=f'{self.label_full}|total' ), @@ -374,14 +374,14 @@ def add_share_to_penalty(self, name: str, expression: linopy.LinearExpression) - def do_modeling(self): for effect in self.effects: - effect.create_model(self._sys_model) - self.penalty = self.add(ShareAllocationModel(self._sys_model, shares_are_time_series=False, label_of_element='Penalty')) + effect.create_model(self._model) + self.penalty = self.add(ShareAllocationModel(self._model, shares_are_time_series=False, label_of_element='Penalty')) for model in [effect.model for effect in self.effects] + [self.penalty]: model.do_modeling() self._add_share_between_effects() - self._sys_model.add_objective( + self._model.add_objective( self.effects.objective_effect.model.total + self.penalty.total ) diff --git a/flixOpt/elements.py b/flixOpt/elements.py index 31ca92367..bba75d965 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -306,10 +306,10 @@ def __init__(self, model: SystemModel, element: Flow): def do_modeling(self): # eq relative_minimum(t) * size <= flow_rate(t) <= relative_maximum(t) * size self.flow_rate: linopy.Variable = self.add( - self._sys_model.add_variables( + self._model.add_variables( lower=self.absolute_flow_rate_bounds[0] if self.element.on_off_parameters is None else 0, upper=self.absolute_flow_rate_bounds[1], - coords=self._sys_model.coords, + coords=self._model.coords, name=f'{self.label_full}|flow_rate' ), 'flow_rate' @@ -319,7 +319,7 @@ def do_modeling(self): if self.element.on_off_parameters is not None: self.on_off: OnOffModel = self.add( OnOffModel( - model=self._sys_model, + model=self._model, label_of_element=self.label_of_element, on_off_parameters=self.element.on_off_parameters, defining_variables=[self.flow_rate], @@ -334,7 +334,7 @@ def do_modeling(self): if isinstance(self.element.size, InvestParameters): self._investment: InvestmentModel = self.add( InvestmentModel( - model=self._sys_model, + model=self._model, label_of_element=self.label_of_element, parameters=self.element.size, defining_variable=self.flow_rate, @@ -346,7 +346,7 @@ def do_modeling(self): self._investment.do_modeling() self.total_flow_hours = self.add( - self._sys_model.add_variables( + self._model.add_variables( lower=self.element.flow_hours_total_min if self.element.flow_hours_total_min is not None else -np.inf, upper=self.element.flow_hours_total_max if self.element.flow_hours_total_max is not None else np.inf, coords=None, @@ -356,8 +356,8 @@ def do_modeling(self): ) self.add( - self._sys_model.add_constraints( - self.total_flow_hours == (self.flow_rate * self._sys_model.hours_per_step).sum(), + self._model.add_constraints( + self.total_flow_hours == (self.flow_rate * self._model.hours_per_step).sum(), name=f'{self.label_full}|total_flow_hours' ), 'total_flow_hours' @@ -372,10 +372,10 @@ def do_modeling(self): def _create_shares(self): # Arbeitskosten: if self.element.effects_per_flow_hour != {}: - self._sys_model.effects.add_share_to_effects( + self._model.effects.add_share_to_effects( name=self.label_full, # Use the full label of the element expressions={ - effect: self.flow_rate * self._sys_model.hours_per_step * factor.active_data + effect: self.flow_rate * self._model.hours_per_step * factor.active_data for effect, factor in self.element.effects_per_flow_hour.items() }, target='operation', @@ -387,12 +387,12 @@ def _create_bounds_for_load_factor(self): # eq: var_sumFlowHours <= size * dt_tot * load_factor_max if self.element.load_factor_max is not None: name_short = 'load_factor_max' - flow_hours_per_size_max = self._sys_model.hours_per_step.sum() * self.element.load_factor_max + flow_hours_per_size_max = self._model.hours_per_step.sum() * self.element.load_factor_max size = self.element.size if self._investment is None else self._investment.size if self._investment is not None: self.add( - self._sys_model.add_constraints( + self._model.add_constraints( self.total_flow_hours <= size * flow_hours_per_size_max, name=f'{self.label_full}|{name_short}', ), @@ -402,12 +402,12 @@ def _create_bounds_for_load_factor(self): # eq: size * sum(dt)* load_factor_min <= var_sumFlowHours if self.element.load_factor_min is not None: name_short = 'load_factor_min' - flow_hours_per_size_min = self._sys_model.hours_per_step.sum() * self.element.load_factor_min + flow_hours_per_size_min = self._model.hours_per_step.sum() * self.element.load_factor_min size = self.element.size if self._investment is None else self._investment.size if self._investment is not None: self.add( - self._sys_model.add_constraints( + self._model.add_constraints( self.total_flow_hours >= size * flow_hours_per_size_min, name=f'{self.label_full}|{name_short}', ), @@ -447,7 +447,7 @@ def do_modeling(self) -> None: self.add(flow.model.flow_rate, flow.label_full) inputs = sum([flow.model.flow_rate for flow in self.element.inputs]) outputs = sum([flow.model.flow_rate for flow in self.element.outputs]) - eq_bus_balance = self.add(self._sys_model.add_constraints( + eq_bus_balance = self.add(self._model.add_constraints( inputs == outputs, name=f'{self.label_full}|balance' )) @@ -455,20 +455,20 @@ def do_modeling(self) -> None: # Fehlerplus/-minus: if self.element.with_excess: excess_penalty = np.multiply( - self._sys_model.hours_per_step, self.element.excess_penalty_per_flow_hour.active_data + self._model.hours_per_step, self.element.excess_penalty_per_flow_hour.active_data ) - self.excess_input = self.add(self._sys_model.add_variables( - lower=0, coords=self._sys_model.coords, name=f'{self.label_full}|excess_input'), + self.excess_input = self.add(self._model.add_variables( + lower=0, coords=self._model.coords, name=f'{self.label_full}|excess_input'), 'excess_input' ) - self.excess_output = self.add(self._sys_model.add_variables( - lower=0, coords=self._sys_model.coords, name=f'{self.label_full}|excess_output'), + self.excess_output = self.add(self._model.add_variables( + lower=0, coords=self._model.coords, name=f'{self.label_full}|excess_output'), 'excess_output' ) eq_bus_balance.lhs -= -self.excess_input + self.excess_output - self._sys_model.effects.add_share_to_penalty(self.label_of_element, (self.excess_input * excess_penalty).sum()) - self._sys_model.effects.add_share_to_penalty(self.label_of_element, (self.excess_output * excess_penalty).sum()) + self._model.effects.add_share_to_penalty(self.label_of_element, (self.excess_input * excess_penalty).sum()) + self._model.effects.add_share_to_penalty(self.label_of_element, (self.excess_output * excess_penalty).sum()) def results_structure(self): inputs = [flow.model.flow_rate.name for flow in self.element.inputs] @@ -500,14 +500,14 @@ def do_modeling(self): flow.on_off_parameters = OnOffParameters() for flow in all_flows: - self.add(flow.create_model(self._sys_model), flow.label) + self.add(flow.create_model(self._model), flow.label) for sub_model in self.sub_models: sub_model.do_modeling() if self.element.on_off_parameters: self.on_off = self.add(OnOffModel( - self._sys_model, + self._model, self.element.on_off_parameters, self.label_of_element, defining_variables=[flow.model.flow_rate for flow in all_flows], @@ -519,7 +519,7 @@ def do_modeling(self): if self.element.prevent_simultaneous_flows: # Simultanious Useage --> Only One FLow is On at a time, but needs a Binary for every flow on_variables = [flow.model.on_off.on for flow in self.element.prevent_simultaneous_flows] - simultaneous_use = self.add(PreventSimultaneousUsageModel(self._sys_model, on_variables, self.label_full)) + simultaneous_use = self.add(PreventSimultaneousUsageModel(self._model, on_variables, self.label_full)) simultaneous_use.do_modeling() def results_structure(self): diff --git a/flixOpt/features.py b/flixOpt/features.py index 33fc52c26..3e79aed43 100644 --- a/flixOpt/features.py +++ b/flixOpt/features.py @@ -48,13 +48,13 @@ def __init__( def do_modeling(self): if self.parameters.fixed_size and not self.parameters.optional: - self.size = self.add(self._sys_model.add_variables( + self.size = self.add(self._model.add_variables( lower=self.parameters.fixed_size, upper=self.parameters.fixed_size, name=f'{self.label_full}|size'), 'size') else: - self.size = self.add(self._sys_model.add_variables( + self.size = self.add(self._model.add_variables( lower=0 if self.parameters.optional else self.parameters.minimum_size, upper=self.parameters.maximum_size, name=f'{self.label_full}|size'), @@ -62,7 +62,7 @@ def do_modeling(self): # Optional if self.parameters.optional: - self.is_invested = self.add(self._sys_model.add_variables( + self.is_invested = self.add(self._model.add_variables( binary=True, name=f'{self.label_full}|is_invested'), 'is_invested') @@ -79,7 +79,7 @@ def _create_shares(self): # fix_effects: fix_effects = self.parameters.fix_effects if fix_effects != {}: - self._sys_model.effects.add_share_to_effects( + self._model.effects.add_share_to_effects( name=self.label_of_element, expressions={effect: self.is_invested * factor if self.is_invested is not None else factor for effect, factor in fix_effects.items()}, @@ -88,14 +88,14 @@ def _create_shares(self): if self.parameters.divest_effects != {} and self.parameters.optional: # share: divest_effects - isInvested * divest_effects - self._sys_model.effects.add_share_to_effects( + self._model.effects.add_share_to_effects( name=self.label_of_element, expressions={effect: -self.is_invested * factor + factor for effect, factor in fix_effects.items()}, target='invest', ) if self.parameters.specific_effects != {}: - self._sys_model.effects.add_share_to_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='invest', @@ -104,7 +104,7 @@ def _create_shares(self): if self.parameters.effects_in_segments: self._segments = self.add( SegmentedSharesModel( - model=self._sys_model, + model=self._model, label_of_element=self.label_of_element, variable_segments=(self.size, self.parameters.effects_in_segments[0]), share_segments=self.parameters.effects_in_segments[1], @@ -116,20 +116,20 @@ def _create_shares(self): def _create_bounds_for_optional_investment(self): if self.parameters.fixed_size: # eq: investment_size = isInvested * fixed_size - self.add(self._sys_model.add_constraints( + self.add(self._model.add_constraints( self.size == self.is_invested * self.parameters.fixed_size, name=f'{self.label_full}|is_invested'), 'is_invested') else: # eq1: P_invest <= isInvested * investSize_max - self.add(self._sys_model.add_constraints( + self.add(self._model.add_constraints( self.size <= self.is_invested * self.parameters.maximum_size, name=f'{self.label_full}|is_invested_ub'), 'is_invested_ub') # eq2: P_invest >= isInvested * max(epsilon, investSize_min) - self.add(self._sys_model.add_constraints( + self.add(self._model.add_constraints( self.size >= self.is_invested * np.maximum(CONFIG.modeling.EPSILON,self.parameters.minimum_size), name=f'{self.label_full}|is_invested_lb'), 'is_invested_lb') @@ -138,7 +138,7 @@ def _create_bounds_for_defining_variable(self): variable = self._defining_variable lb_relative, ub_relative = self._relative_bounds_of_defining_variable if np.all(lb_relative == ub_relative): - self.add(self._sys_model.add_constraints( + self.add(self._model.add_constraints( variable == self.size * ub_relative, name=f'{self.label_full}|fix_{variable.name}'), f'fix_{variable.name}') @@ -148,14 +148,14 @@ def _create_bounds_for_defining_variable(self): return # eq: defining_variable(t) <= size * upper_bound(t) - self.add(self._sys_model.add_constraints( + self.add(self._model.add_constraints( variable <= self.size * ub_relative, name=f'{self.label_full}|ub_{variable.name}'), f'ub_{variable.name}') if self._on_variable is None: # eq: defining_variable(t) >= investment_size * relative_minimum(t) - self.add(self._sys_model.add_constraints( + self.add(self._model.add_constraints( variable >= self.size * lb_relative, name=f'{self.label_full}|lb_{variable.name}'), f'lb_{variable.name}') @@ -167,7 +167,7 @@ def _create_bounds_for_defining_variable(self): # eq: - defining_variable(t) + mega * On(t) + size * relative_minimum(t) <= + mega mega = lb_relative * self.parameters.maximum_size on = self._on_variable - self.add(self._sys_model.add_constraints( + self.add(self._model.add_constraints( variable >= mega * (on - 1) + self.size * lb_relative, name=f'{self.label_full}|lb_{variable.name}'), f'lb_{variable.name}') @@ -232,16 +232,16 @@ def __init__( def do_modeling(self): self.on = self.add( - self._sys_model.add_variables( + self._model.add_variables( name=f'{self.label_full}|on', binary=True, - coords=self._sys_model.coords, + coords=self._model.coords, ), 'on', ) self.total_on_hours = self.add( - self._sys_model.add_variables( + self._model.add_variables( lower=self.parameters.on_hours_total_min if self.parameters.on_hours_total_min is not None else 0, upper=self.parameters.on_hours_total_max if self.parameters.on_hours_total_max is not None else np.inf, name=f'{self.label_full}|on_hours_total' @@ -250,8 +250,8 @@ def do_modeling(self): ) self.add( - self._sys_model.add_constraints( - self.total_on_hours == (self.on * self._sys_model.hours_per_step).sum(), + self._model.add_constraints( + self.total_on_hours == (self.on * self._model.hours_per_step).sum(), name=f'{self.label_full}|on_hours_total' ), 'on_hours_total' @@ -261,16 +261,16 @@ def do_modeling(self): if self.parameters.use_off: self.off = self.add( - self._sys_model.add_variables( + self._model.add_variables( name=f'{self.label_full}|off', binary=True, - coords=self._sys_model.coords, + coords=self._model.coords, ), 'off' ) # eq: var_on(t) + var_off(t) = 1 - self.add(self._sys_model.add_constraints(self.on + self.off == 1, name=f'{self.label_full}|off'), 'off') + self.add(self._model.add_constraints(self.on + self.off == 1, name=f'{self.label_full}|off'), 'off') if self.parameters.use_consecutive_on_hours: self.consecutive_on_hours = self._get_duration_in_hours( @@ -291,13 +291,13 @@ def do_modeling(self): ) if self.parameters.use_switch_on: - self.switch_on = self.add(self._sys_model.add_variables( - binary=True, name=f'{self.label_full}|switch_on', coords=self._sys_model.coords), 'switch_on') + self.switch_on = self.add(self._model.add_variables( + binary=True, name=f'{self.label_full}|switch_on', coords=self._model.coords),'switch_on') - self.switch_off = self.add(self._sys_model.add_variables( - binary=True, name=f'{self.label_full}|switch_off', coords=self._sys_model.coords), 'switch_off') + self.switch_off = self.add(self._model.add_variables( + binary=True, name=f'{self.label_full}|switch_off', coords=self._model.coords), 'switch_off') - self.switch_on_nr = self.add(self._sys_model.add_variables( + self.switch_on_nr = self.add(self._model.add_variables( upper=self.parameters.switch_on_total_max if self.parameters.switch_on_total_max is not None else np.inf, name=f'{self.label_full}|switch_on_nr'), 'switch_on_nr') @@ -323,7 +323,7 @@ def _add_on_constraints(self): # eq: On(t) * max(epsilon, lower_bound) <= Q_th(t) self.add( - self._sys_model.add_constraints( + self._model.add_constraints( self.on * np.maximum(CONFIG.modeling.EPSILON, lb) <= def_var, name=f'{self.label_full}|on_con1' ), @@ -332,7 +332,7 @@ def _add_on_constraints(self): # eq: Q_th(t) <= Q_th_max * On(t) self.add( - self._sys_model.add_constraints( + self._model.add_constraints( self.on * np.maximum(CONFIG.modeling.EPSILON, ub) >= def_var, name=f'{self.label_full}|on_con2' ), @@ -346,7 +346,7 @@ def _add_on_constraints(self): # When all defining variables are 0, On is 0 # eq: On(t) * Epsilon <= sum(alle Leistungen(t)) self.add( - self._sys_model.add_constraints( + self._model.add_constraints( self.on * lb <= sum(self._defining_variables), name=f'{self.label_full}|on_con1' ), @@ -358,7 +358,7 @@ def _add_on_constraints(self): # --> damit Gleichungswerte nicht zu groß werden, noch durch nr_of_flows geteilt: # eq: sum( Leistung(t,i) / nr_of_flows ) - sum(Leistung_max(i)) / nr_of_flows * On(t) <= 0 self.add( - self._sys_model.add_constraints( + self._model.add_constraints( self.on * ub >= sum([def_var / nr_of_def_vars for def_var in self._defining_variables]), name=f'{self.label_full}|on_con2' ), @@ -421,29 +421,29 @@ def _get_duration_in_hours( """ assert binary_variable is not None, f'Duration Variable of {self.label_full} must be defined to add constraints' - mega = self._sys_model.hours_per_step.sum() + previous_duration + mega = self._model.hours_per_step.sum() + previous_duration if maximum_duration is not None: first_step_max: Scalar = maximum_duration.isel(time=0) - if previous_duration + self._sys_model.hours_per_step[0] > first_step_max: + if previous_duration + self._model.hours_per_step[0] > first_step_max: logger.warning( f'The maximum duration of "{variable_name}" is set to {maximum_duration.active_data}h, ' f'but the consecutive_duration previous to this model is {previous_duration}h. ' f'This forces "{binary_variable.name} = 0" in the first time step ' - f'(dt={self._sys_model.hours_per_step[0]}h)!' + f'(dt={self._model.hours_per_step[0]}h)!' ) - duration_in_hours = self.add(self._sys_model.add_variables( + duration_in_hours = self.add(self._model.add_variables( lower=0, upper=maximum_duration.active_data if maximum_duration is not None else mega, - coords=self._sys_model.coords, + coords=self._model.coords, name=f'{self.label_full}|{variable_name}'), variable_name ) # 1) eq: duration(t) - On(t) * BIG <= 0 - self.add(self._sys_model.add_constraints( + self.add(self._model.add_constraints( duration_in_hours <= binary_variable * mega, name=f'{self.label_full}|{variable_name}_con1'), f'{variable_name}_con1' @@ -452,10 +452,10 @@ def _get_duration_in_hours( # 2a) eq: duration(t) - duration(t-1) <= dt(t) # on(t)=1 -> duration(t) - duration(t-1) <= dt(t) # on(t)=0 -> duration(t-1) >= negat. value - self.add(self._sys_model.add_constraints( + self.add(self._model.add_constraints( duration_in_hours.isel(time=slice(1, None)) <= - duration_in_hours.isel(time=slice(None, -1)) + self._sys_model.hours_per_step.isel(time=slice(None, -1)), + duration_in_hours.isel(time=slice(None, -1)) + self._model.hours_per_step.isel(time=slice(None, -1)), name=f'{self.label_full}|{variable_name}_con2a'), f'{variable_name}_con2a' ) @@ -466,10 +466,10 @@ def _get_duration_in_hours( # on(t)=1 -> duration(t)- duration(t-1) >= dt(t) # on(t)=0 -> duration(t)- duration(t-1) >= negat. value - self.add(self._sys_model.add_constraints( + self.add(self._model.add_constraints( duration_in_hours.isel(time=slice(1, None)) >= - duration_in_hours.isel(time=slice(None, -1)) + self._sys_model.hours_per_step.isel(time=slice(None, -1)) + duration_in_hours.isel(time=slice(None, -1)) + self._model.hours_per_step.isel(time=slice(None, -1)) + (binary_variable.isel(time=slice(1, None)) - 1) * mega, name=f'{self.label_full}|{variable_name}_con2b'), f'{variable_name}_con2b' @@ -483,7 +483,7 @@ def _get_duration_in_hours( # Note: (previous values before t=1 are not recognised!) # eq: duration(t) >= minimum_duration(t) * [On(t) - On(t+1)] for t=1..(n-1) # eq: -duration(t) + minimum_duration(t) * On(t) - minimum_duration(t) * On(t+1) <= 0 - self.add(self._sys_model.add_constraints( + self.add(self._model.add_constraints( duration_in_hours >= (binary_variable.isel(time=slice(None, -1)) - binary_variable.isel(time=slice(1, None))) @@ -497,7 +497,7 @@ def _get_duration_in_hours( # Note: Only if the previous consecutive_duration is smaller than the minimum duration # and the previous_duration is greater 0! # eq: On(t=0) = 1 - self.add(self._sys_model.add_constraints( + self.add(self._model.add_constraints( binary_variable.isel(time=0) == 1, name=f'{self.label_full}|{variable_name}_minimum_inital'), f'{variable_name}_minimum_inital' @@ -505,8 +505,8 @@ def _get_duration_in_hours( # 4) first index: # eq: duration(t=0)= dt(0) * On(0) - self.add(self._sys_model.add_constraints( - duration_in_hours.isel(time=0) == self._sys_model.hours_per_step.isel(time=0) * binary_variable.isel(time=0), + self.add(self._model.add_constraints( + duration_in_hours.isel(time=0) == self._model.hours_per_step.isel(time=0) * binary_variable.isel(time=0), name=f'{self.label_full}|{variable_name}_initial'), f'{variable_name}_initial' ) @@ -523,7 +523,7 @@ def _add_switch_constraints(self): # % Schaltänderung aus On-Variable # % SwitchOn(t)-SwitchOff(t) = On(t)-On(t-1) self.add( - self._sys_model.add_constraints( + self._model.add_constraints( self.switch_on.isel(time=slice(1, None)) - self.switch_off.isel(time=slice(1, None)) == self.on.isel(time=slice(1,None)) - self.on.isel(time=slice(None,-1)), @@ -534,7 +534,7 @@ def _add_switch_constraints(self): # Initital switch on # eq: SwitchOn(t=0)-SwitchOff(t=0) = On(t=0) - On(t=-1) self.add( - self._sys_model.add_constraints( + self._model.add_constraints( self.switch_on.isel(time=0) - self.switch_off.isel(time=0) == self.on.isel(time=0) - self.previous_on_values[-1], @@ -545,7 +545,7 @@ def _add_switch_constraints(self): ## Entweder SwitchOff oder SwitchOn # eq: SwitchOn(t) + SwitchOff(t) <= 1.1 self.add( - self._sys_model.add_constraints( + self._model.add_constraints( self.switch_on + self.switch_off <= 1.1, name=f'{self.label_full}|switch_on_or_off' ), @@ -555,7 +555,7 @@ def _add_switch_constraints(self): ## Anzahl Starts: # eq: nrSwitchOn = sum(SwitchOn(t)) self.add( - self._sys_model.add_constraints( + self._model.add_constraints( self.switch_on_nr == self.switch_on.sum(), name=f'{self.label_full}|switch_on_nr' ), @@ -566,7 +566,7 @@ def _create_shares(self): # Anfahrkosten: effects_per_switch_on = self.parameters.effects_per_switch_on if effects_per_switch_on != {}: - self._sys_model.effects.add_share_to_effects( + self._model.effects.add_share_to_effects( name=self.label_of_element, expressions={effect: self.switch_on * factor for effect, factor in effects_per_switch_on.items()}, target='operation', @@ -575,9 +575,9 @@ def _create_shares(self): # Betriebskosten: effects_per_running_hour = self.parameters.effects_per_running_hour if effects_per_running_hour != {}: - self._sys_model.effects.add_share_to_effects( + self._model.effects.add_share_to_effects( name=self.label_of_element, - expressions={effect: self.on * factor * self._sys_model.hours_per_step + expressions={effect: self.on * factor * self._model.hours_per_step for effect, factor in effects_per_running_hour.items()}, target='operation', ) @@ -592,11 +592,11 @@ def previous_off_values(self) -> np.ndarray: @property def previous_consecutive_on_hours(self) -> Scalar: - return self.compute_consecutive_duration(self.previous_on_values, self._sys_model.hours_per_step) + return self.compute_consecutive_duration(self.previous_on_values, self._model.hours_per_step) @property def previous_consecutive_off_hours(self) -> Scalar: - return self.compute_consecutive_duration(self.previous_off_values, self._sys_model.hours_per_step) + return self.compute_consecutive_duration(self.previous_off_values, self._model.hours_per_step) @staticmethod def compute_previous_on_states(previous_values: List[Optional[NumericData]], epsilon: float = 1e-5) -> np.ndarray: @@ -703,29 +703,29 @@ def __init__( self.sample_points = sample_points def do_modeling(self): - self.in_segment = self.add(self._sys_model.add_variables( + self.in_segment = self.add(self._model.add_variables( binary=True, name=f'{self.label_full}|in_segment', - coords=self._sys_model.coords if self._as_time_series else None), + coords=self._model.coords if self._as_time_series else None), 'in_segment' ) - self.lambda0 = self.add(self._sys_model.add_variables( + self.lambda0 = self.add(self._model.add_variables( lower=0, upper=1, name=f'{self.label_full}|lambda0', - coords=self._sys_model.coords if self._as_time_series else None), + coords=self._model.coords if self._as_time_series else None), 'lambda0' ) - self.lambda1 = self.add(self._sys_model.add_variables( + self.lambda1 = self.add(self._model.add_variables( lower=0, upper=1, name=f'{self.label_full}|lambda1', - coords=self._sys_model.coords if self._as_time_series else None), + coords=self._model.coords if self._as_time_series else None), 'lambda1' ) # eq: lambda0(t) + lambda1(t) = in_segment(t) - self.add(self._sys_model.add_constraints( + self.add(self._model.add_constraints( self.in_segment == self.lambda0 + self.lambda1, name=f'{self.label_full}|in_segment'), 'in_segment' @@ -774,7 +774,7 @@ def do_modeling(self): self._segment_models = [ self.add( SegmentModel( - self._sys_model, + self._model, label_of_element=self.label_of_element, segment_index=i, sample_points=sample_points, @@ -789,8 +789,8 @@ def do_modeling(self): # eq: - v(t) + (v_0_0 * lambda_0_0 + v_0_1 * lambda_0_1) + (v_1_0 * lambda_1_0 + v_1_1 * lambda_1_1) ... = 0 # -> v_0_0, v_0_1 = Stützstellen des Segments 0 for var_name in self._sample_points.keys(): - variable = self._sys_model.variables[var_name] - self.add(self._sys_model.add_constraints( + variable = self._model.variables[var_name] + self.add(self._model.add_constraints( variable == sum([segment.lambda0 * segment.sample_points[var_name][0] + segment.lambda1 * segment.sample_points[var_name][1] for segment in self._segment_models]), @@ -804,8 +804,8 @@ def do_modeling(self): self.outside_segments = self._can_be_outside_segments rhs = self.outside_segments elif self._can_be_outside_segments is True: - self.outside_segments = self.add(self._sys_model.add_variables( - coords=self._sys_model.coords, + self.outside_segments = self.add(self._model.add_variables( + coords=self._model.coords, binary=True, name=f'{self.label_full}|outside_segments'), 'outside_segments' @@ -814,7 +814,7 @@ def do_modeling(self): else: rhs = 1 - self.add(self._sys_model.add_constraints( + self.add(self._model.add_constraints( sum([segment.in_segment for segment in self._segment_models]) <= rhs, name=f'{self.label_full}|{variable.name}_single_segment'), 'single_segment' @@ -860,27 +860,27 @@ def __init__( def do_modeling(self): self.total = self.add( - self._sys_model.add_variables( + self._model.add_variables( lower=self._total_min, upper=self._total_max, coords=None, name=f'{self.label_full}|total' ), 'total' ) # eq: sum = sum(share_i) # skalar - self._eq_total = self.add(self._sys_model.add_constraints(self.total == 0, name=f'{self.label_full}|total'), 'total') + self._eq_total = self.add(self._model.add_constraints(self.total == 0, name=f'{self.label_full}|total'), 'total') if self._shares_are_time_series: self.total_per_timestep = self.add( - self._sys_model.add_variables( - lower=-np.inf if (self._min_per_hour is None) else np.multiply(self._min_per_hour, self._sys_model.hours_per_step), - upper=np.inf if (self._max_per_hour is None) else np.multiply(self._max_per_hour, self._sys_model.hours_per_step), - coords=self._sys_model.coords, + self._model.add_variables( + lower=-np.inf if (self._min_per_hour is None) else np.multiply(self._min_per_hour, self._model.hours_per_step), + upper=np.inf if (self._max_per_hour is None) else np.multiply(self._max_per_hour, self._model.hours_per_step), + coords=self._model.coords, name=f'{self.label_full}|total_per_timestep' ), 'total_per_timestep' ) self._eq_total_per_timestep = self.add( - self._sys_model.add_constraints(self.total_per_timestep == 0, name=f'{self.label_full}|total_per_timestep'), + self._model.add_constraints(self.total_per_timestep == 0, name=f'{self.label_full}|total_per_timestep'), 'total_per_timestep' ) @@ -911,14 +911,14 @@ def add_share( self.share_constraints[name].lhs -= expression else: self.shares[name] = self.add( - self._sys_model.add_variables( - coords=None if isinstance(expression, linopy.LinearExpression) and expression.ndim == 0 or not isinstance(expression, linopy.LinearExpression) else self._sys_model.coords, + self._model.add_variables( + coords=None if isinstance(expression, linopy.LinearExpression) and expression.ndim == 0 or not isinstance(expression, linopy.LinearExpression) else self._model.coords, name=f'{name}->{self.label_full}' ), name ) self.share_constraints[name] = self.add( - self._sys_model.add_constraints( + self._model.add_constraints( self.shares[name] == expression, name=f'{name}->{self.label_full}' ), name @@ -953,8 +953,8 @@ def __init__( def do_modeling(self): self._shares = { - effect: self.add(self._sys_model.add_variables( - coords=self._sys_model.coords if self._as_tme_series else None, + effect: self.add(self._model.add_variables( + coords=self._model.coords if self._as_tme_series else None, name=f'{self.label_full}|{effect}'), f'{effect}' ) for effect in self._share_segments @@ -968,7 +968,7 @@ def do_modeling(self): self._segments_model = self.add( MultipleSegmentsModel( - model=self._sys_model, + model=self._model, label_of_element=self.label_of_element, sample_points=segments, can_be_outside_segments=self._can_be_outside_segments, @@ -978,7 +978,7 @@ def do_modeling(self): self._segments_model.do_modeling() # Shares - self._sys_model.effects.add_share_to_effects( + self._model.effects.add_share_to_effects( name=self.label_of_element, expressions={effect: variable*1 for effect, variable in self._shares.items()}, target='operation' if self._as_tme_series else 'invest', @@ -1012,6 +1012,6 @@ def __init__(self, model: SystemModel, variables: List[linopy.Variable], label_o def do_modeling(self): # eq: sum(flow_i.on(t)) <= 1.1 (1 wird etwas größer gewählt wg. Binärvariablengenauigkeit) - self.add(self._sys_model.add_constraints(sum(self._simultanious_use_variables) <= 1.1, - name=f'{self.label_full}|prevent_simultaneous_use'), + self.add(self._model.add_constraints(sum(self._simultanious_use_variables) <= 1.1, + name=f'{self.label_full}|prevent_simultaneous_use'), 'prevent_simultaneous_use') diff --git a/flixOpt/structure.py b/flixOpt/structure.py index e6c32a44c..fa8765f1f 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -285,7 +285,7 @@ def __init__(self, model: SystemModel, label_of_element: str, label: Optional[st label_full : str The full label of the model. Can overwrite the full label constructed from the other labels. """ - self._sys_model = model + self._model = model self.label_of_element = label_of_element self._label = label self._label_full = label_full @@ -368,11 +368,11 @@ def label_full(self) -> str: @property def variables_direct(self) -> linopy.Variables: - return self._sys_model.variables[self._variables_direct] + return self._model.variables[self._variables_direct] @property def constraints_direct(self) -> linopy.Constraints: - return self._sys_model.constraints[self._constraints_direct] + return self._model.constraints[self._constraints_direct] @property def _variables(self) -> List[str]: @@ -398,11 +398,11 @@ def _constraints(self) -> List[str]: @property def variables(self) -> linopy.Variables: - return self._sys_model.variables[self._variables] + return self._model.variables[self._variables] @property def constraints(self) -> linopy.Constraints: - return self._sys_model.constraints[self._constraints] + return self._model.constraints[self._constraints] @property def all_sub_models(self) -> List['Model']: From 879a2c5e47a1a2a223127eaf7bf38c441a956fb2 Mon Sep 17 00:00:00 2001 From: fel15133 Date: Sun, 16 Mar 2025 19:02:24 +0100 Subject: [PATCH 350/507] error-handling for unsolvable parameter combinations of initial_state and charge_state_min/max --- flixOpt/components.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/flixOpt/components.py b/flixOpt/components.py index 39258f953..44fa835c4 100644 --- a/flixOpt/components.py +++ b/flixOpt/components.py @@ -503,6 +503,11 @@ def _initial_and_final_charge_state(self): name = f'{self.label_full}|{name_short}' if utils.is_number(self.element.initial_charge_state): + if self.element.initial_charge_state > self.charge_state.upper.isel(time=0).values: + raise Exception(f'{self.label_full}: initial_charge_state {self.element.initial_charge_state} is above allowed maximum charge_state {self.charge_state.upper.isel(time=0).values}') + if self.element.initial_charge_state < self.charge_state.lower.isel(time=0).values: + raise Exception(f'{self.label_full}: initial_charge_state {self.element.initial_charge_state} is below allowed minimum charge_state {self.charge_state.lower.isel(time=0).values}') + self.add(self._model.add_constraints( self.charge_state.isel(time=0) == self.element.initial_charge_state, name=name), From 090905b84061c7e2b9fb066e0348cb934f385292 Mon Sep 17 00:00:00 2001 From: fel15133 Date: Sun, 16 Mar 2025 20:33:18 +0100 Subject: [PATCH 351/507] check extended for InvestParameters --- flixOpt/components.py | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/flixOpt/components.py b/flixOpt/components.py index 44fa835c4..1ccbde5c4 100644 --- a/flixOpt/components.py +++ b/flixOpt/components.py @@ -503,10 +503,7 @@ def _initial_and_final_charge_state(self): name = f'{self.label_full}|{name_short}' if utils.is_number(self.element.initial_charge_state): - if self.element.initial_charge_state > self.charge_state.upper.isel(time=0).values: - raise Exception(f'{self.label_full}: initial_charge_state {self.element.initial_charge_state} is above allowed maximum charge_state {self.charge_state.upper.isel(time=0).values}') - if self.element.initial_charge_state < self.charge_state.lower.isel(time=0).values: - raise Exception(f'{self.label_full}: initial_charge_state {self.element.initial_charge_state} is below allowed minimum charge_state {self.charge_state.lower.isel(time=0).values}') + self._check_initial_charge_state(self.element.initial_charge_state) self.add(self._model.add_constraints( self.charge_state.isel(time=0) == self.element.initial_charge_state, @@ -550,6 +547,27 @@ def absolute_charge_state_bounds(self) -> Tuple[NumericData, NumericData]: relative_upper_bound * self.element.capacity_in_flow_hours.maximum_size, ) + def _check_initial_charge_state(self, initial_charge_state: Scalar): + """ + Error handling for initial_charge_state, + avoiding non-solvable models for the given capacity (or range of capacity-size) + """ + relative_lower_bound, relative_upper_bound = self.relative_charge_state_bounds + if not isinstance(self.element.capacity_in_flow_hours, InvestParameters): + initial_charge_state_ub = self.charge_state.upper.isel(time=0).values + initial_charge_state_lb = self.charge_state.lower.isel(time=0).values + else: + # initial_charge_state shall be in allowed range of charge_state(time=0) for any allowed capacity! + initial_charge_state_ub = relative_upper_bound.isel(time=0).values * self.element.capacity_in_flow_hours.minimum_size + initial_charge_state_lb = relative_lower_bound.isel(time=0).values * self.element.capacity_in_flow_hours.maximum_size + + if initial_charge_state > initial_charge_state_ub: + raise Exception( + f'{self.label_full}: initial_charge_state {self.element.initial_charge_state} is above allowed maximum charge_state {initial_charge_state_ub}') + if initial_charge_state < initial_charge_state_lb: + raise Exception( + f'{self.label_full}: initial_charge_state {self.element.initial_charge_state} is below allowed minimum charge_state {initial_charge_state_lb}') + @property def relative_charge_state_bounds(self) -> Tuple[NumericData, NumericData]: return ( From 880039184efdc3bda29d41297834608c7bace260 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 17 Mar 2025 09:47:11 +0100 Subject: [PATCH 352/507] Move validation from Model to Element --- flixOpt/components.py | 55 ++++++++++++++++++++++++------------------- flixOpt/effects.py | 5 ++++ flixOpt/elements.py | 6 +++++ 3 files changed, 42 insertions(+), 24 deletions(-) diff --git a/flixOpt/components.py b/flixOpt/components.py index 1ccbde5c4..340f7c90d 100644 --- a/flixOpt/components.py +++ b/flixOpt/components.py @@ -149,7 +149,7 @@ def __init__( capacity_in_flow_hours: Union[Scalar, InvestParameters], relative_minimum_charge_state: NumericData = 0, relative_maximum_charge_state: NumericData = 1, - initial_charge_state: Optional[Union[Scalar, Literal['lastValueOfSim']]] = 0, + initial_charge_state: Union[Scalar, Literal['lastValueOfSim']] = 0, minimal_final_charge_state: Optional[Scalar] = None, maximal_final_charge_state: Optional[Scalar] = None, eta_charge: NumericData = 1, @@ -220,6 +220,7 @@ def __init__( self.prevent_simultaneous_charge_and_discharge = prevent_simultaneous_charge_and_discharge def create_model(self, model: SystemModel) -> 'StorageModel': + self._plausibility_checks() self.model = StorageModel(model, self) return self.model @@ -239,6 +240,34 @@ def transform_data(self, flow_system: 'FlowSystem') -> None: if isinstance(self.capacity_in_flow_hours, InvestParameters): self.capacity_in_flow_hours.transform_data(flow_system) + def _plausibility_checks(self) -> None: + """ + Check for infeasible or uncommon combinations of parameters + """ + if utils.is_number(self.initial_charge_state): + if isinstance(self.capacity_in_flow_hours, InvestParameters): + if self.capacity_in_flow_hours.fixed_size is None: + maximum_capacity = self.capacity_in_flow_hours.maximum_size + minimum_capacity = self.capacity_in_flow_hours.minimum_size + else: + maximum_capacity = self.capacity_in_flow_hours.fixed_size + minimum_capacity = self.capacity_in_flow_hours.fixed_size + else: + maximum_capacity = self.capacity_in_flow_hours + minimum_capacity = self.capacity_in_flow_hours + + minimum_inital_capacity = minimum_capacity * self.relative_minimum_charge_state.isel(time=1) + maximum_inital_capacity = maximum_capacity * self.relative_maximum_charge_state.isel(time=1) + + if self.initial_charge_state > maximum_inital_capacity: + raise ValueError(f'{self.label_full}: {self.initial_charge_state=} ' + f'is above allowed maximum charge_state {maximum_inital_capacity}') + if self.initial_charge_state < minimum_inital_capacity: + raise ValueError(f'{self.label_full}: {self.initial_charge_state=} ' + f'is below allowed minimum charge_state {minimum_inital_capacity}') + elif self.initial_charge_state != 'lastValueOfSim': + raise ValueError(f'{self.label_full}: {self.initial_charge_state=} has an invalid value') + @register_class_for_io class Transmission(Component): @@ -322,6 +351,7 @@ def _plausibility_checks(self): ) def create_model(self, model) -> 'TransmissionModel': + self._plausibility_checks() self.model = TransmissionModel(model, self) return self.model @@ -503,8 +533,6 @@ def _initial_and_final_charge_state(self): name = f'{self.label_full}|{name_short}' if utils.is_number(self.element.initial_charge_state): - self._check_initial_charge_state(self.element.initial_charge_state) - self.add(self._model.add_constraints( self.charge_state.isel(time=0) == self.element.initial_charge_state, name=name), @@ -547,27 +575,6 @@ def absolute_charge_state_bounds(self) -> Tuple[NumericData, NumericData]: relative_upper_bound * self.element.capacity_in_flow_hours.maximum_size, ) - def _check_initial_charge_state(self, initial_charge_state: Scalar): - """ - Error handling for initial_charge_state, - avoiding non-solvable models for the given capacity (or range of capacity-size) - """ - relative_lower_bound, relative_upper_bound = self.relative_charge_state_bounds - if not isinstance(self.element.capacity_in_flow_hours, InvestParameters): - initial_charge_state_ub = self.charge_state.upper.isel(time=0).values - initial_charge_state_lb = self.charge_state.lower.isel(time=0).values - else: - # initial_charge_state shall be in allowed range of charge_state(time=0) for any allowed capacity! - initial_charge_state_ub = relative_upper_bound.isel(time=0).values * self.element.capacity_in_flow_hours.minimum_size - initial_charge_state_lb = relative_lower_bound.isel(time=0).values * self.element.capacity_in_flow_hours.maximum_size - - if initial_charge_state > initial_charge_state_ub: - raise Exception( - f'{self.label_full}: initial_charge_state {self.element.initial_charge_state} is above allowed maximum charge_state {initial_charge_state_ub}') - if initial_charge_state < initial_charge_state_lb: - raise Exception( - f'{self.label_full}: initial_charge_state {self.element.initial_charge_state} is below allowed minimum charge_state {initial_charge_state_lb}') - @property def relative_charge_state_bounds(self) -> Tuple[NumericData, NumericData]: return ( diff --git a/flixOpt/effects.py b/flixOpt/effects.py index 4d695633c..e4db4aea3 100644 --- a/flixOpt/effects.py +++ b/flixOpt/effects.py @@ -126,9 +126,14 @@ def transform_data(self, flow_system: 'FlowSystem'): ) def create_model(self, model: SystemModel) -> 'EffectModel': + self._plausibility_checks() self.model = EffectModel(model, self) return self.model + def _plausibility_checks(self) -> None: + # TODO: Check for plausibility + pass + class EffectModel(ElementModel): def __init__(self, model: SystemModel, element: Effect): diff --git a/flixOpt/elements.py b/flixOpt/elements.py index bba75d965..09c571f09 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -62,6 +62,7 @@ def __init__( self.flows: Dict[str, Flow] = {flow.label: flow for flow in self.inputs + self.outputs} def create_model(self, model: SystemModel) -> 'ComponentModel': + self._plausibility_checks() self.model = ComponentModel(model, self) return self.model @@ -75,6 +76,10 @@ def infos(self, use_numpy=True, use_element_label=False) -> Dict: infos['outputs'] = [flow.infos(use_numpy, use_element_label) for flow in self.outputs] return infos + def _plausibility_checks(self) -> None: + # TODO: Check for plausibility + pass + @register_class_for_io class Bus(Element): @@ -104,6 +109,7 @@ def __init__( self.outputs: List[Flow] = [] def create_model(self, model: SystemModel) -> 'BusModel': + self._plausibility_checks() self.model = BusModel(model, self) return self.model From 5eba750fd33de5ca630fa92ac5ebd08fed1db189 Mon Sep 17 00:00:00 2001 From: fel15133 Date: Mon, 17 Mar 2025 11:51:25 +0100 Subject: [PATCH 353/507] bugfix in max/min --- flixOpt/components.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/flixOpt/components.py b/flixOpt/components.py index 340f7c90d..55f920f97 100644 --- a/flixOpt/components.py +++ b/flixOpt/components.py @@ -256,8 +256,10 @@ def _plausibility_checks(self) -> None: maximum_capacity = self.capacity_in_flow_hours minimum_capacity = self.capacity_in_flow_hours - minimum_inital_capacity = minimum_capacity * self.relative_minimum_charge_state.isel(time=1) - maximum_inital_capacity = maximum_capacity * self.relative_maximum_charge_state.isel(time=1) + # initial capacity >= allowed min for maximum_size: + minimum_inital_capacity = maximum_capacity * self.relative_minimum_charge_state.isel(time=1) + # initial capacity <= allowed max for minimum_size: + maximum_inital_capacity = minimum_capacity * self.relative_maximum_charge_state.isel(time=1) if self.initial_charge_state > maximum_inital_capacity: raise ValueError(f'{self.label_full}: {self.initial_charge_state=} ' From cea5d4d2d4c14580e796655874ad3e7fc4b34e27 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 17 Mar 2025 13:55:18 +0100 Subject: [PATCH 354/507] Change saving routine --- flixOpt/calculation.py | 12 ++++--- flixOpt/results.py | 75 +++++++++++++++++++++++++++++++----------- 2 files changed, 64 insertions(+), 23 deletions(-) diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py index 66f6ef443..c7e7beca9 100644 --- a/flixOpt/calculation.py +++ b/flixOpt/calculation.py @@ -164,7 +164,10 @@ def solve(self, self.results = CalculationResults.from_calculation(self) - def save_results(self, save_flow_system: bool = False, compression: int = 0): + def save_results(self, + save_flow_system: bool = True, + save_model: bool = False, + compression: int = 0): """ Saves the results of the calculation to a folder with the name of the calculation. The folder is created if it does not exist. @@ -180,11 +183,12 @@ def save_results(self, save_flow_system: bool = False, compression: int = 0): compression : int, optional Compression level for the netCDF file, by default 0 wich leads to no compression. Currently, only the Flow System file can be compressed. + save_model: + Wether to save the model to file. If False, the model is not saved. + The model file size is rougly 100 times larger than the solution file. """ t_start = timeit.default_timer() - with open(self.folder / f'{self.name}_infos.yaml', 'w', encoding='utf-8') as f: - yaml.dump(self.infos, f, allow_unicode=True, sort_keys=False, indent=4) - self.results.to_file(self.folder, self.name) + self.results.to_file(self.folder, self.name, save_model=save_model) if save_flow_system: self.flow_system.to_netcdf(self.folder / f'{self.name}_flowsystem.nc', compression) self.durations['saving'] = round(timeit.default_timer() - t_start, 2) diff --git a/flixOpt/results.py b/flixOpt/results.py index 52db7ee83..388786d1b 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -9,6 +9,7 @@ import pandas as pd import plotly import xarray as xr +import yaml from . import plotting from .core import TimeSeriesCollection @@ -63,36 +64,47 @@ class CalculationResults: def from_file(cls, folder: Union[str, pathlib.Path], name: str): """ Create CalculationResults directly from file""" folder = pathlib.Path(folder) - path = folder / name - nc_file = path.with_suffix('.nc') - logger.info(f'loading calculation "{name}" from file ("{nc_file}")') - model = linopy.read_netcdf(nc_file) - with open(path.with_suffix('.json'), 'r', encoding='utf-8') as f: + + model_file = folder / f'{name}_model.nc' + if model_file.exists(): + logger.info(f'loading the linopy model "{name}" from file ("{model_file}")') + model = linopy.read_netcdf(model_file) + else: + model = None + + solution_file = folder / f'{name}_solution.nc' + solution = xr.load_dataset(solution_file) + with open(folder / f'{name}.json', 'r', encoding='utf-8') as f: meta_data = json.load(f) - return cls(model=model, name=name, folder= folder, **meta_data) + return cls(solution=solution,name=name, folder=folder, model=model, **meta_data) @classmethod def from_calculation(cls, calculation: 'Calculation'): """Create CalculationResults directly from a Calculation""" return cls(model=calculation.model, + solution=calculation.model.solution, results_structure=_results_structure(calculation.flow_system), infos=calculation.infos, network_infos=calculation.flow_system.network_infos(), name=calculation.name, folder=calculation.folder) - def __init__(self, - model: linopy.Model, - results_structure: Dict[str, Dict[str, Dict]], - name: str, - infos: Dict, - network_infos: Dict, - folder: Optional[pathlib.Path] = None): - self.model = model + def __init__( + self, + solution: xr.Dataset, + results_structure: Dict[str, Dict[str, Dict]], + name: str, + infos: Dict, + network_infos: Dict, + folder: Optional[pathlib.Path] = None, + model: Optional[linopy.Model] = None, + ): + self.solution = solution self._results_structure = results_structure self.infos = infos self.network_infos = network_infos self.name = name + self.model = model self.folder = pathlib.Path(folder) if folder is not None else pathlib.Path.cwd() / 'results' self.components = {label: ComponentResults.from_json(self, infos) for label, infos in results_structure['Components'].items()} @@ -115,20 +127,45 @@ def __getitem__(self, key: str) -> Union['ComponentResults', 'BusResults', 'Effe return self.effects[key] raise KeyError(f'No element with label {key} found.') - def to_file(self, folder: Optional[Union[str, pathlib.Path]] = None, name: Optional[str] = None, *args, **kwargs): - """Save the results to a file""" + def to_file(self, + folder: Optional[Union[str, pathlib.Path]] = None, + name: Optional[str] = None, + save_model: bool = False): + """ + Save the results to a file + Args: + folder: The folder where the results should be saved. + name: The name of the results file. + save_model: Wether to save the model to file. If True, the (linopy) model is saved as a .nc file. + The model file size is rougly 100 times larger than the solution file. + """ folder = self.folder if folder is None else pathlib.Path(folder) - name = self.name if name is None else name - path = folder / name if not folder.exists(): try: folder.mkdir(parents=False) except FileNotFoundError as e: raise FileNotFoundError(f'Folder {folder} and its parent do not exist. Please create them first.') from e - self.model.to_netcdf(path.with_suffix('.nc'), *args, **kwargs) + name = self.name if name is None else name + path = folder / name + + model_path = folder / f'{name}_model.nc' + solution_path = folder / f'{name}_solution.nc' + infos_path = folder / f'{name}_infos.yaml' + + self.solution.to_netcdf(solution_path) + + with open(infos_path, 'w', encoding='utf-8') as f: + yaml.dump(self.infos, f, allow_unicode=True, sort_keys=False, indent=4) + with open(path.with_suffix('.json'), 'w', encoding='utf-8') as f: json.dump(self._get_meta_data(), f, indent=4, ensure_ascii=False) + + if save_model: + if self.model is None: + logger.critical('No model in the CalculationResults. Saving the model is not possible.') + self.model.to_netcdf(self.model, model_path) + logger.info(f'Saved calculation results "{name}" to {path}') def _get_meta_data(self) -> Dict: From 17ca855b90e8fb8912990ee327bfb5605063a2fb Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 17 Mar 2025 14:29:04 +0100 Subject: [PATCH 355/507] Make saving of model optional --- flixOpt/results.py | 95 ++++++++++++++++++++++++---------------------- 1 file changed, 49 insertions(+), 46 deletions(-) diff --git a/flixOpt/results.py b/flixOpt/results.py index 388786d1b..344803b63 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -65,18 +65,20 @@ def from_file(cls, folder: Union[str, pathlib.Path], name: str): """ Create CalculationResults directly from file""" folder = pathlib.Path(folder) - model_file = folder / f'{name}_model.nc' - if model_file.exists(): - logger.info(f'loading the linopy model "{name}" from file ("{model_file}")') - model = linopy.read_netcdf(model_file) + model_path, solution_path, _, json_path = cls._get_paths( + folder= folder, name=name) + + if model_path.exists(): + logger.info(f'loading the linopy model "{name}" from file ("{model_path}")') + model = linopy.read_netcdf(model_path) else: model = None - solution_file = folder / f'{name}_solution.nc' - solution = xr.load_dataset(solution_file) - with open(folder / f'{name}.json', 'r', encoding='utf-8') as f: + solution = xr.load_dataset(solution_path) + with open(json_path, 'r', encoding='utf-8') as f: meta_data = json.load(f) - return cls(solution=solution,name=name, folder=folder, model=model, **meta_data) + + return cls(solution=solution, name=name, folder=folder, model=model, **meta_data) @classmethod def from_calculation(cls, calculation: 'Calculation'): @@ -146,19 +148,15 @@ def to_file(self, except FileNotFoundError as e: raise FileNotFoundError(f'Folder {folder} and its parent do not exist. Please create them first.') from e - name = self.name if name is None else name - path = folder / name - - model_path = folder / f'{name}_model.nc' - solution_path = folder / f'{name}_solution.nc' - infos_path = folder / f'{name}_infos.yaml' + model_path, solution_path, infos_path, json_path = self._get_paths( + folder= folder, name= self.name if name is None else name) self.solution.to_netcdf(solution_path) with open(infos_path, 'w', encoding='utf-8') as f: yaml.dump(self.infos, f, allow_unicode=True, sort_keys=False, indent=4) - with open(path.with_suffix('.json'), 'w', encoding='utf-8') as f: + with open(json_path, 'w', encoding='utf-8') as f: json.dump(self._get_meta_data(), f, indent=4, ensure_ascii=False) if save_model: @@ -166,7 +164,7 @@ def to_file(self, logger.critical('No model in the CalculationResults. Saving the model is not possible.') self.model.to_netcdf(self.model, model_path) - logger.info(f'Saved calculation results "{name}" to {path}') + logger.info(f'Saved calculation results "{name}" to {solution_path.parent}') def _get_meta_data(self) -> Dict: return { @@ -176,7 +174,7 @@ def _get_meta_data(self) -> Dict: } def plot_heatmap(self, - variable: str, + variable_name: str, heatmap_timeframes: Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'] = 'D', heatmap_timesteps_per_frame: Literal['W', 'D', 'h', '15min', 'min'] = 'h', color_map: str = 'portland', @@ -184,8 +182,8 @@ def plot_heatmap(self, show: bool = True ) -> plotly.graph_objs.Figure: return plot_heatmap( - dataarray=self.model.variables[variable].solution, - name=variable, + dataarray=self.solution[variable_name], + name=variable_name, folder=self.folder, heatmap_timeframes=heatmap_timeframes, heatmap_timesteps_per_frame=heatmap_timesteps_per_frame, @@ -193,17 +191,19 @@ def plot_heatmap(self, save=save, show=show) + @staticmethod + def _get_paths(folder: pathlib.Path, name: str) -> Tuple[pathlib.Path, pathlib.Path, pathlib.Path, pathlib.Path]: + model_path = folder / f'{name}_model.nc' + solution_path = folder / f'{name}_solution.nc' + infos_path = folder / f'{name}_infos.yaml' + json_path = folder/f'{name}_structure.json' + return model_path, solution_path, infos_path, json_path + @property def storages(self) -> List['ComponentResults']: return [comp for comp in self.components.values() if comp.is_storage] - @property - def variables(self) -> linopy.Variables: - return self.model.variables - @property - def constraints(self) -> linopy.Constraints: - return self.model.constraints class _ElementResults: @@ -224,13 +224,16 @@ def __init__(self, self._variable_names = variables self._constraint_names = constraints - self.variables = self._calculation_results.model.variables[self._variable_names] - self.constraints = self._calculation_results.model.constraints[self._constraint_names] + self.solution = self._calculation_results.solution[self._variable_names] - @property - def variables_time(self): - return self.variables[[name for name in self._variable_names if 'time' in self.variables[name].dims]] + self._variable_names_time = [name for name in self._variable_names if 'time' in self.solution[name].dims] + if self._calculation_results.model is not None: + self.variables = self._calculation_results.model.variables[self._variable_names] + self.constraints = self._calculation_results.model.constraints[self._constraint_names] + else: + self.variables = None + self.constraints = None class _NodeResults(_ElementResults): @classmethod @@ -271,9 +274,9 @@ def node_balance(self, negate_outputs: bool = False, threshold: Optional[float] = 1e-5, with_last_timestep: bool = False) -> xr.Dataset: - variables = [name for name in self.variables if name.endswith(('|flow_rate', '|excess_input', '|excess_output'))] + variable_names = [name for name in self._variable_names if name.endswith(('|flow_rate', '|excess_input', '|excess_output'))] return sanitize_dataset( - ds=self.variables[variables].solution, + ds=self.solution[variable_names], threshold=threshold, timesteps=self._calculation_results.timesteps_extra if with_last_timestep else None, negate=( @@ -293,17 +296,17 @@ class ComponentResults(_NodeResults): @property def is_storage(self) -> bool: - return self._charge_state in self.variables + return self._charge_state in self._variable_names @property def _charge_state(self) -> str: return f'{self.label}|charge_state' @property - def charge_state(self) -> linopy.Variable: + def charge_state(self) -> xr.DataArray: if not self.is_storage: raise ValueError(f'Cant get charge_state. "{self.label}" is not a storage') - return self.variables[self._charge_state] + return self.solution[self._charge_state] def plot_charge_state(self, save: Union[bool, pathlib.Path] = False, @@ -314,9 +317,9 @@ def plot_charge_state(self, mode='area', title=f'Operation Balance of {self.label}', show=False) - charge_state = self.charge_state.solution.to_dataframe() + charge_state = self.charge_state.to_dataframe() fig.add_trace(plotly.graph_objs.Scatter( - x=charge_state.index, y=charge_state.values.flatten(), mode='lines', name=self.charge_state.name)) + x=charge_state.index, y=charge_state.values.flatten(), mode='lines', name=self._charge_state)) return plotly_save_and_show( fig, @@ -331,9 +334,9 @@ def charge_state_and_flow_rates(self, threshold: Optional[float] = 1e-5) -> xr.Dataset: if not self.is_storage: raise ValueError(f'Cant get charge_state. "{self.label}" is not a storage') - variables = self.inputs + self.outputs + [self._charge_state] + variable_names = self.inputs + self.outputs + [self._charge_state] return sanitize_dataset( - ds=self.variables[variables].solution, + ds=self.solution[variable_names], threshold=threshold, timesteps=self._calculation_results.timesteps_extra, negate=( @@ -349,7 +352,7 @@ class EffectResults(_ElementResults): def get_shares_from(self, element: str): """ Get the shares from an Element (without subelements) to the Effect""" - return self.variables[[name for name in self._variable_names if name.startswith(f'{element}->')]] + return self.solution[[name for name in self._variable_names if name.startswith(f'{element}->')]] class SegmentedCalculationResults: @@ -399,17 +402,17 @@ def __init__(self, self.folder = pathlib.Path(folder) if folder is not None else pathlib.Path.cwd() / 'results' self.hours_per_timestep = TimeSeriesCollection.calculate_hours_per_timestep(self.all_timesteps) - def solution_without_overlap(self, variable: str) -> xr.DataArray: + def solution_without_overlap(self, variable_name: str) -> xr.DataArray: """Returns the solution of a variable without overlap""" - dataarrays = [result.model.variables[variable].solution.isel(time=slice(None, self.timesteps_per_segment)) + dataarrays = [result.solution[variable_name].isel(time=slice(None, self.timesteps_per_segment)) for result in self.segment_results[:-1] - ] + [self.segment_results[-1].model.variables[variable].solution] + ] + [self.segment_results[-1].solution[variable_name]] return xr.concat(dataarrays, dim='time') def plot_heatmap( self, - variable: str, + variable_name: str, heatmap_timeframes: Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'] = 'D', heatmap_timesteps_per_frame: Literal['W', 'D', 'h', '15min', 'min'] = 'h', color_map: str = 'portland', @@ -417,8 +420,8 @@ def plot_heatmap( show: bool = True ) -> plotly.graph_objs.Figure: return plot_heatmap( - dataarray=self.solution_without_overlap(variable), - name=variable, + dataarray=self.solution_without_overlap(variable_name), + name=variable_name, folder=self.folder, heatmap_timeframes=heatmap_timeframes, heatmap_timesteps_per_frame=heatmap_timesteps_per_frame, From 35017535891974953d5e3da7e1cab3d35f89c772 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 17 Mar 2025 16:08:35 +0100 Subject: [PATCH 356/507] Add compression to results saving --- flixOpt/results.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/flixOpt/results.py b/flixOpt/results.py index 344803b63..c8d4e793d 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -1,4 +1,5 @@ import datetime +import importlib.util import json import logging import pathlib @@ -132,7 +133,8 @@ def __getitem__(self, key: str) -> Union['ComponentResults', 'BusResults', 'Effe def to_file(self, folder: Optional[Union[str, pathlib.Path]] = None, name: Optional[str] = None, - save_model: bool = False): + save_model: bool = False, + compression: int = 5): """ Save the results to a file Args: @@ -140,6 +142,7 @@ def to_file(self, name: The name of the results file. save_model: Wether to save the model to file. If True, the (linopy) model is saved as a .nc file. The model file size is rougly 100 times larger than the solution file. + compression: The compression level to use when saving the solution file. """ folder = self.folder if folder is None else pathlib.Path(folder) if not folder.exists(): @@ -151,7 +154,14 @@ def to_file(self, model_path, solution_path, infos_path, json_path = self._get_paths( folder= folder, name= self.name if name is None else name) - self.solution.to_netcdf(solution_path) + encoding = None + if compression: + if importlib.util.find_spec('netCDF4') is None: + logger.warning('Saved results without compression due to missing dependency "netcdf4". ' + 'To use compression install with "pip install netcdf4"') + else: + encoding = {data_var: {"zlib": True, "complevel": 5} for data_var in self.solution.data_vars} + self.solution.to_netcdf(solution_path, encoding=encoding) with open(infos_path, 'w', encoding='utf-8') as f: yaml.dump(self.infos, f, allow_unicode=True, sort_keys=False, indent=4) From be04e1f712c6fd64aca3af229d503b02daaeb5d0 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 17 Mar 2025 16:31:41 +0100 Subject: [PATCH 357/507] Improve logic of wether to compress or not --- flixOpt/flow_system.py | 13 ++++++++++--- flixOpt/results.py | 11 ++++++----- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/flixOpt/flow_system.py b/flixOpt/flow_system.py index 39d3160fc..5b8a10691 100644 --- a/flixOpt/flow_system.py +++ b/flixOpt/flow_system.py @@ -190,11 +190,18 @@ def as_dataset(self, constants_in_dataset: bool = False) -> xr.Dataset: return ds def to_netcdf(self, path: Union[str, pathlib.Path], compression: int = 0): - if compression != 0 and importlib.util.find_spec('netCDF4') is None: - raise ModuleNotFoundError('Encoding is only supported with netCDF4. Install netcdf4 via pip install netcdf4.') ds = self.as_dataset() ds.attrs = {'flow_system': json.dumps(ds.attrs)} - ds.to_netcdf(path, encoding=None if compression == 0 else {k: dict(zlib=True, complevel=compression).copy() for k in ds.data_vars}) + + encoding = None + if compression != 0: + if importlib.util.find_spec('netCDF4') is not None: + encoding = {data_var: {"zlib": True, "complevel": 5} for data_var in ds.data_vars} + else: + logger.warning('CalculationResults were exported without compression due to missing dependency "netcdf4".' + 'Install netcdf4 via `pip install netcdf4`.') + + ds.to_netcdf(path, encoding=encoding) logger.info(f'Saved FlowSystem to {path}') def plot_network( diff --git a/flixOpt/results.py b/flixOpt/results.py index c8d4e793d..afe309301 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -155,12 +155,13 @@ def to_file(self, folder= folder, name= self.name if name is None else name) encoding = None - if compression: - if importlib.util.find_spec('netCDF4') is None: - logger.warning('Saved results without compression due to missing dependency "netcdf4". ' - 'To use compression install with "pip install netcdf4"') - else: + if compression != 0: + if importlib.util.find_spec('netCDF4') is not None: encoding = {data_var: {"zlib": True, "complevel": 5} for data_var in self.solution.data_vars} + else: + logger.warning('CalculationResults were exported without compression due to missing dependency "netcdf4".' + 'Install netcdf4 via `pip install netcdf4`.') + self.solution.to_netcdf(solution_path, encoding=encoding) with open(infos_path, 'w', encoding='utf-8') as f: From 3db0d9aee238a8c80150081bc0d2e5cb054ac269 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 17 Mar 2025 17:32:23 +0100 Subject: [PATCH 358/507] Update tests --- examples/00_Minmal/minimal_example.py | 2 +- flixOpt/results.py | 9 +++++++-- tests/test_integration.py | 6 +++--- tests/test_io.py | 4 ++-- 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/examples/00_Minmal/minimal_example.py b/examples/00_Minmal/minimal_example.py index bbda64dab..330727337 100644 --- a/examples/00_Minmal/minimal_example.py +++ b/examples/00_Minmal/minimal_example.py @@ -58,7 +58,7 @@ # --- Analyze Results --- # Access the results of an element - df1 = calculation.results['costs'].variables_time.solution.to_dataframe() + df1 = calculation.results['costs'].solution_time.to_dataframe() # Plot the results of a specific element calculation.results['District Heating'].plot_node_balance() diff --git a/flixOpt/results.py b/flixOpt/results.py index afe309301..626e82038 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -214,7 +214,9 @@ def _get_paths(folder: pathlib.Path, name: str) -> Tuple[pathlib.Path, pathlib.P def storages(self) -> List['ComponentResults']: return [comp for comp in self.components.values() if comp.is_storage] - + @property + def objective(self) -> float: + return self.infos['Main Results']['Objective'] class _ElementResults: @@ -239,6 +241,8 @@ def __init__(self, self._variable_names_time = [name for name in self._variable_names if 'time' in self.solution[name].dims] + self.solution_time = self._calculation_results.solution[self._variable_names_time] + if self._calculation_results.model is not None: self.variables = self._calculation_results.model.variables[self._variable_names] self.constraints = self._calculation_results.model.constraints[self._constraint_names] @@ -246,6 +250,7 @@ def __init__(self, self.variables = None self.constraints = None + class _NodeResults(_ElementResults): @classmethod def from_json(cls, calculation_results, json_data: Dict) -> '_NodeResults': @@ -440,7 +445,7 @@ def plot_heatmap( save=save, show=show) - def to_file(self, folder: Optional[Union[str, pathlib.Path]] = None, name: Optional[str] = None, *args, **kwargs): + def to_file(self, folder: Optional[Union[str, pathlib.Path]] = None, name: Optional[str] = None): """Save the results to a file""" folder = self.folder if folder is None else pathlib.Path(folder) name = self.name if name is None else name diff --git a/tests/test_integration.py b/tests/test_integration.py index b5d39bc1a..c192154a2 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -73,12 +73,12 @@ def test_results_persistence(self, simple_flow_system, highs_solver): # Verify key variables from loaded results assert_almost_equal_numeric( - results.model.variables['costs|total'].solution.values, + results.solution['costs|total'].values, 81.88394666666667, 'costs doesnt match expected value', ) assert_almost_equal_numeric( - results.model.variables['CO2|total'].solution.values, + results.solution['CO2|total'].values, 255.09184, 'CO2 doesnt match expected value' ) @@ -427,7 +427,7 @@ def test_modeling_types_costs(self, modeling_calculation): ) else: assert_almost_equal_numeric( - sum(calc.results.solution_without_overlap('costs(operation)|total_per_timestep')), + calc.results.solution_without_overlap('costs(operation)|total_per_timestep').sum(), expected_costs[modeling_type], f'Costs do not match for {modeling_type} modeling type' ) diff --git a/tests/test_io.py b/tests/test_io.py index dff224dd3..bba2c11bc 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -39,8 +39,8 @@ def test_flow_system_file_io(flow_system, highs_solver): 'objective of loaded flow_system doesnt match the original') assert_almost_equal_numeric( - calculation_0.results.model.variables['costs|total'].solution.values, - calculation_1.results.model.variables['costs|total'].solution.values, + calculation_0.results.solution['costs|total'].values, + calculation_1.results.solution['costs|total'].values, 'costs doesnt match expected value', ) From a3d4d7926c24043a74dfe109c501599ccae46426 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 17 Mar 2025 17:45:13 +0100 Subject: [PATCH 359/507] add .log to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 69585dfd3..cc2179b07 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ *.pyc +*.log results/ .idea/ .venv/ From 8c6c522990e5859c5e36d38df83798987e17e183 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 17 Mar 2025 17:47:01 +0100 Subject: [PATCH 360/507] Bugfix saving the model --- flixOpt/results.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixOpt/results.py b/flixOpt/results.py index 626e82038..03c087de1 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -173,7 +173,7 @@ def to_file(self, if save_model: if self.model is None: logger.critical('No model in the CalculationResults. Saving the model is not possible.') - self.model.to_netcdf(self.model, model_path) + self.model.to_netcdf(model_path) logger.info(f'Saved calculation results "{name}" to {solution_path.parent}') From ee348f0714d870abdc19e6bb6f70254547f88698 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 17 Mar 2025 17:52:41 +0100 Subject: [PATCH 361/507] Improve --- flixOpt/calculation.py | 8 ++++---- flixOpt/results.py | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py index c7e7beca9..7c5552c92 100644 --- a/flixOpt/calculation.py +++ b/flixOpt/calculation.py @@ -166,8 +166,8 @@ def solve(self, def save_results(self, save_flow_system: bool = True, - save_model: bool = False, - compression: int = 0): + compression: int = 5, + save_linopy_model: bool = False): """ Saves the results of the calculation to a folder with the name of the calculation. The folder is created if it does not exist. @@ -183,12 +183,12 @@ def save_results(self, compression : int, optional Compression level for the netCDF file, by default 0 wich leads to no compression. Currently, only the Flow System file can be compressed. - save_model: + save_linopy_model: Wether to save the model to file. If False, the model is not saved. The model file size is rougly 100 times larger than the solution file. """ t_start = timeit.default_timer() - self.results.to_file(self.folder, self.name, save_model=save_model) + self.results.to_file(self.folder, self.name, save_linopy_model=save_linopy_model) if save_flow_system: self.flow_system.to_netcdf(self.folder / f'{self.name}_flowsystem.nc', compression) self.durations['saving'] = round(timeit.default_timer() - t_start, 2) diff --git a/flixOpt/results.py b/flixOpt/results.py index 03c087de1..20f050e24 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -133,14 +133,14 @@ def __getitem__(self, key: str) -> Union['ComponentResults', 'BusResults', 'Effe def to_file(self, folder: Optional[Union[str, pathlib.Path]] = None, name: Optional[str] = None, - save_model: bool = False, - compression: int = 5): + compression: int = 5, + save_linopy_model: bool = False,): """ Save the results to a file Args: folder: The folder where the results should be saved. name: The name of the results file. - save_model: Wether to save the model to file. If True, the (linopy) model is saved as a .nc file. + save_linopy_model: Wether to save the model to file. If True, the (linopy) model is saved as a .nc file. The model file size is rougly 100 times larger than the solution file. compression: The compression level to use when saving the solution file. """ @@ -170,7 +170,7 @@ def to_file(self, with open(json_path, 'w', encoding='utf-8') as f: json.dump(self._get_meta_data(), f, indent=4, ensure_ascii=False) - if save_model: + if save_linopy_model: if self.model is None: logger.critical('No model in the CalculationResults. Saving the model is not possible.') self.model.to_netcdf(model_path) From 2ef329bacfc2b7c2035218d92791b793028ef908 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 18 Mar 2025 09:43:02 +0100 Subject: [PATCH 362/507] Remove save_model function from Calculation. Rely on results.py Ad document_model() to doucument math --- flixOpt/calculation.py | 29 --------------- flixOpt/io.py | 82 ++++++++++++++++++++++++++++++++++++++++++ flixOpt/results.py | 16 ++++++--- flixOpt/structure.py | 23 ++++++++++++ 4 files changed, 116 insertions(+), 34 deletions(-) diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py index 7c5552c92..3e38a886d 100644 --- a/flixOpt/calculation.py +++ b/flixOpt/calculation.py @@ -164,35 +164,6 @@ def solve(self, self.results = CalculationResults.from_calculation(self) - def save_results(self, - save_flow_system: bool = True, - compression: int = 5, - save_linopy_model: bool = False): - """ - Saves the results of the calculation to a folder with the name of the calculation. - The folder is created if it does not exist. - - The CalculationResults are saved as a .nc and a .json file. - The calculation infos are saved as a .yaml file. - Optionally, the flow_system is saved as a .nc file. - - Parameters - ---------- - save_flow_system : bool, optional - Whether to save the flow_system, by default False - compression : int, optional - Compression level for the netCDF file, by default 0 wich leads to no compression. - Currently, only the Flow System file can be compressed. - save_linopy_model: - Wether to save the model to file. If False, the model is not saved. - The model file size is rougly 100 times larger than the solution file. - """ - t_start = timeit.default_timer() - self.results.to_file(self.folder, self.name, save_linopy_model=save_linopy_model) - if save_flow_system: - self.flow_system.to_netcdf(self.folder / f'{self.name}_flowsystem.nc', compression) - self.durations['saving'] = round(timeit.default_timer() - t_start, 2) - def _activate_time_series(self): self.flow_system.transform_data() self.flow_system.time_series_collection.activate_timesteps( diff --git a/flixOpt/io.py b/flixOpt/io.py index c52a0167a..95add71bf 100644 --- a/flixOpt/io.py +++ b/flixOpt/io.py @@ -1,5 +1,7 @@ import datetime import json +import re +import yaml import logging import pathlib from typing import Dict, Literal, Union @@ -55,6 +57,7 @@ def replace_timeseries(obj, mode: Literal['name', 'stats', 'data'] = 'name'): else: return obj + def insert_dataarray(obj, ds: xr.Dataset): """Recursively inserts TimeSeries objects into a dataset.""" if isinstance(obj, dict): @@ -83,3 +86,82 @@ def remove_none_and_empty(obj): else: return obj + + +def save_to_yaml(data, output_file='formatted_output.yaml'): + """ + Save dictionary data to YAML with proper multi-line string formatting. + Handles complex string patterns including backticks, special characters, + and various newline formats. + + Args: + data (dict): Dictionary containing string data + output_file (str): Path to output YAML file + """ + # Process strings to normalize all newlines and handle special patterns + processed_data = process_complex_strings(data) + + # Define a custom representer for strings + def represent_str(dumper, data): + # Use literal block style (|) for any string with newlines + if '\n' in data: + return dumper.represent_scalar('tag:yaml.org,2002:str', data, style='|') + + # Use quoted style for strings with special characters to ensure proper parsing + elif any(char in data for char in ':`{}[]#,&*!|>%@'): + return dumper.represent_scalar('tag:yaml.org,2002:str', data, style='"') + + # Use plain style for simple strings + return dumper.represent_scalar('tag:yaml.org,2002:str', data) + + # Add the string representer to SafeDumper + yaml.add_representer(str, represent_str, Dumper=yaml.SafeDumper) + + # Write to file with settings that ensure proper formatting + with open(output_file, 'w', encoding='utf-8') as file: + yaml.dump( + processed_data, + file, + Dumper=yaml.SafeDumper, + sort_keys=False, # Preserve dictionary order + default_flow_style=False, # Use block style for mappings + width=float('inf'), # Don't wrap long lines + allow_unicode=True, # Support Unicode characters + ) + + print(f'Data saved to {output_file}') + +def process_complex_strings(data): + """ + Process dictionary data recursively with comprehensive string normalization. + Handles various types of strings and special formatting. + + Args: + data: The data to process (dict, list, str, or other) + + Returns: + Processed data with normalized strings + """ + if isinstance(data, dict): + return {k: process_complex_strings(v) for k, v in data.items()} + elif isinstance(data, list): + return [process_complex_strings(item) for item in data] + elif isinstance(data, str): + # Step 1: Normalize line endings to \n + normalized = data.replace('\r\n', '\n').replace('\r', '\n') + + # Step 2: Handle escaped newlines with robust regex + normalized = re.sub(r'(? Dict: diff --git a/flixOpt/structure.py b/flixOpt/structure.py index fa8765f1f..1dadb659b 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -58,6 +58,29 @@ def do_modeling(self): for bus_model in bus_models: # Buses after Components, because FlowModels are created in ComponentModels bus_model.do_modeling() + def document_model(self, path: pathlib.Path = None) -> Dict[str, str]: + """ + Convert the model variables and constraints to a structured string representation. + This can take multiple seconds for large models. + The output can be saved to a yaml file with readable formating applied. + + Args: + path (pathlib.Path, optional): Path to save the document. Defaults to None. + """ + from .io import save_to_yaml + documentation = { + 'variables': {variable_name: variable.__repr__() for variable_name, variable in self.variables.items()}, + 'constraints': {constraint_name: constraint.__repr__() for constraint_name, constraint in self.constraints.items()}, + 'objective': self.objective.__repr__(), + } + + if path is not None: + if path.suffix not in ['.yaml', '.yml']: + raise ValueError(f'Invalid file extension for path {path}. Only .yaml and .yml are supported') + save_to_yaml(documentation, path) + + return documentation + @property def hours_per_step(self): return self.time_series_collection.hours_per_timestep From 95c75d60deb1ac4ce16e91f24dbb5cacc0d9e33c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 18 Mar 2025 10:33:26 +0100 Subject: [PATCH 363/507] Include flow_system in results saving --- flixOpt/io.py | 38 +++++++++++++++++++---- flixOpt/results.py | 74 +++++++++++++++++++++++++++++--------------- flixOpt/structure.py | 23 -------------- 3 files changed, 81 insertions(+), 54 deletions(-) diff --git a/flixOpt/io.py b/flixOpt/io.py index 95add71bf..3d4bb4c67 100644 --- a/flixOpt/io.py +++ b/flixOpt/io.py @@ -4,13 +4,15 @@ import yaml import logging import pathlib -from typing import Dict, Literal, Union +from typing import Dict, Literal, Union, TYPE_CHECKING import xarray as xr +import linopy from .core import TimeSeries from .flow_system import FlowSystem + logger = logging.getLogger('flixOpt') @@ -88,7 +90,7 @@ def remove_none_and_empty(obj): return obj -def save_to_yaml(data, output_file='formatted_output.yaml'): +def _save_to_yaml(data, output_file='formatted_output.yaml'): """ Save dictionary data to YAML with proper multi-line string formatting. Handles complex string patterns including backticks, special characters, @@ -99,7 +101,7 @@ def save_to_yaml(data, output_file='formatted_output.yaml'): output_file (str): Path to output YAML file """ # Process strings to normalize all newlines and handle special patterns - processed_data = process_complex_strings(data) + processed_data = _process_complex_strings(data) # Define a custom representer for strings def represent_str(dumper, data): @@ -131,7 +133,8 @@ def represent_str(dumper, data): print(f'Data saved to {output_file}') -def process_complex_strings(data): + +def _process_complex_strings(data): """ Process dictionary data recursively with comprehensive string normalization. Handles various types of strings and special formatting. @@ -143,9 +146,9 @@ def process_complex_strings(data): Processed data with normalized strings """ if isinstance(data, dict): - return {k: process_complex_strings(v) for k, v in data.items()} + return {k: _process_complex_strings(v) for k, v in data.items()} elif isinstance(data, list): - return [process_complex_strings(item) for item in data] + return [_process_complex_strings(item) for item in data] elif isinstance(data, str): # Step 1: Normalize line endings to \n normalized = data.replace('\r\n', '\n').replace('\r', '\n') @@ -165,3 +168,26 @@ def process_complex_strings(data): return normalized else: return data + + +def document_linopy_model(model: linopy.Model, path: pathlib.Path = None) -> Dict[str, str]: + """ + Convert all model variables and constraints to a structured string representation. + This can take multiple seconds for large models. + The output can be saved to a yaml file with readable formating applied. + + Args: + path (pathlib.Path, optional): Path to save the document. Defaults to None. + """ + documentation = { + 'variables': {variable_name: variable.__repr__() for variable_name, variable in self.variables.items()}, + 'constraints': {constraint_name: constraint.__repr__() for constraint_name, constraint in self.constraints.items()}, + 'objective': model.objective.__repr__(), + } + + if path is not None: + if path.suffix not in ['.yaml', '.yml']: + raise ValueError(f'Invalid file extension for path {path}. Only .yaml and .yml are supported') + _save_to_yaml(documentation, path) + + return documentation diff --git a/flixOpt/results.py b/flixOpt/results.py index 90910096f..c5563ff31 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -14,7 +14,7 @@ from . import plotting from .core import TimeSeriesCollection -from .io import _results_structure +from .io import _results_structure, document_linopy_model if TYPE_CHECKING: from .calculation import Calculation, SegmentedCalculation @@ -66,8 +66,7 @@ def from_file(cls, folder: Union[str, pathlib.Path], name: str): """ Create CalculationResults directly from file""" folder = pathlib.Path(folder) - model_path, solution_path, _, json_path = cls._get_paths( - folder= folder, name=name) + model_path, solution_path, _, json_path, flow_system_path = cls._get_paths(folder= folder, name=name) if model_path.exists(): logger.info(f'loading the linopy model "{name}" from file ("{model_path}")') @@ -84,17 +83,21 @@ def from_file(cls, folder: Union[str, pathlib.Path], name: str): @classmethod def from_calculation(cls, calculation: 'Calculation'): """Create CalculationResults directly from a Calculation""" - return cls(model=calculation.model, - solution=calculation.model.solution, - results_structure=_results_structure(calculation.flow_system), - infos=calculation.infos, - network_infos=calculation.flow_system.network_infos(), - name=calculation.name, - folder=calculation.folder) + return cls( + solution=calculation.model.solution, + flow_system=calculation.flow_system.as_dataset(), + results_structure=_results_structure(calculation.flow_system), + infos=calculation.infos, + network_infos=calculation.flow_system.network_infos(), + model=calculation.model, + name=calculation.name, + folder=calculation.folder, + ) def __init__( self, solution: xr.Dataset, + flow_system: xr.Dataset, results_structure: Dict[str, Dict[str, Dict]], name: str, infos: Dict, @@ -103,6 +106,7 @@ def __init__( model: Optional[linopy.Model] = None, ): self.solution = solution + self.flow_system = flow_system self._results_structure = results_structure self.infos = infos self.network_infos = network_infos @@ -130,13 +134,14 @@ def __getitem__(self, key: str) -> Union['ComponentResults', 'BusResults', 'Effe return self.effects[key] raise KeyError(f'No element with label {key} found.') - def to_file(self, - folder: Optional[Union[str, pathlib.Path]] = None, - name: Optional[str] = None, - compression: int = 5, - save_linopy_model: bool = False, - document_model: bool = True, - ): + def to_file( + self, + folder: Optional[Union[str, pathlib.Path]] = None, + name: Optional[str] = None, + compression: int = 5, + document_model: bool = True, + save_linopy_model: bool = False, + ): """ Save the results to a file Args: @@ -154,18 +159,28 @@ def to_file(self, except FileNotFoundError as e: raise FileNotFoundError(f'Folder {folder} and its parent do not exist. Please create them first.') from e - model_path, solution_path, infos_path, json_path = self._get_paths( + model_path, solution_path, infos_path, json_path, flow_system_path, model_doc_path = self._get_paths( folder= folder, name= self.name if name is None else name) - encoding = None + ENCODE = False if compression != 0: if importlib.util.find_spec('netCDF4') is not None: - encoding = {data_var: {"zlib": True, "complevel": 5} for data_var in self.solution.data_vars} + ENCODE = True else: logger.warning('CalculationResults were exported without compression due to missing dependency "netcdf4".' 'Install netcdf4 via `pip install netcdf4`.') - self.solution.to_netcdf(solution_path, encoding=encoding) + self.solution.to_netcdf( + solution_path, + encoding=None if not ENCODE else {data_var: {"zlib": True, "complevel": 5} + for data_var in self.solution.data_vars} + ) + + self.flow_system.to_netcdf( + flow_system_path, + encoding=None if not ENCODE else {data_var: {"zlib": True, "complevel": 5} + for data_var in self.flow_system.data_vars} + ) with open(infos_path, 'w', encoding='utf-8') as f: yaml.dump(self.infos, f, allow_unicode=True, sort_keys=False, indent=4, width=1000) @@ -176,10 +191,14 @@ def to_file(self, if save_linopy_model: if self.model is None: logger.critical('No model in the CalculationResults. Saving the model is not possible.') - self.model.to_netcdf(model_path) + else: + self.model.to_netcdf(model_path) if document_model: - self.model.document_model(path=model_path.parent / f'{name}_model_documentation.yaml') + if self.model is None: + logger.critical('No model in the CalculationResults. Documenting the model is not possible.') + else: + document_linopy_model(self.model, path=model_doc_path) logger.info(f'Saved calculation results "{name}" to {solution_path.parent}') @@ -209,12 +228,17 @@ def plot_heatmap(self, show=show) @staticmethod - def _get_paths(folder: pathlib.Path, name: str) -> Tuple[pathlib.Path, pathlib.Path, pathlib.Path, pathlib.Path]: + def _get_paths( + folder: pathlib.Path, + name: str + ) -> Tuple[pathlib.Path, pathlib.Path, pathlib.Path, pathlib.Path, pathlib.Path, pathlib.Path]: model_path = folder / f'{name}_model.nc' solution_path = folder / f'{name}_solution.nc' infos_path = folder / f'{name}_infos.yaml' json_path = folder/f'{name}_structure.json' - return model_path, solution_path, infos_path, json_path + flow_system_path = folder / f'{name}_flowsystem.nc' + model_documentation_path = folder / f'{name}_model_doc.yaml' + return model_path, solution_path, infos_path, json_path, flow_system_path, model_documentation_path @property def storages(self) -> List['ComponentResults']: diff --git a/flixOpt/structure.py b/flixOpt/structure.py index 1dadb659b..fa8765f1f 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -58,29 +58,6 @@ def do_modeling(self): for bus_model in bus_models: # Buses after Components, because FlowModels are created in ComponentModels bus_model.do_modeling() - def document_model(self, path: pathlib.Path = None) -> Dict[str, str]: - """ - Convert the model variables and constraints to a structured string representation. - This can take multiple seconds for large models. - The output can be saved to a yaml file with readable formating applied. - - Args: - path (pathlib.Path, optional): Path to save the document. Defaults to None. - """ - from .io import save_to_yaml - documentation = { - 'variables': {variable_name: variable.__repr__() for variable_name, variable in self.variables.items()}, - 'constraints': {constraint_name: constraint.__repr__() for constraint_name, constraint in self.constraints.items()}, - 'objective': self.objective.__repr__(), - } - - if path is not None: - if path.suffix not in ['.yaml', '.yml']: - raise ValueError(f'Invalid file extension for path {path}. Only .yaml and .yml are supported') - save_to_yaml(documentation, path) - - return documentation - @property def hours_per_step(self): return self.time_series_collection.hours_per_timestep From 5fc9147ea165027bddcf031d64c836f734da6333 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 18 Mar 2025 10:36:21 +0100 Subject: [PATCH 364/507] Update all saving in examples and tests --- examples/01_Simple/simple_example.py | 5 ++++- tests/test_io.py | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/examples/01_Simple/simple_example.py b/examples/01_Simple/simple_example.py index 95b7137ec..d27eb216c 100644 --- a/examples/01_Simple/simple_example.py +++ b/examples/01_Simple/simple_example.py @@ -114,4 +114,7 @@ # Convert the results for the storage component to a dataframe and display df = calculation.results['Storage'].charge_state_and_flow_rates() print(df) - calculation.save_results(save_flow_system=True) + + #Save results to file for later usage + calculation.results.to_file() + diff --git a/tests/test_io.py b/tests/test_io.py index bba2c11bc..151e8c838 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -27,7 +27,7 @@ def test_flow_system_file_io(flow_system, highs_solver): calculation_0.do_modeling() calculation_0.solve(highs_solver) - calculation_0.save_results(save_flow_system=True, compression=5) + calculation_0.results.to_file() flow_system_1 = fx.FlowSystem.from_netcdf(f'results/{calculation_0.name}_flowsystem.nc') calculation_1 = fx.FullCalculation('Loaded_IO', flow_system=flow_system_1) From 8fa68dfef1108bdc9e22c67d55fae3a3a14f8a6d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 18 Mar 2025 11:05:37 +0100 Subject: [PATCH 365/507] Update results loading and saving --- flixOpt/flow_system.py | 17 +---------------- flixOpt/io.py | 4 ++-- flixOpt/results.py | 10 +++++++--- 3 files changed, 10 insertions(+), 21 deletions(-) diff --git a/flixOpt/flow_system.py b/flixOpt/flow_system.py index 5b8a10691..61a7630be 100644 --- a/flixOpt/flow_system.py +++ b/flixOpt/flow_system.py @@ -186,24 +186,9 @@ def as_dict(self, data_mode: Literal['data', 'name', 'stats'] = 'data') -> Dict: def as_dataset(self, constants_in_dataset: bool = False) -> xr.Dataset: ds = self.time_series_collection.to_dataset(include_constants=constants_in_dataset) - ds.attrs = self.as_dict(data_mode='name') + ds.attrs = {'flow_system': json.dumps(self.as_dict(data_mode='name'))} return ds - def to_netcdf(self, path: Union[str, pathlib.Path], compression: int = 0): - ds = self.as_dataset() - ds.attrs = {'flow_system': json.dumps(ds.attrs)} - - encoding = None - if compression != 0: - if importlib.util.find_spec('netCDF4') is not None: - encoding = {data_var: {"zlib": True, "complevel": 5} for data_var in ds.data_vars} - else: - logger.warning('CalculationResults were exported without compression due to missing dependency "netcdf4".' - 'Install netcdf4 via `pip install netcdf4`.') - - ds.to_netcdf(path, encoding=encoding) - logger.info(f'Saved FlowSystem to {path}') - def plot_network( self, path: Union[bool, str, pathlib.Path] = 'flow_system.html', diff --git a/flixOpt/io.py b/flixOpt/io.py index 3d4bb4c67..3f89679a0 100644 --- a/flixOpt/io.py +++ b/flixOpt/io.py @@ -180,8 +180,8 @@ def document_linopy_model(model: linopy.Model, path: pathlib.Path = None) -> Dic path (pathlib.Path, optional): Path to save the document. Defaults to None. """ documentation = { - 'variables': {variable_name: variable.__repr__() for variable_name, variable in self.variables.items()}, - 'constraints': {constraint_name: constraint.__repr__() for constraint_name, constraint in self.constraints.items()}, + 'variables': {variable_name: variable.__repr__() for variable_name, variable in model.variables.items()}, + 'constraints': {constraint_name: constraint.__repr__() for constraint_name, constraint in model.constraints.items()}, 'objective': model.objective.__repr__(), } diff --git a/flixOpt/results.py b/flixOpt/results.py index c5563ff31..6c2830924 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -66,7 +66,7 @@ def from_file(cls, folder: Union[str, pathlib.Path], name: str): """ Create CalculationResults directly from file""" folder = pathlib.Path(folder) - model_path, solution_path, _, json_path, flow_system_path = cls._get_paths(folder= folder, name=name) + model_path, solution_path, _, json_path, flow_system_path, _ = cls._get_paths(folder=folder, name=name) if model_path.exists(): logger.info(f'loading the linopy model "{name}" from file ("{model_path}")') @@ -74,11 +74,15 @@ def from_file(cls, folder: Union[str, pathlib.Path], name: str): else: model = None - solution = xr.load_dataset(solution_path) with open(json_path, 'r', encoding='utf-8') as f: meta_data = json.load(f) - return cls(solution=solution, name=name, folder=folder, model=model, **meta_data) + return cls(solution=xr.load_dataset(solution_path), + flow_system=xr.load_dataset(flow_system_path), + name=name, + folder=folder, + model=model, + **meta_data) @classmethod def from_calculation(cls, calculation: 'Calculation'): From a13a5bf0c93ff515a8ace6737fc9d72eafb6b783 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 18 Mar 2025 11:13:18 +0100 Subject: [PATCH 366/507] Add special handling to previous values for saving to work --- flixOpt/elements.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/flixOpt/elements.py b/flixOpt/elements.py index 09c571f09..aac60f411 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -218,7 +218,7 @@ def __init__( self.flow_hours_total_min = flow_hours_total_min self.on_off_parameters = on_off_parameters - self.previous_flow_rate = previous_flow_rate + self.previous_flow_rate = previous_flow_rate if not isinstance(previous_flow_rate, list) else np.array(previous_flow_rate) self.component: str = 'UnknownComponent' self.is_input_in_component: Optional[bool] = None @@ -263,6 +263,12 @@ def infos(self, use_numpy=True, use_element_label=False) -> Dict: infos['is_input_in_component'] = self.is_input_in_component return infos + def to_dict(self) -> Dict: + data = super().to_dict() + if isinstance(data.get('previous_flow_rate'), np.ndarray): + data['previous_flow_rate'] = data['previous_flow_rate'].tolist() + return data + def _plausibility_checks(self) -> None: # TODO: Incorporate into Variable? (Lower_bound can not be greater than upper bound if np.any(self.relative_minimum > self.relative_maximum): From 0477510e3edae1ff28a13ad0ba87e896c7dff680 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 18 Mar 2025 16:32:30 +0100 Subject: [PATCH 367/507] Add verbose flag to workflow --- .github/workflows/python-app.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-app.yaml b/.github/workflows/python-app.yaml index 5d65ad61e..a193553f4 100644 --- a/.github/workflows/python-app.yaml +++ b/.github/workflows/python-app.yaml @@ -82,7 +82,7 @@ jobs: - name: Upload to TestPyPI run: | - twine upload --repository testpypi dist/* + twine upload --repository testpypi dist/* --verbose env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.TEST_PYPI_API_TOKEN }} From d87f460dcdbc201b52b2c4e4b45d759966ebcf58 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 18 Mar 2025 16:44:59 +0100 Subject: [PATCH 368/507] Bugfix in import in workflow --- .github/workflows/python-app.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-app.yaml b/.github/workflows/python-app.yaml index a193553f4..3d05a915d 100644 --- a/.github/workflows/python-app.yaml +++ b/.github/workflows/python-app.yaml @@ -99,7 +99,7 @@ jobs: (sleep 30 && pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ $PACKAGE_NAME) || \ (sleep 60 && pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ $PACKAGE_NAME) # Basic import test - python -c "import $PACKAGE_NAME; print('Installation successful!')" + python -c "import flixOpt; print('Installation successful!')" publish-pypi: name: Publish to PyPI From e565670ea53abc952b25936a40e51801cc65ee44 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 18 Mar 2025 17:09:29 +0100 Subject: [PATCH 369/507] Add release to pypi --- .github/workflows/python-app.yaml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/.github/workflows/python-app.yaml b/.github/workflows/python-app.yaml index 3d05a915d..531989a63 100644 --- a/.github/workflows/python-app.yaml +++ b/.github/workflows/python-app.yaml @@ -124,3 +124,24 @@ jobs: - name: Build the distribution run: | python -m build + + - name: Upload to PyPI + run: | + twine upload dist/* --verbose + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} + + - name: Verify PyPI installation + run: | + # Create a temporary environment to test installation + python -m venv prod_test_env + source prod_test_env/bin/activate + # Get the package name from the built distribution + PACKAGE_NAME=$(ls dist/*.tar.gz | head -n 1 | sed 's/dist\///' | sed 's/-[0-9].*$//') + # Wait for PyPI to index the package + sleep 60 + # Install from PyPI + pip install $PACKAGE_NAME + # Basic import test + python -c "import flixOpt; print('PyPI installation successful!')" \ No newline at end of file From 0b6e2572e5e7ef04717eab37f98af408b019952c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 18 Mar 2025 17:19:40 +0100 Subject: [PATCH 370/507] Update project name --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 36e99c42f..e9db3c04c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools>=61.0.0", "wheel", "setuptools_scm[toml]>=6.2"] build-backend = "setuptools.build_meta" [project] -name = "flixOpt" +name = "flixopt" dynamic = ["version"] description = "Vector based energy and material flow optimization framework in Python." readme = "README.md" From 6bf55c219f8deb28889d11f2cd27ce92b3da97f1 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 18 Mar 2025 20:54:35 +0100 Subject: [PATCH 371/507] Split to and from netcdf from as dataset for flow_system --- flixOpt/flow_system.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/flixOpt/flow_system.py b/flixOpt/flow_system.py index 61a7630be..be302c792 100644 --- a/flixOpt/flow_system.py +++ b/flixOpt/flow_system.py @@ -186,9 +186,23 @@ def as_dict(self, data_mode: Literal['data', 'name', 'stats'] = 'data') -> Dict: def as_dataset(self, constants_in_dataset: bool = False) -> xr.Dataset: ds = self.time_series_collection.to_dataset(include_constants=constants_in_dataset) - ds.attrs = {'flow_system': json.dumps(self.as_dict(data_mode='name'))} + ds.attrs = self.as_dict(data_mode='name') return ds + def to_netcdf(self, path: Union[str, pathlib.Path], compression: int = 0): + ds = self.as_dataset() + ds.attrs = {'flow_system': json.dumps(ds.attrs)} + + encoding = None + if compression != 0: + if importlib.util.find_spec('netCDF4') is not None: + encoding = {k: dict(zlib=True, complevel=compression) for k in ds.data_vars} + else: + logger.warning('FlowSystem was exported without compression due to missing dependency "netcdf4".' + 'Install netcdf4 via `pip install netcdf4`.') + ds.to_netcdf(path, encoding=encoding) + logger.info(f'Saved FlowSystem to {path}') + def plot_network( self, path: Union[bool, str, pathlib.Path] = 'flow_system.html', From c8557e2007868212223bd5668333d31a856d23c3 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 18 Mar 2025 21:20:43 +0100 Subject: [PATCH 372/507] Improve saving and loading --- flixOpt/flow_system.py | 21 +++++++++++++++++---- flixOpt/results.py | 16 +++++++++++----- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/flixOpt/flow_system.py b/flixOpt/flow_system.py index be302c792..e7a6a9e1f 100644 --- a/flixOpt/flow_system.py +++ b/flixOpt/flow_system.py @@ -117,7 +117,7 @@ def from_netcdf(cls, path: Union[str, pathlib.Path]): """ with xr.open_dataset(path) as ds: ds = ds.load() - ds.attrs = json.loads(ds.attrs['flow_system']) + ds.attrs = json.loads(ds.attrs['attrs']) return cls.from_dataset(ds) def add_elements(self, *elements: Element) -> None: @@ -185,13 +185,26 @@ def as_dict(self, data_mode: Literal['data', 'name', 'stats'] = 'data') -> Dict: return io.replace_timeseries(data, data_mode) def as_dataset(self, constants_in_dataset: bool = False) -> xr.Dataset: + """ + Convert the FlowSystem to a xarray Dataset. + + Args: + constants_in_dataset: If True, constants are included as Dataset variables. + """ ds = self.time_series_collection.to_dataset(include_constants=constants_in_dataset) ds.attrs = self.as_dict(data_mode='name') return ds - def to_netcdf(self, path: Union[str, pathlib.Path], compression: int = 0): - ds = self.as_dataset() - ds.attrs = {'flow_system': json.dumps(ds.attrs)} + def to_netcdf(self, path: Union[str, pathlib.Path], compression: int = 0, constants_in_dataset: bool = False): + """ + Saves the FlowSystem to a netCDF file. + Args: + path: The path to the netCDF file. + compression: The compression level to use when saving the file. + constants_in_dataset: If True, constants are included as Dataset variables. + """ + ds = self.as_dataset(constants_in_dataset=True) + ds.attrs = {'attrs': json.dumps(ds.attrs)} encoding = None if compression != 0: diff --git a/flixOpt/results.py b/flixOpt/results.py index 6c2830924..d3b6d795c 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -68,6 +68,10 @@ def from_file(cls, folder: Union[str, pathlib.Path], name: str): model_path, solution_path, _, json_path, flow_system_path, _ = cls._get_paths(folder=folder, name=name) + solution = xr.load_dataset(solution_path) + flow_system = xr.load_dataset(flow_system_path) + flow_system.attrs = json.loads(flow_system.attrs['attrs']) + if model_path.exists(): logger.info(f'loading the linopy model "{name}" from file ("{model_path}")') model = linopy.read_netcdf(model_path) @@ -77,8 +81,8 @@ def from_file(cls, folder: Union[str, pathlib.Path], name: str): with open(json_path, 'r', encoding='utf-8') as f: meta_data = json.load(f) - return cls(solution=xr.load_dataset(solution_path), - flow_system=xr.load_dataset(flow_system_path), + return cls(solution=solution, + flow_system=flow_system, name=name, folder=folder, model=model, @@ -89,7 +93,7 @@ def from_calculation(cls, calculation: 'Calculation'): """Create CalculationResults directly from a Calculation""" return cls( solution=calculation.model.solution, - flow_system=calculation.flow_system.as_dataset(), + flow_system=calculation.flow_system.as_dataset(constants_in_dataset=True), results_structure=_results_structure(calculation.flow_system), infos=calculation.infos, network_infos=calculation.flow_system.network_infos(), @@ -180,7 +184,9 @@ def to_file( for data_var in self.solution.data_vars} ) - self.flow_system.to_netcdf( + flow_system_ds = self.flow_system.copy() + flow_system_ds.attrs = {'attrs': json.dumps(flow_system_ds.attrs)} + flow_system_ds.to_netcdf( flow_system_path, encoding=None if not ENCODE else {data_var: {"zlib": True, "complevel": 5} for data_var in self.flow_system.data_vars} @@ -411,7 +417,7 @@ class SegmentedCalculationResults: """ @classmethod def from_calculation(cls, calculation: 'SegmentedCalculation'): - return cls([CalculationResults.from_calculation(calc) for calc in calculation.sub_calculations], + return cls([calc.results for calc in calculation.sub_calculations], all_timesteps=calculation.all_timesteps, timesteps_per_segment=calculation.timesteps_per_segment, overlap_timesteps=calculation.overlap_timesteps, From 83bba920be74b84130feb3a09b7b9f9ee55ce499 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 19 Mar 2025 09:13:10 +0100 Subject: [PATCH 373/507] Feature/docs (#189) * Add docs * Change docs type to numpy * Automate docs * Only deploy docs on release * ruff check --- .github/workflows/python-app.yaml | 18 +++ docs/SUMMARY.md | 6 + .../Mathematical Description.md | 108 +++++++++++++++ docs/concepts-and-math/index.md | 103 ++++++++++++++ docs/contribute.md | 49 +++++++ docs/examples/00-Minimal Example.md | 5 + docs/examples/01-Basic Example.md | 5 + docs/examples/02-Complex Example.md | 10 ++ docs/examples/03-Calculation Modes.md | 5 + docs/getting-started.md | 42 ++++++ docs/images/architecture_flixOpt.png | Bin 0 -> 70605 bytes docs/images/flixopt-icon.svg | 1 + docs/index.md | 100 ++++++++++++++ docs/javascripts/mathjax.js | 18 +++ mkdocs.yml | 129 ++++++++++++++++++ pyproject.toml | 9 ++ scripts/gen_ref_pages.py | 55 ++++++++ 17 files changed, 663 insertions(+) create mode 100644 docs/SUMMARY.md create mode 100644 docs/concepts-and-math/Mathematical Description.md create mode 100644 docs/concepts-and-math/index.md create mode 100644 docs/contribute.md create mode 100644 docs/examples/00-Minimal Example.md create mode 100644 docs/examples/01-Basic Example.md create mode 100644 docs/examples/02-Complex Example.md create mode 100644 docs/examples/03-Calculation Modes.md create mode 100644 docs/getting-started.md create mode 100644 docs/images/architecture_flixOpt.png create mode 100644 docs/images/flixopt-icon.svg create mode 100644 docs/index.md create mode 100644 docs/javascripts/mathjax.js create mode 100644 mkdocs.yml create mode 100644 scripts/gen_ref_pages.py diff --git a/.github/workflows/python-app.yaml b/.github/workflows/python-app.yaml index 531989a63..9d1e30083 100644 --- a/.github/workflows/python-app.yaml +++ b/.github/workflows/python-app.yaml @@ -56,6 +56,24 @@ jobs: - name: Run tests run: pytest -v -p no:warnings + deploy-docs: + runs-on: ubuntu-latest + if: github.event_name == 'release' && github.event.action == 'created' # Only on release creation + steps: + - uses: actions/checkout@v4 + - name: Configure Git Credentials + run: | + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + - uses: actions/setup-python@v5 + with: + python-version: 3.11 + - name: Install dependencies + run: | + pip install -e .[docs] + - name: Deploy documentation + run: mkdocs gh-deploy --force + publish-testpypi: name: Publish to TestPyPI runs-on: ubuntu-22.04 diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md new file mode 100644 index 000000000..c270941af --- /dev/null +++ b/docs/SUMMARY.md @@ -0,0 +1,6 @@ +- [Home](index.md) +- [Getting Started](getting-started.md) +- [Concepts & Math](concepts-and-math/) +- [Examples](examples/) +- [API-Reference](api-reference/) +- [Contribute](contribute.md) \ No newline at end of file diff --git a/docs/concepts-and-math/Mathematical Description.md b/docs/concepts-and-math/Mathematical Description.md new file mode 100644 index 000000000..34549a2b9 --- /dev/null +++ b/docs/concepts-and-math/Mathematical Description.md @@ -0,0 +1,108 @@ + +# Mathematical Notation + +## Naming Conventions + +flixOpt uses the following naming conventions: + +- All optimization variables are denoted by italic letters (e.g., $x$, $y$, $z$) +- All parameters and constants are denoted by non italic small letters (e.g., $\text{a}$, $\text{b}$, $\text{c}$) +- All Sets are denoted by greek capital letters (e.g., $\mathcal{F}$, $\mathcal{E}$) +- All units of a set are denoted by greek small letters (e.g., $\mathcal{f}$, $\mathcal{e}$) +- The letter $i$ is used to denote an index (e.g., $i=1,\dots,\text n$) +- All time steps are denoted by the letter $\text{t}$ (e.g., $\text{t}_0$, $\text{t}_1$, $\text{t}_i$) + +## Buses + +The balance equation for a bus is: + +$$ \label{eq:bus_balance} + \sum_{f_\text{in} \in \mathcal{F}_\text{in}} p_{f_\text{in}}(\text{t}_i) = + \sum_{f_\text{out} \in \mathcal{F}_\text{out}} p_{f_\text{out}}(\text{t}_i) +$$ + +Optionally, a Bus can have a `excess_penalty_per_flow_hour` parameter, which allows to penalize the balance for missing or excess flow-rates. +This is usefull as it handles a possible ifeasiblity gently. + +This changes the balance to + +$$ \label{eq:bus_balance-excess} + \sum_{f_\text{in} \in \mathcal{F}_\text{in}} p_{f_ \text{in}}(\text{t}_i) + \phi_\text{in}(\text{t}_i) = + \sum_{f_\text{out} \in \mathcal{F}_\text{out}} p_{f_\text{out}}(\text{t}_i) + \phi_\text{out}(\text{t}_i) +$$ + +The penalty term is defined as + +$$ \label{eq:bus_penalty} + s_{b \rightarrow \Phi}(\text{t}_i) = + \text a_{b \rightarrow \Phi}(\text{t}_i) \cdot \Delta \text{t}_i + \cdot [ \phi_\text{in}(\text{t}_i) + \phi_\text{out}(\text{t}_i) ] +$$ + +With: + +- $\mathcal{F}_\text{in}$ and $\mathcal{F}_\text{out}$ being the set of all incoming and outgoing flows +- $p_{f_\text{in}}(\text{t}_i)$ and $p_{f_\text{out}}(\text{t}_i)$ being the flow-rate at time $\text{t}_i$ for flow $f_\text{in}$ and $f_\text{out}$, respectively +- $\phi_\text{in}(\text{t}_i)$ and $\phi_\text{out}(\text{t}_i)$ being the missing or excess flow-rate at time $\text{t}_i$, respectively +- $\text{t}_i$ being the time step +- $s_{b \rightarrow \Phi}(\text{t}_i)$ being the penalty term +- $\text a_{b \rightarrow \Phi}(\text{t}_i)$ being the penalty coefficient (`excess_penalty_per_flow_hour`) + +## Flows + +The flow-rate is the main optimization variable of the Flow. It's limited by the size of the Flow and relative bounds \eqref{eq:flow_rate}. + +$$ \label{eq:flow_rate} + \text P \cdot \text p^{\text{L}}_{\text{rel}}(\text{t}_{i}) + \leq p(\text{t}_{i}) \leq + \text P \cdot \text p^{\text{U}}_{\text{rel}}(\text{t}_{i}) +$$ + +With: + +- $\text P$ being the size of the Flow +- $p(\text{t}_{i})$ being the flow-rate at time $\text{t}_{i}$ +- $\text p^{\text{L}}_{\text{rel}}(\text{t}_{i})$ being the relative lower bound (typically 0) +- $\text p^{\text{U}}_{\text{rel}}(\text{t}_{i})$ being the relative upper bound (typically 1) + +With $\text p^{\text{L}}_{\text{rel}}(\text{t}_{i}) = 0$ and $\text p^{\text{U}}_{\text{rel}}(\text{t}_{i}) = 1$, +equation \eqref{eq:flow_rate} simplifies to + +$$ + 0 \leq p(\text{t}_{i}) \leq \text P +$$ + + +This mathematical Formulation can be extended or changed when using [OnOffParameters](#omoffparameters) +to define the On/Off state of the Flow, or [InvestParameters](#investments), +which changes the size of the Flow from a constant to an optimization variable. + +## LinearConverters +[`LinearConverters`][flixOpt.components.LinearConverter] define a ratio between incoming and outgoing [Flows](#flows). + +$$ \label{eq:Linear-Transformer-Ratio} + \sum_{f_{\text{in}} \in \mathcal F_{in}} \text a_{f_{\text{in}}}(\text{t}_i) \cdot p_{f_\text{in}}(\text{t}_i) = \sum_{f_{\text{out}} \in \mathcal F_{out}} \text b_{f_\text{out}}(\text{t}_i) \cdot p_{f_\text{out}}(\text{t}_i) +$$ + +With: + +- $\mathcal F_{in}$ and $\mathcal F_{out}$ being the set of all incoming and outgoing flows +- $p_{f_\text{in}}(\text{t}_i)$ and $p_{f_\text{out}}(\text{t}_i)$ being the flow-rate at time $\text{t}_i$ for flow $f_\text{in}$ and $f_\text{out}$, respectively +- $\text a_{f_\text{in}}(\text{t}_i)$ and $\text b_{f_\text{out}}(\text{t}_i)$ being the ratio of the flow-rate at time $\text{t}_i$ for flow $f_\text{in}$ and $f_\text{out}$, respectively + +With one incoming **Flow** and one outgoing **Flow**, this can be simplified to: + +$$ \label{eq:Linear-Transformer-Ratio-simple} + \text a(\text{t}_i) \cdot p_{f_\text{in}}(\text{t}_i) = p_{f_\text{out}}(\text{t}_i) +$$ + +where $\text a$ can be interpreted as the conversion efficiency of the **LinearTransformer**. +#### Piecewise Concersion factors +The conversion efficiency can be defined as a piecewise function. + +## Effects +## Features +### InvestParameters +### OnOffParameters + +## Calculation Modes \ No newline at end of file diff --git a/docs/concepts-and-math/index.md b/docs/concepts-and-math/index.md new file mode 100644 index 000000000..84cf37500 --- /dev/null +++ b/docs/concepts-and-math/index.md @@ -0,0 +1,103 @@ +# flixOpt Concepts & Mathematical Description + +flixOpt is built around a set of core concepts that work together to represent and optimize energy and material flow systems. This page provides a high-level overview of these concepts and how they interact. + +## Core Concepts + +### FlowSystem + +The [`FlowSystem`][flixOpt.flow_system.FlowSystem] is the central organizing unit in flixOpt. +Every flixOpt model starts with creating a FlowSystem. It: + +- Defines the timesteps for the optimization +- Contains and connects [components](#components), [buses](#buses), and [flows](#flows) +- Manages the [effects](#effects) (objectives and constraints) + +### Timesteps +Time steps are defined as a sequence of discrete time steps $\text{t}_i \in \mathcal{T} \quad \text{for} \quad i \in \{1, 2, \dots, \text{n}\}$ (left-aligned in its timespan). +From this sequence, the corresponding time intervals $\Delta \text{t}_i \in \Delta \mathcal{T}$ are derived as + +$$\Delta \text{t}_i = \text{t}_{i+1} - \text{t}_i \quad \text{for} \quad i \in \{1, 2, \dots, \text{n}-1\}$$ + +The final time interval $\Delta \text{t}_\text n$ defaults to $\Delta \text{t}_\text n = \Delta \text{t}_{\text n-1}$, but is of course customizable. +Non-equidistant time steps are also supported. + +### Buses + +[`Bus`][flixOpt.elements.Bus] objects represent nodes or connection points in a FlowSystem. They: + +- Balance incoming and outgoing flows +- Can represent physical networks like heat, electricity, or gas +- Handle infeasible balances gently by allowing the balance to be closed in return for a big Penalty (optional) + +### Flows + +[`Flow`][flixOpt.elements.Flow] objects represent the movement of energy or material between a [Bus](#buses) and a [Component](#components) in a predefined direction. + +- Have a `flow_rate`, which is the main optimization variable of a Flow +- Have a `size` which defines how much energy or material can be moved (fixed or part of an investment decision) +- Have constraints to limit the flow-rate (min/max, total flow hours, on/off etc.) +- Can have fixed profiles (for demands or renewable generation) +- Can have [Effects](#effects) associated by their use (operation, investment, on/off, ...) + +### Components + +[`Component`][flixOpt.elements.Component] objects usually represent physical entities in your system that interact with [`Flows`][flixOpt.elements.Flow]. They include: + +- [`LinearConverters`][flixOpt.components.LinearConverter] - Converts input flows to output flows with (piecewise) linear relationships +- [`Storages`][flixOpt.components.Storage] - Stores energy or material over time +- [`Sources`][flixOpt.components.Source] / [`Sinks`][flixOpt.components.Sink] / [`SourceAndSinks`][flixOpt.components.SourceAndSink] - Produce or consume flows. They are usually used to model external demands or supplies. +- [`Transmissions`][flixOpt.components.Transmission] - Moves flows between locations with possible losses +- Specialized [`LinearConverters`][flixOpt.components.LinearConverter] like [`Boilers`][flixOpt.linear_converters.Boiler], [`HeatPumps`][flixOpt.linear_converters.HeatPump], [`CHPs`][flixOpt.linear_converters.CHP], etc. These simplify the usage of the `LinearConverter` class and can also be used as blueprint on how to define custom classes or parameterize existing ones. + +### Effects + +[`Effect`][flixOpt.effects.Effect] objects represent impacts or metrics related to your system, such as: + +- Costs (investment, operation) +- Emissions (CO₂, NOx, etc.) +- Resource consumption + +These can be freely defined and crosslink to each other (`CO₂` ──[specific CO₂-costs]─→ `Costs`). +One effect is designated as the **optimization objective** (typically Costs), while others can have constraints. +This effect can incorporate several other effects, which woul result in a weighted objective from multiple effects. + +### Calculation Modes + +flixOpt offers different calculation approaches: + +- [`FullCalculation`][flixOpt.calculation.FullCalculation] - Solves the entire problem at once +- [`SegmentedCalculation`][flixOpt.calculation.SegmentedCalculation] - Solves the problem in segments (with optioinal overlap), improving performance for large problems +- [`AggregatedCalculation`][flixOpt.calculation.AggregatedCalculation] - Uses typical periods to reduce computational requirements + +## How These Concepts Work Together + +1. You create a `FlowSystem` with a specified time series +2. You add elements to the FLowSystem: + - `Bus` objects as connection points + - `Component` objects like Boilers, Storages, etc.. They include `Flow` which define the connection to a Bus. + - `Effect` objects to represent costs, emissions, etc. +3.You choose a calculation mode and solver +4.flixOpt converts your model into a mathematical optimization problem +5.The solver finds the optimal solution +6.You analyze the results with built-in or external tools + +## Advanced Usage +flixOpt uses [linopy](https://github.com/PyPSA/linopy) to model the mathematical optimization problem. +Any model created with flixOpt can be extended or modified using the great [linopy API](https://linopy.readthedocs.io/en/latest/api.html). +This allows to adjust your model to very specific requirements without loosing the convenience of flixOpt. + + + +## Architechture (outdated) +![Architecture](../images/architecture_flixOpt.png) + + + + + + + + + + diff --git a/docs/contribute.md b/docs/contribute.md new file mode 100644 index 000000000..d4535c0c0 --- /dev/null +++ b/docs/contribute.md @@ -0,0 +1,49 @@ +# Contributing to the Project + +We warmly welcome contributions from the community! This guide will help you get started with contributing to our project. + +## Development Setup +1. Clone the repository `git clone https://github.com/flixOpt/flixopt.git` +2. Install the development dependencies `pip install -editable .[dev, docs]` +3. Run `pytest` and `ruff check .` to ensure your code passes all tests + +## Documentation +flixOpt uses [mkdocs](https://www.mkdocs.org/) to generate documentation. To preview the documentation locally, run `mkdocs serve` in the root directory. + +## Helpful Commands +- `mkdocs serve` to preview the documentation locally. Navigate to `http://127.0.0.1:8000/` to view the documentation. +- `pytest` to run the test suite (You can also run the provided python script `run_all_test.py`) +- `ruff check .` to run the linter +- `ruff check . --fix` to automatically fix linting issues + +--- +# Best practices + +## Coding Guidelines + +- Follow PEP 8 style guidelines +- Write clear, commented code +- Include type hints +- Create or update tests for new functionality +- Ensure 100% test coverage for new code + +## Branches +As we start to think flixOpt in **Releases**, we decided to introduce multiple **dev**-branches instead of only one: +Following the **Semantic Versioning** guidelines, we introduced: +- `next/patch`: This is where all pull requests for the next patch release (1.0.x) go. +- `next/minor`: This is where all pull requests for the next minor release (1.x.0) go. +- `next/major`: This is where all pull requests for the next major release (x.0.0) go. + +Everything else remains in `feature/...`-branches. + +## Pull requests +Every feature or bugfix should be merged into one of the 3 [release branches](#branches), using **Squash and merge** or a regular **single commit**. +At some point, `next/minor` or `next/major` will get merged into `main` using a regular **Merge** (not squash). +*This ensures that Features are kept separate, and the `next/...`branches stay in synch with ``main`.* + +## Releases +As stated, we follow **Semantic Versioning**. +Right after one of the 3 [release branches](#branches) is merged into main, a **Tag** should be added to the merge commit and pushed to the main branch. The tag has the form `v1.2.3`. +With this tag, a release with **Release Notes** must be created. + +*This is our current best practice* diff --git a/docs/examples/00-Minimal Example.md b/docs/examples/00-Minimal Example.md new file mode 100644 index 000000000..c61283951 --- /dev/null +++ b/docs/examples/00-Minimal Example.md @@ -0,0 +1,5 @@ +# Minimal Example + +```python +{! ../examples/00_Minmal/minimal_example.py !} +``` \ No newline at end of file diff --git a/docs/examples/01-Basic Example.md b/docs/examples/01-Basic Example.md new file mode 100644 index 000000000..600f2516a --- /dev/null +++ b/docs/examples/01-Basic Example.md @@ -0,0 +1,5 @@ +# Simple example + +```python +{! ../examples/01_Simple/simple_example.py !} +``` \ No newline at end of file diff --git a/docs/examples/02-Complex Example.md b/docs/examples/02-Complex Example.md new file mode 100644 index 000000000..d5373c083 --- /dev/null +++ b/docs/examples/02-Complex Example.md @@ -0,0 +1,10 @@ +# Complex example +This saves the results of a calculation to file and reloads them to analyze the results +## Build the Model +```python +{! ../examples/02_Complex/complex_example.py !} +``` +## Load the Results from file +```python +{! ../examples/02_Complex/complex_example_results.py !} +``` \ No newline at end of file diff --git a/docs/examples/03-Calculation Modes.md b/docs/examples/03-Calculation Modes.md new file mode 100644 index 000000000..e94f815a8 --- /dev/null +++ b/docs/examples/03-Calculation Modes.md @@ -0,0 +1,5 @@ +# Calculation Mode comparison +**Note:** This example relies on time series data. You can find it in the `examples` folder of the flixOpt repository. +```python +{! ../examples/03_Calculation_types/example_calculation_types.py !} +``` diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 000000000..cbc28f58f --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,42 @@ +# Getting Started with flixOpt + +This guide will help you install flixOpt, understand its basic concepts, and run your first optimization model. + +## Installation + +### Basic Installation + +Install flixOpt directly into your environment using pip: + +```bash +pip install git+https://github.com/flixOpt/flixOpt.git +``` + +This provides the core functionality with the HiGHS solver included. + +### Full Installation + +For all features including interactive network visualizations and time series aggregation: + +```bash +pip install "flixOpt[full] @ git+https://github.com/flixOpt/flixOpt.git" +``` + +## Basic Workflow + +Working with flixOpt follows a general pattern: + +1. **Create a [`FlowSystem`][flixOpt.flow_system.FlowSystem]** with a time series +2. **Define [`Effects`][flixOpt.effects.Effect]** (costs, emissions, etc.) +3. **Define [`Buses`][flixOpt.elements.Bus]** as connection points in your system +4. **Add [`Components`][flixOpt.components]** like converters, storage, sources/sinks with their Flows +5. **Run [`Calculations`][flixOpt.calculation]** to optimize your system +6. **Analyze [`Results`][flixOpt.results]** using built-in or external visualization tools + +## Next Steps + +Now that you've installed flixOpt and understand the basic workflow, you can: + +- Learn about the [core concepts of flixOpt](concepts-and-math/index.md) +- Explore some [examples](examples/index.md) +- Check the [API reference](api-reference/index.md) for detailed documentation diff --git a/docs/images/architecture_flixOpt.png b/docs/images/architecture_flixOpt.png new file mode 100644 index 0000000000000000000000000000000000000000..1469a9de8f1f77a98a2221ef45cb87728dfef214 GIT binary patch literal 70605 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

2$d}Ww3Vyz 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 \ No newline at end of file diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 000000000..1e89912eb --- /dev/null +++ b/docs/index.md @@ -0,0 +1,100 @@ +# flixOpt: Energy and Material Flow Optimization Framework + +**flixOpt** is a Python-based optimization framework designed to tackle energy and material flow problems using mixed-integer linear programming (MILP). Combining flexibility and efficiency, it provides a powerful platform for both dispatch and investment optimization challenges. + +## 🚀 Introduction + +flixOpt was developed by [TU Dresden](https://github.com/gewv-tu-dresden) as part of the SMARTBIOGRID project, funded by the German Federal Ministry for Economic Affairs and Energy. Building on the Matlab-based flixOptMat framework, flixOpt also incorporates concepts from [oemof/solph](https://github.com/oemof/oemof-solph). + +Although flixOpt is in its early stages, it is fully functional and ready for experimentation. Feedback and collaboration are highly encouraged to help shape its future. + +## 🌟 Key Features + +- **High-level Interface** with low-level control + - User-friendly interface for defining energy systems + - Fine-grained control for advanced configurations + - Pre-defined components like CHP, Heat Pump, Cooling Tower, etc. + +- **Investment Optimization** + - Combined dispatch and investment optimization + - Size and discrete investment decisions + - Integration with On/Off variables and constraints + +- **Multiple Effects** + - Couple effects (e.g., specific CO2 costs) + - Set constraints (e.g., max CO2 emissions) + - Easily switch optimization targets (e.g., costs vs CO2) + +- **Calculation Modes** + - **Full Mode** - Exact solutions with high computational requirements + - **Segmented Mode** - Speed up complex systems with variable time overlap + - **Aggregated Mode** - Typical periods for large-scale simulations + +## 📦 Installation + +Install flixOpt directly using pip: + +```bash +pip install git+https://github.com/flixOpt/flixOpt.git +``` + +For full functionality including visualization and time series aggregation: + +```bash +pip install "flixOpt[full] @ git+https://github.com/flixOpt/flixOpt.git" +``` + +## 🖥️ Quick Example + +```python +import flixOpt as fo +import numpy as np + +# Create timesteps +time_series = fo.create_datetime_array('2023-01-01', steps=24, freq='1h') +system = fo.FlowSystem(time_series) + +# Create buses +heat_bus = fo.Bus("Heat") +electricity_bus = fo.Bus("Electricity") + +# Create flows +heat_demand = fo.Flow( + label="heat_demand", + bus=heat_bus, + fixed_relative_profile=100*np.sin(np.linspace(0, 2*np.pi, 24))**2 + 50 +) + +# Create a heat pump component +heat_pump = fo.linear_converters.HeatPump( + label="HeatPump", + COP=3.0, + P_el=fo.Flow("power", electricity_bus), + Q_th=fo.Flow("heat", heat_bus) +) + +# Add everything to the system +system.add_elements(heat_bus, electricity_bus) +system.add_components(heat_pump) +``` + +## ⚙️ How It Works + +flixOpt transforms your energy system model into a mathematical optimization problem, solves it using state-of-the-art solvers, and returns the optimal operation strategy and investment decisions. + +## 🛠️ Compatible Solvers + +flixOpt works with various solvers: + +- HiGHS (installed by default) +- CBC +- GLPK +- Gurobi +- CPLEX + +## 📝 Citation + +If you use flixOpt in your research or project, please cite: + +- **Main Citation:** [DOI:10.18086/eurosun.2022.04.07](https://doi.org/10.18086/eurosun.2022.04.07) +- **Short Overview:** [DOI:10.13140/RG.2.2.14948.24969](https://doi.org/10.13140/RG.2.2.14948.24969) diff --git a/docs/javascripts/mathjax.js b/docs/javascripts/mathjax.js new file mode 100644 index 000000000..bb7094d50 --- /dev/null +++ b/docs/javascripts/mathjax.js @@ -0,0 +1,18 @@ +window.MathJax = { + tex: { + inlineMath: [['$', '$'], ['\\(', '\\)']], + displayMath: [['$$', '$$'], ['\\[', '\\]']], + processEscapes: true, + tags: 'all' + }, + options: { + skipHtmlTags: ['script', 'noscript', 'style', 'textarea', 'pre'] + } +}; + +document$.subscribe(() => { + MathJax.startup.output.clearCache() + MathJax.typesetClear() + MathJax.texReset() + MathJax.typesetPromise() +}) \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 000000000..a257aca50 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,129 @@ +# Options: +# https://mkdocstrings.github.io/python/usage/configuration/docstrings/ +# https://squidfunk.github.io/mkdocs-material/setup/ + +site_name: flixopt +site_description: Energy and Material Flow Optimization Framework +site_url: https://flixopt.github.io/flixopt/ +repo_url: https://github.com/flixOpt/flixopt +repo_name: flixOpt/flixopt + + +theme: + name: material + palette: + # Light mode + - media: "(prefers-color-scheme: light)" + scheme: default + primary: teal + accent: blue + toggle: + icon: material/brightness-7 + name: Switch to dark mode + # Dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: teal # Can be different from light mode + accent: blue + toggle: + icon: material/brightness-4 + name: Switch to light mode + logo: images/flixopt-icon.svg + favicon: images/flixopt-icon.svg + icon: + repo: fontawesome/brands/github + features: + - navigation.instant + - navigation.instant.progress + - navigation.tracking + - navigation.tabs + - navigation.sections + - navigation.top + - navigation.footer + - toc.follow + - navigation.indexes + - search.suggest + - search.highlight + - content.action.edit + - content.action.view + - content.code.copy + - content.code.annotate + - content.tooltips + - content.code.copy + +markdown_extensions: + - admonition + - codehilite + - markdown_include.include: + base_path: docs + - pymdownx.highlight: + anchor_linenums: true + line_spans: __span + pygments_lang_class: true + - pymdownx.inlinehilite + - pymdownx.snippets + - pymdownx.superfences + - attr_list + - abbr + - md_in_html + - footnotes + - tables + - pymdownx.tabbed: + alternate_style: true + - pymdownx.arithmatex: + generic: true + - pymdownx.emoji: + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg + - pymdownx.snippets: + base_path: .. + + +plugins: + - search # Enables the search functionality in the documentation + - table-reader # Allows including tables from external files + - include-markdown + - gen-files: + scripts: + - scripts/gen_ref_pages.py + - literate-nav: + nav_file: SUMMARY.md + implicit_index: true # This makes index.md the default landing page + - mkdocstrings: # Handles automatic API documentation generation + default_handler: python # Sets Python as the default language + handlers: + python: # Configuration for Python code documentation + options: + docstring_style: numpy # Sets google as the docstring style + modernize_annotations: true # Improves type annotations + merge_init_into_class: true # Promotes constructor parameters to class-level documentation + docstring_section_style: table # Renders parameter sections as a table (also: list, spacy) + + members_order: source # Orders members as they appear in the source code + inherited_members: false # Include members inherited from parent classes + show_if_no_docstring: false # Documents objects even if they don't have docstrings + + group_by_category: true + heading_level: 1 # Sets the base heading level for documented objects + line_length: 80 + filters: ["!^_", "^__init__$"] + show_root_heading: true # whether the documented object's name should be displayed as a heading at the beginning of its documentation + show_source: false # Shows the source code implementation from documentation + show_object_full_path: false # Displays simple class names instead of full import paths + show_docstring_attributes: true # Shows class attributes in the documentation + show_category_heading: true # Displays category headings (Methods, Attributes, etc.) for organization + show_signature: true # Shows method signatures with parameters + show_signature_annotations: true # Includes type annotations in the signatures when available + show_root_toc_entry: false # Whether to show a link to the root of the documentation in the sidebar + separate_signature: true # Displays signatures separate from descriptions for cleaner layout + + extra: + infer_type_annotations: true # Uses Python type hints to supplement docstring information + +extra_javascript: + - javascripts/mathjax.js # Custom MathJax 3 CDN Configuration + - https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js #MathJax 3 CDN + - https://polyfill.io/v3/polyfill.min.js?features=es6 #Support for older browsers + +watch: + - flixOpt \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index e9db3c04c..da9847137 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,15 @@ full = [ "tsam >= 2.3.1", # Used for time series aggregation ] +docs = [ + "mkdocs-material==9.*", + "mkdocstrings-python", + "mkdocs-section-index", + "mkdocs-table-reader-plugin", + "mkdocs-gen-files", + "mkdocs-include-markdown-plugin" +] + [project.urls] homepage = "https://tu-dresden.de/ing/maschinenwesen/iet/gewv/forschung/forschungsprojekte/flixopt" repository = "https://github.com/flixOpt/flixopt" diff --git a/scripts/gen_ref_pages.py b/scripts/gen_ref_pages.py new file mode 100644 index 000000000..8a1b2ff1d --- /dev/null +++ b/scripts/gen_ref_pages.py @@ -0,0 +1,55 @@ +"""Generate the code reference pages and navigation.""" + +import sys +from pathlib import Path + +import mkdocs_gen_files + +# Add the project root to sys.path to ensure modules can be imported +root = Path(__file__).parent.parent +sys.path.insert(0, str(root)) + +nav = mkdocs_gen_files.Nav() + +src = root / "flixOpt" +api_dir = "api-reference" + +for path in sorted(src.rglob("*.py")): + module_path = path.relative_to(src).with_suffix("") + doc_path = path.relative_to(src).with_suffix(".md") + full_doc_path = Path(api_dir, doc_path) + + parts = tuple(module_path.parts) + + if parts[-1] == "__init__": + parts = parts[:-1] + if not parts: + continue # Skip the root __init__.py + doc_path = doc_path.with_name("index.md") + full_doc_path = full_doc_path.with_name("index.md") + elif parts[-1] == "__main__" or parts[-1].startswith("_"): + continue + + # Only add to navigation if there are actual parts + if parts: + nav[parts] = doc_path.as_posix() + + # Generate documentation file - always using the flixOpt prefix + with mkdocs_gen_files.open(full_doc_path, "w") as fd: + # Use 'flixOpt.' prefix for all module references + module_id = "flixOpt." + ".".join(parts) + fd.write(f"::: {module_id}\n" + f" options:\n" + f" inherited_members: true\n") + + mkdocs_gen_files.set_edit_path(full_doc_path, path.relative_to(root)) + +# Create an index file for the API reference +with mkdocs_gen_files.open(f"{api_dir}/index.md", "w") as index_file: + index_file.write("# API Reference\n\n") + index_file.write( + "This section contains the documentation for all modules and classes in flixOpt.\n" + "For more information on how to use the classes and functions, see the [Concepts & Math](../concepts-and-math/index.md) section.\n") + +with mkdocs_gen_files.open(f"{api_dir}/SUMMARY.md", "w") as nav_file: + nav_file.writelines(nav.build_literate_nav()) From 3befb8787d1ef0eeff0b7bebe0302b02f4e6cc95 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 19 Mar 2025 09:20:56 +0100 Subject: [PATCH 374/507] Update docs dependencies --- pyproject.toml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index da9847137..0ae5d87fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,12 +57,15 @@ full = [ ] docs = [ - "mkdocs-material==9.*", + "mkdocs-material>=9.0.0", "mkdocstrings-python", - "mkdocs-section-index", "mkdocs-table-reader-plugin", "mkdocs-gen-files", - "mkdocs-include-markdown-plugin" + "mkdocs-include-markdown-plugin", + "mkdocs-literate-nav", + "markdown-include", + "pymdown-extensions", + "pygments" ] [project.urls] From 1a76684729b5b7b4d6ed2fd009495c7e0e825ed3 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 19 Mar 2025 09:25:26 +0100 Subject: [PATCH 375/507] Update workflow to run docs without release --- .github/workflows/python-app.yaml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python-app.yaml b/.github/workflows/python-app.yaml index 9d1e30083..d376e22ba 100644 --- a/.github/workflows/python-app.yaml +++ b/.github/workflows/python-app.yaml @@ -10,6 +10,13 @@ on: types: [opened, synchronize, reopened] release: types: [created] # Trigger when a release is created + workflow_dispatch: # Allow manual triggering of the workflow + inputs: + deploy_docs: + description: 'Deploy documentation' + required: true + default: true + type: boolean jobs: lint: @@ -58,7 +65,8 @@ jobs: deploy-docs: runs-on: ubuntu-latest - if: github.event_name == 'release' && github.event.action == 'created' # Only on release creation + # Run on release creation OR manual dispatch with deploy_docs=true + if: github.event_name == 'release' && github.event.action == 'created' || github.event_name == 'workflow_dispatch' && inputs.deploy_docs steps: - uses: actions/checkout@v4 - name: Configure Git Credentials @@ -68,8 +76,10 @@ jobs: - uses: actions/setup-python@v5 with: python-version: 3.11 - - name: Install dependencies + + - name: Install documentation dependencies run: | + python -m pip install --upgrade pip pip install -e .[docs] - name: Deploy documentation run: mkdocs gh-deploy --force From 33af58bd529da51940d615f4874151af1f488cb8 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 19 Mar 2025 09:32:08 +0100 Subject: [PATCH 376/507] separate documentation workflow --- .github/workflows/deploy-docs.yaml | 31 ++++++++++++++++++++++++++++++ .github/workflows/python-app.yaml | 28 --------------------------- 2 files changed, 31 insertions(+), 28 deletions(-) create mode 100644 .github/workflows/deploy-docs.yaml diff --git a/.github/workflows/deploy-docs.yaml b/.github/workflows/deploy-docs.yaml new file mode 100644 index 000000000..6006fb32c --- /dev/null +++ b/.github/workflows/deploy-docs.yaml @@ -0,0 +1,31 @@ +name: Documentation + +on: + release: + types: [created] # Automatically deploy docs on release + workflow_dispatch: # Allow manual triggering + +jobs: + deploy-docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # Fetch all history for proper versioning + + - name: Configure Git Credentials + run: | + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + + - uses: actions/setup-python@v5 + with: + python-version: 3.11 + + - name: Install documentation dependencies + run: | + python -m pip install --upgrade pip + pip install mkdocs-material mkdocstrings-python mkdocs-table-reader-plugin mkdocs-include-markdown-plugin mkdocs-gen-files mkdocs-literate-nav markdown-include pymdown-extensions pygments + + - name: Deploy documentation + run: mkdocs gh-deploy --force \ No newline at end of file diff --git a/.github/workflows/python-app.yaml b/.github/workflows/python-app.yaml index d376e22ba..531989a63 100644 --- a/.github/workflows/python-app.yaml +++ b/.github/workflows/python-app.yaml @@ -10,13 +10,6 @@ on: types: [opened, synchronize, reopened] release: types: [created] # Trigger when a release is created - workflow_dispatch: # Allow manual triggering of the workflow - inputs: - deploy_docs: - description: 'Deploy documentation' - required: true - default: true - type: boolean jobs: lint: @@ -63,27 +56,6 @@ jobs: - name: Run tests run: pytest -v -p no:warnings - deploy-docs: - runs-on: ubuntu-latest - # Run on release creation OR manual dispatch with deploy_docs=true - if: github.event_name == 'release' && github.event.action == 'created' || github.event_name == 'workflow_dispatch' && inputs.deploy_docs - steps: - - uses: actions/checkout@v4 - - name: Configure Git Credentials - run: | - git config user.name github-actions[bot] - git config user.email 41898282+github-actions[bot]@users.noreply.github.com - - uses: actions/setup-python@v5 - with: - python-version: 3.11 - - - name: Install documentation dependencies - run: | - python -m pip install --upgrade pip - pip install -e .[docs] - - name: Deploy documentation - run: mkdocs gh-deploy --force - publish-testpypi: name: Publish to TestPyPI runs-on: ubuntu-22.04 From b4a290b50ddb1760434fe3f8a5e6a350990ef157 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 19 Mar 2025 09:32:45 +0100 Subject: [PATCH 377/507] separate documentation workflow --- .github/workflows/deploy-docs.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy-docs.yaml b/.github/workflows/deploy-docs.yaml index 6006fb32c..a1a475ae5 100644 --- a/.github/workflows/deploy-docs.yaml +++ b/.github/workflows/deploy-docs.yaml @@ -25,7 +25,7 @@ jobs: - name: Install documentation dependencies run: | python -m pip install --upgrade pip - pip install mkdocs-material mkdocstrings-python mkdocs-table-reader-plugin mkdocs-include-markdown-plugin mkdocs-gen-files mkdocs-literate-nav markdown-include pymdown-extensions pygments + pip install -e .[docs] - name: Deploy documentation run: mkdocs gh-deploy --force \ No newline at end of file From f061b2ea4f99379bdf0d7964ba336c407430f284 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 19 Mar 2025 09:41:05 +0100 Subject: [PATCH 378/507] Add docs to Readme and pyproject.toml --- README.md | 10 ++++++++++ pyproject.toml | 1 + 2 files changed, 11 insertions(+) diff --git a/README.md b/README.md index 2a48b398f..fbc4716e1 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,11 @@ # flixOpt: Energy and Material Flow Optimization Framework +[![📚 Documentation](https://img.shields.io/badge/📚_docs-online-brightgreen.svg)](https://flixopt.github.io/flixopt/) +[![CI](https://github.com/flixOpt/flixopt/actions/workflows/python-app.yaml/badge.svg)](https://github.com/flixOpt/flixopt/actions/workflows/python-app.yaml) +[![PyPI version](https://badge.fury.io/py/flixopt.svg)](https://badge.fury.io/py/flixopt) +[![Python Versions](https://img.shields.io/pypi/pyversions/flixopt.svg)](https://pypi.org/project/flixopt/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + **flixOpt** is a Python-based optimization framework designed to tackle energy and material flow problems using mixed-integer linear programming (MILP). Combining flexibility and efficiency, it provides a powerful platform for both dispatch and investment optimization challenges. --- @@ -22,6 +28,10 @@ We recommend installing flixOpt with all dependencies, which enables interactive --- +## 📚 Documentation + +Full documentation is available at [https://flixopt.github.io/flixopt/](https://flixopt.github.io/flixopt/) + ## 🌟 Key Features and Concepts ### 💡 High-level Interface... diff --git a/pyproject.toml b/pyproject.toml index 0ae5d87fa..f386a060c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,6 +71,7 @@ docs = [ [project.urls] homepage = "https://tu-dresden.de/ing/maschinenwesen/iet/gewv/forschung/forschungsprojekte/flixopt" repository = "https://github.com/flixOpt/flixopt" +documentation = "https://flixopt.github.io/flixopt/" [tool.setuptools.packages.find] where = ["."] From 4b9623031bc25ee16037b852959f5b3a41e9175c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 19 Mar 2025 09:43:37 +0100 Subject: [PATCH 379/507] Bugfix docs --- docs/index.md | 30 +----------------------------- 1 file changed, 1 insertion(+), 29 deletions(-) diff --git a/docs/index.md b/docs/index.md index 1e89912eb..d2d467603 100644 --- a/docs/index.md +++ b/docs/index.md @@ -47,35 +47,7 @@ pip install "flixOpt[full] @ git+https://github.com/flixOpt/flixOpt.git" ## 🖥️ Quick Example ```python -import flixOpt as fo -import numpy as np - -# Create timesteps -time_series = fo.create_datetime_array('2023-01-01', steps=24, freq='1h') -system = fo.FlowSystem(time_series) - -# Create buses -heat_bus = fo.Bus("Heat") -electricity_bus = fo.Bus("Electricity") - -# Create flows -heat_demand = fo.Flow( - label="heat_demand", - bus=heat_bus, - fixed_relative_profile=100*np.sin(np.linspace(0, 2*np.pi, 24))**2 + 50 -) - -# Create a heat pump component -heat_pump = fo.linear_converters.HeatPump( - label="HeatPump", - COP=3.0, - P_el=fo.Flow("power", electricity_bus), - Q_th=fo.Flow("heat", heat_bus) -) - -# Add everything to the system -system.add_elements(heat_bus, electricity_bus) -system.add_components(heat_pump) +{! ../examples/00_Minmal/minimal_example.py !} ``` ## ⚙️ How It Works From 6b096e3996ae83e972236b4120f61ef1bf4ff5e7 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 19 Mar 2025 09:56:16 +0100 Subject: [PATCH 380/507] Fix formating --- docs/index.md | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/index.md b/docs/index.md index d2d467603..f7650f3db 100644 --- a/docs/index.md +++ b/docs/index.md @@ -11,24 +11,24 @@ Although flixOpt is in its early stages, it is fully functional and ready for ex ## 🌟 Key Features - **High-level Interface** with low-level control - - User-friendly interface for defining energy systems - - Fine-grained control for advanced configurations - - Pre-defined components like CHP, Heat Pump, Cooling Tower, etc. + - User-friendly interface for defining energy systems + - Fine-grained control for advanced configurations + - Pre-defined components like CHP, Heat Pump, Cooling Tower, etc. - **Investment Optimization** - - Combined dispatch and investment optimization - - Size and discrete investment decisions - - Integration with On/Off variables and constraints + - Combined dispatch and investment optimization + - Size and discrete investment decisions + - Integration with On/Off variables and constraints - **Multiple Effects** - - Couple effects (e.g., specific CO2 costs) - - Set constraints (e.g., max CO2 emissions) - - Easily switch optimization targets (e.g., costs vs CO2) + - Couple effects (e.g., specific CO2 costs) + - Set constraints (e.g., max CO2 emissions) + - Easily switch optimization targets (e.g., costs vs CO2) - **Calculation Modes** - - **Full Mode** - Exact solutions with high computational requirements - - **Segmented Mode** - Speed up complex systems with variable time overlap - - **Aggregated Mode** - Typical periods for large-scale simulations + - **Full Mode** - Exact solutions with high computational requirements + - **Segmented Mode** - Speed up complex systems with variable time overlap + - **Aggregated Mode** - Typical periods for large-scale simulations ## 📦 Installation From 6971535f4616d2aeba19b70aa910c10972051e0a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 19 Mar 2025 09:58:57 +0100 Subject: [PATCH 381/507] Update workflow to work without release --- .github/workflows/deploy-docs.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy-docs.yaml b/.github/workflows/deploy-docs.yaml index a1a475ae5..326552f7c 100644 --- a/.github/workflows/deploy-docs.yaml +++ b/.github/workflows/deploy-docs.yaml @@ -25,7 +25,8 @@ jobs: - name: Install documentation dependencies run: | python -m pip install --upgrade pip - pip install -e .[docs] + # Install all documentation dependencies directly instead of using -e .[docs] + pip install mkdocs-material mkdocstrings-python mkdocs-table-reader-plugin mkdocs-include-markdown-plugin mkdocs-gen-files mkdocs-literate-nav markdown-include pymdown-extensions pygments - name: Deploy documentation run: mkdocs gh-deploy --force \ No newline at end of file From 1e161d6a1b5497b26732a7713aead939957161d5 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 19 Mar 2025 10:26:01 +0100 Subject: [PATCH 382/507] Update pip install commands to install from pypi --- README.md | 4 ++-- docs/getting-started.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index fbc4716e1..b105c6fcd 100644 --- a/README.md +++ b/README.md @@ -21,10 +21,10 @@ Although flixOpt is in its early stages, it is fully functional and ready for ex ## 📦 Installation Install flixOpt directly into your environment using pip. Thanks to [HiGHS](https://github.com/ERGO-Code/HiGHS?tab=readme-ov-file), flixOpt can be used without further setup. -`pip install git+https://github.com/flixOpt/flixopt.git` +`pip install flixopt` We recommend installing flixOpt with all dependencies, which enables interactive network visualizations by [pyvis](https://github.com/WestHealth/pyvis) and time series aggregation by [tsam](https://github.com/FZJ-IEK3-VSA/tsam). -`pip install "flixOpt[full] @ git+https://github.com/flixOpt/flixopt.git"` +`pip install "flixopt[full]"` --- diff --git a/docs/getting-started.md b/docs/getting-started.md index cbc28f58f..ebfb2c5f4 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -9,7 +9,7 @@ This guide will help you install flixOpt, understand its basic concepts, and run Install flixOpt directly into your environment using pip: ```bash -pip install git+https://github.com/flixOpt/flixOpt.git +pip install flixopt ``` This provides the core functionality with the HiGHS solver included. @@ -19,7 +19,7 @@ This provides the core functionality with the HiGHS solver included. For all features including interactive network visualizations and time series aggregation: ```bash -pip install "flixOpt[full] @ git+https://github.com/flixOpt/flixOpt.git" +pip install "flixopt[full]"" ``` ## Basic Workflow From 8595534bc45ec20e26b27ef5f824e905898426d7 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 19 Mar 2025 10:34:49 +0100 Subject: [PATCH 383/507] Make the docs more readable --- docs/index.md | 22 ++++------------------ 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/docs/index.md b/docs/index.md index f7650f3db..35419dd18 100644 --- a/docs/index.md +++ b/docs/index.md @@ -30,29 +30,15 @@ Although flixOpt is in its early stages, it is fully functional and ready for ex - **Segmented Mode** - Speed up complex systems with variable time overlap - **Aggregated Mode** - Typical periods for large-scale simulations -## 📦 Installation +## 🛠️ Getting Started -Install flixOpt directly using pip: +See the [Getting Started Guide](getting-started.md) to start using flixOpt. -```bash -pip install git+https://github.com/flixOpt/flixOpt.git -``` - -For full functionality including visualization and time series aggregation: - -```bash -pip install "flixOpt[full] @ git+https://github.com/flixOpt/flixOpt.git" -``` - -## 🖥️ Quick Example - -```python -{! ../examples/00_Minmal/minimal_example.py !} -``` +See the [Examples](examples/) section for detailed examples. ## ⚙️ How It Works -flixOpt transforms your energy system model into a mathematical optimization problem, solves it using state-of-the-art solvers, and returns the optimal operation strategy and investment decisions. +See our [Concepts & Math](concepts-and-math/index.md) to understand the core concepts of flixOpt. ## 🛠️ Compatible Solvers From 699a2193b0fb4691014b331e1ff57171720eedbd Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 19 Mar 2025 10:38:01 +0100 Subject: [PATCH 384/507] Bugfix in docs --- docs/getting-started.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index ebfb2c5f4..702a8171b 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -38,5 +38,5 @@ Working with flixOpt follows a general pattern: Now that you've installed flixOpt and understand the basic workflow, you can: - Learn about the [core concepts of flixOpt](concepts-and-math/index.md) -- Explore some [examples](examples/index.md) +- Explore some [examples](examples/) - Check the [API reference](api-reference/index.md) for detailed documentation From 78827e02546438f3d720709e92fec7ba7f6f99ea Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 19 Mar 2025 11:52:25 +0100 Subject: [PATCH 385/507] Add possibilities to filter the solution. --- examples/00_Minmal/minimal_example.py | 2 +- flixOpt/results.py | 51 ++++++++++++++++++++++++--- 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/examples/00_Minmal/minimal_example.py b/examples/00_Minmal/minimal_example.py index 330727337..728a8d70d 100644 --- a/examples/00_Minmal/minimal_example.py +++ b/examples/00_Minmal/minimal_example.py @@ -58,7 +58,7 @@ # --- Analyze Results --- # Access the results of an element - df1 = calculation.results['costs'].solution_time.to_dataframe() + df1 = calculation.results['costs'].filter_solution('numeric').to_dataframe() # Plot the results of a specific element calculation.results['District Heating'].plot_node_balance() diff --git a/flixOpt/results.py b/flixOpt/results.py index d3b6d795c..48935a512 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -133,6 +133,22 @@ def __init__( self.timesteps_extra = pd.DatetimeIndex([datetime.datetime.fromisoformat(date) for date in results_structure['Time']], name='time') self.hours_per_timestep = TimeSeriesCollection.calculate_hours_per_timestep(self.timesteps_extra) + def filter_solution(self, + variable_dims: Optional[Literal['scalar', 'numeric']] = None, + element: Optional[str] = None) -> xr.Dataset: + """ + Filter the solution to a specific variable dimension and element. + If no element is specified, all elements are included. + + Args: + variable_dims: The dimension of the variables to filter for. + element: The element to filter for. + """ + if element is None: + return filter_dataset(self.solution, variable_dims) + else: + return filter_dataset(self[element].solution, variable_dims) + def __getitem__(self, key: str) -> Union['ComponentResults', 'BusResults', 'EffectResults']: if key in self.components: return self.components[key] @@ -279,10 +295,6 @@ def __init__(self, self.solution = self._calculation_results.solution[self._variable_names] - self._variable_names_time = [name for name in self._variable_names if 'time' in self.solution[name].dims] - - self.solution_time = self._calculation_results.solution[self._variable_names_time] - if self._calculation_results.model is not None: self.variables = self._calculation_results.model.variables[self._variable_names] self.constraints = self._calculation_results.model.constraints[self._constraint_names] @@ -290,6 +302,16 @@ def __init__(self, self.variables = None self.constraints = None + def filter_solution(self, variable_dims: Optional[Literal['scalar', 'numeric']] = None) -> xr.Dataset: + """ + Filter the solution to a specific dimension. + If no element is specified, all elements are included. + + Args: + variable_dims: The dimension of the variables to filter for. + """ + return filter_dataset(self.solution, variable_dims) + class _NodeResults(_ElementResults): @classmethod @@ -596,3 +618,24 @@ def sanitize_dataset( if timesteps is not None and not ds.indexes['time'].equals(timesteps): ds = ds.reindex({'time': timesteps}, fill_value=np.nan) return ds + + +def filter_dataset( + ds: xr.Dataset, + variable_dims: Optional[Literal['scalar', 'numeric']] = None, +) -> xr.Dataset: + """ + Filters a dataset by its dimensions. + Args: + ds: The dataset to filter. + variable_dims: The dimension of the variables to filter for. + """ + if variable_dims is None: + return ds + + if variable_dims == 'scalar': + return ds[[name for name, da in ds.data_vars.items() if len(da.dims) == 0]] + elif variable_dims == 'numeric': + return ds[[name for name, da in ds.data_vars.items() if len(da.dims) >= 1]] + else: + raise ValueError(f'Not allowed value for "filter_dataset": {variable_dims=}') \ No newline at end of file From acf0f1cf4bd579e6945ed1d209f19e252b067e48 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 19 Mar 2025 16:43:50 +0100 Subject: [PATCH 386/507] Update the filtering --- flixOpt/results.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/flixOpt/results.py b/flixOpt/results.py index 48935a512..76170ea04 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -144,10 +144,9 @@ def filter_solution(self, variable_dims: The dimension of the variables to filter for. element: The element to filter for. """ - if element is None: - return filter_dataset(self.solution, variable_dims) - else: + if element is not None: return filter_dataset(self[element].solution, variable_dims) + return filter_dataset(self.solution, variable_dims) def __getitem__(self, key: str) -> Union['ComponentResults', 'BusResults', 'EffectResults']: if key in self.components: @@ -304,8 +303,7 @@ def __init__(self, def filter_solution(self, variable_dims: Optional[Literal['scalar', 'numeric']] = None) -> xr.Dataset: """ - Filter the solution to a specific dimension. - If no element is specified, all elements are included. + Filter the solution of the element by dimension. Args: variable_dims: The dimension of the variables to filter for. @@ -626,6 +624,7 @@ def filter_dataset( ) -> xr.Dataset: """ Filters a dataset by its dimensions. + Args: ds: The dataset to filter. variable_dims: The dimension of the variables to filter for. @@ -638,4 +637,4 @@ def filter_dataset( elif variable_dims == 'numeric': return ds[[name for name, da in ds.data_vars.items() if len(da.dims) >= 1]] else: - raise ValueError(f'Not allowed value for "filter_dataset": {variable_dims=}') \ No newline at end of file + raise ValueError(f'Not allowed value for "filter_dataset()": {variable_dims=}') \ No newline at end of file From 4e6fb8955da57535f10a1e4c69c441f3fc4f0938 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 19 Mar 2025 16:47:46 +0100 Subject: [PATCH 387/507] Make .variabes and .constraints to properties, that raise an error if the model is not availlable. --- flixOpt/results.py | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/flixOpt/results.py b/flixOpt/results.py index 76170ea04..ace2ec0f9 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -294,13 +294,6 @@ def __init__(self, self.solution = self._calculation_results.solution[self._variable_names] - if self._calculation_results.model is not None: - self.variables = self._calculation_results.model.variables[self._variable_names] - self.constraints = self._calculation_results.model.constraints[self._constraint_names] - else: - self.variables = None - self.constraints = None - def filter_solution(self, variable_dims: Optional[Literal['scalar', 'numeric']] = None) -> xr.Dataset: """ Filter the solution of the element by dimension. @@ -310,6 +303,30 @@ def filter_solution(self, variable_dims: Optional[Literal['scalar', 'numeric']] """ return filter_dataset(self.solution, variable_dims) + @property + def variables(self) -> linopy.Variables: + """ + Returns the variables of the element. + + Raises: + ValueError: If the linopy model is not availlable. + """ + if self._calculation_results.model is None: + raise ValueError('The linopy model is not available.') + return self._calculation_results.model.variables[self._variable_names] + + @property + def constraints(self) -> linopy.Constraints: + """ + Returns the variables of the element. + + Raises: + ValueError: If the linopy model is not availlable. + """ + if self._calculation_results.model is None: + raise ValueError('The linopy model is not available.') + return self._calculation_results.model.constraints[self._variable_names] + class _NodeResults(_ElementResults): @classmethod From 55bedd7351c2b998481241a276e054a5634ea9de Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 19 Mar 2025 16:49:12 +0100 Subject: [PATCH 388/507] Rename function --- examples/01_Simple/simple_example.py | 2 +- flixOpt/results.py | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/examples/01_Simple/simple_example.py b/examples/01_Simple/simple_example.py index d27eb216c..5493c0a48 100644 --- a/examples/01_Simple/simple_example.py +++ b/examples/01_Simple/simple_example.py @@ -112,7 +112,7 @@ calculation.results.plot_heatmap('CHP(Q_th)|flow_rate') # Convert the results for the storage component to a dataframe and display - df = calculation.results['Storage'].charge_state_and_flow_rates() + df = calculation.results['Storage'].node_balance_with_charge_state() print(df) #Save results to file for later usage diff --git a/flixOpt/results.py b/flixOpt/results.py index ace2ec0f9..6441bc4d6 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -421,10 +421,11 @@ def plot_charge_state(self, show=show, save=True if save else False) - def charge_state_and_flow_rates(self, - negate_inputs: bool = True, - negate_outputs: bool = False, - threshold: Optional[float] = 1e-5) -> xr.Dataset: + def node_balance_with_charge_state( + self, + negate_inputs: bool = True, + negate_outputs: bool = False, + threshold: Optional[float] = 1e-5) -> xr.Dataset: if not self.is_storage: raise ValueError(f'Cant get charge_state. "{self.label}" is not a storage') variable_names = self.inputs + self.outputs + [self._charge_state] From 620f0aa0ea51bb410fa6874f647a7e1db20ed6ca Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 19 Mar 2025 17:45:20 +0100 Subject: [PATCH 389/507] Add variables and constraints back to CalculationResults --- flixOpt/results.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/flixOpt/results.py b/flixOpt/results.py index 6441bc4d6..006e09f55 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -273,6 +273,17 @@ def storages(self) -> List['ComponentResults']: def objective(self) -> float: return self.infos['Main Results']['Objective'] + @property + def variables(self) -> linopy.Variables: + if self.model is None: + raise ValueError('The linopy model is not available.') + return self.model.variables + + @property + def constraints(self) -> linopy.Constraints: + if self.model is None: + raise ValueError('The linopy model is not available.') + return self.model.constraints class _ElementResults: @classmethod From e791e3b3d4fbd6f43590effae68abfd8c09b0752 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 20 Mar 2025 09:19:36 +0100 Subject: [PATCH 390/507] Ensure to_dataset contains no views (copy data) --- flixOpt/core.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/flixOpt/core.py b/flixOpt/core.py index 9d89585e0..05a69f766 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -723,12 +723,15 @@ def to_dataset(self, include_constants: bool = True) -> xr.Dataset: else: series_to_include = self.non_constants - ds = xr.Dataset({ts.name: ts.active_data for ts in series_to_include}, - coords={'time': self.timesteps_extra}) + # Create individual datasets and merge them + ds = xr.merge([ts.active_data.to_dataset(name=ts.name) for ts in series_to_include]) + + # Ensure the correct time coordinates + ds = ds.reindex(time=self.timesteps_extra) ds.attrs.update({ - "timesteps_extra": f"{self.timesteps_extra[0]} ... {self.timesteps_extra[-1]} | len={len(self.timesteps_extra)}", - "hours_per_timestep": self._format_stats(self.hours_per_timestep), + 'timesteps_extra': f'{self.timesteps_extra[0]} ... {self.timesteps_extra[-1]} | len={len(self.timesteps_extra)}', + 'hours_per_timestep': self._format_stats(self.hours_per_timestep), }) return ds From 9f2d5f5164ffd30aa900a2f960d8a09627e3079f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 20 Mar 2025 15:01:29 +0100 Subject: [PATCH 391/507] Reorder class --- flixOpt/results.py | 95 ++++++++++++++++++++++++---------------------- 1 file changed, 50 insertions(+), 45 deletions(-) diff --git a/flixOpt/results.py b/flixOpt/results.py index 006e09f55..46d3b1604 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -133,6 +133,39 @@ def __init__( self.timesteps_extra = pd.DatetimeIndex([datetime.datetime.fromisoformat(date) for date in results_structure['Time']], name='time') self.hours_per_timestep = TimeSeriesCollection.calculate_hours_per_timestep(self.timesteps_extra) + def __getitem__(self, key: str) -> Union['ComponentResults', 'BusResults', 'EffectResults']: + if key in self.components: + return self.components[key] + if key in self.buses: + return self.buses[key] + if key in self.effects: + return self.effects[key] + raise KeyError(f'No element with label {key} found.') + + @property + def storages(self) -> List['ComponentResults']: + """All storages in the results.""" + return [comp for comp in self.components.values() if comp.is_storage] + + @property + def objective(self) -> float: + """ The objective result of the optimization. """ + return self.infos['Main Results']['Objective'] + + @property + def variables(self) -> linopy.Variables: + """ The variables of the optimization. Only available if the linopy.Model is available. """ + if self.model is None: + raise ValueError('The linopy model is not available.') + return self.model.variables + + @property + def constraints(self) -> linopy.Constraints: + """The constraints of the optimization. Only available if the linopy.Model is available.""" + if self.model is None: + raise ValueError('The linopy model is not available.') + return self.model.constraints + def filter_solution(self, variable_dims: Optional[Literal['scalar', 'numeric']] = None, element: Optional[str] = None) -> xr.Dataset: @@ -148,14 +181,23 @@ def filter_solution(self, return filter_dataset(self[element].solution, variable_dims) return filter_dataset(self.solution, variable_dims) - def __getitem__(self, key: str) -> Union['ComponentResults', 'BusResults', 'EffectResults']: - if key in self.components: - return self.components[key] - if key in self.buses: - return self.buses[key] - if key in self.effects: - return self.effects[key] - raise KeyError(f'No element with label {key} found.') + def plot_heatmap(self, + variable_name: str, + heatmap_timeframes: Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'] = 'D', + heatmap_timesteps_per_frame: Literal['W', 'D', 'h', '15min', 'min'] = 'h', + color_map: str = 'portland', + save: Union[bool, pathlib.Path] = False, + show: bool = True + ) -> plotly.graph_objs.Figure: + return plot_heatmap( + dataarray=self.solution[variable_name], + name=variable_name, + folder=self.folder, + heatmap_timeframes=heatmap_timeframes, + heatmap_timesteps_per_frame=heatmap_timesteps_per_frame, + color_map=color_map, + save=save, + show=show) def to_file( self, @@ -234,24 +276,6 @@ def _get_meta_data(self) -> Dict: 'network_infos': self.network_infos, } - def plot_heatmap(self, - variable_name: str, - heatmap_timeframes: Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'] = 'D', - heatmap_timesteps_per_frame: Literal['W', 'D', 'h', '15min', 'min'] = 'h', - color_map: str = 'portland', - save: Union[bool, pathlib.Path] = False, - show: bool = True - ) -> plotly.graph_objs.Figure: - return plot_heatmap( - dataarray=self.solution[variable_name], - name=variable_name, - folder=self.folder, - heatmap_timeframes=heatmap_timeframes, - heatmap_timesteps_per_frame=heatmap_timesteps_per_frame, - color_map=color_map, - save=save, - show=show) - @staticmethod def _get_paths( folder: pathlib.Path, @@ -265,25 +289,6 @@ def _get_paths( model_documentation_path = folder / f'{name}_model_doc.yaml' return model_path, solution_path, infos_path, json_path, flow_system_path, model_documentation_path - @property - def storages(self) -> List['ComponentResults']: - return [comp for comp in self.components.values() if comp.is_storage] - - @property - def objective(self) -> float: - return self.infos['Main Results']['Objective'] - - @property - def variables(self) -> linopy.Variables: - if self.model is None: - raise ValueError('The linopy model is not available.') - return self.model.variables - - @property - def constraints(self) -> linopy.Constraints: - if self.model is None: - raise ValueError('The linopy model is not available.') - return self.model.constraints class _ElementResults: @classmethod From 0aec66eedab732de9f777f330d6ea920c796760c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 20 Mar 2025 15:02:07 +0100 Subject: [PATCH 392/507] Reorder classes --- .../example_calculation_types.py | 46 ++----------------- 1 file changed, 5 insertions(+), 41 deletions(-) diff --git a/examples/03_Calculation_types/example_calculation_types.py b/examples/03_Calculation_types/example_calculation_types.py index 673b22f34..2a7349fac 100644 --- a/examples/03_Calculation_types/example_calculation_types.py +++ b/examples/03_Calculation_types/example_calculation_types.py @@ -15,7 +15,7 @@ if __name__ == '__main__': # Calculation Types - full, segmented, aggregated = True, True, True + full, segmented, aggregated = True, False, False # Segmented Properties segment_length, overlap_length = 96, 1 @@ -35,7 +35,7 @@ # Data Import data_import = pd.read_csv(pathlib.Path('Zeitreihen2020.csv'), index_col=0).sort_index() filtered_data = data_import['2020-01-01':'2020-01-2 23:45:00'] - # filtered_data = data_import[0:500] # Alternatively filter by index + filtered_data = data_import[0:10000] # Alternatively filter by index filtered_data.index = pd.to_datetime(filtered_data.index) timesteps = filtered_data.index @@ -158,10 +158,11 @@ calculations: List[Union[fx.FullCalculation, fx.AggregatedCalculation, fx.SegmentedCalculation]] = [] if full: - calculation = fx.FullCalculation('Full', flow_system) + calculation = fx.FullCalculation('Full_long', flow_system) calculation.do_modeling() - calculation.solve(fx.solvers.HighsSolver(0, 60)) + calculation.solve(fx.solvers.GurobiSolver(0.05, 60)) calculations.append(calculation) + calculation.results.to_file() if segmented: calculation = fx.SegmentedCalculation('Segmented', flow_system, segment_length, overlap_length) @@ -176,40 +177,3 @@ calculation.do_modeling() calculation.solve(fx.solvers.HighsSolver(0, 60)) calculations.append(calculation) - - # Get solutions for plotting for different calculations - def get_solutions(calcs: List, variable: str) -> xr.Dataset: - dataarrays = [] - for calc in calcs: - if calc.name == 'Segmented': - dataarrays.append(calc.results.solution_without_overlap(variable).rename(calc.name)) - else: - dataarrays.append(calc.results.model.variables[variable].solution.rename(calc.name)) - return xr.merge(dataarrays) - - # --- Plotting for comparison --- - fx.plotting.with_plotly( - get_solutions(calculations, 'Speicher|charge_state').to_dataframe(), - mode='line', title='Charge State Comparison', ylabel='Charge state', path='results/Charge State.html', save=True - ) - - fx.plotting.with_plotly( - get_solutions(calculations, 'BHKW2(Q_th)|flow_rate').to_dataframe(), - mode='line', title='BHKW2(Q_th) Flow Rate Comparison', ylabel='Flow rate', path='results/BHKW2 Thermal Power.html', save=True - ) - - fx.plotting.with_plotly( - get_solutions(calculations, 'costs(operation)|total_per_timestep').to_dataframe(), - mode='line', title='Operation Cost Comparison', ylabel='Costs [€]', path='results/Operation Costs.html', save=True - ) - - fx.plotting.with_plotly( - pd.DataFrame(get_solutions(calculations, 'costs(operation)|total_per_timestep').to_dataframe().sum()).T, - mode='bar', title='Total Cost Comparison', ylabel='Costs [€]' - ).update_layout(barmode='group').write_html('results/Total Costs.html') - - fx.plotting.with_plotly( - pd.DataFrame([calc.durations for calc in calculations], index=[calc.name for calc in calculations]), 'bar' - ).update_layout( - title='Duration Comparison', xaxis_title='Calculation type', yaxis_title='Time (s)' - ).write_html('results/Speed Comparison.html') From 29db6776a894e52b388c0cd0461d8d54971b349b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 20 Mar 2025 15:47:21 +0100 Subject: [PATCH 393/507] Reorder classes --- flixOpt/results.py | 46 +++++++++++++++++++++++----------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/flixOpt/results.py b/flixOpt/results.py index 46d3b1604..08d6b6ba8 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -310,15 +310,6 @@ def __init__(self, self.solution = self._calculation_results.solution[self._variable_names] - def filter_solution(self, variable_dims: Optional[Literal['scalar', 'numeric']] = None) -> xr.Dataset: - """ - Filter the solution of the element by dimension. - - Args: - variable_dims: The dimension of the variables to filter for. - """ - return filter_dataset(self.solution, variable_dims) - @property def variables(self) -> linopy.Variables: """ @@ -343,6 +334,15 @@ def constraints(self) -> linopy.Constraints: raise ValueError('The linopy model is not available.') return self._calculation_results.model.constraints[self._variable_names] + def filter_solution(self, variable_dims: Optional[Literal['scalar', 'numeric']] = None) -> xr.Dataset: + """ + Filter the solution of the element by dimension. + + Args: + variable_dims: The dimension of the variables to filter for. + """ + return filter_dataset(self.solution, variable_dims) + class _NodeResults(_ElementResults): @classmethod @@ -512,6 +512,19 @@ def __init__(self, self.folder = pathlib.Path(folder) if folder is not None else pathlib.Path.cwd() / 'results' self.hours_per_timestep = TimeSeriesCollection.calculate_hours_per_timestep(self.all_timesteps) + @property + def meta_data(self) -> Dict[str, Union[int, List[str]]]: + return { + 'all_timesteps': [datetime.datetime.isoformat(date) for date in self.all_timesteps], + 'timesteps_per_segment': self.timesteps_per_segment, + 'overlap_timesteps': self.overlap_timesteps, + 'sub_calculations': [calc.name for calc in self.segment_results] + } + + @property + def segment_names(self) -> List[str]: + return [segment.name for segment in self.segment_results] + def solution_without_overlap(self, variable_name: str) -> xr.DataArray: """Returns the solution of a variable without overlap""" dataarrays = [result.solution[variable_name].isel(time=slice(None, self.timesteps_per_segment)) @@ -556,19 +569,6 @@ def to_file(self, folder: Optional[Union[str, pathlib.Path]] = None, name: Optio json.dump(self.meta_data, f, indent=4, ensure_ascii=False) logger.info(f'Saved calculation "{name}" to {path}') - @property - def meta_data(self) -> Dict[str, Union[int, List[str]]]: - return { - 'all_timesteps': [datetime.datetime.isoformat(date) for date in self.all_timesteps], - 'timesteps_per_segment': self.timesteps_per_segment, - 'overlap_timesteps': self.overlap_timesteps, - 'sub_calculations': [calc.name for calc in self.segment_results] - } - - @property - def segment_names(self) -> List[str]: - return [segment.name for segment in self.segment_results] - def plotly_save_and_show(fig: plotly.graph_objs.Figure, default_filename: pathlib.Path, @@ -671,4 +671,4 @@ def filter_dataset( elif variable_dims == 'numeric': return ds[[name for name, da in ds.data_vars.items() if len(da.dims) >= 1]] else: - raise ValueError(f'Not allowed value for "filter_dataset()": {variable_dims=}') \ No newline at end of file + raise ValueError(f'Not allowed value for "filter_dataset()": {variable_dims=}') From 1ccbbcf43f84983c1b1620ce33b4e8ca914c2abd Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 20 Mar 2025 15:47:29 +0100 Subject: [PATCH 394/507] rename variable --- flixOpt/io.py | 9 ++++----- flixOpt/results.py | 8 ++++---- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/flixOpt/io.py b/flixOpt/io.py index 3f89679a0..d528dad7e 100644 --- a/flixOpt/io.py +++ b/flixOpt/io.py @@ -1,18 +1,17 @@ import datetime import json -import re -import yaml import logging import pathlib -from typing import Dict, Literal, Union, TYPE_CHECKING +import re +from typing import TYPE_CHECKING, Dict, Literal, Union -import xarray as xr import linopy +import xarray as xr +import yaml from .core import TimeSeries from .flow_system import FlowSystem - logger = logging.getLogger('flixOpt') diff --git a/flixOpt/results.py b/flixOpt/results.py index 08d6b6ba8..68b51812c 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -227,17 +227,17 @@ def to_file( model_path, solution_path, infos_path, json_path, flow_system_path, model_doc_path = self._get_paths( folder= folder, name= self.name if name is None else name) - ENCODE = False + apply_encoding = False if compression != 0: if importlib.util.find_spec('netCDF4') is not None: - ENCODE = True + apply_encoding = True else: logger.warning('CalculationResults were exported without compression due to missing dependency "netcdf4".' 'Install netcdf4 via `pip install netcdf4`.') self.solution.to_netcdf( solution_path, - encoding=None if not ENCODE else {data_var: {"zlib": True, "complevel": 5} + encoding=None if not apply_encoding else {data_var: {"zlib": True, "complevel": 5} for data_var in self.solution.data_vars} ) @@ -245,7 +245,7 @@ def to_file( flow_system_ds.attrs = {'attrs': json.dumps(flow_system_ds.attrs)} flow_system_ds.to_netcdf( flow_system_path, - encoding=None if not ENCODE else {data_var: {"zlib": True, "complevel": 5} + encoding=None if not apply_encoding else {data_var: {"zlib": True, "complevel": 5} for data_var in self.flow_system.data_vars} ) From 9abdc4354dd5b5142dbef7eed513c5fe89155f89 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 20 Mar 2025 15:49:12 +0100 Subject: [PATCH 395/507] Revert "Reorder classes" This reverts commit 0aec66eedab732de9f777f330d6ea920c796760c. --- .../example_calculation_types.py | 46 +++++++++++++++++-- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/examples/03_Calculation_types/example_calculation_types.py b/examples/03_Calculation_types/example_calculation_types.py index 2a7349fac..673b22f34 100644 --- a/examples/03_Calculation_types/example_calculation_types.py +++ b/examples/03_Calculation_types/example_calculation_types.py @@ -15,7 +15,7 @@ if __name__ == '__main__': # Calculation Types - full, segmented, aggregated = True, False, False + full, segmented, aggregated = True, True, True # Segmented Properties segment_length, overlap_length = 96, 1 @@ -35,7 +35,7 @@ # Data Import data_import = pd.read_csv(pathlib.Path('Zeitreihen2020.csv'), index_col=0).sort_index() filtered_data = data_import['2020-01-01':'2020-01-2 23:45:00'] - filtered_data = data_import[0:10000] # Alternatively filter by index + # filtered_data = data_import[0:500] # Alternatively filter by index filtered_data.index = pd.to_datetime(filtered_data.index) timesteps = filtered_data.index @@ -158,11 +158,10 @@ calculations: List[Union[fx.FullCalculation, fx.AggregatedCalculation, fx.SegmentedCalculation]] = [] if full: - calculation = fx.FullCalculation('Full_long', flow_system) + calculation = fx.FullCalculation('Full', flow_system) calculation.do_modeling() - calculation.solve(fx.solvers.GurobiSolver(0.05, 60)) + calculation.solve(fx.solvers.HighsSolver(0, 60)) calculations.append(calculation) - calculation.results.to_file() if segmented: calculation = fx.SegmentedCalculation('Segmented', flow_system, segment_length, overlap_length) @@ -177,3 +176,40 @@ calculation.do_modeling() calculation.solve(fx.solvers.HighsSolver(0, 60)) calculations.append(calculation) + + # Get solutions for plotting for different calculations + def get_solutions(calcs: List, variable: str) -> xr.Dataset: + dataarrays = [] + for calc in calcs: + if calc.name == 'Segmented': + dataarrays.append(calc.results.solution_without_overlap(variable).rename(calc.name)) + else: + dataarrays.append(calc.results.model.variables[variable].solution.rename(calc.name)) + return xr.merge(dataarrays) + + # --- Plotting for comparison --- + fx.plotting.with_plotly( + get_solutions(calculations, 'Speicher|charge_state').to_dataframe(), + mode='line', title='Charge State Comparison', ylabel='Charge state', path='results/Charge State.html', save=True + ) + + fx.plotting.with_plotly( + get_solutions(calculations, 'BHKW2(Q_th)|flow_rate').to_dataframe(), + mode='line', title='BHKW2(Q_th) Flow Rate Comparison', ylabel='Flow rate', path='results/BHKW2 Thermal Power.html', save=True + ) + + fx.plotting.with_plotly( + get_solutions(calculations, 'costs(operation)|total_per_timestep').to_dataframe(), + mode='line', title='Operation Cost Comparison', ylabel='Costs [€]', path='results/Operation Costs.html', save=True + ) + + fx.plotting.with_plotly( + pd.DataFrame(get_solutions(calculations, 'costs(operation)|total_per_timestep').to_dataframe().sum()).T, + mode='bar', title='Total Cost Comparison', ylabel='Costs [€]' + ).update_layout(barmode='group').write_html('results/Total Costs.html') + + fx.plotting.with_plotly( + pd.DataFrame([calc.durations for calc in calculations], index=[calc.name for calc in calculations]), 'bar' + ).update_layout( + title='Duration Comparison', xaxis_title='Calculation type', yaxis_title='Time (s)' + ).write_html('results/Speed Comparison.html') From 86158646e8af1b391538f5f3cd78a55856a35c14 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 20 Mar 2025 16:04:36 +0100 Subject: [PATCH 396/507] Improve model documentation --- flixOpt/io.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/flixOpt/io.py b/flixOpt/io.py index d528dad7e..0f78fbe28 100644 --- a/flixOpt/io.py +++ b/flixOpt/io.py @@ -179,9 +179,16 @@ def document_linopy_model(model: linopy.Model, path: pathlib.Path = None) -> Dic path (pathlib.Path, optional): Path to save the document. Defaults to None. """ documentation = { + 'objective': model.objective.__repr__(), + 'nvars': model.nvars, + 'nvarsbin': model.binaries.nvars, + 'nvarscont': model.continuous.nvars, + 'ncons': model.ncons, 'variables': {variable_name: variable.__repr__() for variable_name, variable in model.variables.items()}, 'constraints': {constraint_name: constraint.__repr__() for constraint_name, constraint in model.constraints.items()}, - 'objective': model.objective.__repr__(), + 'binaries': list(model.binaries), + 'integers': list(model.integers), + 'continuous': list(model.continuous), } if path is not None: From acaf7d859dc39f5cf1fc2e7f59e704aaa98df10e Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 20 Mar 2025 16:42:11 +0100 Subject: [PATCH 397/507] Add improved handling for infeasible models --- flixOpt/calculation.py | 5 +++++ flixOpt/io.py | 23 ++++++++++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py index 3e38a886d..e2967d469 100644 --- a/flixOpt/calculation.py +++ b/flixOpt/calculation.py @@ -153,6 +153,11 @@ def solve(self, **solver.options) self.durations['solving'] = round(timeit.default_timer() - t_start, 2) + if self.model.status == 'warning': + from .io import document_linopy_model + document_linopy_model(self.model, self.folder / f'{self.name}_model_doc.yaml') + #TODO: Raise an exception here? + # Log the formatted output if log_main_results: logger.info(f'{" Main Results ":#^80}') diff --git a/flixOpt/io.py b/flixOpt/io.py index 0f78fbe28..7c5337ac5 100644 --- a/flixOpt/io.py +++ b/flixOpt/io.py @@ -180,17 +180,38 @@ def document_linopy_model(model: linopy.Model, path: pathlib.Path = None) -> Dic """ documentation = { 'objective': model.objective.__repr__(), + 'termination_condition': model.termination_condition, + 'status': model.status, 'nvars': model.nvars, 'nvarsbin': model.binaries.nvars, 'nvarscont': model.continuous.nvars, 'ncons': model.ncons, 'variables': {variable_name: variable.__repr__() for variable_name, variable in model.variables.items()}, - 'constraints': {constraint_name: constraint.__repr__() for constraint_name, constraint in model.constraints.items()}, + 'constraints': { + constraint_name: constraint.__repr__() for constraint_name, constraint in model.constraints.items() + }, 'binaries': list(model.binaries), 'integers': list(model.integers), 'continuous': list(model.continuous), + 'infeasible_constraints': '', } + if model.status == 'warning': + logger.critical(f'The model has a warning status {model.status=}. Trying to extract infeasibilities') + try: + from contextlib import redirect_stdout + import io + f = io.StringIO() + + # Redirect stdout to our buffer + with redirect_stdout(f): + model.print_infeasibilities() + + documentation['infeasible_constraints'] = f.getvalue() + except NotImplementedError: + logger.critical('Infeasible constraints could not get retrieved. This functionality is only availlable with gurobi') + documentation['infeasible_constraints'] = 'Not possible to retrieve infeasible constraints' + if path is not None: if path.suffix not in ['.yaml', '.yml']: raise ValueError(f'Invalid file extension for path {path}. Only .yaml and .yml are supported') From a62307936b06206f91984dd760b8fc49c2b4ceba Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 20 Mar 2025 16:42:39 +0100 Subject: [PATCH 398/507] Add improved handling for infeasible models --- flixOpt/io.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixOpt/io.py b/flixOpt/io.py index 7c5337ac5..4b421db00 100644 --- a/flixOpt/io.py +++ b/flixOpt/io.py @@ -199,8 +199,8 @@ def document_linopy_model(model: linopy.Model, path: pathlib.Path = None) -> Dic if model.status == 'warning': logger.critical(f'The model has a warning status {model.status=}. Trying to extract infeasibilities') try: - from contextlib import redirect_stdout import io + from contextlib import redirect_stdout f = io.StringIO() # Redirect stdout to our buffer From acbcb8ac1975c54aba591525275481dc6c554626 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 20 Mar 2025 21:13:33 +0100 Subject: [PATCH 399/507] Change filter key word --- flixOpt/results.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/flixOpt/results.py b/flixOpt/results.py index 68b51812c..5dff9d34d 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -167,7 +167,7 @@ def constraints(self) -> linopy.Constraints: return self.model.constraints def filter_solution(self, - variable_dims: Optional[Literal['scalar', 'numeric']] = None, + variable_dims: Optional[Literal['scalar', 'time']] = None, element: Optional[str] = None) -> xr.Dataset: """ Filter the solution to a specific variable dimension and element. @@ -334,7 +334,7 @@ def constraints(self) -> linopy.Constraints: raise ValueError('The linopy model is not available.') return self._calculation_results.model.constraints[self._variable_names] - def filter_solution(self, variable_dims: Optional[Literal['scalar', 'numeric']] = None) -> xr.Dataset: + def filter_solution(self, variable_dims: Optional[Literal['scalar', 'time']] = None) -> xr.Dataset: """ Filter the solution of the element by dimension. @@ -654,7 +654,7 @@ def sanitize_dataset( def filter_dataset( ds: xr.Dataset, - variable_dims: Optional[Literal['scalar', 'numeric']] = None, + variable_dims: Optional[Literal['scalar', 'time']] = None, ) -> xr.Dataset: """ Filters a dataset by its dimensions. @@ -668,7 +668,7 @@ def filter_dataset( if variable_dims == 'scalar': return ds[[name for name, da in ds.data_vars.items() if len(da.dims) == 0]] - elif variable_dims == 'numeric': - return ds[[name for name, da in ds.data_vars.items() if len(da.dims) >= 1]] + elif variable_dims == 'time': + return ds[[name for name, da in ds.data_vars.items() if 'time' in da.dims]] else: raise ValueError(f'Not allowed value for "filter_dataset()": {variable_dims=}') From 71a39f581ae55be39167344d9f46652d9bc427bc Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 20 Mar 2025 21:14:25 +0100 Subject: [PATCH 400/507] Change filter key word --- examples/00_Minmal/minimal_example.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/00_Minmal/minimal_example.py b/examples/00_Minmal/minimal_example.py index 728a8d70d..bb2181367 100644 --- a/examples/00_Minmal/minimal_example.py +++ b/examples/00_Minmal/minimal_example.py @@ -58,7 +58,7 @@ # --- Analyze Results --- # Access the results of an element - df1 = calculation.results['costs'].filter_solution('numeric').to_dataframe() + df1 = calculation.results['costs'].filter_solution('time').to_dataframe() # Plot the results of a specific element calculation.results['District Heating'].plot_node_balance() From 364a380976c39593b149125fa0e8b17cafd9fa1c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 21 Mar 2025 09:12:48 +0100 Subject: [PATCH 401/507] Move netcdf saving routine to io.py --- flixOpt/flow_system.py | 31 +++++++-------------- flixOpt/io.py | 51 +++++++++++++++++++++++++++++++++++ flixOpt/results.py | 61 ++++++++++++++---------------------------- 3 files changed, 80 insertions(+), 63 deletions(-) diff --git a/flixOpt/flow_system.py b/flixOpt/flow_system.py index e7a6a9e1f..a0001b318 100644 --- a/flixOpt/flow_system.py +++ b/flixOpt/flow_system.py @@ -2,7 +2,6 @@ This module contains the FlowSystem class, which is used to collect instances of many other classes by the end User. """ -import importlib.util import json import logging import pathlib @@ -16,7 +15,7 @@ from rich.console import Console from rich.pretty import Pretty -from . import io +from . import io as fx_io from .core import NumericData, NumericDataTS, TimeSeries, TimeSeriesCollection, TimeSeriesData from .effects import Effect, EffectCollection, EffectTimeSeries, EffectValuesDict, EffectValuesUser from .elements import Bus, Component, Flow @@ -76,7 +75,7 @@ def from_dataset(cls, ds: xr.Dataset): hours_of_previous_timesteps=ds.attrs['hours_of_previous_timesteps'], ) - structure = io.insert_dataarray({key: ds.attrs[key] for key in ['components', 'buses', 'effects']}, ds) + structure = fx_io.insert_dataarray({key: ds.attrs[key] for key in ['components', 'buses', 'effects']}, ds) flow_system.add_elements( * [Bus.from_dict(bus) for bus in structure['buses'].values()] + [Effect.from_dict(effect) for effect in structure['effects'].values()] @@ -115,10 +114,7 @@ def from_netcdf(cls, path: Union[str, pathlib.Path]): """ Load a FlowSystem from a netcdf file """ - with xr.open_dataset(path) as ds: - ds = ds.load() - ds.attrs = json.loads(ds.attrs['attrs']) - return cls.from_dataset(ds) + return cls.from_dataset(fx_io.load_dataset_from_netcdf(path)) def add_elements(self, *elements: Element) -> None: """ @@ -179,10 +175,10 @@ def as_dict(self, data_mode: Literal['data', 'name', 'stats'] = 'data') -> Dict: "hours_of_previous_timesteps": self.time_series_collection.hours_of_previous_timesteps, } if data_mode == 'data': - return io.replace_timeseries(data, 'data') + return fx_io.replace_timeseries(data, 'data') elif data_mode == 'stats': - return io.remove_none_and_empty(io.replace_timeseries(data, data_mode)) - return io.replace_timeseries(data, data_mode) + return fx_io.remove_none_and_empty(fx_io.replace_timeseries(data, data_mode)) + return fx_io.replace_timeseries(data, data_mode) def as_dataset(self, constants_in_dataset: bool = False) -> xr.Dataset: """ @@ -195,7 +191,7 @@ def as_dataset(self, constants_in_dataset: bool = False) -> xr.Dataset: ds.attrs = self.as_dict(data_mode='name') return ds - def to_netcdf(self, path: Union[str, pathlib.Path], compression: int = 0, constants_in_dataset: bool = False): + def to_netcdf(self, path: Union[str, pathlib.Path], compression: int = 0, constants_in_dataset: bool = True): """ Saves the FlowSystem to a netCDF file. Args: @@ -203,17 +199,8 @@ def to_netcdf(self, path: Union[str, pathlib.Path], compression: int = 0, consta compression: The compression level to use when saving the file. constants_in_dataset: If True, constants are included as Dataset variables. """ - ds = self.as_dataset(constants_in_dataset=True) - ds.attrs = {'attrs': json.dumps(ds.attrs)} - - encoding = None - if compression != 0: - if importlib.util.find_spec('netCDF4') is not None: - encoding = {k: dict(zlib=True, complevel=compression) for k in ds.data_vars} - else: - logger.warning('FlowSystem was exported without compression due to missing dependency "netcdf4".' - 'Install netcdf4 via `pip install netcdf4`.') - ds.to_netcdf(path, encoding=encoding) + ds = self.as_dataset(constants_in_dataset=constants_in_dataset) + fx_io.save_dataset_to_netcdf(ds, path, compression=compression) logger.info(f'Saved FlowSystem to {path}') def plot_network( diff --git a/flixOpt/io.py b/flixOpt/io.py index 4b421db00..5e77d4a31 100644 --- a/flixOpt/io.py +++ b/flixOpt/io.py @@ -1,4 +1,5 @@ import datetime +import importlib.util import json import logging import pathlib @@ -218,3 +219,53 @@ def document_linopy_model(model: linopy.Model, path: pathlib.Path = None) -> Dic _save_to_yaml(documentation, path) return documentation + + +def save_dataset_to_netcdf( + ds: xr.Dataset, + path: Union[str, pathlib.Path], + compression: int = 0, +) -> None: + """ + Save a dataset to a netcdf file. Store the attrs as a json string in the 'attrs' attribute. + + Args: + ds: Dataset to save. + path: Path to save the dataset to. + compression: Compression level for the dataset. Default is 5. + + Raises: + ValueError: If the path has an invalid file extension. + """ + if path.suffix not in ['.nc', '.nc4']: + raise ValueError(f'Invalid file extension for path {path}. Only .nc and .nc4 are supported') + + apply_encoding = False + if compression != 0: + if importlib.util.find_spec('netCDF4') is not None: + apply_encoding = True + else: + logger.warning('CalculationResults were exported without compression due to missing dependency "netcdf4".' + 'Install netcdf4 via `pip install netcdf4`.') + ds = ds.copy(deep=True) + ds.attrs = {'attrs': json.dumps(ds.attrs)} + ds.to_netcdf( + path, + encoding=None if not apply_encoding else {data_var: {"zlib": True, "complevel": 5} + for data_var in ds.data_vars} + ) + + +def load_dataset_from_netcdf(path: Union[str, pathlib.Path]) -> xr.Dataset: + """ + Load a dataset from a netcdf file. Load the attrs from the 'attrs' attribute. + + Args: + path: Path to load the dataset from. + + Returns: + Dataset: Loaded dataset. + """ + ds = xr.load_dataset(path) + ds.attrs = json.loads(ds.attrs['attrs']) + return ds diff --git a/flixOpt/results.py b/flixOpt/results.py index 5dff9d34d..6fa460797 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -1,5 +1,4 @@ import datetime -import importlib.util import json import logging import pathlib @@ -14,7 +13,7 @@ from . import plotting from .core import TimeSeriesCollection -from .io import _results_structure, document_linopy_model +from . import io as fx_io if TYPE_CHECKING: from .calculation import Calculation, SegmentedCalculation @@ -68,10 +67,6 @@ def from_file(cls, folder: Union[str, pathlib.Path], name: str): model_path, solution_path, _, json_path, flow_system_path, _ = cls._get_paths(folder=folder, name=name) - solution = xr.load_dataset(solution_path) - flow_system = xr.load_dataset(flow_system_path) - flow_system.attrs = json.loads(flow_system.attrs['attrs']) - if model_path.exists(): logger.info(f'loading the linopy model "{name}" from file ("{model_path}")') model = linopy.read_netcdf(model_path) @@ -81,8 +76,8 @@ def from_file(cls, folder: Union[str, pathlib.Path], name: str): with open(json_path, 'r', encoding='utf-8') as f: meta_data = json.load(f) - return cls(solution=solution, - flow_system=flow_system, + return cls(solution=fx_io.load_dataset_from_netcdf(solution_path), + flow_system=fx_io.load_dataset_from_netcdf(flow_system_path), name=name, folder=folder, model=model, @@ -91,10 +86,13 @@ def from_file(cls, folder: Union[str, pathlib.Path], name: str): @classmethod def from_calculation(cls, calculation: 'Calculation'): """Create CalculationResults directly from a Calculation""" + solution = calculation.model.solution + solution.reindex(time=calculation.flow_system.time_series_collection.timesteps_extra) + solution.attrs = fx_io._results_structure(calculation.flow_system) + return cls( - solution=calculation.model.solution, + solution=solution, flow_system=calculation.flow_system.as_dataset(constants_in_dataset=True), - results_structure=_results_structure(calculation.flow_system), infos=calculation.infos, network_infos=calculation.flow_system.network_infos(), model=calculation.model, @@ -106,7 +104,6 @@ def __init__( self, solution: xr.Dataset, flow_system: xr.Dataset, - results_structure: Dict[str, Dict[str, Dict]], name: str, infos: Dict, network_infos: Dict, @@ -115,22 +112,21 @@ def __init__( ): self.solution = solution self.flow_system = flow_system - self._results_structure = results_structure self.infos = infos self.network_infos = network_infos self.name = name self.model = model self.folder = pathlib.Path(folder) if folder is not None else pathlib.Path.cwd() / 'results' self.components = {label: ComponentResults.from_json(self, infos) - for label, infos in results_structure['Components'].items()} + for label, infos in self.solution.attrs['Components'].items()} self.buses = {label: BusResults.from_json(self, infos) - for label, infos in results_structure['Buses'].items()} + for label, infos in self.solution.attrs['Buses'].items()} self.effects = {label: EffectResults.from_json(self, infos) - for label, infos in results_structure['Effects'].items()} + for label, infos in self.solution.attrs['Effects'].items()} - self.timesteps_extra = pd.DatetimeIndex([datetime.datetime.fromisoformat(date) for date in results_structure['Time']], name='time') + self.timesteps_extra = self.solution.indexes['time'] self.hours_per_timestep = TimeSeriesCollection.calculate_hours_per_timestep(self.timesteps_extra) def __getitem__(self, key: str) -> Union['ComponentResults', 'BusResults', 'EffectResults']: @@ -227,27 +223,8 @@ def to_file( model_path, solution_path, infos_path, json_path, flow_system_path, model_doc_path = self._get_paths( folder= folder, name= self.name if name is None else name) - apply_encoding = False - if compression != 0: - if importlib.util.find_spec('netCDF4') is not None: - apply_encoding = True - else: - logger.warning('CalculationResults were exported without compression due to missing dependency "netcdf4".' - 'Install netcdf4 via `pip install netcdf4`.') - - self.solution.to_netcdf( - solution_path, - encoding=None if not apply_encoding else {data_var: {"zlib": True, "complevel": 5} - for data_var in self.solution.data_vars} - ) - - flow_system_ds = self.flow_system.copy() - flow_system_ds.attrs = {'attrs': json.dumps(flow_system_ds.attrs)} - flow_system_ds.to_netcdf( - flow_system_path, - encoding=None if not apply_encoding else {data_var: {"zlib": True, "complevel": 5} - for data_var in self.flow_system.data_vars} - ) + fx_io.save_dataset_to_netcdf(self.solution, solution_path, compression=compression) + fx_io.save_dataset_to_netcdf(self.flow_system, flow_system_path, compression=compression) with open(infos_path, 'w', encoding='utf-8') as f: yaml.dump(self.infos, f, allow_unicode=True, sort_keys=False, indent=4, width=1000) @@ -265,13 +242,12 @@ def to_file( if self.model is None: logger.critical('No model in the CalculationResults. Documenting the model is not possible.') else: - document_linopy_model(self.model, path=model_doc_path) + fx_io.document_linopy_model(self.model, path=model_doc_path) logger.info(f'Saved calculation results "{name}" to {solution_path.parent}') def _get_meta_data(self) -> Dict: return { - 'results_structure': self._results_structure, 'infos': self.infos, 'network_infos': self.network_infos, } @@ -552,7 +528,10 @@ def plot_heatmap( save=save, show=show) - def to_file(self, folder: Optional[Union[str, pathlib.Path]] = None, name: Optional[str] = None): + def to_file(self, + folder: Optional[Union[str, pathlib.Path]] = None, + name: Optional[str] = None, + compression: int = 5): """Save the results to a file""" folder = self.folder if folder is None else pathlib.Path(folder) name = self.name if name is None else name @@ -563,7 +542,7 @@ def to_file(self, folder: Optional[Union[str, pathlib.Path]] = None, name: Optio except FileNotFoundError as e: raise FileNotFoundError(f'Folder {folder} and its parent do not exist. Please create them first.') from e for segment in self.segment_results: - segment.to_file(folder, f'{name}-{segment.name}') + segment.to_file(folder=folder, name=f'{name}-{segment.name}', compression=compression) with open(path.with_suffix('.json'), 'w', encoding='utf-8') as f: json.dump(self.meta_data, f, indent=4, ensure_ascii=False) From 16b4ded18ea52106ba0170bd7d8f338a527839c7 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 21 Mar 2025 09:19:33 +0100 Subject: [PATCH 402/507] Make loading the linopy model from file not break the code --- flixOpt/results.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/flixOpt/results.py b/flixOpt/results.py index 6fa460797..40786c6c7 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -11,9 +11,9 @@ import xarray as xr import yaml +from . import io as fx_io from . import plotting from .core import TimeSeriesCollection -from . import io as fx_io if TYPE_CHECKING: from .calculation import Calculation, SegmentedCalculation @@ -67,11 +67,13 @@ def from_file(cls, folder: Union[str, pathlib.Path], name: str): model_path, solution_path, _, json_path, flow_system_path, _ = cls._get_paths(folder=folder, name=name) + model = None if model_path.exists(): - logger.info(f'loading the linopy model "{name}" from file ("{model_path}")') - model = linopy.read_netcdf(model_path) - else: - model = None + try: + logger.info(f'loading the linopy model "{name}" from file ("{model_path}")') + model = linopy.read_netcdf(model_path) + except Exception as e: + logger.critical(f'Could not load the linopy model "{name}" from file ("{model_path}"): {e}') with open(json_path, 'r', encoding='utf-8') as f: meta_data = json.load(f) From 8296bc25a4baa01429807c435de27e503715f67f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 21 Mar 2025 09:34:03 +0100 Subject: [PATCH 403/507] Improve docstrings --- flixOpt/io.py | 4 +- flixOpt/results.py | 100 ++++++++++++++++++++++++++++----------------- 2 files changed, 64 insertions(+), 40 deletions(-) diff --git a/flixOpt/io.py b/flixOpt/io.py index 5e77d4a31..ec08f1968 100644 --- a/flixOpt/io.py +++ b/flixOpt/io.py @@ -232,7 +232,7 @@ def save_dataset_to_netcdf( Args: ds: Dataset to save. path: Path to save the dataset to. - compression: Compression level for the dataset. Default is 5. + compression: Compression level for the dataset (0-9). 0 means no compression. 5 is a good default. Raises: ValueError: If the path has an invalid file extension. @@ -245,7 +245,7 @@ def save_dataset_to_netcdf( if importlib.util.find_spec('netCDF4') is not None: apply_encoding = True else: - logger.warning('CalculationResults were exported without compression due to missing dependency "netcdf4".' + logger.warning('Dataset was exported without compression due to missing dependency "netcdf4".' 'Install netcdf4 via `pip install netcdf4`.') ds = ds.copy(deep=True) ds.attrs = {'attrs': json.dumps(ds.attrs)} diff --git a/flixOpt/results.py b/flixOpt/results.py index 40786c6c7..462c83cd2 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -23,46 +23,56 @@ class CalculationResults: - """ - Results for a Calculation. - This class is used to collect the results of a Calculation. - It is used to analyze the results and to visualize the results. - - Parameters - ---------- - model : linopy.Model - The linopy model that was used to solve the calculation. - infos : Dict - Information about the calculation, - results_structure : Dict[str, Dict[str, Dict]] - The structure of the flow_system that was used to solve the calculation. - - Attributes - ---------- - model : linopy.Model - The linopy model that was used to solve the calculation. - components : Dict[str, ComponentResults] - A dictionary of ComponentResults for each component in the flow_system. - buses : Dict[str, BusResults] - A dictionary of BusResults for each bus in the flow_system. - effects : Dict[str, EffectResults] - A dictionary of EffectResults for each effect in the flow_system. - timesteps_extra : pd.DatetimeIndex - The extra timesteps of the flow_system. - hours_per_timestep : xr.DataArray - The duration of each timestep in hours. - - Class Methods - ------- - from_file(folder: Union[str, pathlib.Path], name: str) - Create CalculationResults directly from file. - from_calculation(calculation: Calculation) - Create CalculationResults directly from a Calculation. + """Results container for Calculation results. + This class is used to collect the results of a Calculation. + It provides access to component, bus, and effect + results, and includes methods for filtering, plotting, and saving results. + + The recommended way to create instances is through the class methods + `from_file()` or `from_calculation()`, rather than direct initialization. + + Attributes: + solution (xr.Dataset): Dataset containing optimization results. + flow_system (xr.Dataset): Dataset containing the flow system. + infos (Dict): Information about the calculation. + network_infos (Dict): Information about the network structure. + name (str): Name identifier for the calculation. + model (linopy.Model): The optimization model (if available). + folder (pathlib.Path): Path to the results directory. + components (Dict[str, ComponentResults]): Results for each component. + buses (Dict[str, BusResults]): Results for each bus. + effects (Dict[str, EffectResults]): Results for each effect. + timesteps_extra (pd.DatetimeIndex): The extended timesteps. + hours_per_timestep (xr.DataArray): Duration of each timestep in hours. + + Example: + Load results from saved files: + + >>> results = CalculationResults.from_file("results_dir", "optimization_run_1") + >>> element_result = results["Boiler"] + >>> results.plot_heatmap("Boiler(Q_th)|flow_rate") + >>> results.to_file(compression=5) + >>> results.to_file(folder="new_results_dir", compression=5) # Save the results to a new folder """ @classmethod def from_file(cls, folder: Union[str, pathlib.Path], name: str): - """ Create CalculationResults directly from file""" + """Create CalculationResults instance by loading from saved files. + + This method loads the calculation results from previously saved files, + including the solution, flow system, model (if available), and metadata. + + Args: + folder: Path to the directory containing the saved files. + name: Base name of the saved files (without file extensions). + + Returns: + CalculationResults: A new instance containing the loaded data. + + Raises: + FileNotFoundError: If required files cannot be found. + ValueError: If files exist but cannot be properly loaded. + """ folder = pathlib.Path(folder) model_path, solution_path, _, json_path, flow_system_path, _ = cls._get_paths(folder=folder, name=name) @@ -87,7 +97,21 @@ def from_file(cls, folder: Union[str, pathlib.Path], name: str): @classmethod def from_calculation(cls, calculation: 'Calculation'): - """Create CalculationResults directly from a Calculation""" + """Create CalculationResults directly from a Calculation object. + + This method extracts the solution, flow system, and other relevant + information directly from an existing Calculation object. + + Args: + calculation: A Calculation object containing a solved model. + + Returns: + CalculationResults: A new instance containing the results from + the provided calculation. + + Raises: + AttributeError: If the calculation doesn't have required attributes. + """ solution = calculation.model.solution solution.reindex(time=calculation.flow_system.time_series_collection.timesteps_extra) solution.attrs = fx_io._results_structure(calculation.flow_system) @@ -210,7 +234,7 @@ def to_file( Args: folder: The folder where the results should be saved. Defaults to the folder of the calculation. name: The name of the results file. If not provided, Defaults to the name of the calculation. - compression: The compression level to use when saving the solution file. + compression: The compression level to use when saving the solution file (0-9). 0 means no compression. document_model: Wether to document the mathematical formulations in the model. save_linopy_model: Wether to save the model to file. If True, the (linopy) model is saved as a .nc file. The model file size is rougly 100 times larger than the solution file. From 7459fb1bf7ef431138f4ae6fbdd88e964450bb9a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 21 Mar 2025 09:50:30 +0100 Subject: [PATCH 404/507] Remove som io functions and move results structure into SystemModel --- flixOpt/io.py | 25 ------------------------- flixOpt/results.py | 9 +++------ flixOpt/structure.py | 22 ++++++++++++++++++++++ 3 files changed, 25 insertions(+), 31 deletions(-) diff --git a/flixOpt/io.py b/flixOpt/io.py index ec08f1968..b996ba6dc 100644 --- a/flixOpt/io.py +++ b/flixOpt/io.py @@ -16,29 +16,6 @@ logger = logging.getLogger('flixOpt') -def _results_structure(flow_system: FlowSystem) -> Dict[str, Dict]: - return { - 'Components': { - comp.label_full: comp.model.results_structure() - for comp in sorted(flow_system.components.values(), key=lambda component: component.label_full.upper()) - }, - 'Buses': { - bus.label_full: bus.model.results_structure() - for bus in sorted(flow_system.buses.values(), key=lambda bus: bus.label_full.upper()) - }, - 'Effects': { - effect.label_full: effect.model.results_structure() - for effect in sorted(flow_system.effects, key=lambda effect: effect.label_full.upper()) - }, - 'Time': [datetime.datetime.isoformat(date) for date in flow_system.time_series_collection.timesteps_extra], - } - - -def structure_to_json(flow_system: FlowSystem, path: Union[str, pathlib.Path] = 'system_model.json'): - with open(path, 'w', encoding='utf-8') as f: - json.dump(_results_structure(flow_system), f, indent=4, ensure_ascii=False) - - def replace_timeseries(obj, mode: Literal['name', 'stats', 'data'] = 'name'): """Recursively replaces TimeSeries objects with their names prefixed by '::::'.""" if isinstance(obj, dict): @@ -131,8 +108,6 @@ def represent_str(dumper, data): allow_unicode=True, # Support Unicode characters ) - print(f'Data saved to {output_file}') - def _process_complex_strings(data): """ diff --git a/flixOpt/results.py b/flixOpt/results.py index 462c83cd2..944ffd48c 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -112,12 +112,8 @@ def from_calculation(cls, calculation: 'Calculation'): Raises: AttributeError: If the calculation doesn't have required attributes. """ - solution = calculation.model.solution - solution.reindex(time=calculation.flow_system.time_series_collection.timesteps_extra) - solution.attrs = fx_io._results_structure(calculation.flow_system) - return cls( - solution=solution, + solution=calculation.model.solution, flow_system=calculation.flow_system.as_dataset(constants_in_dataset=True), infos=calculation.infos, network_infos=calculation.flow_system.network_infos(), @@ -240,6 +236,7 @@ def to_file( The model file size is rougly 100 times larger than the solution file. """ folder = self.folder if folder is None else pathlib.Path(folder) + name = self.name if name is None else name if not folder.exists(): try: folder.mkdir(parents=False) @@ -247,7 +244,7 @@ def to_file( raise FileNotFoundError(f'Folder {folder} and its parent do not exist. Please create them first.') from e model_path, solution_path, infos_path, json_path, flow_system_path, model_doc_path = self._get_paths( - folder= folder, name= self.name if name is None else name) + folder= folder, name=name) fx_io.save_dataset_to_netcdf(self.solution, solution_path, compression=compression) fx_io.save_dataset_to_netcdf(self.flow_system, flow_system_path, compression=compression) diff --git a/flixOpt/structure.py b/flixOpt/structure.py index fa8765f1f..e4347c16d 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -58,6 +58,28 @@ def do_modeling(self): for bus_model in bus_models: # Buses after Components, because FlowModels are created in ComponentModels bus_model.do_modeling() + @property + def solution(self): + solution = super().solution + solution.attrs = { + 'Components': { + comp.label_full: comp.model.results_structure() + for comp in sorted( + self.flow_system.components.values(), key=lambda component: component.label_full.upper() + ) + }, + 'Buses': { + bus.label_full: bus.model.results_structure() + for bus in sorted(self.flow_system.buses.values(), key=lambda bus: bus.label_full.upper()) + }, + 'Effects': { + effect.label_full: effect.model.results_structure() + for effect in sorted(self.flow_system.effects, key=lambda effect: effect.label_full.upper()) + }, + } + solution.reindex(time=self.time_series_collection.timesteps_extra) + return solution + @property def hours_per_step(self): return self.time_series_collection.hours_per_timestep From 745d0df8d469b11930e95a1c035299151b01edef Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 21 Mar 2025 11:58:41 +0100 Subject: [PATCH 405/507] Improve how results are saved --- examples/01_Simple/simple_example.py | 2 +- flixOpt/calculation.py | 11 +++++++---- flixOpt/io.py | 15 ++++++++++++++- flixOpt/results.py | 17 ++--------------- 4 files changed, 24 insertions(+), 21 deletions(-) diff --git a/examples/01_Simple/simple_example.py b/examples/01_Simple/simple_example.py index 5493c0a48..0e87c5611 100644 --- a/examples/01_Simple/simple_example.py +++ b/examples/01_Simple/simple_example.py @@ -1,5 +1,5 @@ """ -THis script shows how to use the flixOpt framework to model a simple energy system. +This script shows how to use the flixOpt framework to model a simple energy system. """ import numpy as np diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py index e2967d469..acdfc5beb 100644 --- a/flixOpt/calculation.py +++ b/flixOpt/calculation.py @@ -8,12 +8,11 @@ 3. SegmentedCalculation: Solves a SystemModel for each individual Segment of the FlowSystem. """ -import json import logging import math import pathlib import timeit -from typing import Any, Dict, List, Literal, Optional, Union +from typing import Any, Dict, List, Optional, Union import numpy as np import pandas as pd @@ -23,13 +22,14 @@ from .aggregation import AggregationModel, AggregationParameters from .components import Storage from .config import CONFIG -from .core import NumericData, Scalar +from .core import Scalar from .elements import Component from .features import InvestmentModel from .flow_system import FlowSystem from .results import CalculationResults, SegmentedCalculationResults from .solvers import _Solver from .structure import SystemModel, copy_and_convert_datatypes, get_compact_representation +from . import io as fx_io logger = logging.getLogger('flixOpt') @@ -154,8 +154,11 @@ def solve(self, self.durations['solving'] = round(timeit.default_timer() - t_start, 2) if self.model.status == 'warning': + # Save the model and the flow_system to file in case of infeasibility + _, _, _, _, flow_system_path, model_documentation = fx_io.get_paths(self.folder, self.name) from .io import document_linopy_model - document_linopy_model(self.model, self.folder / f'{self.name}_model_doc.yaml') + document_linopy_model(self.model, self.folder / f'{self.name}--model_documentation.yaml') + self.flow_system.to_netcdf(flow_system_path) #TODO: Raise an exception here? # Log the formatted output diff --git a/flixOpt/io.py b/flixOpt/io.py index b996ba6dc..3bdb8b02d 100644 --- a/flixOpt/io.py +++ b/flixOpt/io.py @@ -4,7 +4,7 @@ import logging import pathlib import re -from typing import TYPE_CHECKING, Dict, Literal, Union +from typing import Dict, Literal, Union, Tuple import linopy import xarray as xr @@ -244,3 +244,16 @@ def load_dataset_from_netcdf(path: Union[str, pathlib.Path]) -> xr.Dataset: ds = xr.load_dataset(path) ds.attrs = json.loads(ds.attrs['attrs']) return ds + + +def get_paths( + folder: pathlib.Path, + name: str +) -> Tuple[pathlib.Path, pathlib.Path, pathlib.Path, pathlib.Path, pathlib.Path, pathlib.Path]: + model_path = folder / f'{name}--model.nc' + solution_path = folder / f'{name}--solution.nc' + infos_path = folder / f'{name}--infos.yaml' + json_path = folder/f'{name}--structure.json' + flow_system_path = folder / f'{name}--flow_system.nc' + model_documentation_path = folder / f'{name}--model_documentation.yaml' + return model_path, solution_path, infos_path, json_path, flow_system_path, model_documentation_path diff --git a/flixOpt/results.py b/flixOpt/results.py index 944ffd48c..310ead198 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -75,7 +75,7 @@ def from_file(cls, folder: Union[str, pathlib.Path], name: str): """ folder = pathlib.Path(folder) - model_path, solution_path, _, json_path, flow_system_path, _ = cls._get_paths(folder=folder, name=name) + model_path, solution_path, _, json_path, flow_system_path, _ = fx_io.get_paths(folder=folder, name=name) model = None if model_path.exists(): @@ -243,7 +243,7 @@ def to_file( except FileNotFoundError as e: raise FileNotFoundError(f'Folder {folder} and its parent do not exist. Please create them first.') from e - model_path, solution_path, infos_path, json_path, flow_system_path, model_doc_path = self._get_paths( + model_path, solution_path, infos_path, json_path, flow_system_path, model_doc_path = fx_io.get_paths( folder= folder, name=name) fx_io.save_dataset_to_netcdf(self.solution, solution_path, compression=compression) @@ -275,19 +275,6 @@ def _get_meta_data(self) -> Dict: 'network_infos': self.network_infos, } - @staticmethod - def _get_paths( - folder: pathlib.Path, - name: str - ) -> Tuple[pathlib.Path, pathlib.Path, pathlib.Path, pathlib.Path, pathlib.Path, pathlib.Path]: - model_path = folder / f'{name}_model.nc' - solution_path = folder / f'{name}_solution.nc' - infos_path = folder / f'{name}_infos.yaml' - json_path = folder/f'{name}_structure.json' - flow_system_path = folder / f'{name}_flowsystem.nc' - model_documentation_path = folder / f'{name}_model_doc.yaml' - return model_path, solution_path, infos_path, json_path, flow_system_path, model_documentation_path - class _ElementResults: @classmethod From 7640f0b0cbfe67c47e941f83585b75c96c711caf Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 21 Mar 2025 12:14:54 +0100 Subject: [PATCH 406/507] Update filenames of export --- examples/00_Minmal/minimal_example.py | 2 +- flixOpt/calculation.py | 2 +- flixOpt/io.py | 6 ++--- flixOpt/results.py | 39 +++++++++++++-------------- 4 files changed, 24 insertions(+), 25 deletions(-) diff --git a/examples/00_Minmal/minimal_example.py b/examples/00_Minmal/minimal_example.py index bb2181367..f58d44e64 100644 --- a/examples/00_Minmal/minimal_example.py +++ b/examples/00_Minmal/minimal_example.py @@ -68,4 +68,4 @@ # df2.to_csv('results/District Heating.csv') # Save results to csv # Print infos to the console. - pprint(calculation.infos) + pprint(calculation.summary) diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py index acdfc5beb..3c7976483 100644 --- a/flixOpt/calculation.py +++ b/flixOpt/calculation.py @@ -114,7 +114,7 @@ def main_results(self) -> Dict[str, Union[Scalar, Dict]]: } @property - def infos(self): + def summary(self): return { 'Name': self.name, 'Number of timesteps': len(self.flow_system.time_series_collection.timesteps), diff --git a/flixOpt/io.py b/flixOpt/io.py index 3bdb8b02d..ebaeae9f9 100644 --- a/flixOpt/io.py +++ b/flixOpt/io.py @@ -252,8 +252,8 @@ def get_paths( ) -> Tuple[pathlib.Path, pathlib.Path, pathlib.Path, pathlib.Path, pathlib.Path, pathlib.Path]: model_path = folder / f'{name}--model.nc' solution_path = folder / f'{name}--solution.nc' - infos_path = folder / f'{name}--infos.yaml' - json_path = folder/f'{name}--structure.json' + summary_path = folder / f'{name}--summary.yaml' + network_path = folder/f'{name}--network.json' flow_system_path = folder / f'{name}--flow_system.nc' model_documentation_path = folder / f'{name}--model_documentation.yaml' - return model_path, solution_path, infos_path, json_path, flow_system_path, model_documentation_path + return model_path, solution_path, summary_path, network_path, flow_system_path, model_documentation_path diff --git a/flixOpt/results.py b/flixOpt/results.py index 310ead198..a5f50dfcb 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -35,7 +35,7 @@ class CalculationResults: Attributes: solution (xr.Dataset): Dataset containing optimization results. flow_system (xr.Dataset): Dataset containing the flow system. - infos (Dict): Information about the calculation. + summary (Dict): Information about the calculation. network_infos (Dict): Information about the network structure. name (str): Name identifier for the calculation. model (linopy.Model): The optimization model (if available). @@ -75,7 +75,8 @@ def from_file(cls, folder: Union[str, pathlib.Path], name: str): """ folder = pathlib.Path(folder) - model_path, solution_path, _, json_path, flow_system_path, _ = fx_io.get_paths(folder=folder, name=name) + model_path, solution_path, summary_path, network_path, flow_system_path, _ = fx_io.get_paths(folder=folder, + name=name) model = None if model_path.exists(): @@ -85,15 +86,19 @@ def from_file(cls, folder: Union[str, pathlib.Path], name: str): except Exception as e: logger.critical(f'Could not load the linopy model "{name}" from file ("{model_path}"): {e}') - with open(json_path, 'r', encoding='utf-8') as f: - meta_data = json.load(f) + with open(summary_path, 'r', encoding='utf-8') as f: + summary = yaml.load(f, Loader=yaml.FullLoader) + + with open(network_path, 'r', encoding='utf-8') as f: + network_infos = json.load(f) return cls(solution=fx_io.load_dataset_from_netcdf(solution_path), flow_system=fx_io.load_dataset_from_netcdf(flow_system_path), name=name, folder=folder, model=model, - **meta_data) + summary=summary, + network_infos=network_infos) @classmethod def from_calculation(cls, calculation: 'Calculation'): @@ -115,7 +120,7 @@ def from_calculation(cls, calculation: 'Calculation'): return cls( solution=calculation.model.solution, flow_system=calculation.flow_system.as_dataset(constants_in_dataset=True), - infos=calculation.infos, + summary=calculation.summary, network_infos=calculation.flow_system.network_infos(), model=calculation.model, name=calculation.name, @@ -127,14 +132,14 @@ def __init__( solution: xr.Dataset, flow_system: xr.Dataset, name: str, - infos: Dict, + summary: Dict, network_infos: Dict, folder: Optional[pathlib.Path] = None, model: Optional[linopy.Model] = None, ): self.solution = solution self.flow_system = flow_system - self.infos = infos + self.summary = summary self.network_infos = network_infos self.name = name self.model = model @@ -168,7 +173,7 @@ def storages(self) -> List['ComponentResults']: @property def objective(self) -> float: """ The objective result of the optimization. """ - return self.infos['Main Results']['Objective'] + return self.summary['Main Results']['Objective'] @property def variables(self) -> linopy.Variables: @@ -243,17 +248,17 @@ def to_file( except FileNotFoundError as e: raise FileNotFoundError(f'Folder {folder} and its parent do not exist. Please create them first.') from e - model_path, solution_path, infos_path, json_path, flow_system_path, model_doc_path = fx_io.get_paths( + model_path, solution_path, summary_path, network_path, flow_system_path, model_doc_path = fx_io.get_paths( folder= folder, name=name) fx_io.save_dataset_to_netcdf(self.solution, solution_path, compression=compression) fx_io.save_dataset_to_netcdf(self.flow_system, flow_system_path, compression=compression) - with open(infos_path, 'w', encoding='utf-8') as f: - yaml.dump(self.infos, f, allow_unicode=True, sort_keys=False, indent=4, width=1000) + with open(summary_path, 'w', encoding='utf-8') as f: + yaml.dump(self.summary, f, allow_unicode=True, sort_keys=False, indent=4, width=1000) - with open(json_path, 'w', encoding='utf-8') as f: - json.dump(self._get_meta_data(), f, indent=4, ensure_ascii=False) + with open(network_path, 'w', encoding='utf-8') as f: + json.dump(self.network_infos, f, indent=4, ensure_ascii=False) if save_linopy_model: if self.model is None: @@ -269,12 +274,6 @@ def to_file( logger.info(f'Saved calculation results "{name}" to {solution_path.parent}') - def _get_meta_data(self) -> Dict: - return { - 'infos': self.infos, - 'network_infos': self.network_infos, - } - class _ElementResults: @classmethod From 20ac5e29b637b6629ea25b33e8ae37ecc77ec3c9 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 21 Mar 2025 12:24:49 +0100 Subject: [PATCH 407/507] Update test to load from correct path --- tests/test_io.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_io.py b/tests/test_io.py index 151e8c838..e79d82888 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -12,6 +12,8 @@ simple_flow_system, ) +from flixOpt.io import get_paths + @pytest.fixture(params=[flow_system_base, flow_system_segments_of_flows, simple_flow_system, flow_system_long]) def flow_system(request): @@ -28,7 +30,8 @@ def test_flow_system_file_io(flow_system, highs_solver): calculation_0.solve(highs_solver) calculation_0.results.to_file() - flow_system_1 = fx.FlowSystem.from_netcdf(f'results/{calculation_0.name}_flowsystem.nc') + path = get_paths(calculation_0.folder, calculation_0.name)[4] + flow_system_1 = fx.FlowSystem.from_netcdf(path) calculation_1 = fx.FullCalculation('Loaded_IO', flow_system=flow_system_1) calculation_1.do_modeling() From 1acd0003a942b4bcf7a65007d0598f7b3e8c05fd Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 23 Mar 2025 10:55:05 +0100 Subject: [PATCH 408/507] ruff check --- flixOpt/calculation.py | 2 +- flixOpt/io.py | 4 +--- tests/test_io.py | 3 +-- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py index 3c7976483..ea4e102df 100644 --- a/flixOpt/calculation.py +++ b/flixOpt/calculation.py @@ -18,6 +18,7 @@ import pandas as pd import yaml +from . import io as fx_io from . import utils as utils from .aggregation import AggregationModel, AggregationParameters from .components import Storage @@ -29,7 +30,6 @@ from .results import CalculationResults, SegmentedCalculationResults from .solvers import _Solver from .structure import SystemModel, copy_and_convert_datatypes, get_compact_representation -from . import io as fx_io logger = logging.getLogger('flixOpt') diff --git a/flixOpt/io.py b/flixOpt/io.py index ebaeae9f9..c30db90aa 100644 --- a/flixOpt/io.py +++ b/flixOpt/io.py @@ -1,17 +1,15 @@ -import datetime import importlib.util import json import logging import pathlib import re -from typing import Dict, Literal, Union, Tuple +from typing import Dict, Literal, Tuple, Union import linopy import xarray as xr import yaml from .core import TimeSeries -from .flow_system import FlowSystem logger = logging.getLogger('flixOpt') diff --git a/tests/test_io.py b/tests/test_io.py index e79d82888..36c5a1d2f 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -3,6 +3,7 @@ import pytest import flixOpt as fx +from flixOpt.io import get_paths from .conftest import ( assert_almost_equal_numeric, @@ -12,8 +13,6 @@ simple_flow_system, ) -from flixOpt.io import get_paths - @pytest.fixture(params=[flow_system_base, flow_system_segments_of_flows, simple_flow_system, flow_system_long]) def flow_system(request): From 9ce3ec90e9d058490685aeee95c970c00bc6e459 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 23 Mar 2025 11:38:34 +0100 Subject: [PATCH 409/507] Improve filenames and switch to .nc4 --- flixOpt/io.py | 6 +++--- flixOpt/results.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/flixOpt/io.py b/flixOpt/io.py index c30db90aa..d1527ceba 100644 --- a/flixOpt/io.py +++ b/flixOpt/io.py @@ -248,10 +248,10 @@ def get_paths( folder: pathlib.Path, name: str ) -> Tuple[pathlib.Path, pathlib.Path, pathlib.Path, pathlib.Path, pathlib.Path, pathlib.Path]: - model_path = folder / f'{name}--model.nc' - solution_path = folder / f'{name}--solution.nc' + model_path = folder / f'{name}--linopy_model.nc4' + solution_path = folder / f'{name}--solution.nc4' summary_path = folder / f'{name}--summary.yaml' network_path = folder/f'{name}--network.json' - flow_system_path = folder / f'{name}--flow_system.nc' + flow_system_path = folder / f'{name}--flow_system.nc4' model_documentation_path = folder / f'{name}--model_documentation.yaml' return model_path, solution_path, summary_path, network_path, flow_system_path, model_documentation_path diff --git a/flixOpt/results.py b/flixOpt/results.py index a5f50dfcb..4fc18f892 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -237,7 +237,7 @@ def to_file( name: The name of the results file. If not provided, Defaults to the name of the calculation. compression: The compression level to use when saving the solution file (0-9). 0 means no compression. document_model: Wether to document the mathematical formulations in the model. - save_linopy_model: Wether to save the model to file. If True, the (linopy) model is saved as a .nc file. + save_linopy_model: Wether to save the model to file. If True, the (linopy) model is saved as a .nc4 file. The model file size is rougly 100 times larger than the solution file. """ folder = self.folder if folder is None else pathlib.Path(folder) @@ -468,7 +468,7 @@ def from_file(cls, folder: Union[str, pathlib.Path], name: str): """ Create SegmentedCalculationResults directly from file""" folder = pathlib.Path(folder) path = folder / name - nc_file = path.with_suffix('.nc') + nc_file = path.with_suffix('.nc4') logger.info(f'loading calculation "{name}" from file ("{nc_file}")') with open(path.with_suffix('.json'), 'r', encoding='utf-8') as f: meta_data = json.load(f) From 5f1ba64dc5f91589bcd65428c8616fce843194ac Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 23 Mar 2025 11:54:00 +0100 Subject: [PATCH 410/507] Use an object to store the paths for saving --- flixOpt/calculation.py | 8 ++++---- flixOpt/io.py | 38 +++++++++++++++++++++++++++----------- flixOpt/results.py | 37 +++++++++++++++++-------------------- tests/test_io.py | 6 +++--- 4 files changed, 51 insertions(+), 38 deletions(-) diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py index ea4e102df..e89e9dce6 100644 --- a/flixOpt/calculation.py +++ b/flixOpt/calculation.py @@ -155,11 +155,11 @@ def solve(self, if self.model.status == 'warning': # Save the model and the flow_system to file in case of infeasibility - _, _, _, _, flow_system_path, model_documentation = fx_io.get_paths(self.folder, self.name) + paths = fx_io.CalculationResultsPaths(self.folder, self.name) from .io import document_linopy_model - document_linopy_model(self.model, self.folder / f'{self.name}--model_documentation.yaml') - self.flow_system.to_netcdf(flow_system_path) - #TODO: Raise an exception here? + document_linopy_model(self.model, paths.model_documentation) + self.flow_system.to_netcdf(paths.flow_system) + raise RuntimeError(f'Model was infeasible. Please check {paths.model_documentation=} and {paths.flow_system=} for more information.') # Log the formatted output if log_main_results: diff --git a/flixOpt/io.py b/flixOpt/io.py index d1527ceba..c48cdff08 100644 --- a/flixOpt/io.py +++ b/flixOpt/io.py @@ -4,6 +4,7 @@ import pathlib import re from typing import Dict, Literal, Tuple, Union +from dataclasses import dataclass import linopy import xarray as xr @@ -244,14 +245,29 @@ def load_dataset_from_netcdf(path: Union[str, pathlib.Path]) -> xr.Dataset: return ds -def get_paths( - folder: pathlib.Path, - name: str -) -> Tuple[pathlib.Path, pathlib.Path, pathlib.Path, pathlib.Path, pathlib.Path, pathlib.Path]: - model_path = folder / f'{name}--linopy_model.nc4' - solution_path = folder / f'{name}--solution.nc4' - summary_path = folder / f'{name}--summary.yaml' - network_path = folder/f'{name}--network.json' - flow_system_path = folder / f'{name}--flow_system.nc4' - model_documentation_path = folder / f'{name}--model_documentation.yaml' - return model_path, solution_path, summary_path, network_path, flow_system_path, model_documentation_path +@dataclass +class CalculationResultsPaths: + """Container for all paths related to saving CalculationResults.""" + folder: pathlib.Path + name: str + + def __post_init__(self): + """Initialize all path attributes.""" + self.model = self.folder / f"{self.name}--linopy_model.nc4" + self.solution = self.folder / f"{self.name}--solution.nc4" + self.summary = self.folder / f"{self.name}--summary.yaml" + self.network = self.folder / f"{self.name}--network.json" + self.flow_system = self.folder / f"{self.name}--flow_system.nc4" + self.model_documentation = self.folder / f"{self.name}--model_documentation.yaml" + + def create_folders(self, parents: bool = False) -> None: + """Ensure the folder exists. + Args: + parents: Whether to create the parent folders if they do not exist. + """ + if not self.folder.exists(): + try: + self.folder.mkdir(parents=parents) + except FileNotFoundError as e: + raise FileNotFoundError(f'Folder {self.folder} and its parent do not exist. Please create them first.') from e + diff --git a/flixOpt/results.py b/flixOpt/results.py index 4fc18f892..e140751ee 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -74,26 +74,24 @@ def from_file(cls, folder: Union[str, pathlib.Path], name: str): ValueError: If files exist but cannot be properly loaded. """ folder = pathlib.Path(folder) - - model_path, solution_path, summary_path, network_path, flow_system_path, _ = fx_io.get_paths(folder=folder, - name=name) + paths = fx_io.CalculationResultsPaths(folder, name) model = None - if model_path.exists(): + if paths.model.exists(): try: - logger.info(f'loading the linopy model "{name}" from file ("{model_path}")') - model = linopy.read_netcdf(model_path) + logger.info(f'loading the linopy model "{name}" from file ("{paths.model}")') + model = linopy.read_netcdf(paths.model) except Exception as e: - logger.critical(f'Could not load the linopy model "{name}" from file ("{model_path}"): {e}') + logger.critical(f'Could not load the linopy model "{name}" from file ("{paths.model}"): {e}') - with open(summary_path, 'r', encoding='utf-8') as f: + with open(paths.summary, 'r', encoding='utf-8') as f: summary = yaml.load(f, Loader=yaml.FullLoader) - with open(network_path, 'r', encoding='utf-8') as f: + with open(paths.network, 'r', encoding='utf-8') as f: network_infos = json.load(f) - return cls(solution=fx_io.load_dataset_from_netcdf(solution_path), - flow_system=fx_io.load_dataset_from_netcdf(flow_system_path), + return cls(solution=fx_io.load_dataset_from_netcdf(paths.solution), + flow_system=fx_io.load_dataset_from_netcdf(paths.flow_system), name=name, folder=folder, model=model, @@ -248,31 +246,30 @@ def to_file( except FileNotFoundError as e: raise FileNotFoundError(f'Folder {folder} and its parent do not exist. Please create them first.') from e - model_path, solution_path, summary_path, network_path, flow_system_path, model_doc_path = fx_io.get_paths( - folder= folder, name=name) + paths = fx_io.CalculationResultsPaths(folder, name) - fx_io.save_dataset_to_netcdf(self.solution, solution_path, compression=compression) - fx_io.save_dataset_to_netcdf(self.flow_system, flow_system_path, compression=compression) + fx_io.save_dataset_to_netcdf(self.solution, paths.solution, compression=compression) + fx_io.save_dataset_to_netcdf(self.flow_system, paths.flow_system, compression=compression) - with open(summary_path, 'w', encoding='utf-8') as f: + with open(paths.summary, 'w', encoding='utf-8') as f: yaml.dump(self.summary, f, allow_unicode=True, sort_keys=False, indent=4, width=1000) - with open(network_path, 'w', encoding='utf-8') as f: + with open(paths.network, 'w', encoding='utf-8') as f: json.dump(self.network_infos, f, indent=4, ensure_ascii=False) if save_linopy_model: if self.model is None: logger.critical('No model in the CalculationResults. Saving the model is not possible.') else: - self.model.to_netcdf(model_path) + self.model.to_netcdf(paths.model) if document_model: if self.model is None: logger.critical('No model in the CalculationResults. Documenting the model is not possible.') else: - fx_io.document_linopy_model(self.model, path=model_doc_path) + fx_io.document_linopy_model(self.model, path=paths.model_documentation) - logger.info(f'Saved calculation results "{name}" to {solution_path.parent}') + logger.info(f'Saved calculation results "{name}" to {paths.model_documentation.parent}') class _ElementResults: diff --git a/tests/test_io.py b/tests/test_io.py index 36c5a1d2f..08176f980 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -3,7 +3,7 @@ import pytest import flixOpt as fx -from flixOpt.io import get_paths +from flixOpt.io import CalculationResultsPaths from .conftest import ( assert_almost_equal_numeric, @@ -29,8 +29,8 @@ def test_flow_system_file_io(flow_system, highs_solver): calculation_0.solve(highs_solver) calculation_0.results.to_file() - path = get_paths(calculation_0.folder, calculation_0.name)[4] - flow_system_1 = fx.FlowSystem.from_netcdf(path) + paths = CalculationResultsPaths(calculation_0.folder, calculation_0.name) + flow_system_1 = fx.FlowSystem.from_netcdf(paths.flow_system) calculation_1 = fx.FullCalculation('Loaded_IO', flow_system=flow_system_1) calculation_1.do_modeling() From b701f99bf0406e9039d1d4e34fd549599213894e Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 23 Mar 2025 11:56:34 +0100 Subject: [PATCH 411/507] Enable to change the name and folder of the Paths object --- flixOpt/io.py | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/flixOpt/io.py b/flixOpt/io.py index c48cdff08..af820f12a 100644 --- a/flixOpt/io.py +++ b/flixOpt/io.py @@ -3,7 +3,7 @@ import logging import pathlib import re -from typing import Dict, Literal, Tuple, Union +from typing import Dict, Literal, Tuple, Union, Optional from dataclasses import dataclass import linopy @@ -248,17 +248,22 @@ def load_dataset_from_netcdf(path: Union[str, pathlib.Path]) -> xr.Dataset: @dataclass class CalculationResultsPaths: """Container for all paths related to saving CalculationResults.""" + folder: pathlib.Path name: str def __post_init__(self): """Initialize all path attributes.""" - self.model = self.folder / f"{self.name}--linopy_model.nc4" - self.solution = self.folder / f"{self.name}--solution.nc4" - self.summary = self.folder / f"{self.name}--summary.yaml" - self.network = self.folder / f"{self.name}--network.json" - self.flow_system = self.folder / f"{self.name}--flow_system.nc4" - self.model_documentation = self.folder / f"{self.name}--model_documentation.yaml" + self._update_paths() + + def _update_paths(self): + """Update all path attributes based on current folder and name.""" + self.model = self.folder / f'{self.name}--linopy_model.nc4' + self.solution = self.folder / f'{self.name}--solution.nc4' + self.summary = self.folder / f'{self.name}--summary.yaml' + self.network = self.folder / f'{self.name}--network.json' + self.flow_system = self.folder / f'{self.name}--flow_system.nc4' + self.model_documentation = self.folder / f'{self.name}--model_documentation.yaml' def create_folders(self, parents: bool = False) -> None: """Ensure the folder exists. @@ -269,5 +274,14 @@ def create_folders(self, parents: bool = False) -> None: try: self.folder.mkdir(parents=parents) except FileNotFoundError as e: - raise FileNotFoundError(f'Folder {self.folder} and its parent do not exist. Please create them first.') from e - + raise FileNotFoundError( + f'Folder {self.folder} and its parent do not exist. Please create them first.' + ) from e + + def update(self, new_name: Optional[str] = None, new_folder: Optional[pathlib.Path] = None) -> None: + """Update name and/or folder and refresh all paths.""" + if new_name is not None: + self.name = new_name + if new_folder is not None: + self.folder = new_folder + self._update_paths() From 56d38131bbb39c7b1d9139e28351cc016d4ecad2 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 23 Mar 2025 11:59:21 +0100 Subject: [PATCH 412/507] rename path --- flixOpt/io.py | 2 +- flixOpt/results.py | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/flixOpt/io.py b/flixOpt/io.py index af820f12a..9587bde13 100644 --- a/flixOpt/io.py +++ b/flixOpt/io.py @@ -258,7 +258,7 @@ def __post_init__(self): def _update_paths(self): """Update all path attributes based on current folder and name.""" - self.model = self.folder / f'{self.name}--linopy_model.nc4' + self.linopy_model = self.folder / f'{self.name}--linopy_model.nc4' self.solution = self.folder / f'{self.name}--solution.nc4' self.summary = self.folder / f'{self.name}--summary.yaml' self.network = self.folder / f'{self.name}--network.json' diff --git a/flixOpt/results.py b/flixOpt/results.py index e140751ee..20bc40d1d 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -77,12 +77,12 @@ def from_file(cls, folder: Union[str, pathlib.Path], name: str): paths = fx_io.CalculationResultsPaths(folder, name) model = None - if paths.model.exists(): + if paths.linopy_model.exists(): try: - logger.info(f'loading the linopy model "{name}" from file ("{paths.model}")') - model = linopy.read_netcdf(paths.model) + logger.info(f'loading the linopy model "{name}" from file ("{paths.linopy_model}")') + model = linopy.read_netcdf(paths.linopy_model) except Exception as e: - logger.critical(f'Could not load the linopy model "{name}" from file ("{paths.model}"): {e}') + logger.critical(f'Could not load the linopy model "{name}" from file ("{paths.linopy_model}"): {e}') with open(paths.summary, 'r', encoding='utf-8') as f: summary = yaml.load(f, Loader=yaml.FullLoader) @@ -261,7 +261,7 @@ def to_file( if self.model is None: logger.critical('No model in the CalculationResults. Saving the model is not possible.') else: - self.model.to_netcdf(paths.model) + self.model.to_netcdf(paths.linopy_model) if document_model: if self.model is None: From 1309dc1a04d2553046abc4e89ddb05a4699de983 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 23 Mar 2025 12:08:30 +0100 Subject: [PATCH 413/507] improve CalculationPaths --- flixOpt/io.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/flixOpt/io.py b/flixOpt/io.py index 9587bde13..1ba289e6d 100644 --- a/flixOpt/io.py +++ b/flixOpt/io.py @@ -265,6 +265,17 @@ def _update_paths(self): self.flow_system = self.folder / f'{self.name}--flow_system.nc4' self.model_documentation = self.folder / f'{self.name}--model_documentation.yaml' + def all_paths(self) -> Dict[str, pathlib.Path]: + """Return a dictionary of all paths.""" + return { + 'linopy_model': self.linopy_model, + 'solution': self.solution, + 'summary': self.summary, + 'network': self.network, + 'flow_system': self.flow_system, + 'model_documentation': self.model_documentation, + } + def create_folders(self, parents: bool = False) -> None: """Ensure the folder exists. Args: @@ -283,5 +294,7 @@ def update(self, new_name: Optional[str] = None, new_folder: Optional[pathlib.Pa if new_name is not None: self.name = new_name if new_folder is not None: + if not new_folder.is_dir() or not new_folder.exists(): + raise FileNotFoundError(f'Folder {new_folder} does not exist or is not a directory.') self.folder = new_folder self._update_paths() From 40a443d03e2dad4a8726c4985c1697c03150e25d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 23 Mar 2025 14:48:01 +0100 Subject: [PATCH 414/507] Update linopy dependency --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2d8b8fd03..46b568b8d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ classifiers = [ dependencies = [ "numpy >= 1.21.5, < 2", "PyYAML >= 6.0", - "linopy @ git+https://github.com/PyPSA/linopy.git", # Some features of linopy are not yet released + "linopy >= 0.5.1", "rich >= 13.0.1", "highspy >= 1.5.3", # Default solver "pandas >= 2, < 3", # Used in post-processing From 24deb11c9f4ab02aff79ebfa6b77163b1f7eae42 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 23 Mar 2025 17:22:08 +0100 Subject: [PATCH 415/507] Feature/linopy/docs (#180) * Update to google docs style * Add release notes * Add math to docs --- docs/SUMMARY.md | 1 + docs/release-notes/_template.txt | 32 ++++ docs/release-notes/index.md | 11 ++ docs/release-notes/v2.0.0.md | 93 +++++++++ examples/linopy_native_experiments.py | 158 ---------------- flixOpt/aggregation.py | 47 +++-- flixOpt/calculation.py | 65 +++---- flixOpt/components.py | 239 ++++++++++++----------- flixOpt/config.py | 9 +- flixOpt/core.py | 108 +++++------ flixOpt/effects.py | 58 ++---- flixOpt/elements.py | 161 ++++++++-------- flixOpt/features.py | 97 ++++------ flixOpt/flow_system.py | 82 ++++---- flixOpt/interface.py | 97 ++++------ flixOpt/linear_converters.py | 153 +++++---------- flixOpt/plotting.py | 262 +++++++++----------------- flixOpt/results.py | 81 ++++---- flixOpt/solvers.py | 15 ++ flixOpt/structure.py | 134 ++++++------- flixOpt/utils.py | 23 ++- mkdocs.yml | 2 +- pics/flixopt-icon.svg | 1 + scripts/gen_ref_pages.py | 5 +- 24 files changed, 841 insertions(+), 1093 deletions(-) create mode 100644 docs/release-notes/_template.txt create mode 100644 docs/release-notes/index.md create mode 100644 docs/release-notes/v2.0.0.md delete mode 100644 examples/linopy_native_experiments.py create mode 100644 pics/flixopt-icon.svg diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index c270941af..d2a5654b2 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -3,4 +3,5 @@ - [Concepts & Math](concepts-and-math/) - [Examples](examples/) - [API-Reference](api-reference/) +- [Release Notes](release-notes/) - [Contribute](contribute.md) \ No newline at end of file diff --git a/docs/release-notes/_template.txt b/docs/release-notes/_template.txt new file mode 100644 index 000000000..fe85a0554 --- /dev/null +++ b/docs/release-notes/_template.txt @@ -0,0 +1,32 @@ +# Release v{version} + +**Release Date:** YYYY-MM-DD + +## What's New + +* Feature 1 - Description +* Feature 2 - Description + +## Improvements + +* Improvement 1 - Description +* Improvement 2 - Description + +## Bug Fixes + +* Fixed issue with X +* Resolved problem with Y + +## Breaking Changes + +* Change 1 - Migration instructions +* Change 2 - Migration instructions + +## Deprecations + +* Feature X will be removed in v{next_version} + +## Dependencies + +* Added dependency X v1.2.3 +* Updated dependency Y to v2.0.0 \ No newline at end of file diff --git a/docs/release-notes/index.md b/docs/release-notes/index.md new file mode 100644 index 000000000..3dfb50c7f --- /dev/null +++ b/docs/release-notes/index.md @@ -0,0 +1,11 @@ +# Release Notes + +This page provides links to release notes for all versions of flixopt. + +## Latest Release + +* [v2.0.0](v2.0.0.md) - ????- Migration from pyomo to linopy, improving performance and usability + +## Previous Releases + +None \ No newline at end of file diff --git a/docs/release-notes/v2.0.0.md b/docs/release-notes/v2.0.0.md new file mode 100644 index 000000000..44656d962 --- /dev/null +++ b/docs/release-notes/v2.0.0.md @@ -0,0 +1,93 @@ +# Release v2.0.0 + +**Release Date:** March 23, 2025 + +## 🚀 Major Framework Changes + +- **Migration from Pyomo to Linopy**: Completely rebuilt the optimization foundation to use Linopy instead of Pyomo + - Significant performance improvements, especially for large models + - Internal useage of linopys own mathematical modeling language + - use a flixOpt model as a baseline and extend it with custom constraints or variables using linopys own modeling language +- **xarray-Based Data Architecture**: Redesigned data handling to rely on xarray.Dataset throughout the package for: + - Improved solution representation and analysis + - Enhanced time series management + - Consistent serialization across all elements + - Reduced file size and improved performance +- **Saving and restoring unsolved Models**: The FlowSystem is now fully serializable and can be saved to a file. + - Share your work with others by saving the FlowSystem to a file + - Load a FlowSystem from a file to extend or modify your work + +## 🔧 Key Improvements + +### Model Handling + +- **Full Model Export/Import**: As a result of the migration to Linopy, the linopy.Model can be exported before or after the solve. +- **Improved Infeasible Model Handling**: Added better detection and reporting for infeasible optimization models +- **Improved Model Documentation**: Every model can be documented in a .yaml file, containing human readable mathematical formulations of all variables and constraints. THis is used to document every Calculation. + +### Calculation Results and documentation: +The `CalculationResults` class has been completely redesigned to provide a more streamlined and intuitive interface. +Accessing the results of a Calculation is now as simple as: +```python +fx.FullCalculation('Sim1', flow_system) +calculation.solve(fx.solvers.HighsSolver()) +calculation.results +``` +This access doesn`t change if you save and load the results to a file or use them in your script directly! + +- **Improved Documentation**: The FlowSystem as well as a model Documentation is created for every model run. +- **Results without saving to file**: The results of a Calculation can now be properly accessed without saving the results to a file first. +- **Unified Solution exploration**: Every `Calculation` has a `Calculation.results` attribute, which accesses the solution. This can be saved and reloaded without any information loss. +- **Improved Calculation Results**: The results of a Calculation are now more intuitive and easier to access. The `CalculationResults` class has been completely redesigned to provide a more streamlined and intuitive interface. + +### Data Management & I/O + +- **Unified Serialization**: Standardized serialization and deserialization across all elements +- **Compression Support**: Added data compression when saving results to reduce file size +- **to_netcdf/from_netcdf Methods**: Added for FlowSystem and other core components + +### Details +#### TimeSeries Enhancements + +- **xarray Integration**: Redesigned TimeSeries to depend on xr.DataArray +- **datatypes**: Added support for more datatypes, with methods for conversion to TimeSeries +- **Improved TimeSeriesCollection**: Enhanced indexing, representation, and dataset conversion +- **Simplified Time Management**: Removed period concepts and focused on timesteps for more intuitive time handling + +## 📚 Documentation + +- **Google Style Docstrings**: Updated all docstrings to Google style format + +## 🐛 Bug Fixes + +## 🔄 Dependencies + +- **Linopy**: Added as the core dependency replacing Pyomo +- **xarray**: Now a critical dependency for data handling +- **netcdf4**: Optional dependency for compressing netCDF files + +### Dropped Dependencies +- **pyomo**: Removed as the core dependency replacing Linopy + +## 📋 Migration Notes + +This version represents a significant architecture change. If you're upgrading: + +- Code that directly accessed Pyomo models will need to be updated to work with Linopy +- Data handling now uses xarray.Dataset throughout, which may require changes in how you interact with results +- The way labels are constructed has changed throughout the system +- The results of calculations are now handled differently, and may require changes in how you access results + +For complete details, please refer to the full commit history. + +## Installation + +```bash +pip install flixopt==2.0.0 +``` + +## Upgrading + +```bash +pip install --upgrade flixopt +``` \ No newline at end of file diff --git a/examples/linopy_native_experiments.py b/examples/linopy_native_experiments.py deleted file mode 100644 index 6407d42f1..000000000 --- a/examples/linopy_native_experiments.py +++ /dev/null @@ -1,158 +0,0 @@ -from typing import Dict, List, Literal, Optional, Tuple - -import linopy -import matplotlib.pyplot as plt -import numpy as np -import pandas as pd -import plotly.express as px -import xarray as xr - - -class SystemModel(linopy.Model): - def __init__( - self, - timesteps: pd.DatetimeIndex, - hours_of_last_step: Optional[float] = None, - periods: Optional[List[int]] = None, - ): - """ - Parameters - ---------- - timesteps : pd.DatetimeIndex - The timesteps of the model. - hours_of_last_step : Optional[float], optional - The duration of the last time step. Uses the last time interval if not specified - periods : Optional[List[int]], optional - The periods of the model. Every period has the same timesteps. - Usually years are used as periods. - """ - super().__init__(force_dim_names=True) - self.timesteps = timesteps - self.timesteps.name = 'time' - self.periods = pd.Index(periods, name='period') if periods is not None else None - - if hours_of_last_step: - last_date = pd.DatetimeIndex([self.timesteps[-1] + pd.to_timedelta(hours_of_last_step, 'h')]) - else: - last_date = pd.DatetimeIndex([self.timesteps[-1] + (self.timesteps[-1] - self.timesteps[-2])]) - self.timesteps_extra = self.timesteps.append(last_date) - self.timesteps_extra.name = 'time' - hours_per_step = self.timesteps_extra.to_series().diff()[1:].values / pd.to_timedelta(1, 'h') - self.hours_per_step = xr.DataArray( - data=np.tile(hours_per_step, (len(self.periods), 1)) if self.periods is not None else hours_per_step, - coords=self.coords, - name='hours_per_step' - ) - - @property - def snapshots(self): - return xr.Dataset( - coords={'period': list(self.periods), 'time': list(self.timesteps)} if self.periods is not None else {'time': list(self.timesteps)}, - ) - - @property - def coords(self): - return self.snapshots.coords - - @property - def time_variables(self, filter_by: Optional[Literal['binary', 'continous', 'integer']] = None): - if filter_by is None: - all_variables = super().variables - elif filter_by == 'binary': - all_variables = super().binaries - elif filter_by == 'integer': - all_variables = super().integers - elif filter_by == 'continous': - all_variables = super().continuous - else: - raise ValueError(f'Invalid filter_by "{filter_by}", must be one of "binary", "continous", "integer"') - return all_variables[[name for name in all_variables if 'time' in all_variables[name].dims]] - - @property - def index_shape(self) -> Tuple[int, int]: - return len(self.periods) if self.periods is not None else 1, len(self.timesteps) - - -m = SystemModel(pd.date_range(start='2025-01-01', end='2025-01-08', freq='h', name='time'), periods=[2025, 2030]) - -rng = np.random.default_rng(seed=42) -random_array = rng.random(m.index_shape) - -total = pd.Index(range(1), name='total') - -x = m.add_variables(lower=0, coords=m.coords, name="x") # x is a variable for every timestep and period -y = m.add_variables(lower=0, coords=m.coords, name="y") # y is a variable for every timestep -z = m.add_variables(lower=0, name="z") # z is a scalar variable - -factor = xr.DataArray(random_array * 10, coords=m.coords) - -con1 = m.add_constraints(3 * x + 7 * y >= 10 * factor, name="con1") -con2 = m.add_constraints(5 * x + 2 * y >= 3 * factor, name="con2") -con3 = m.add_constraints(z >= 3, name="con3") - -# Complex constraint -con_weekly = m.add_constraints( - (3 * y).where(m.snapshots['period'] == 2025).sum() <= 20, name="con_per_period") - -# Size constraint, using a scalar variable -s = m.add_variables(lower=0, name="s") -con_size = m.add_constraints(x <= s, name="con_size") - -# Size constraint, using a period variable -s_per_period = m.add_variables(lower=0, coords=(m.periods,), name="s_per_period") -con_size_per_period = m.add_constraints(x <= s_per_period, name="con_size_per_period") - -# Constraint the total over the month of April to be 11000 -total_of_kw1 = m.add_variables(upper=11000, name="total_of_KW1") -con_per_month = m.add_constraints( - (m.hours_per_step * x).where(x.coords['time'].dt.week == 1).sum() <= total_of_kw1, - name="con_total_per_month" -) - - -##### Storage ##### -# Add a variable thats one step longer (charge state) -charge_state = m.add_variables(lower=100, coords=(m.periods, m.timesteps_extra), name="charge_state") -flow_storage = m.add_variables(lower=-100, upper=100, coords=m.coords, name="flow_netto_charging") - -con_storage = m.add_constraints( - charge_state.isel(time=slice(1, None)) == charge_state.isel(time=slice(None, -1)) * 0.99 + flow_storage, - name="con_storage" -) - -# Start every period with 1000 kWh -con_storage_start = m.add_constraints( - charge_state.isel(time=0) == xr.DataArray([1000, 2000], coords=(m.periods,)), - name="con_storage_start" -) -# Start = End for every period -start_is_end = False -if start_is_end: - con_storage_start_end = m.add_constraints( - charge_state.isel(time=0) == charge_state.isel(time=-1), - name="con_storage_start_end" - ) -m.add_constraints(charge_state.isel(period=0, time=40) == 6*charge_state.isel(period=1, time=40), name="couple_periods") -m.add_objective((x + 2 * y).sum() + z) - -m.solve() - -# --- Plotting --- -# plot all results directly -m.time_variables.solution.to_dataframe().plot(grid=True, ylabel="Optimal Value", title="All Time variables",) -plt.xticks(rotation=90) -plt.tight_layout() -plt.show() - -# Order the dataframe by period and time -df = m.time_variables.solution.to_dataframe() - -# Plotting per period is easy -fig = px.line(charge_state.solution.to_dataframe().reset_index(), x="time", y="solution", color="period", title="Charge State in MWh") -fig.show() - -# Plotting the whole is even easier -fig= charge_state.solution.plot() -fig.figure.show() - - diff --git a/flixOpt/aggregation.py b/flixOpt/aggregation.py index 3d010e264..7f6648c86 100644 --- a/flixOpt/aggregation.py +++ b/flixOpt/aggregation.py @@ -52,8 +52,16 @@ def __init__( time_series_for_high_peaks: List[str] = None, time_series_for_low_peaks: List[str] = None, ): + """ - Write a docstring please + Args: + original_data: The original data to aggregate + hours_per_time_step: The duration of each timestep in hours. + hours_per_period: The duration of each period in hours. + nr_of_periods: The number of typical periods to use in the aggregation. + weights: The weights for aggregation. If None, all time series are equally weighted. + time_series_for_high_peaks: List of time series to use for explicitly selecting periods with high values. + time_series_for_low_peaks: List of time series to use for explicitly selecting periods with low values. """ if not TSAM_AVAILABLE: raise ImportError("The 'tsam' package is required for clustering functionality. " @@ -225,29 +233,20 @@ def __init__( """ Initializes aggregation parameters for time series data - Parameters - ---------- - hours_per_period : float - Duration of each period in hours. - nr_of_periods : int - Number of typical periods to use in the aggregation. - fix_storage_flows : bool - Whether to aggregate storage flows (load/unload); if other flows - are fixed, fixing storage flows is usually not required. - aggregate_data_and_fix_non_binary_vars : bool - Whether to aggregate all time series data, which allows to fix all time series variables (like flow_rate), - or only fix binary variables. If False non time_series data is changed!! If True, the mathematical Problem - is simplified even further. - percentage_of_period_freedom : float, optional - Specifies the maximum percentage (0–100) of binary values within each period - that can deviate as "free variables", chosen by the solver (default is 0). - This allows binary variables to be 'partly equated' between aggregated periods. - penalty_of_period_freedom : float, optional - The penalty associated with each "free variable"; defaults to 0. Added to Penalty - time_series_for_high_peaks : list of TimeSeriesData - List of time series to use for explicitly selecting periods with high values. - time_series_for_low_peaks : list of TimeSeriesData - List of time series to use for explicitly selecting periods with low values. + Args: + hours_per_period: Duration of each period in hours. + nr_of_periods: Number of typical periods to use in the aggregation. + fix_storage_flows: Whether to aggregate storage flows (load/unload); if other flows + are fixed, fixing storage flows is usually not required. + aggregate_data_and_fix_non_binary_vars: Whether to aggregate all time series data, which allows to fix all time series variables (like flow_rate), + or only fix binary variables. If False non time_series data is changed!! If True, the mathematical Problem + is simplified even further. + percentage_of_period_freedom: Specifies the maximum percentage (0–100) of binary values within each period + that can deviate as "free variables", chosen by the solver (default is 0). + This allows binary variables to be 'partly equated' between aggregated periods. + penalty_of_period_freedom: The penalty associated with each "free variable"; defaults to 0. Added to Penalty + time_series_for_high_peaks: List of TimeSeriesData to use for explicitly selecting periods with high values. + time_series_for_low_peaks: List of TimeSeriesData to use for explicitly selecting periods with low values. """ self.hours_per_period = hours_per_period self.nr_of_periods = nr_of_periods diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py index e2967d469..6056a42c7 100644 --- a/flixOpt/calculation.py +++ b/flixOpt/calculation.py @@ -47,16 +47,11 @@ def __init__( folder: Optional[pathlib.Path] = None, ): """ - Parameters - ---------- - name : str - name of calculation - flow_system : FlowSystem - flow_system which should be calculated - active_timesteps : List[int] or None - list with indices, which should be used for calculation. If None, then all timesteps are used. - folder : pathlib.Path or None - folder where results should be saved. If None, then the current working directory is used. + Args: + name: name of calculation + flow_system: flow_system which should be calculated + active_timesteps: list with indices, which should be used for calculation. If None, then all timesteps are used. + folder: folder where results should be saved. If None, then the current working directory is used. """ self.name = name self.flow_system = flow_system @@ -191,25 +186,19 @@ def __init__( folder: Optional[pathlib.Path] = None ): """ - Class for Optimizing the FLowSystem including: + Class for Optimizing the `FlowSystem` including: 1. Aggregating TimeSeriesData via typical periods using tsam. 2. Equalizing variables of typical periods. - Parameters - ---------- - name : str - name of calculation - flow_system : FlowSystem - flow_system which should be calculated - aggregation_parameters : AggregationParameters - Parameters for aggregation. See documentation of AggregationParameters class. - components_to_clusterize: List[Component] or None - List of Components to perform aggregation on. If None, then all components are aggregated. - This means, teh variables in the components are equalized to each other, according to the typical periods - computed in the DataAggregation - active_timesteps : pd.DatetimeIndex or None - list with indices, which should be used for calculation. If None, then all timesteps are used. - folder : pathlib.Path or None - folder where results should be saved. If None, then the current working directory is used. + Args: + name: name of calculation + flow_system: flow_system which should be calculated + aggregation_parameters: Parameters for aggregation. See documentation of AggregationParameters class. + components_to_clusterize: List of Components to perform aggregation on. If None, then all components are aggregated. + This means, teh variables in the components are equalized to each other, according to the typical periods + computed in the DataAggregation + active_timesteps: pd.DatetimeIndex or None + list with indices, which should be used for calculation. If None, then all timesteps are used. + folder: folder where results should be saved. If None, then the current working directory is used. """ super().__init__(name, flow_system, active_timesteps, folder=folder) self.aggregation_parameters = aggregation_parameters @@ -276,7 +265,7 @@ def _perform_aggregation(self): class SegmentedCalculation(Calculation): def __init__( self, - name, + name: str, flow_system: FlowSystem, timesteps_per_segment: int, overlap_timesteps: int, @@ -294,19 +283,13 @@ def __init__( don't really work in this Calculation. Lower bounds to such SUMS can lead to weird results. This is NOT yet explicitly checked for... - Parameters - ---------- - name : str - name of calculation - flow_system : FlowSystem - flow_system which should be calculated - timesteps_per_segment : int - The number of time_steps per individual segment (without the overlap) - overlap_timesteps : int - The number of time_steps that are added to each individual model. Used for better - results of storages) - folder : pathlib.Path or None - folder where results should be saved. If None, then the current working directory is used. + Args: + name: name of calculation + flow_system: flow_system which should be calculated + timesteps_per_segment: The number of time_steps per individual segment (without the overlap) + overlap_timesteps: The number of time_steps that are added to each individual model. Used for better + results of storages) + folder: folder where results should be saved. If None, then the current working directory is used. """ super().__init__(name, flow_system, folder=folder) self.timesteps_per_segment = timesteps_per_segment diff --git a/flixOpt/components.py b/flixOpt/components.py index 55f920f97..c71c37000 100644 --- a/flixOpt/components.py +++ b/flixOpt/components.py @@ -21,10 +21,12 @@ logger = logging.getLogger('flixOpt') + @register_class_for_io class LinearConverter(Component): """ - Converts one FLow into another via linear conversion factors + Converts input-Flows into output-Flows via linear conversion factors + """ def __init__( @@ -38,25 +40,20 @@ def __init__( meta_data: Optional[Dict] = None, ): """ - Parameters - ---------- - label : str - name. - meta_data : Optional[Dict] - used to store more information about the element. Is not used internally, but saved in the results - inputs : input flows. - outputs : output flows. - on_off_parameters: Information about on and off states. See class OnOffParameters. - conversion_factors : linear relation between flows. - Either 'conversion_factors' or 'segmented_conversion_factors' can be used! - example heat pump: - segmented_conversion_factors : Segmented linear relation between flows. - Each Flow gets a List of Segments assigned. - If FLows need to be 0 (or Off), include a "Zero-Segment" "(0, 0)", or use on_off_parameters - Either 'segmented_conversion_factors' or 'conversion_factors' can be used! - --> "gaps" can be expressed by a segment not starting at the end of the prior segment : [(1,3), (4,5)] - --> "points" can expressed as segment with same begin and end : [(3,3), (4,4)] - + Args: + label: The label of the Element. Used to identify it in the FlowSystem + inputs: The input Flows + outputs: The output Flows + on_off_parameters: Information about on and off states. See class OnOffParameters. + conversion_factors: linear relation between flows. + Either 'conversion_factors' or 'segmented_conversion_factors' can be used! + segmented_conversion_factors: Segmented linear relation between flows. + Each Flow gets a List of Segments assigned. + If FLows need to be 0 (or Off), include a "Zero-Segment" "(0, 0)", or use on_off_parameters + Either 'segmented_conversion_factors' or 'conversion_factors' can be used! + --> "gaps" can be expressed by a segment not starting at the end of the prior segment: [(1,3), (4,5)] + --> "points" can expressed as segment with same begin and end: [(3,3), (4,4)] + meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types. """ super().__init__(label, inputs, outputs, on_off_parameters, meta_data=meta_data) self.conversion_factors = conversion_factors or [] @@ -131,16 +128,57 @@ def degrees_of_freedom(self): @register_class_for_io class Storage(Component): - """ - Klasse Storage - """ - - # TODO: Dabei fällt mir auf. Vielleicht sollte man mal überlegen, ob man für Ladeleistungen bereits in dem - # jeweiligen Zeitschritt mit einem Verlust berücksichtigt. Zumindest für große Zeitschritte bzw. große Verluste - # eventuell relevant. - # -> Sprich: speicherverlust = charge_state(t) * relative_loss_per_hour * dt + 0.5 * Q_lade(t) * dt * relative_loss_per_hour * dt - # -> müsste man aber auch für den sich ändernden Ladezustand berücksichtigten + r""" + **Storages** have one incoming and one outgoing **Flow** - $f_\text{in}$ and $f_\text{out}$ - + each with an efficiency $\eta_\text{in}$ and $\eta_\text{out}$. + Further, storages have a `size` $\text C$ and a state of charge $c(\text{t}_i)$. + Similarly to the flow-rate $p(\text{t}_i)$ of a [`Flow`][flixOpt.elements.Flow], + the `size` $\text C$ combined with a relative upper bound + $\text c^{\text{U}}_\text{rel}(\text t_{i})$ and lower bound $\text c^{\text{L}}_\text{rel}(\text t_{i})$ + limits the state of charge $c(\text{t}_i)$ by $\eqref{eq:Storage_Bounds}$. + + $$ \label{eq:Storage_Bounds} + \text C \cdot \text c^{\text{L}}_{\text{rel}}(\text t_{i}) + \leq c(\text{t}_i) \leq + \text C \cdot \text c^{\text{U}}_{\text{rel}}(\text t_{i}) + $$ + + Where: + + - $\text C$ is the storage capacity + - $c(\text{t}_i)$ is the state of charge at time $\text{t}_i$ + - $\text c^{\text{L}}_{\text{rel}}(\text t_{i})$ is the relative lower bound (typically 0) + - $\text c^{\text{U}}_{\text{rel}}(\text t_{i})$ is the relative upper bound (typically 1) + + With $\text c^{\text{L}}_{\text{rel}}(\text t_{i}) = 0$ and $\text c^{\text{U}}_{\text{rel}}(\text t_{i}) = 1$, + Equation $\eqref{eq:Storage_Bounds}$ simplifies to + + $$ 0 \leq c(\text t_{i}) \leq \text C $$ + + The state of charge $c(\text{t}_i)$ decreases by a fraction of the prior state of charge. The belonging parameter + $ \dot{ \text c}_\text{rel, loss}(\text{t}_i)$ expresses the "loss fraction per hour". The storage balance from $\text{t}_i$ to $\text t_{i+1}$ is + + $$ + \begin{align*} + c(\text{t}_{i+1}) &= c(\text{t}_{i}) \cdot (1-\dot{\text{c}}_\text{rel,loss}(\text{t}_i) \cdot \Delta \text{t}_{i}) \\ + &\quad + p_{f_\text{in}}(\text{t}_i) \cdot \Delta \text{t}_i \cdot \eta_\text{in}(\text{t}_i) \\ + &\quad - \frac{p_{f_\text{out}}(\text{t}_i) \cdot \Delta \text{t}_i}{\eta_\text{out}(\text{t}_i)} + \tag{3} + \end{align*} + $$ + + Where: + + - $c(\text{t}_{i+1})$ is the state of charge at time $\text{t}_{i+1}$ + - $c(\text{t}_{i})$ is the state of charge at time $\text{t}_{i}$ + - $\dot{\text{c}}_\text{rel,loss}(\text{t}_i)$ is the relative loss rate (self-discharge) per hour + - $\Delta \text{t}_{i}$ is the time step duration in hours + - $p_{f_\text{in}}(\text{t}_i)$ is the input flow rate at time $\text{t}_i$ + - $\eta_\text{in}(\text{t}_i)$ is the charging efficiency at time $\text{t}_i$ + - $p_{f_\text{out}}(\text{t}_i)$ is the output flow rate at time $\text{t}_i$ + - $\eta_\text{out}(\text{t}_i)$ is the discharging efficiency at time $\text{t}_i$ + """ def __init__( self, label: str, @@ -159,41 +197,29 @@ def __init__( meta_data: Optional[Dict] = None, ): """ - constructor of storage - - Parameters - ---------- - label : str - description. - meta_data : Optional[Dict] - used to store more information about the element. Is not used internally, but saved in the results - charging : Flow - ingoing flow. - discharging : Flow - outgoing flow. - capacity_in_flow_hours : Scalar or InvestParameter - nominal capacity of the storage - relative_minimum_charge_state : float or TS, optional - minimum relative charge state. The default is 0. - relative_maximum_charge_state : float or TS, optional - maximum relative charge state. The default is 1. - initial_charge_state : None, float (0...1), 'lastValueOfSim', optional - storage charge_state at the beginning. The default is 0. - float: defined charge_state at start of first timestep - None: free to choose by optimizer - 'lastValueOfSim': chargeState0 is equal to chargestate of last timestep ("closed simulation") - minimal_final_charge_state : float or None, optional - minimal value of chargeState at the end of timeseries. - maximal_final_charge_state : float or None, optional - maximal value of chargeState at the end of timeseries. - eta_charge : float, optional - efficiency factor of charging/loading. The default is 1. - eta_discharge : TYPE, optional - efficiency factor of uncharging/unloading. The default is 1. - relative_loss_per_hour : float or TS. optional - loss per chargeState-Unit per hour. The default is 0. - prevent_simultaneous_charge_and_discharge : boolean, optional - should simultaneously Loading and Unloading be avoided? (Attention, Performance maybe becomes worse with avoidInAndOutAtOnce=True). The default is True. + Storages have one incoming and one outgoing Flow each with an efficiency. + Further, storages have a `size` and a `charge_state`. + Similarly to the flow-rate of a Flow, the `size` combined with a relative upper and lower bound + limits the `charge_state` of the storage. + + For mathematical details take a look at our online documentation + + Args: + label: The label of the Element. Used to identify it in the FlowSystem + charging: ingoing flow. + discharging: outgoing flow. + capacity_in_flow_hours: nominal capacity/size of the storage + relative_minimum_charge_state: minimum relative charge state. The default is 0. + relative_maximum_charge_state: maximum relative charge state. The default is 1. + initial_charge_state: storage charge_state at the beginning. The default is 0. + minimal_final_charge_state: minimal value of chargeState at the end of timeseries. + maximal_final_charge_state: maximal value of chargeState at the end of timeseries. + eta_charge: efficiency factor of charging/loading. The default is 1. + eta_discharge: efficiency factor of uncharging/unloading. The default is 1. + relative_loss_per_hour: loss per chargeState-Unit per hour. The default is 0. + prevent_simultaneous_charge_and_discharge: If True, loading and unloading at the same time is not possible. + Increases the number of binary variables, but is recommended for easier evaluation. The default is True. + meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types. """ # TODO: fixed_relative_chargeState implementieren super().__init__( @@ -290,32 +316,24 @@ def __init__( absolute_losses: Optional[NumericDataTS] = None, on_off_parameters: OnOffParameters = None, prevent_simultaneous_flows_in_both_directions: bool = True, + meta_data: Optional[Dict] = None, ): """ Initializes a Transmission component (Pipe, cable, ...) that models the flows between two sides with potential losses. - Parameters - ---------- - label : str - The name of the transmission component. - in1 : Flow - The inflow at side A. Pass InvestmentParameters here. - out1 : Flow - The outflow at side B. - in2 : Optional[Flow], optional - The optional inflow at side B. - If in1 got Investmentparameters, the size of this Flow will be equal to in1 (with no extra effects!) - out2 : Optional[Flow], optional - The optional outflow at side A. - relative_losses : Optional[NumericDataTS], optional - The relative loss between inflow and outflow, e.g., 0.02 for 2% loss. - absolute_losses : Optional[NumericDataTS], optional - The absolute loss, occur only when the Flow is on. Induces the creation of the ON-Variable - on_off_parameters : OnOffParameters, optional - Parameters defining the on/off behavior of the component. - prevent_simultaneous_flows_in_both_directions : bool, default=True - If True, prevents simultaneous flows in both directions. + Args: + label: The label of the Element. Used to identify it in the FlowSystem + in1: The inflow at side A. Pass InvestmentParameters here. + out1: The outflow at side B. + in2: The optional inflow at side B. + If in1 got InvestParameters, the size of this Flow will be equal to in1 (with no extra effects!) + out2: The optional outflow at side A. + relative_losses: The relative loss between inflow and outflow, e.g., 0.02 for 2% loss. + absolute_losses: The absolute loss, occur only when the Flow is on. Induces the creation of the ON-Variable + on_off_parameters: Parameters defining the on/off behavior of the component. + prevent_simultaneous_flows_in_both_directions: If True, inflow and outflow are not allowed to be both non-zero at same timestep. + meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types. """ super().__init__( label, @@ -325,6 +343,7 @@ def __init__( prevent_simultaneous_flows=None if in2 is None or prevent_simultaneous_flows_in_both_directions is False else [in1, in2], + meta_data=meta_data, ) self.in1 = in1 self.out1 = out1 @@ -590,10 +609,6 @@ class SourceAndSink(Component): """ class for source (output-flow) and sink (input-flow) in one commponent """ - - # source : Flow - # sink : Flow - def __init__( self, label: str, @@ -603,20 +618,12 @@ def __init__( meta_data: Optional[Dict] = None, ): """ - Parameters - ---------- - label : str - name of sourceAndSink - meta_data : Optional[Dict] - used to store more information about the element. Is not used internally, but saved in the results - source : Flow - output-flow of this component - sink : Flow - input-flow of this component - prevent_simultaneous_sink_and_source: boolean. Default ist True. - True: inflow and outflow are not allowed to be both non-zero at same timestep. - False: inflow and outflow are working independently. - + Args: + label: The label of the Element. Used to identify it in the FlowSystem + source: output-flow of this component + sink: input-flow of this component + prevent_simultaneous_sink_and_source: If True, inflow and outflow can not be active simultaniously. + meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types. """ super().__init__( label, @@ -634,14 +641,10 @@ def __init__( class Source(Component): def __init__(self, label: str, source: Flow, meta_data: Optional[Dict] = None): """ - Parameters - ---------- - label : str - name of source - meta_data : Optional[Dict] - used to store more information about the element. Is not used internally, but saved in the results - source : Flow - output-flow of source + Args: + label: The label of the Element. Used to identify it in the FlowSystem + source: output-flow of source + meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types. """ super().__init__(label, outputs=[source], meta_data=meta_data) self.source = source @@ -651,16 +654,10 @@ def __init__(self, label: str, source: Flow, meta_data: Optional[Dict] = None): class Sink(Component): def __init__(self, label: str, sink: Flow, meta_data: Optional[Dict] = None): """ - constructor of sink - - Parameters - ---------- - label : str - name of sink. - meta_data : Optional[Dict] - used to store more information about the element. Is not used internally, but saved in the results - sink : Flow - input-flow of sink + Args: + label: The label of the Element. Used to identify it in the FlowSystem + meta_data: used to store more information about the element. Is not used internally, but saved in the results + sink: input-flow of sink """ super().__init__(label, inputs=[sink], meta_data=meta_data) self.sink = sink diff --git a/flixOpt/config.py b/flixOpt/config.py index 5ba7decd1..e07458bd8 100644 --- a/flixOpt/config.py +++ b/flixOpt/config.py @@ -14,10 +14,11 @@ def merge_configs(defaults: dict, overrides: dict) -> dict: """ Merge the default configuration with user-provided overrides. - - :param defaults: Default configuration dictionary. - :param overrides: User configuration dictionary. - :return: Merged configuration dictionary. + Args: + defaults: Default configuration dictionary. + overrides: User configuration dictionary. + Returns: + Merged configuration dictionary. """ for key, value in overrides.items(): if isinstance(value, dict) and key in defaults and isinstance(defaults[key], dict): diff --git a/flixOpt/core.py b/flixOpt/core.py index 05a69f766..960b2b1f4 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -16,9 +16,14 @@ logger = logging.getLogger('flixOpt') -Scalar = Union[int, float] # Datatype +Scalar = Union[int, float] +"""A type representing a single number, either integer or float.""" + NumericData = Union[int, float, np.integer, np.floating, np.ndarray, pd.Series, pd.DataFrame, xr.DataArray] +"""Represents any form of numeric data, from simple scalars to complex data structures.""" + NumericDataTS = Union[NumericData, 'TimeSeriesData'] +"""Represents either standard numeric data or TimeSeriesData.""" class ConversionError(Exception): @@ -93,19 +98,13 @@ def __init__(self, data: NumericData, agg_group: Optional[str] = None, agg_weigh --> this 3 series of same type share one weight, i.e. internally assigned each weight = 1/3 (instead of standard weight = 1) - Parameters - ---------- - data : Union[int, float, np.ndarray] - The timeseries data, which can be a scalar, array, or numpy array. - agg_group : str, optional - The group this TimeSeriesData is a part of. agg_weight is split between members of a group. Default is None. - agg_weight : float, optional - The weight for calculation_type 'aggregated', should be between 0 and 1. Default is None. + Args: + data: The timeseries data, which can be a scalar, array, or numpy array. + agg_group: The group this TimeSeriesData is a part of. agg_weight is split between members of a group. Default is None. + agg_weight: The weight for calculation_type 'aggregated', should be between 0 and 1. Default is None. - Raises - ------ - Exception - If both agg_group and agg_weight are set, an exception is raised. + Raises: + Exception: If both agg_group and agg_weight are set, an exception is raised. """ self.data = data self.agg_group = agg_group @@ -153,7 +152,7 @@ def from_datasource(cls, """ Initialize the TimeSeries from multiple data sources. - Parameters: + Args: data: The time series data name: The name of the TimeSeries timesteps: The timesteps of the TimeSeries @@ -177,7 +176,7 @@ def from_json(cls, data: Optional[Dict[str, Any]] = None, path: Optional[str] = """ Load a TimeSeries from a dictionary or json file. - Parameters: + Args: data: Dictionary containing TimeSeries data path: Path to a JSON file containing TimeSeries data @@ -215,7 +214,7 @@ def __init__(self, """ Initialize a TimeSeries with a DataArray. - Parameters: + Args: data: The DataArray containing time series data name: The name of the TimeSeries aggregation_weight: The weight in aggregation calculations @@ -259,7 +258,7 @@ def to_json(self, path: Optional[pathlib.Path] = None) -> Dict[str, Any]: """ Save the TimeSeries to a dictionary or JSON file. - Parameters: + Args: path: Optional path to save JSON file Returns: @@ -317,7 +316,7 @@ def active_timesteps(self, timesteps: Optional[pd.DatetimeIndex]): """ Set active_timesteps and refresh active_data. - Parameters: + Args: timesteps: New timesteps to activate, or None to use all stored timesteps Raises: @@ -347,7 +346,7 @@ def stored_data(self, value: NumericData): """ Update stored_data and refresh active_data. - Parameters: + Args: value: New data to store """ new_data = DataConverter.as_dataarray(value, timesteps=self.active_timesteps) @@ -410,7 +409,7 @@ def __gt__(self, other): """ Compare if this TimeSeries is greater than another. - Parameters: + Args: other: Another TimeSeries to compare with Returns: @@ -472,7 +471,16 @@ def __init__( hours_of_last_timestep: Optional[float] = None, hours_of_previous_timesteps: Optional[Union[float, np.ndarray]] = None ): - """Initialize with timesteps and optional duration settings.""" + + """ + Args: + timesteps: The timesteps of the Collection. + 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! + """ # Prepare and validate timesteps self._validate_timesteps(timesteps) self.hours_of_previous_timesteps = self._calculate_hours_of_previous_timesteps( @@ -517,18 +525,13 @@ def create_time_series( """ Creates a TimeSeries from the given data and adds it to the collection. - Parameters - ---------- - data: Union[int, float, np.ndarray, pd.Series, pd.DataFrame, xr.DataArray] + Args: + data: The data to create the TimeSeries from. + name: The name of the TimeSeries. + needs_extra_timestep: Whether to create an additional timestep at the end of the timesteps. The data to create the TimeSeries from. - name: str - The name of the TimeSeries. - needs_extra_timestep: bool, optional - Whether to create an additional timestep at the end of the timesteps. - - Returns - ------- - TimeSeries + + Returns: The created TimeSeries. """ @@ -579,11 +582,10 @@ def activate_timesteps(self, active_timesteps: Optional[pd.DatetimeIndex] = None Update active timesteps for the collection and all time series. If no arguments are provided, the active timesteps are reset. - Parameters - ---------- - active_timesteps : Optional[pd.DatetimeIndex] - The active timesteps of the model. - If None, the all timesteps of the TimeSeriesCollection are taken.""" + Args: + active_timesteps: The active timesteps of the model. + If None, the all timesteps of the TimeSeriesCollection are taken. + """ if active_timesteps is None: return self.reset() @@ -625,12 +627,9 @@ def insert_new_data(self, data: pd.DataFrame, include_extra_timestep: bool = Fal """ Update time series with new data from a DataFrame. - Parameters - ---------- - data : pd.DataFrame - DataFrame containing new data with timestamps as index - include_extra_timestep : bool, optional - Whether the provided data already includes the extra timestep, by default False + Args: + data: DataFrame containing new data with timestamps as index + include_extra_timestep: Whether the provided data already includes the extra timestep, by default False """ if not isinstance(data, pd.DataFrame): raise TypeError(f"data must be a pandas DataFrame, got {type(data).__name__}") @@ -673,16 +672,11 @@ def to_dataframe(self, """ Convert collection to DataFrame with optional filtering and timestep control. - Parameters - ---------- - filtered : Literal['all', 'constant', 'non_constant'], optional - Filter time series by variability, by default 'non_constant' - include_extra_timestep : bool, optional - Whether to include the extra timestep in the result, by default True + Args: + filtered: Filter time series by variability, by default 'non_constant' + include_extra_timestep: Whether to include the extra timestep in the result, by default True - Returns - ------- - pd.DataFrame + Returns: DataFrame representation of the collection """ include_constants = filtered != 'non_constant' @@ -707,14 +701,10 @@ def to_dataset(self, include_constants: bool = True) -> xr.Dataset: """ Combine all time series into a single Dataset with all timesteps. - Parameters - ---------- - include_constants : bool, optional - Whether to include time series with constant values, by default True + Args: + include_constants: Whether to include time series with constant values, by default True - Returns - ------- - xr.Dataset + Returns: Dataset containing all selected time series with all timesteps """ # Determine which series to include diff --git a/flixOpt/effects.py b/flixOpt/effects.py index e4db4aea3..2e6c4fefc 100644 --- a/flixOpt/effects.py +++ b/flixOpt/effects.py @@ -50,45 +50,25 @@ def __init__( maximum_total: Optional[Scalar] = None, ): """ - Parameters - ---------- - label : str - name - unit : str - unit of effect, i.g. €, kg_CO2, kWh_primaryEnergy - description : str - long name - meta_data : Optional[Dict] - used to store more information about the element. Is not used internally, but saved in the results - is_standard : boolean, optional - true, if Standard-Effect (for direct input of value without effect (alternatively to dict)) , else false - is_objective : boolean, optional - true, if optimization target - specific_share_to_other_effects_operation : {effectType: TS, ...}, i.g. 180 €/t_CO2, input as {costs: 180}, optional - share to other effects (only operation) - specific_share_to_other_effects_invest : {effectType: TS, ...}, i.g. 180 €/t_CO2, input as {costs: 180}, optional - share to other effects (only invest). - minimum_operation : scalar, optional - minimal sum (only operation) of the effect - maximum_operation : scalar, optional - maximal sum (nur operation) of the effect. - minimum_operation_per_hour : scalar or TS - maximum value per hour (only operation) of effect (=sum of all effect-shares) for each timestep! - maximum_operation_per_hour : scalar or TS - minimum value per hour (only operation) of effect (=sum of all effect-shares) for each timestep! - minimum_invest : scalar, optional - minimal sum (only invest) of the effect - maximum_invest : scalar, optional - maximal sum (only invest) of the effect - minimum_total : scalar, optional - min sum of effect (invest+operation). - maximum_total : scalar, optional - max sum of effect (invest+operation). - - Returns - ------- - None. - + Args: + label: The label of the Element. Used to identify it in the FlowSystem + unit: The unit of effect, i.g. €, kg_CO2, kWh_primaryEnergy + description: The long name + is_standard: true, if Standard-Effect (for direct input of value without effect (alternatively to dict)) , else false + is_objective: true, if optimization target + specific_share_to_other_effects_operation: {effectType: TS, ...}, i.g. 180 €/t_CO2, input as {costs: 180}, optional + share to other effects (only operation) + specific_share_to_other_effects_invest: {effectType: TS, ...}, i.g. 180 €/t_CO2, input as {costs: 180}, optional + share to other effects (only invest). + minimum_operation: minimal sum (only operation) of the effect. + maximum_operation: maximal sum (nur operation) of the effect. + minimum_operation_per_hour: max. value per hour (only operation) of effect (=sum of all effect-shares) for each timestep! + maximum_operation_per_hour: min. value per hour (only operation) of effect (=sum of all effect-shares) for each timestep! + minimum_invest: minimal sum (only invest) of the effect + maximum_invest: maximal sum (only invest) of the effect + minimum_total: min sum of effect (invest+operation). + maximum_total: max sum of effect (invest+operation). + meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types. """ super().__init__(label, meta_data=meta_data) self.label = label diff --git a/flixOpt/elements.py b/flixOpt/elements.py index aac60f411..031db9830 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -25,7 +25,11 @@ @register_class_for_io class Component(Element): """ - basic component class for all components + A Component contains incoming and outgoing [`Flows`][flixOpt.elements.Flow]. It defines how these Flows interact with each other. + The On or Off state of the Component is defined by all its Flows. Its on, if any of its FLows is On. + It's mathematically advisable to define the On/Off state in a FLow rather than a Component if possible, + as this introduces less binary variables to the Model + Constraints to the On/Off state are defined by the [`on_off_parameters`][flixOpt.interface.OnOffParameters]. """ def __init__( @@ -38,20 +42,17 @@ def __init__( meta_data: Optional[Dict] = None, ): """ - Parameters - ---------- - label : str - name. - meta_data : Optional[Dict] - used to store more information about the element. Is not used internally, but saved in the results - inputs : input flows. - outputs : output flows. - on_off_parameters: Information about on and off state of Component. - Component is On/Off, if all connected Flows are On/Off. - Induces On-Variable in all FLows! - See class OnOffParameters. - prevent_simultaneous_flows: Define a Group of Flows. Only one them can be on at a time. - Induces On-Variable in all FLows! + Args: + label: The label of the Element. Used to identify it in the FlowSystem + inputs: input flows. + outputs: output flows. + on_off_parameters: Information about on and off state of Component. + Component is On/Off, if all connected Flows are On/Off. + Induces On-Variable in all FLows! + See class OnOffParameters. + prevent_simultaneous_flows: Define a Group of Flows. Only one them can be on at a time. + Induces On-Variable in all Flows! If possible, use OnOffParameters in a single Flow instead. + meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types. """ super().__init__(label, meta_data=meta_data) self.inputs: List['Flow'] = inputs or [] @@ -70,7 +71,7 @@ def transform_data(self, flow_system: 'FlowSystem') -> None: if self.on_off_parameters is not None: self.on_off_parameters.transform_data(flow_system, self.label_full) - def infos(self, use_numpy=True, use_element_label=False) -> Dict: + def infos(self, use_numpy=True, use_element_label: bool = False) -> Dict: infos = super().infos(use_numpy, use_element_label) infos['inputs'] = [flow.infos(use_numpy, use_element_label) for flow in self.inputs] infos['outputs'] = [flow.infos(use_numpy, use_element_label) for flow in self.outputs] @@ -83,25 +84,46 @@ def _plausibility_checks(self) -> None: @register_class_for_io class Bus(Element): - """ - realizing balance of all linked flows - (penalty flow is excess can be activated) + r""" + A Bus represents a nodal balance between the flow rates of its incoming and outgoing [Flows][flixOpt.elements.Flow] + ($\mathcal{F}_\text{in}$ and $\mathcal{F}_\text{out}$), + which must hold for every time step $\text{t}_i \in \mathcal{T}$. + + $$ + \sum_{f_\text{in} \in \mathcal{F}_\text{in}} p_{f_\text{in}}(\text{t}_i) = + \sum_{f_\text{out} \in \mathcal{F}_\text{out}} p_{f_\text{out}}(\text{t}_i) + $$ + + To handle ifeasiblities gently, 2 variables $\phi_\text{in}(\text{t}_i)\geq0$ and + $\phi_\text{out}(\text{t}_i)\geq0$ might be introduced. + These represent the missing or excess flow_rate in Bus. E certain amount of penalty occurs for each missing or + excess flow_rate in the balance (`excess_penalty_per_flow_hour`), so they usually dont affect the Optimization. + The penalty term is defined as + + $$ + s_{b \rightarrow \Phi}(\text{t}_i) = + \text a_{b \rightarrow \Phi}(\text{t}_i) \cdot \Delta \text{t}_i + \cdot [ \phi_\text{in}(\text{t}_i) + \phi_\text{out}(\text{t}_i) ] + $$ + + Which changes the balance to + + $$ + \sum_{f_\text{in} \in \mathcal{F}_\text{in}} p_{f_ \text{in}}(\text{t}_i) + \phi_\text{in}(\text{t}_i) = + \sum_{f_\text{out} \in \mathcal{F}_\text{out}} p_{f_\text{out}}(\text{t}_i) + \phi_\text{out}(\text{t}_i) + $$ """ def __init__( self, label: str, excess_penalty_per_flow_hour: Optional[NumericDataTS] = 1e5, meta_data: Optional[Dict] = None ): """ - Parameters - ---------- - label : str - name. - meta_data : Optional[Dict] - used to store more information about the element. Is not used internally, but saved in the results - excess_penalty_per_flow_hour : none or scalar, array or TimeSeriesData - excess costs / penalty costs (bus balance compensation) - (none/ 0 -> no penalty). The default is 1e5. - (Take care: if you use a timeseries (no scalar), timeseries is aggregated if calculation_type = aggregated!) + Args: + label: The label of the Element. Used to identify it in the FlowSystem + excess_penalty_per_flow_hour: excess costs / penalty costs (bus balance compensation) + (none/ 0 -> no penalty). The default is 1e5. + (Take care: if you use a timeseries (no scalar), timeseries is aggregated if calculation_type = aggregated!) + meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types. """ super().__init__(label, meta_data=meta_data) self.excess_penalty_per_flow_hour = excess_penalty_per_flow_hour @@ -131,16 +153,20 @@ def with_excess(self) -> bool: class Connection: # input/output-dock (TODO: # -> wäre cool, damit Komponenten auch auch ohne Knoten verbindbar - # input wären wie Flow,aber statt bus : connectsTo -> hier andere Connection oder aber Bus (dort keine Connection, weil nicht notwendig) + # input wären wie Flow,aber statt bus: connectsTo -> hier andere Connection oder aber Bus (dort keine Connection, weil nicht notwendig) def __init__(self): + """ + This class is not yet implemented! + """ raise NotImplementedError() @register_class_for_io class Flow(Element): - """ - flows are inputs and outputs of components + r""" + A **Flow** moves energy (or material) between a [Bus][flixOpt.elements.Bus] and a [Component][flixOpt.elements.Component] in a predefined direction. + The flow-rate is the main optimization variable of the **Flow**. """ def __init__( @@ -161,48 +187,33 @@ def __init__( meta_data: Optional[Dict] = None, ): r""" - Parameters - ---------- - label : str - name of flow - meta_data : Optional[Dict] - used to store more information about the element. Is not used internally, but saved in the results - bus : Bus, optional - bus to which flow is linked - size : scalar, InvestmentParameters, optional - size of the flow. If InvestmentParameters is used, size is optimized. - If size is None, a default value is used. - relative_minimum : scalar, array, TimeSeriesData, optional - min value is relative_minimum multiplied by size - relative_maximum : scalar, array, TimeSeriesData, optional - max value is relative_maximum multiplied by size. If size = max then relative_maximum=1 - load_factor_min : scalar, optional - minimal load factor general: avg Flow per nominalVal/investSize - (e.g. boiler, kW/kWh=h; solarthermal: kW/m²; - def: :math:`load\_factor:= sumFlowHours/ (nominal\_val \cdot \Delta t_{tot})` - load_factor_max : scalar, optional - maximal load factor (see minimal load factor) - effects_per_flow_hour : scalar, array, TimeSeriesData, optional - operational costs, costs per flow-"work" - on_off_parameters : OnOffParameters, optional - If present, flow can be "off", i.e. be zero (only relevant if relative_minimum > 0) - Therefore a binary var "on" is used. Further, several other restrictions and effects can be modeled - through this On/Off State (See OnOffParameters) - flow_hours_total_max : TYPE, optional - maximum flow-hours ("flow-work") - (if size is not const, maybe load_factor_max fits better for you!) - flow_hours_total_min : TYPE, optional - minimum flow-hours ("flow-work") - (if size is not const, maybe load_factor_min fits better for you!) - fixed_relative_profile : scalar, array, TimeSeriesData, optional - fixed relative values for flow (if given). - flow_rate(t) := fixed_relative_profile(t) * size(t) - With this value, the flow_rate is no opt-variable anymore; - (relative_minimum u. relative_maximum are iverwritten) - used for fixed load profiles, i.g. heat demand, wind-power, solarthermal - If the load-profile is just an upper limit, use relative_maximum instead. - previous_flow_rate : scalar, array, optional - previous flow rate of the component. + Args: + label: The label of the FLow. Used to identify it in the FlowSystem. Its `full_label` consists of the label of the Component and the label of the Flow. + bus: blabel of the bus the flow is connected to. + size: size of the flow. If InvestmentParameters is used, size is optimized. + If size is None, a default value is used. + relative_minimum: min value is relative_minimum multiplied by size + relative_maximum: max value is relative_maximum multiplied by size. If size = max then relative_maximum=1 + load_factor_min: minimal load factor general: avg Flow per nominalVal/investSize + (e.g. boiler, kW/kWh=h; solarthermal: kW/m²; + def: :math:`load\_factor:= sumFlowHours/ (nominal\_val \cdot \Delta t_{tot})` + load_factor_max: maximal load factor (see minimal load factor) + effects_per_flow_hour: operational costs, costs per flow-"work" + on_off_parameters: If present, flow can be "off", i.e. be zero (only relevant if relative_minimum > 0) + Therefore a binary var "on" is used. Further, several other restrictions and effects can be modeled + through this On/Off State (See OnOffParameters) + flow_hours_total_max: maximum flow-hours ("flow-work") + (if size is not const, maybe load_factor_max is the better choice!) + flow_hours_total_min: minimum flow-hours ("flow-work") + (if size is not predefined, maybe load_factor_min is the better choice!) + fixed_relative_profile: fixed relative values for flow (if given). + flow_rate(t) := fixed_relative_profile(t) * size(t) + With this value, the flow_rate is no optimization-variable anymore. + (relative_minimum and relative_maximum are ignored) + used for fixed load or supply profiles, i.g. heat demand, wind-power, solarthermal + If the load-profile is just an upper limit, use relative_maximum instead. + previous_flow_rate: previous flow rate of the component. + meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types. """ super().__init__(label, meta_data=meta_data) self.size = size or CONFIG.modeling.BIG # Default size @@ -258,7 +269,7 @@ def transform_data(self, flow_system: 'FlowSystem'): if isinstance(self.size, InvestParameters): self.size.transform_data(flow_system) - def infos(self, use_numpy=True, use_element_label=False) -> Dict: + def infos(self, use_numpy: bool = True, use_element_label: bool = False) -> Dict: infos = super().infos(use_numpy, use_element_label) infos['is_input_in_component'] = self.is_input_in_component return infos diff --git a/flixOpt/features.py b/flixOpt/features.py index 3e79aed43..2e4754463 100644 --- a/flixOpt/features.py +++ b/flixOpt/features.py @@ -193,22 +193,14 @@ def __init__( """ Constructor for OnOffModel - Parameters - ---------- - model: SystemModel - Reference to the SystemModel - on_off_parameters: OnOffParameters - Parameters for the OnOffModel - label_of_element: - Label of the Parent - defining_variables: - List of Variables that are used to define the OnOffModel - defining_bounds: - List of Tuples, defining the absolute bounds of each defining variable - previous_values: - List of previous values of the defining variables - label: - Label of the OnOffModel + Args: + model: Reference to the SystemModel + on_off_parameters: Parameters for the OnOffModel + label_of_element: Label of the Parent + defining_variables: List of Variables that are used to define the OnOffModel + defining_bounds: List of Tuples, defining the absolute bounds of each defining variable + previous_values: List of previous values of the defining variables + label: Label of the OnOffModel """ super().__init__(model, label_of_element, label) assert len(defining_variables) == len(defining_bounds), 'Every defining Variable needs bounds to Model OnOff' @@ -388,20 +380,16 @@ def _get_duration_in_hours( The minimum duration in the last time step is not restricted. Previous values before t=0 are not recognised! - Parameters: - variable_label (str): - Label for the duration variable to be created. - binary_variable (linopy.Variable): - Time-series binary variable (e.g., [0, 0, 1, 1, 1, 0, ...]) representing activity states. - minimum_duration (Optional[TimeSeries]): - Minimum duration the activity must remain active once started. + Args: + variable_name: Label for the duration variable to be created. + binary_variable: Time-series binary variable (e.g., [0, 0, 1, 1, 1, 0, ...]) representing activity states. + minimum_duration: Minimum duration the activity must remain active once started. If None, no minimum duration constraint is applied. - maximum_duration (Optional[TimeSeries]): - Maximum duration the activity can remain active. + maximum_duration: Maximum duration the activity can remain active. If None, the maximum duration is set to the total available time. Returns: - linopy.Variable: The created duration variable representing consecutive active durations. + The created duration variable representing consecutive active durations. Example: binary_variable: [0, 0, 1, 1, 1, 1, 0, 1, 1, 1, 0, ...] @@ -603,16 +591,11 @@ def compute_previous_on_states(previous_values: List[Optional[NumericData]], eps """ Computes the previous 'on' states {0, 1} of defining variables as a binary array from their previous values. - Parameters: - ---------- - previous_values: List[NumericData] - List of previous values of the defining variables. In Range [0, inf] or None (ignored) - epsilon : float, optional - Tolerance for equality to determine "off" state, default is 1e-5. + Args: + previous_values: List of previous values of the defining variables. In Range [0, inf] or None (ignored) + epsilon: Tolerance for equality to determine "off" state, default is 1e-5. Returns: - ------- - np.ndarray A binary array (0 and 1) indicating the previous on/off states of the variables. Returns `array([0])` if no previous values are available. """ @@ -637,16 +620,11 @@ def compute_consecutive_duration( hours_per_timestep is handled in a way, that maximizes compatability. Its length must only be as long as the last consecutive duration in binary_values. - Parameters - ---------- - binary_values : int, np.ndarray - An int or 1D binary array containing only `0`s and `1`s. - hours_per_timestep : int, float, np.ndarray - The duration of each timestep in hours. + Args: + binary_values: An int or 1D binary array containing only `0`s and `1`s. + hours_per_timestep: The duration of each timestep in hours. - Returns - ------- - np.ndarray + Returns: The duration of the binary variable in hours. Raises @@ -744,19 +722,15 @@ def __init__( label: str = 'MultipleSegments', ): """ - Parameters - ---------- - model : linopy.Model - Model to which the segmented variable belongs. - label_of_element : str - Name of the parent variable. - sample_points : dict[str, list[tuple[float, float]]] - Dictionary mapping variables (names) to their sample points for each segment. - The sample points are tuples of the form (start, end). - can_be_outside_segments : bool or linopy.Variable, optional - Whether the variable can be outside the segments. If True, a variable is created. - If False or None, no variable is created. If a Variable is passed, it is used. - as_time_series : bool, optional + Args: + model: Model to which the segmented variable belongs. + label_of_element: Name of the parent variable. + sample_points: Dictionary mapping variables (names) to their sample points for each segment. + The sample points are tuples of the form (start, end). + can_be_outside_segments: Whether the variable can be outside the segments. If True, a variable is created. + If False or None, no variable is created. If a Variable is passed, it is used. + as_time_series: Whether to create a scalar or time series variable. + label: Name of the Model. """ super().__init__(model, label_of_element, label) self.outside_segments: Optional[linopy.Variable] = None @@ -898,14 +872,9 @@ def add_share( The variable representing the total share is on the left hand side (lhs) of the constraint. var_total = sum(expressions) - Parameters - ---------- - system_model : SystemModel - The system model. - name : str - The name of the share. - expression : linopy.LinearExpression - The expression of the share. Added to the right hand side of the constraint. + Args: + name: The name of the share. + expression: The expression of the share. Added to the right hand side of the constraint. """ if name in self.shares: self.share_constraints[name].lhs -= expression diff --git a/flixOpt/flow_system.py b/flixOpt/flow_system.py index e7a6a9e1f..0b2578635 100644 --- a/flixOpt/flow_system.py +++ b/flixOpt/flow_system.py @@ -40,17 +40,13 @@ def __init__( hours_of_previous_timesteps: Optional[Union[int, float, np.ndarray]] = None, ): """ - Parameters - ---------- - timesteps : pd.DatetimeIndex - The timesteps of the model. - hours_of_last_timestep : Optional[float], optional - The duration of the last time step. Uses the last time interval if not specified - hours_of_previous_timesteps : Union[int, float, np.ndarray] - 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! + Args: + timesteps: The timesteps 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! """ self.time_series_collection = TimeSeriesCollection( timesteps=timesteps, @@ -86,6 +82,12 @@ def from_dataset(cls, ds: xr.Dataset): @classmethod def from_dict(cls, data: Dict) -> 'FlowSystem': + """ + Load a FlowSystem from a dictionary. + + Args: + data: Dictionary containing the FlowSystem data. + """ timesteps_extra = pd.DatetimeIndex(data['timesteps_extra'], name='time') hours_of_last_timestep = TimeSeriesCollection.calculate_hours_per_timestep(timesteps_extra).isel(time=-1).item() @@ -122,13 +124,11 @@ def from_netcdf(cls, path: Union[str, pathlib.Path]): def add_elements(self, *elements: Element) -> None: """ - add all modeling elements, like storages, boilers, heatpumps, buses, ... - - Parameters - ---------- - *elements : childs of Element like Boiler, HeatPump, Bus,... - modeling Elements + Add Components(Storages, Boilers, Heatpumps, ...), Buses or Effects to the FlowSystem + Args: + *elements: childs of Element like Boiler, HeatPump, Bus,... + modeling Elements """ if self._connected: warnings.warn( @@ -152,10 +152,8 @@ def to_json(self, path: Union[str, pathlib.Path]): This not meant to be reloaded and recreate the object, but rather used to document or compare the flow_system to others. - Parameters: - ----------- - path : Union[str, pathlib.Path] - The path to the json file. + Args: + path: The path to the json file. """ with open(path, 'w', encoding='utf-8') as f: json.dump(self.as_dict('stats'), f, indent=4, ensure_ascii=False) @@ -230,33 +228,23 @@ def plot_network( """ Visualizes the network structure of a FlowSystem using PyVis, saving it as an interactive HTML file. - Parameters: - - path (Union[bool, str, pathlib.Path], default='flow_system.html'): - Path to save the HTML visualization. - - `False`: Visualization is created but not saved. - - `str` or `Path`: Specifies file path (default: 'flow_system.html'). - - - controls (Union[bool, List[str]], default=True): - UI controls to add to the visualization. - - `True`: Enables all available controls. - - `List`: Specify controls, e.g., ['nodes', 'layout']. - - Options: 'nodes', 'edges', 'layout', 'interaction', 'manipulation', 'physics', 'selection', 'renderer'. - - - show (bool, default=True): - Whether to open the visualization in the web browser. + Args: + path: Path to save the HTML visualization. + - `False`: Visualization is created but not saved. + - `str` or `Path`: Specifies file path (default: 'flow_system.html'). + controls: UI controls to add to the visualization. + - `True`: Enables all available controls. + - `List`: Specify controls, e.g., ['nodes', 'layout']. + - Options: 'nodes', 'edges', 'layout', 'interaction', 'manipulation', 'physics', 'selection', 'renderer'. + show: Whether to open the visualization in the web browser. Returns: - Optional[pyvis.network.Network]: The `Network` instance representing the visualization, or `None` if `pyvis` is not installed. - Usage: - - Visualize and open the network with default options: - >>> self.plot_network() - - - Save the visualization without opening: - >>> self.plot_network(show=False) - - - Visualize with custom controls and path: - >>> self.plot_network(path='output/custom_network.html', controls=['nodes', 'layout']) + Examples: + >>> flow_system.plot_network() + >>> flow_system.plot_network(show=False) + >>> flow_system.plot_network(path='output/custom_network.html', controls=['nodes', 'layout']) Notes: - This function requires `pyvis`. If not installed, the function prints a warning and returns `None`. @@ -362,10 +350,8 @@ def _check_if_element_is_unique(self, element: Element) -> None: """ checks if element or label of element already exists in list - Parameters - ---------- - element : Element - new element to check + Args: + element: new element to check """ if element in self.all_elements.values(): raise Exception(f'Element {element.label} already added to FlowSystem!') diff --git a/flixOpt/interface.py b/flixOpt/interface.py index 62d31c794..a285a3032 100644 --- a/flixOpt/interface.py +++ b/flixOpt/interface.py @@ -41,39 +41,29 @@ def __init__( divest_effects: Optional['EffectValuesUserScalar'] = None, ): """ - Parameters - ---------- - fix_effects : None or scalar, optional - Fixed investment costs if invested. - (Attention: Annualize costs to chosen period!) - divest_effects : None or scalar, optional - Fixed divestment costs (if not invested, e.g., demolition costs or contractual penalty). - fixed_size : int, float, optional - Determines if the investment size is fixed. - optional : bool, optional - If True, investment is not forced. - specific_effects : scalar or Dict[Effect: Union[int, float, np.ndarray], optional - Specific costs, e.g., in €/kW_nominal or €/m²_nominal. - Example: {costs: 3, CO2: 0.3} with costs and CO2 representing an Object of class Effect - (Attention: Annualize costs to chosen period!) - effects_in_segments : list or List[ List[Union[int,float]], Dict[cEffecType: Union[List[Union[int,float]], optional - Linear relation in segments [invest_segments, cost_segments]. - Example 1: - [ [5, 25, 25, 100], # size in kW - {costs: [50,250,250,800], # € - PE: [5, 25, 25, 100] # kWh_PrimaryEnergy - } - ] - Example 2 (if only standard-effect): - [ [5, 25, 25, 100], # kW # size in kW - [50,250,250,800] # value for standart effect, typically € - ] # € - (Attention: Annualize costs to chosen period!) - (Args 'specific_effects' and 'fix_effects' can be used in parallel to InvestsizeSegments) - minimum_size : scalar - Min nominal value (only if: size_is_fixed = False). - maximum_size : scalar, Optional - Max nominal value (only if: size_is_fixed = False). + Args: + fix_effects: Fixed investment costs if invested. (Attention: Annualize costs to chosen period!) + divest_effects: Fixed divestment costs (if not invested, e.g., demolition costs or contractual penalty). + fixed_size: Determines if the investment size is fixed. + optional: If True, investment is not forced. + specific_effects: Specific costs, e.g., in €/kW_nominal or €/m²_nominal. + Example: {costs: 3, CO2: 0.3} with costs and CO2 representing an Object of class Effect + (Attention: Annualize costs to chosen period!) + effects_in_segments: Linear relation in segments [invest_segments, cost_segments]. + Example 1: + [ [5, 25, 25, 100], # size in kW + {costs: [50,250,250,800], # € + PE: [5, 25, 25, 100] # kWh_PrimaryEnergy + } + ] + Example 2 (if only standard-effect): + [ [5, 25, 25, 100], # kW # size in kW + [50,250,250,800] # value for standart effect, typically € + ] # € + (Attention: Annualize costs to chosen period!) + (Args 'specific_effects' and 'fix_effects' can be used in parallel to InvestsizeSegments) + minimum_size: Min nominal value (only if: size_is_fixed = False). + maximum_size: Max nominal value (only if: size_is_fixed = False). """ self.fix_effects: EffectValuesUser = fix_effects or {} self.divest_effects: EffectValuesUser = divest_effects or {} @@ -113,35 +103,24 @@ def __init__( force_switch_on: bool = False, ): """ - on_off_parameters class for modeling on and off state of an Element. + Bundles information about the on and off state of an Element. If no parameters are given, the default is to create a binary variable for the on state without further constraints or effects and a variable for the total on hours. - Parameters - ---------- - effects_per_switch_on : scalar, array, TimeSeriesData, optional - cost of one switch from off (var_on=0) to on (var_on=1), - unit i.g. in Euro - effects_per_running_hour : scalar or TS, optional - costs for operating, i.g. in € per hour - on_hours_total_min : scalar, optional - min. overall sum of operating hours. - on_hours_total_max : scalar, optional - max. overall sum of operating hours. - consecutive_on_hours_min : scalar, optional - min sum of operating hours in one piece - (last on-time period of timeseries is not checked and can be shorter) - consecutive_on_hours_max : scalar, optional - max sum of operating hours in one piece - consecutive_off_hours_min : scalar, optional - min sum of non-operating hours in one piece - (last off-time period of timeseries is not checked and can be shorter) - consecutive_off_hours_max : scalar, optional - max sum of non-operating hours in one piece - switch_on_total_max : integer, optional - max nr of switchOn operations - force_switch_on : bool - force creation of switch on variable, even if there is no switch_on_total_max + Args: + effects_per_switch_on: cost of one switch from off (var_on=0) to on (var_on=1), + unit i.g. in Euro + effects_per_running_hour: costs for operating, i.g. in € per hour + on_hours_total_min: min. overall sum of operating hours. + on_hours_total_max: max. overall sum of operating hours. + consecutive_on_hours_min: min sum of operating hours in one piece + (last on-time period of timeseries is not checked and can be shorter) + consecutive_on_hours_max: max sum of operating hours in one piece + consecutive_off_hours_min: min sum of non-operating hours in one piece + (last off-time period of timeseries is not checked and can be shorter) + consecutive_off_hours_max: max sum of non-operating hours in one piece + switch_on_total_max: max nr of switchOn operations + force_switch_on: force creation of switch on variable, even if there is no switch_on_total_max """ self.effects_per_switch_on: EffectValuesUser = effects_per_switch_on or {} self.effects_per_running_hour: EffectValuesUser = effects_per_running_hour or {} diff --git a/flixOpt/linear_converters.py b/flixOpt/linear_converters.py index cbb33772e..adf3a4161 100644 --- a/flixOpt/linear_converters.py +++ b/flixOpt/linear_converters.py @@ -28,20 +28,13 @@ def __init__( meta_data: Optional[Dict] = None, ): """ - constructor for boiler - - Parameters - ---------- - label : str - name of bolier. - eta : float or TS - thermal efficiency. - Q_fu : Flow - fuel input-flow - Q_th : Flow - thermal output-flow. - meta_data : Optional[Dict] - used to store more information about the element. Is not used internally, but saved in the results + Args: + label: The label of the Element. Used to identify it in the FlowSystem + eta: thermal efficiency. + Q_fu: fuel input-flow + Q_th: thermal output-flow. + on_off_parameters: Parameters defining the on/off behavior of the component. + meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types. """ super().__init__( label, @@ -76,19 +69,13 @@ def __init__( meta_data: Optional[Dict] = None, ): """ - Parameters - ---------- - label : str - name of bolier. - eta : float or TS - thermal efficiency. - P_el : Flow - electric input-flow - Q_th : Flow - thermal output-flow. - meta_data : Optional[Dict] - used to store more information about the element. Is not used internally, but saved in the results - + Args: + label: The label of the Element. Used to identify it in the FlowSystem + eta: thermal efficiency. + P_el: electric input-flow + Q_th: thermal output-flow. + on_off_parameters: Parameters defining the on/off behavior of the component. + meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types. """ super().__init__( label, @@ -124,18 +111,13 @@ def __init__( meta_data: Optional[Dict] = None, ): """ - Parameters - ---------- - label : str - name of heatpump. - COP : float or TS - Coefficient of performance. - P_el : Flow - electricity input-flow. - Q_th : Flow - thermal output-flow. - meta_data : Optional[Dict] - used to store more information about the element. Is not used internally, but saved in the results + Args: + label: The label of the Element. Used to identify it in the FlowSystem + COP: Coefficient of performance. + P_el: electricity input-flow. + Q_th: thermal output-flow. + on_off_parameters: Parameters defining the on/off behavior of the component. + meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types. """ super().__init__( label, @@ -171,19 +153,13 @@ def __init__( meta_data: Optional[Dict] = None, ): """ - Parameters - ---------- - label : str - name of cooling tower. - specific_electricity_demand : float or TS - auxiliary electricty demand per cooling power, i.g. 0.02 (2 %). - P_el : Flow - electricity input-flow. - Q_th : Flow - thermal input-flow. - meta_data : Optional[Dict] - used to store more information about the element. Is not used internally, but saved in the results - + Args: + label: The label of the Element. Used to identify it in the FlowSystem + specific_electricity_demand: auxiliary electricty demand per cooling power, i.g. 0.02 (2 %). + P_el: electricity input-flow. + Q_th: thermal input-flow. + on_off_parameters: Parameters defining the on/off behavior of the component. + meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types. """ super().__init__( label, @@ -223,24 +199,15 @@ def __init__( meta_data: Optional[Dict] = None, ): """ - constructor of cCHP - - Parameters - ---------- - label : str - name of CHP-unit. - eta_th : float or TS - thermal efficiency. - eta_el : float or TS - electrical efficiency. - Q_fu : cFlow - fuel input-flow. - P_el : cFlow - electricity output-flow. - Q_th : cFlow - heat output-flow. - meta_data : Optional[Dict] - used to store more information about the element. Is not used internally, but saved in the results + Args: + label: The label of the Element. Used to identify it in the FlowSystem + eta_th: thermal efficiency. + eta_el: electrical efficiency. + Q_fu: fuel input-flow. + P_el: electricity output-flow. + Q_th: heat output-flow. + on_off_parameters: Parameters defining the on/off behavior of the component. + meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types. """ heat = {Q_fu.label: eta_th, Q_th.label: 1} electricity = {Q_fu.label: eta_el, P_el.label: 1} @@ -292,20 +259,14 @@ def __init__( meta_data: Optional[Dict] = None, ): """ - Parameters - ---------- - label : str - name of heatpump. - COP : float, TS - Coefficient of performance. - Q_ab : Flow - Heatsource input-flow. - P_el : Flow - electricity input-flow. - Q_th : Flow - thermal output-flow. - meta_data : Optional[Dict] - used to store more information about the element. Is not used internally, but saved in the results + Args: + label: The label of the Element. Used to identify it in the FlowSystem + COP: Coefficient of performance. + Q_ab: Heatsource input-flow. + P_el: electricity input-flow. + Q_th: thermal output-flow. + on_off_parameters: Parameters defining the on/off behavior of the component. + meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types. """ # super: @@ -337,26 +298,16 @@ def COP(self, value): # noqa: N802 def check_bounds( value: NumericDataTS, parameter_label: str, element_label: str, lower_bound: NumericDataTS, upper_bound: NumericDataTS -): +) -> None: """ Check if the value is within the bounds. The bounds are exclusive. If not, log a warning. - Parameters - ---------- - value: NumericDataTS - The value to check. - parameter_label: str - The label of the value. - element_label: str - The label of the element. - lower_bound: NumericDataTS - The lower bound. - upper_bound: NumericDataTS - The upper bound. - - Returns - ------- - + Args: + value: The value to check. + parameter_label: The label of the value. + element_label: The label of the element. + lower_bound: The lower bound. + upper_bound: The upper bound. """ if isinstance(value, TimeSeriesData): value = value.data diff --git a/flixOpt/plotting.py b/flixOpt/plotting.py index 85ae8f1fa..9a90c664d 100644 --- a/flixOpt/plotting.py +++ b/flixOpt/plotting.py @@ -35,47 +35,28 @@ def with_plotly( """ Plot a DataFrame with Plotly, using either stacked bars or stepped lines. - Parameters - ---------- - data : pd.DataFrame - A DataFrame containing the data to plot, where the index represents - time (e.g., hours), and each column represents a separate data series. - mode : {'bar', 'line'}, default='bar' - The plotting mode. Use 'bar' for stacked bar charts or 'line' for - stepped lines. - colors : List[str], str, default='viridis' - A List of colors (as str) or a name of a colorscale (e.g., 'viridis', 'plasma') to use for - coloring the data series. - title: str - The title of the plot. - ylabel: str - The label for the y-axis. - fig : go.Figure, optional - A Plotly figure object to plot on. If not provided, a new figure - will be created. - show: bool - Wether to show the figure after creation. (This includes saving the figure) - save: bool - Wether to save the figure after creation (without showing) - path: Union[str, pathlib.Path] - Path to save the figure. - - Returns - ------- - go.Figure + Args: + data: A DataFrame containing the data to plot, where the index represents time (e.g., hours), and each column represents a separate data series. + mode: The plotting mode. Use 'bar' for stacked bar charts or 'line' for stepped lines. + colors: A List of colors (as str) or a name of a colorscale (e.g., 'viridis', 'plasma') to use for coloring the data series. + title: The title of the plot. + ylabel: The label for the y-axis. + fig: A Plotly figure object to plot on. If not provided, a new figure will be created. + show: Wether to show the figure after creation. (This includes saving the figure) + save: Wether to save the figure after creation (without showing) + path: Path to save the figure. + + Returns: A Plotly figure object containing the generated plot. - Notes - ----- - - If `mode` is 'bar', bars are stacked for each data series. - - If `mode` is 'line', a stepped line is drawn for each data series. - - The legend is positioned below the plot for a cleaner layout when many - data series are present. - - Examples - -------- - >>> fig = with_plotly(data, mode='bar', colorscale='plasma') - >>> fig.show() + Notes: + - If `mode` is 'bar', bars are stacked for each data series. + - If `mode` is 'line', a stepped line is drawn for each data series. + - The legend is positioned below the plot for a cleaner layout when many data series are present. + + Examples: + >>> fig = with_plotly(data, mode='bar', colorscale='plasma') + >>> fig.show() """ assert mode in ['bar', 'line', 'area'], f"'mode' must be one of {['bar', 'line', 'area']}" if data.empty: @@ -208,45 +189,28 @@ def with_matplotlib( """ Plot a DataFrame with Matplotlib using stacked bars or stepped lines. - Parameters - ---------- - data : pd.DataFrame - A DataFrame containing the data to plot. The index should represent - time (e.g., hours), and each column represents a separate data series. - mode : {'bar', 'line'}, default='bar' - Plotting mode. Use 'bar' for stacked bar charts or 'line' for stepped lines. - colors : List[str], str, default='viridis' - A List of colors (as str) or a name of a colorscale (e.g., 'viridis', 'plasma') to use for - coloring the data series. - figsize: Tuple[int, int], optional - Specify the size of the figure - fig : plt.Figure, optional - A Matplotlib figure object to plot on. If not provided, a new figure - will be created. - ax : plt.Axes, optional - A Matplotlib axes object to plot on. If not provided, a new axes - will be created. - show: bool - Wether to show the figure after creation. - path: Union[str, pathlib.Path] - Path to save the figure to. - - Returns - ------- - Tuple[plt.Figure, plt.Axes] + Args: + data: A DataFrame containing the data to plot. The index should represent time (e.g., hours), and each column represents a separate data series. + mode: Plotting mode. Use 'bar' for stacked bar charts or 'line' for stepped lines. + colors: A List of colors (as str) or a name of a colorscale (e.g., 'viridis', 'plasma') to use for coloring the data series. + figsize: Specify the size of the figure + fig: A Matplotlib figure object to plot on. If not provided, a new figure will be created. + ax: A Matplotlib axes object to plot on. If not provided, a new axes will be created. + show: Wether to show the figure after creation. + path: Path to save the figure to. + + Returns: A tuple containing the Matplotlib figure and axes objects used for the plot. - Notes - ----- - - If `mode` is 'bar', bars are stacked for both positive and negative values. - Negative values are stacked separately without extra labels in the legend. - - If `mode` is 'line', stepped lines are drawn for each data series. - - The legend is placed below the plot to accommodate multiple data series. - - Examples - -------- - >>> fig, ax = with_matplotlib(data, mode='bar', colorscale='plasma') - >>> plt.show() + Notes: + - If `mode` is 'bar', bars are stacked for both positive and negative values. + Negative values are stacked separately without extra labels in the legend. + - If `mode` is 'line', stepped lines are drawn for each data series. + - The legend is placed below the plot to accommodate multiple data series. + + Examples: + >>> fig, ax = with_matplotlib(data, mode='bar', colorscale='plasma') + >>> plt.show() """ assert mode in ['bar', 'line'], f"'mode' must be one of {['bar', 'line']} for matplotlib" @@ -325,32 +289,23 @@ def heat_map_matplotlib( Plots a DataFrame as a heatmap using Matplotlib. The columns of the DataFrame will be displayed on the x-axis, the index will be displayed on the y-axis, and the values will represent the 'heat' intensity in the plot. - Parameters - ---------- - data : pd.DataFrame - A DataFrame containing the data to be visualized. The index will be used for the y-axis, and columns will be used for the x-axis. - The values in the DataFrame will be represented as colors in the heatmap. - color_map : str, optional - The colormap to use for the heatmap. Default is 'viridis'. Matplotlib supports various colormaps like 'plasma', 'inferno', 'cividis', etc. - figsize : tuple of float, optional - The size of the figure to create. Default is (12, 6), which results in a width of 12 inches and a height of 6 inches. - show: bool - Wether to show the figure after creation. - path: Union[str, pathlib.Path] - Path to save the figure to. - - Returns - ------- - tuple of (plt.Figure, plt.Axes) + Args: + data: A DataFrame containing the data to be visualized. The index will be used for the y-axis, and columns will be used for the x-axis. + The values in the DataFrame will be represented as colors in the heatmap. + color_map: The colormap to use for the heatmap. Default is 'viridis'. Matplotlib supports various colormaps like 'plasma', 'inferno', 'cividis', etc. + figsize: The size of the figure to create. Default is (12, 6), which results in a width of 12 inches and a height of 6 inches. + show: Wether to show the figure after creation. + path: Path to save the figure to. + + Returns: A tuple containing the Matplotlib `Figure` and `Axes` objects. The `Figure` contains the overall plot, while the `Axes` is the area where the heatmap is drawn. These can be used for further customization or saving the plot to a file. - Notes - ----- - - The y-axis is flipped so that the first row of the DataFrame is displayed at the top of the plot. - - The color scale is normalized based on the minimum and maximum values in the DataFrame. - - The x-axis labels (periods) are placed at the top of the plot. - - The colorbar is added horizontally at the bottom of the plot, with a label. + Notes: + - The y-axis is flipped so that the first row of the DataFrame is displayed at the top of the plot. + - The color scale is normalized based on the minimum and maximum values in the DataFrame. + - The x-axis labels (periods) are placed at the top of the plot. + - The colorbar is added horizontally at the bottom of the plot, with a label. """ # Get the min and max values for color normalization @@ -404,33 +359,23 @@ def heat_map_plotly( Plots a DataFrame as a heatmap using Plotly. The columns of the DataFrame will be mapped to the x-axis, and the index will be displayed on the y-axis. The values in the DataFrame will represent the 'heat' in the plot. - Parameters - ---------- - data : pd.DataFrame - A DataFrame with the data to be visualized. The index will be used for the y-axis, and columns will be used for the x-axis. - The values in the DataFrame will be represented as colors in the heatmap. - color_map : str, optional - The color scale to use for the heatmap. Default is 'viridis'. Plotly supports various color scales like 'Cividis', 'Inferno', etc. - categorical_labels : bool, optional - If True, the x and y axes are treated as categorical data (i.e., the index and columns will not be interpreted as continuous data). - Default is True. If False, the axes are treated as continuous, which may be useful for time series or numeric data. - show: bool - Wether to show the figure after creation. (This includes saving the figure) - save: bool - Wether to save the figure after creation (without showing) - path: Union[str, pathlib.Path] - Path to save the figure. - - Returns - ------- - go.Figure + Args: + data: A DataFrame with the data to be visualized. The index will be used for the y-axis, and columns will be used for the x-axis. + The values in the DataFrame will be represented as colors in the heatmap. + color_map: The color scale to use for the heatmap. Default is 'viridis'. Plotly supports various color scales like 'Cividis', 'Inferno', etc. + categorical_labels: If True, the x and y axes are treated as categorical data (i.e., the index and columns will not be interpreted as continuous data). + Default is True. If False, the axes are treated as continuous, which may be useful for time series or numeric data. + show: Wether to show the figure after creation. (This includes saving the figure) + save: Wether to save the figure after creation (without showing) + path: Path to save the figure. + + Returns: A Plotly figure object containing the heatmap. This can be further customized and saved or displayed using `fig.show()`. - Notes - ----- - The color bar is automatically scaled to the minimum and maximum values in the data. - The y-axis is reversed to display the first row at the top. + Notes: + The color bar is automatically scaled to the minimum and maximum values in the data. + The y-axis is reversed to display the first row at the top. """ color_bar_min, color_bar_max = data.min().min(), data.max().max() # Min and max values for color scaling @@ -479,18 +424,12 @@ def reshape_to_2d(data_1d: np.ndarray, nr_of_steps_per_column: int) -> np.ndarra The reshaped array will have the number of rows corresponding to the steps per column (e.g., 24 hours per day) and columns representing time periods (e.g., days or months). - Parameters - ---------- - data_1d : np.ndarray - A 1D numpy array with the data to reshape. - - nr_of_steps_per_column : int - The number of steps (rows) per column in the resulting 2D array. For example, - this could be 24 (for hours) or 31 (for days in a month). + Args: + data_1d: A 1D numpy array with the data to reshape. + nr_of_steps_per_column: The number of steps (rows) per column in the resulting 2D array. For example, + this could be 24 (for hours) or 31 (for days in a month). - Returns - ------- - np.ndarray + Returns: The reshaped 2D array. Each internal array corresponds to one column, with the specified number of steps. Each column might represents a time period (e.g., day, month, etc.). """ @@ -533,22 +472,15 @@ def heat_map_data_from_df( based on a specified sample rate. If a non-valid combination of periods and steps per period is used, falls back to numerical indices - Parameters - ---------- - df : pd.DataFrame - A DataFrame with a DateTime index containing the data to reshape. - periods : str - The time interval of each period (columns of the heatmap), - such as 'YS' (year start), 'W' (weekly), 'D' (daily), 'h' (hourly) etc. - steps_per_period : str - The time interval within each period (rows in the heatmap), - such as 'YS' (year start), 'W' (weekly), 'D' (daily), 'h' (hourly) etc. - fill : str, optional - Method to fill missing values: 'ffill' for forward fill or 'bfill' for backward fill. - - Returns - ------- - pd.DataFrame + Args: + df: A DataFrame with a DateTime index containing the data to reshape. + periods: The time interval of each period (columns of the heatmap), + such as 'YS' (year start), 'W' (weekly), 'D' (daily), 'h' (hourly) etc. + steps_per_period: The time interval within each period (rows in the heatmap), + such as 'YS' (year start), 'W' (weekly), 'D' (daily), 'h' (hourly) etc. + fill: Method to fill missing values: 'ffill' for forward fill or 'bfill' for backward fill. + + Returns: A DataFrame suitable for heatmap plotting, with rows representing steps within each period and columns representing each period. """ @@ -620,27 +552,17 @@ def plot_network( """ Visualizes the network structure of a FlowSystem using PyVis, using info-dictionaries. - Parameters: - - path (Union[bool, str, pathlib.Path], default='results/network.html'): - Path to save the HTML visualization. - - `False`: Visualization is created but not saved. - - `str` or `Path`: Specifies file path (default: 'results/network.html'). - - - controls (Union[bool, List[str]], default=True): - UI controls to add to the visualization. - - `True`: Enables all available controls. - - `List`: Specify controls, e.g., ['nodes', 'layout']. - - Options: 'nodes', 'edges', 'layout', 'interaction', 'manipulation', 'physics', 'selection', 'renderer'. - You can play with these and generate a Dictionary from it that can be applied to the network returned by this function. - network.set_options() - https://pyvis.readthedocs.io/en/latest/tutorial.html - - - show (bool, default=True): - Whether to open the visualization in the web browser. - The calculation must be saved to show it. If no path is given, it defaults to 'network.html'. - + Args: + path: Path to save the HTML visualization. `False`: Visualization is created but not saved. `str` or `Path`: Specifies file path (default: 'results/network.html'). + controls: UI controls to add to the visualization. `True`: Enables all available controls. `List`: Specify controls, e.g., ['nodes', 'layout']. + Options: 'nodes', 'edges', 'layout', 'interaction', 'manipulation', 'physics', 'selection', 'renderer'. + You can play with these and generate a Dictionary from it that can be applied to the network returned by this function. + network.set_options() + https://pyvis.readthedocs.io/en/latest/tutorial.html + show: Whether to open the visualization in the web browser. + The calculation must be saved to show it. If no path is given, it defaults to 'network.html'. Returns: - - Optional[pyvis.network.Network]: The `Network` instance representing the visualization, or `None` if `pyvis` is not installed. + The `Network` instance representing the visualization, or `None` if `pyvis` is not installed. Usage: - Visualize and open the network with default options: diff --git a/flixOpt/results.py b/flixOpt/results.py index 5dff9d34d..17ccd29e3 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -29,37 +29,19 @@ class CalculationResults: This class is used to collect the results of a Calculation. It is used to analyze the results and to visualize the results. - Parameters - ---------- - model : linopy.Model - The linopy model that was used to solve the calculation. - infos : Dict - Information about the calculation, - results_structure : Dict[str, Dict[str, Dict]] - The structure of the flow_system that was used to solve the calculation. - - Attributes - ---------- - model : linopy.Model - The linopy model that was used to solve the calculation. - components : Dict[str, ComponentResults] - A dictionary of ComponentResults for each component in the flow_system. - buses : Dict[str, BusResults] - A dictionary of BusResults for each bus in the flow_system. - effects : Dict[str, EffectResults] - A dictionary of EffectResults for each effect in the flow_system. - timesteps_extra : pd.DatetimeIndex - The extra timesteps of the flow_system. - hours_per_timestep : xr.DataArray - The duration of each timestep in hours. - - Class Methods - ------- - from_file(folder: Union[str, pathlib.Path], name: str) - Create CalculationResults directly from file. - from_calculation(calculation: Calculation) - Create CalculationResults directly from a Calculation. - + Attributes: + model: linopy.Model + The linopy model that was used to solve the calculation. + components: Dict[str, ComponentResults] + A dictionary of ComponentResults for each component in the flow_system. + buses: Dict[str, BusResults] + A dictionary of BusResults for each bus in the flow_system. + effects: Dict[str, EffectResults] + A dictionary of EffectResults for each effect in the flow_system. + timesteps_extra: pd.DatetimeIndex + The extra timesteps of the flow_system. + hours_per_timestep: xr.DataArray + The duration of each timestep in hours. """ @classmethod def from_file(cls, folder: Union[str, pathlib.Path], name: str): @@ -113,6 +95,17 @@ def __init__( folder: Optional[pathlib.Path] = None, model: Optional[linopy.Model] = None, ): + """ + Args: + solution: The solution of the optimization. + flow_system: The flow_system that was used to create the calculation as a datatset. + results_structure: The structure of the flow_system that was used to solve the calculation. + name: The name of the calculation. + infos: Information about the calculation, + network_infos: Information about the network. + folder: The folder where the results are saved. + model: The linopy model that was used to solve the calculation. + """ self.solution = solution self.flow_system = flow_system self._results_structure = results_structure @@ -578,15 +571,15 @@ def plotly_save_and_show(fig: plotly.graph_objs.Figure, """ Optionally saves and/or displays a Plotly figure. - Parameters: - - fig (go.Figure): The Plotly figure to display or save. - - default_filename (Path): The default file path if no user filename is provided. - - user_filename (Optional[Path]): An optional user-specified file path. - - show (bool): Whether to display the figure (default: True). - - save (bool): Whether to save the figure (default: False). + Args: + fig: The Plotly figure to display or save. + default_filename: The default file path if no user filename is provided. + user_filename: An optional user-specified file path. + show: Whether to display the figure (default: True). + save: Whether to save the figure (default: False). Returns: - - go.Figure: The input figure. + go.Figure: The input figure. """ filename = user_filename or default_filename if show and not save: @@ -631,14 +624,14 @@ def sanitize_dataset( """ Sanitizes a dataset by dropping variables with small values and optionally reindexing the time axis. - Parameters: - - ds (xr.Dataset): The dataset to sanitize. - - timesteps (Optional[pd.DatetimeIndex]): The timesteps to reindex the dataset to. If None, the original timesteps are kept. - - threshold (Optional[float]): The threshold for dropping variables. If None, no variables are dropped. - - negate (Optional[List[str]]): The variables to negate. If None, no variables are negated. + Args: + ds: The dataset to sanitize. + timesteps: The timesteps to reindex the dataset to. If None, the original timesteps are kept. + threshold: The threshold for dropping variables. If None, no variables are dropped. + negate: The variables to negate. If None, no variables are negated. Returns: - - xr.Dataset: The sanitized dataset. + xr.Dataset: The sanitized dataset. """ if negate is not None: for var in negate: diff --git a/flixOpt/solvers.py b/flixOpt/solvers.py index c9371b572..3f688d930 100644 --- a/flixOpt/solvers.py +++ b/flixOpt/solvers.py @@ -35,6 +35,13 @@ def _options(self) -> Dict[str, Any]: class GurobiSolver(_Solver): + """ + Args: + mip_gap (float): Solver's mip gap setting. The MIP gap describes the accepted (MILP) objective, + and the lower bound, which is the theoretically optimal solution (LP) + time_limit_seconds (int): Solver's time limit in seconds. + extra_options (str): Filename for saving the solver log. + """ name: ClassVar[str] = 'gurobi' @property @@ -46,6 +53,14 @@ def _options(self) -> Dict[str, Any]: class HighsSolver(_Solver): + """ + Args: + mip_gap (float): Solver's mip gap setting. The MIP gap describes the accepted (MILP) objective, + and the lower bound, which is the theoretically optimal solution (LP) + time_limit_seconds (int): Solver's time limit in seconds. + threads (int): Number of threads to use. + extra_options (str): Filename for saving the solver log. + """ threads: Optional[int] = None name: ClassVar[str] = 'highs' diff --git a/flixOpt/structure.py b/flixOpt/structure.py index fa8765f1f..26a336738 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -41,8 +41,16 @@ def register_class_for_io(cls): class SystemModel(linopy.Model): + """ + The SystemModel 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. + """ def __init__(self, flow_system: 'FlowSystem'): + """ + Args: + flow_system: The flow_system that is used to create the model. + """ super().__init__(force_dim_names=True) self.flow_system = flow_system self.time_series_collection = flow_system.time_series_collection @@ -77,31 +85,28 @@ def coords_extra(self) -> Tuple[pd.DatetimeIndex]: class Interface: """ - This class is used to collect arguments about a Model. + This class is used to collect arguments about a Model. Its the base class for all Elements and Models in flixOpt. """ def transform_data(self, flow_system: 'FlowSystem'): """ Transforms the data of the interface to match the FlowSystem's dimensions""" raise NotImplementedError('Every Interface needs a transform_data() method') - def infos(self, use_numpy=True, use_element_label=False) -> Dict: + def infos(self, use_numpy: bool =True, use_element_label: bool = False) -> Dict: """ Generate a dictionary representation of the object's constructor arguments. Excludes default values and empty dictionaries and lists. Converts data to be compatible with JSON. - Parameters: - ----------- - use_numpy bool: - Whether to convert NumPy arrays to lists. Defaults to True. - If True, numeric numpy arrays (`np.ndarray`) are preserved as-is. - If False, they are converted to lists. - use_element_label bool: - Whether to use the element label instead of the infos of the element. Defaults to False. - Note that Elements used as keys in dictionaries are always converted to their labels. + Args: + use_numpy: Whether to convert NumPy arrays to lists. Defaults to True. + If True, numeric numpy arrays (`np.ndarray`) are preserved as-is. + If False, they are converted to lists. + use_element_label: Whether to use the element label instead of the infos of the element. Defaults to False. + Note that Elements used as keys in dictionaries are always converted to their labels. Returns: - Dict: A dictionary representation of the object's constructor arguments. + A dictionary representation of the object's constructor arguments. """ # Get the constructor arguments and their default values @@ -126,10 +131,8 @@ def to_json(self, path: Union[str, pathlib.Path]): Saves the element to a json file. This not meant to be reloaded and recreate the object, but rather used to document or compare the object. - Parameters: - ----------- - path : Union[str, pathlib.Path] - The path to the json file. + Args: + path: The path to the json file. """ data = get_compact_representation(self.infos(use_numpy=True, use_element_label=True)) with open(path, 'w', encoding='utf-8') as f: @@ -206,7 +209,12 @@ def _deserialize_value(cls, value: Any): @classmethod def from_dict(cls, data: Dict) -> 'Interface': - """Create an instance from a dictionary representation.""" + """ + Create an instance from a dictionary representation. + + Args: + data: Dictionary containing the data for the object. + """ return cls._deserialize_dict(data) def __repr__(self): @@ -223,16 +231,13 @@ def __str__(self): class Element(Interface): - """Basic Element of flixOpt""" + """This class is the basic Element of flixOpt. Every Element has a label""" def __init__(self, label: str, meta_data: Dict = None): """ - Parameters - ---------- - label : str - label of the element - meta_data : Optional[Dict] - used to store more information about the element. Is not used internally, but saved in the results + Args: + label: The label of the element + meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types. """ self.label = Element._valid_label(label) self.meta_data = meta_data if meta_data is not None else {} @@ -272,18 +277,15 @@ def _valid_label(label: str) -> str: class Model: - """Stores Variables and Constraints""" + """Stores Variables and Constraints.""" def __init__(self, model: SystemModel, label_of_element: str, label: Optional[str] = None, label_full: Optional[str] = None): """ - Parameters - ---------- - label_of_element : str - The label of the parent (Element). Used to construct the full label of the model. - label : str - The label of the model. Used to construct the full label of the model. - label_full : str - The full label of the model. Can overwrite the full label constructed from the other labels. + Args: + model: The SystemModel 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_full: The full label of the model. Can overwrite the full label constructed from the other labels. """ self._model = model self.label_of_element = label_of_element @@ -310,12 +312,9 @@ def add( """ Add a variable, constraint or sub-model to the model - Parameters - ---------- - item : linopy.Variable, linopy.Constraint, InterfaceModel - The variable, constraint or sub-model to add to the model - short_name : str, optional - The short name of the variable, constraint or sub-model. If not provided, the full name is used. + Args: + item: The variable, constraint or sub-model to add to the model + short_name: The short name of the variable, constraint or sub-model. If not provided, the full name is used. """ # TODO: Check uniquenes of short names if isinstance(item, linopy.Variable): @@ -410,14 +409,13 @@ def all_sub_models(self) -> List['Model']: class ElementModel(Model): - """Interface to create the mathematical Variables and Constraints for Elements""" + """Stores the mathematical Variables and Constraints for Elements""" def __init__(self, model: SystemModel, element: Element): """ - Parameters - ---------- - element : Element - The element this model is created for. + Args: + model: The SystemModel that is used to create the model. + element: The element this model is created for. """ super().__init__(model, label_of_element=element.label_full, label=element.label, label_full=element.label_full) self.element = element @@ -444,43 +442,33 @@ def copy_and_convert_datatypes(data: Any, use_numpy: bool = True, use_element_la - Custom `Element` objects can be represented either by their `label` or their initialization parameters as a dictionary. - Timestamps (`datetime`) are converted to ISO 8601 strings. - Parameters - ---------- - data : Any - The input data to process, which may be deeply nested and contain a mix of types. - use_numpy : bool, optional - If `True`, numeric numpy arrays (`np.ndarray`) are preserved as-is. If `False`, they are converted to lists. - Default is `True`. - use_element_label : bool, optional - If `True`, `Element` objects are represented by their `label`. If `False`, they are converted into a dictionary - based on their initialization parameters. Default is `False`. - - Returns - ------- - Any + Args: + data: The input data to process, which may be deeply nested and contain a mix of types. + use_numpy: If `True`, numeric numpy arrays (`np.ndarray`) are preserved as-is. If `False`, they are converted to lists. + Default is `True`. + use_element_label: If `True`, `Element` objects are represented by their `label`. If `False`, they are converted into a dictionary + based on their initialization parameters. Default is `False`. + + Returns: A transformed version of the input data, containing only JSON-compatible types: - `int`, `float`, `str`, `bool`, `None` - `list`, `dict` - `np.ndarray` (if `use_numpy=True`. This is NOT JSON-compatible) - Raises - ------ - TypeError - If the data cannot be converted to the specified types. + Raises: + TypeError: If the data cannot be converted to the specified types. - Examples - -------- - >>> copy_and_convert_datatypes({'a': np.array([1, 2, 3]), 'b': Element(label='example')}) - {'a': array([1, 2, 3]), 'b': {'class': 'Element', 'label': 'example'}} + Examples: + >>> copy_and_convert_datatypes({'a': np.array([1, 2, 3]), 'b': Element(label='example')}) + {'a': array([1, 2, 3]), 'b': {'class': 'Element', 'label': 'example'}} - >>> copy_and_convert_datatypes({'a': np.array([1, 2, 3]), 'b': Element(label='example')}, use_numpy=False) - {'a': [1, 2, 3], 'b': {'class': 'Element', 'label': 'example'}} + >>> copy_and_convert_datatypes({'a': np.array([1, 2, 3]), 'b': Element(label='example')}, use_numpy=False) + {'a': [1, 2, 3], 'b': {'class': 'Element', 'label': 'example'}} - Notes - ----- - - The function gracefully handles unexpected types by issuing a warning and returning a deep copy of the data. - - Empty collections (lists, dictionaries) and default parameter values in `Element` objects are omitted from the output. - - Numpy arrays with non-numeric data types are automatically converted to lists. + Notes: + - The function gracefully handles unexpected types by issuing a warning and returning a deep copy of the data. + - Empty collections (lists, dictionaries) and default parameter values in `Element` objects are omitted from the output. + - Numpy arrays with non-numeric data types are automatically converted to lists. """ if isinstance(data, np.integer): # This must be checked before checking for regular int and float! return int(data) diff --git a/flixOpt/utils.py b/flixOpt/utils.py index d43c5d999..af0f103e5 100644 --- a/flixOpt/utils.py +++ b/flixOpt/utils.py @@ -34,16 +34,19 @@ def convert_dataarray(data: xr.DataArray, mode: Literal['py', 'numpy', 'xarray', """ Convert a DataArray to a different format. - Parameters - ---------- - data : xr.DataArray - The data to convert. - mode : Literal['py', 'numpy', 'xarray', 'structure'] - Whether to return the dataaray to - - python native types (for json) - - numpy array - - xarray.DataArray - - strings (for structure, storing variable names) + Args: + data: The DataArray to convert. + mode: The mode to convert to. + - 'py': Convert to python native types (for json) + - 'numpy': Convert to numpy array + - 'xarray': Convert to xarray.DataArray + - 'structure': Convert to strings (for structure, storing variable names) + + Returns: + The converted data. + + Raises: + ValueError: If the mode is unknown. """ if mode == 'numpy': return data.values diff --git a/mkdocs.yml b/mkdocs.yml index a257aca50..c5bff3175 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -94,7 +94,7 @@ plugins: handlers: python: # Configuration for Python code documentation options: - docstring_style: numpy # Sets google as the docstring style + docstring_style: google # Sets google as the docstring style modernize_annotations: true # Improves type annotations merge_init_into_class: true # Promotes constructor parameters to class-level documentation docstring_section_style: table # Renders parameter sections as a table (also: list, spacy) diff --git a/pics/flixopt-icon.svg b/pics/flixopt-icon.svg new file mode 100644 index 000000000..04a6a6851 --- /dev/null +++ b/pics/flixopt-icon.svg @@ -0,0 +1 @@ +flixOpt \ No newline at end of file diff --git a/scripts/gen_ref_pages.py b/scripts/gen_ref_pages.py index 8a1b2ff1d..4e155be4d 100644 --- a/scripts/gen_ref_pages.py +++ b/scripts/gen_ref_pages.py @@ -1,5 +1,6 @@ """Generate the code reference pages and navigation.""" +from pathlib import Path import sys from pathlib import Path @@ -48,8 +49,8 @@ with mkdocs_gen_files.open(f"{api_dir}/index.md", "w") as index_file: index_file.write("# API Reference\n\n") index_file.write( - "This section contains the documentation for all modules and classes in flixOpt.\n" - "For more information on how to use the classes and functions, see the [Concepts & Math](../concepts-and-math/index.md) section.\n") + f"This section contains the documentation for all modules and classes in flixOpt.\n" + f"For more information on how to use the classes and functions, see the [Concepts & Math](../concepts-and-math/index.md) section.\n") with mkdocs_gen_files.open(f"{api_dir}/SUMMARY.md", "w") as nav_file: nav_file.writelines(nav.build_literate_nav()) From 568d07cdada7d7d8ff95d370bf6998f2af488fac Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 23 Mar 2025 17:25:56 +0100 Subject: [PATCH 416/507] ruff check --- scripts/gen_ref_pages.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/scripts/gen_ref_pages.py b/scripts/gen_ref_pages.py index 4e155be4d..8a1b2ff1d 100644 --- a/scripts/gen_ref_pages.py +++ b/scripts/gen_ref_pages.py @@ -1,6 +1,5 @@ """Generate the code reference pages and navigation.""" -from pathlib import Path import sys from pathlib import Path @@ -49,8 +48,8 @@ with mkdocs_gen_files.open(f"{api_dir}/index.md", "w") as index_file: index_file.write("# API Reference\n\n") index_file.write( - f"This section contains the documentation for all modules and classes in flixOpt.\n" - f"For more information on how to use the classes and functions, see the [Concepts & Math](../concepts-and-math/index.md) section.\n") + "This section contains the documentation for all modules and classes in flixOpt.\n" + "For more information on how to use the classes and functions, see the [Concepts & Math](../concepts-and-math/index.md) section.\n") with mkdocs_gen_files.open(f"{api_dir}/SUMMARY.md", "w") as nav_file: nav_file.writelines(nav.build_literate_nav()) From e98a3fe57d6237636ff1bc943eb820cc8c1ab5f2 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 23 Mar 2025 18:53:00 +0100 Subject: [PATCH 417/507] Update flixOpt architecture --- pics/pics.pptx | Bin 46213 -> 683344 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/pics/pics.pptx b/pics/pics.pptx index f2b752fb6506cdc9a6e40a72b50d1d3c812b72e4..c6b41df464c805ca8376ea611f6fd3f37d6fda3a 100644 GIT binary patch literal 683344 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 z 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

Z))K8qKx5@TUz9*!mE+X=xC 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

    V)nOq)cf@G%erXQqlaxi$;AHMEC3^ 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

    L{KRa83|) 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 From 9acc78e5648a2aa9663a716f30a05aa17a5dced5 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 23 Mar 2025 19:19:29 +0100 Subject: [PATCH 418/507] Update the flix opt architecture png --- docs/images/architecture_flixOpt-pre2.0.0.png | Bin 0 -> 70605 bytes docs/images/architecture_flixOpt.png | Bin 70605 -> 260219 bytes pics/architecture_flixOpt-pre2.0.0.png | Bin 0 -> 70605 bytes pics/architecture_flixOpt.png | Bin 70605 -> 260219 bytes 4 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/images/architecture_flixOpt-pre2.0.0.png create mode 100644 pics/architecture_flixOpt-pre2.0.0.png diff --git a/docs/images/architecture_flixOpt-pre2.0.0.png b/docs/images/architecture_flixOpt-pre2.0.0.png new file mode 100644 index 0000000000000000000000000000000000000000..1469a9de8f1f77a98a2221ef45cb87728dfef214 GIT binary patch literal 70605 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

    2$d}Ww3Vyz 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%0Dh?piz<=-(Y`T*@cM^S!|~b2`I0IqsRY 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ð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%0DOc|Ut2wZw9)6KJ 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�!d||_t*db3F!YlH~=yDTVQ?`9X)M0+**QGe&e}{ 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 literal 70605 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

    2$d}Ww3Vyz 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

    2$d}Ww3Vyz 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%0Dh?piz<=-(Y`T*@cM^S!|~b2`I0IqsRY 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ð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%0DOc|Ut2wZw9)6KJ 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�!d||_t*db3F!YlH~=yDTVQ?`9X)M0+**QGe&e}{ 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 literal 70605 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

    2$d}Ww3Vyz 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 Date: Sun, 23 Mar 2025 19:19:39 +0100 Subject: [PATCH 419/507] Update the README.md --- README.md | 114 +++++++----------------------------------------------- 1 file changed, 15 insertions(+), 99 deletions(-) diff --git a/README.md b/README.md index b105c6fcd..7fab0e092 100644 --- a/README.md +++ b/README.md @@ -6,109 +6,36 @@ [![Python Versions](https://img.shields.io/pypi/pyversions/flixopt.svg)](https://pypi.org/project/flixopt/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -**flixOpt** is a Python-based optimization framework designed to tackle energy and material flow problems using mixed-integer linear programming (MILP). Combining flexibility and efficiency, it provides a powerful platform for both dispatch and investment optimization challenges. - --- -## 🚀 Introduction +## 🚀 Purpose + +**flixopt** is a Python-based optimization framework designed to tackle energy and material flow problems using mixed-integer linear programming (MILP). + +**flixopt** bridges the gap between high-level energy systems models like [FINE](https://github.com/FZJ-IEK3-VSA/FINE) used for design and (multi-period) investment decisions and low-level dispatch optimization tools used for operation decisions. + +**flixopt** leverages the fast and efficient [linopy](https://github.com/PyPSA/linopy/) for the mathematical modeling and [xarray](https://github.com/pydata/xarray) for data handling. -flixOpt was developed by [TU Dresden](https://github.com/gewv-tu-dresden) as part of the SMARTBIOGRID project, funded by the German Federal Ministry for Economic Affairs and Energy (FKZ: 03KB159B). Building on the Matlab-based flixOptMat framework (developed in the FAKS project), flixOpt also incorporates concepts from [oemof/solph](https://github.com/oemof/oemof-solph). +**flixopt** provides a user-friendly interface with options for advanced users. -Although flixOpt is in its early stages, it is fully functional and ready for experimentation. It is used for investment and operation decisions by energy providing companys as well as research institutions. Feedback and collaboration are highly encouraged to help shape its future. +It was originally developed by [TU Dresden](https://github.com/gewv-tu-dresden) as part of the SMARTBIOGRID project, funded by the German Federal Ministry for Economic Affairs and Energy (FKZ: 03KB159B). Building on the Matlab-based flixOptMat framework (developed in the FAKS project), flixOpt also incorporates concepts from [oemof/solph](https://github.com/oemof/oemof-solph). --- ## 📦 Installation -Install flixOpt directly into your environment using pip. Thanks to [HiGHS](https://github.com/ERGO-Code/HiGHS?tab=readme-ov-file), flixOpt can be used without further setup. +Install flixOpt via pip. `pip install flixopt` +With [HiGHS](https://github.com/ERGO-Code/HiGHS?tab=readme-ov-file) included out of the box, flixopt is ready to use.. -We recommend installing flixOpt with all dependencies, which enables interactive network visualizations by [pyvis](https://github.com/WestHealth/pyvis) and time series aggregation by [tsam](https://github.com/FZJ-IEK3-VSA/tsam). +We recommend installing flixOpt with all dependencies, which enables additional features like interactive network visualizations ([pyvis](https://github.com/WestHealth/pyvis)) and time series aggregation ([tsam](https://github.com/FZJ-IEK3-VSA/tsam)). `pip install "flixopt[full]"` --- ## 📚 Documentation -Full documentation is available at [https://flixopt.github.io/flixopt/](https://flixopt.github.io/flixopt/) - -## 🌟 Key Features and Concepts - -### 💡 High-level Interface... - - flixOpt aims to provide a user-friendly interface for defining and solving energy systems, without sacrificing fine-grained control where necessary. - - This is achieved through a high-level interface with many optional or default parameters. - - The most important concepts are: - - **FlowSystem**: Represents the System that is modeled. - - **Flow**: A Flow represents a stream of matter or energy. In an Energy-System, it could be electricity [kW] - - **Bus**: A Bus represents a balancing node in the Energy-System, typically connecting a demand to a supply. - - **Component**: A Component is a physical entity that consumes or produces matter or energy. It can also transform matter or energy into other kinds of matter or energy. - - **Effect**: Flows and Components can have Effects, related to their usage (or size). Common effects are *costs*, *CO2-emissions*, *primary-energy-demand* or *area-demand*. One Effect is used as the optimization target. The others can be constrained. - - To simplify the modeling process, high-level **Components** (CHP, Boiler, Heat Pump, Cooling Tower, Storage, etc.) are availlable. - -### 🎛️ ...with low-level control -- **Segmented Linear Correlations** - - Accurate modeling for efficiencies, investment effects, and sizes. -- **On/Off Variables** - - Modeling On/Off-Variables and their constraints. - - On-Hours/Off-Hours - - Consecutive On-Hours/ Off-Hours - - Switch On/Off - -### 💰 Investment Optimization -- flixOpt combines dispatch optimization with investment optimization in one model. -- Size and/or discrete investment decisions can be modeled -- Investment decisions can be combined with Modeling On/Off-Variables and their constraints - -### Further Features -- **Multiple Effects** - - Couple effects (e.g., specific CO2 costs) and set constraints (e.g., max CO2 emissions). - - Easily switch between optimization targets (e.g., minimize CO2 or costs). - - This allows to solve questions like "How much does it cost to reduce CO2 emissions by 20%?" - -- **Advanced Time Handling** - - Non-equidistant timesteps supported. - - Energy prices or effects in general can always be defined per hour (or per MWh...) - - - A variety of predefined constraints for operational and investment optimization can be applied. - - Many of these are optional and only applied when necessary, keeping the amount o variables and equations low. - ---- - -## 🖥️ Usage Example -![Usage Example](https://github.com/user-attachments/assets/fa0e12fa-2853-4f51-a9e2-804abbefe20c) - -**Plotting examples**: -![flixOpt plotting](/pics/flixOpt_plotting.jpg) - -## ⚙️ Calculation Modes - -flixOpt offers three calculation modes, tailored to different performance and accuracy needs: - -- **Full Mode** - - Provides exact solutions with high computational requirements. - - Recommended for detailed analyses and investment decision problems. - -- **Segmented Mode** - - Solving a Model segmentwise, this mode can speed up the solving process for complex systems, while being fairly accurate. - - Utilizes variable time overlap to improve accuracy. - - Not suitable for large storage systems or investment decisions. - -- **Aggregated Mode** - - Automatically generates typical periods using [TSAM](https://github.com/FZJ-IEK3-VSA/tsam). - - Balances speed and accuracy, making it ideal for large-scale simulations. - - -## 🏗️ Architecture - -- **Minimal coupling to Pyomo** - - Included independent module is used to organize variables and equations, independently of a specific modeling language. - - While currently only working with [Pyomo](http://www.pyomo.org/), flixOpt is designed to work with different modeling languages with minor modifications ([cvxpy](https://www.cvxpy.org)). - -- **File-based Post-Processing Unit** - - Results are saved to .json and .yaml files for easy access and analysis anytime. - - Internal plotting functions utilizing matplotlib, plotly and pandas simplify results visualization and reporting. - -![Architecture Diagram](/pics/architecture_flixOpt.png) +The documentation is available at [https://flixopt.github.io/flixopt/](https://flixopt.github.io/flixopt/) --- @@ -116,13 +43,11 @@ flixOpt offers three calculation modes, tailored to different performance and ac By default, flixOpt uses the open-source solver [HiGHS](https://highs.dev/) which is installed by default. However, it is compatible with additional solvers such as: -- [CBC](https://github.com/coin-or/Cbc) -- [GLPK](https://www.gnu.org/software/glpk/) - [Gurobi](https://www.gurobi.com/) +- [CBC](https://github.com/coin-or/Cbc) +- [GLPK](https://www.gnu.org/software/glpk/) - [CPLEX](https://www.ibm.com/analytics/cplex-optimizer) -Executables can be found for example [here for CBC](https://portal.ampl.com/dl/open/cbc/) and [here for GLPK](https://sourceforge.net/projects/winglpk/) (Windows: You have to put solver-executables to the PATH-variable) - For detailed licensing and installation instructions, refer to the respective solver documentation. --- @@ -133,12 +58,3 @@ If you use flixOpt in your research or project, please cite the following: - **Main Citation:** [DOI:10.18086/eurosun.2022.04.07](https://doi.org/10.18086/eurosun.2022.04.07) - **Short Overview:** [DOI:10.13140/RG.2.2.14948.24969](https://doi.org/10.13140/RG.2.2.14948.24969) - ---- - -## 🔧 Development and Testing - -Run the tests using: - -```bash -python -m unittest discover -s tests From 65630589a8c379de788ef1718ce1a3625d23c653 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 23 Mar 2025 19:38:29 +0100 Subject: [PATCH 420/507] Update landing page --- docs/index.md | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/docs/index.md b/docs/index.md index 35419dd18..8101c04ba 100644 --- a/docs/index.md +++ b/docs/index.md @@ -11,24 +11,26 @@ Although flixOpt is in its early stages, it is fully functional and ready for ex ## 🌟 Key Features - **High-level Interface** with low-level control - - User-friendly interface for defining energy systems - - Fine-grained control for advanced configurations + - User-friendly interface for defining flow systems - Pre-defined components like CHP, Heat Pump, Cooling Tower, etc. + - Fine-grained control for advanced configurations - **Investment Optimization** - Combined dispatch and investment optimization - - Size and discrete investment decisions - - Integration with On/Off variables and constraints + - Size optimization and discrete investment decisions + - Combined with On/Off variables and constraints -- **Multiple Effects** - - Couple effects (e.g., specific CO2 costs) - - Set constraints (e.g., max CO2 emissions) - - Easily switch optimization targets (e.g., costs vs CO2) +- **Effects, not only Costs --> Multi-criteria Optimization** + - flixopt abstracts costs as so called 'Effects'. This allows to model costs, CO2-emissions, primary-energy-demand or area-demand at the same time. + - Effects can interact with each other(e.g., specific CO2 costs) + - Any of these `Effects` can be used as the optimization objective. + - A **Weigted Sum**of Effects can be used as the optimization objective. + - Every Effect can be constrained ($\epsilon$-constraint method). - **Calculation Modes** - - **Full Mode** - Exact solutions with high computational requirements - - **Segmented Mode** - Speed up complex systems with variable time overlap - - **Aggregated Mode** - Typical periods for large-scale simulations + - **Full** - Solve the model with highest accuracy and computational requirements. + - **Segmented** - Speed up solving by using a rolling horizon. + - **Aggregated** - Speed up solving by identifying typical periods using [TSAM](https://github.com/FZJ-IEK3-VSA/tsam). Suitable for large models. ## 🛠️ Getting Started From 4687f705042f305f54d48d5245e929ec6d6059d5 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 23 Mar 2025 19:45:44 +0100 Subject: [PATCH 421/507] Update readme and landing page --- README.md | 26 ++++++++++++++++++++++++++ docs/index.md | 41 +++++++---------------------------------- 2 files changed, 33 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 7fab0e092..89b286ec5 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,32 @@ It was originally developed by [TU Dresden](https://github.com/gewv-tu-dresden) --- +## 🌟 Key Features + +- **High-level Interface** with low-level control + - User-friendly interface for defining flow systems + - Pre-defined components like CHP, Heat Pump, Cooling Tower, etc. + - Fine-grained control for advanced configurations + +- **Investment Optimization** + - Combined dispatch and investment optimization + - Size optimization and discrete investment decisions + - Combined with On/Off variables and constraints + +- **Effects, not only Costs --> Multi-criteria Optimization** + - flixopt abstracts costs as so called 'Effects'. This allows to model costs, CO2-emissions, primary-energy-demand or area-demand at the same time. + - Effects can interact with each other(e.g., specific CO2 costs) + - Any of these `Effects` can be used as the optimization objective. + - A **Weigted Sum**of Effects can be used as the optimization objective. + - Every Effect can be constrained ($\epsilon$-constraint method). + +- **Calculation Modes** + - **Full** - Solve the model with highest accuracy and computational requirements. + - **Segmented** - Speed up solving by using a rolling horizon. + - **Aggregated** - Speed up solving by identifying typical periods using [TSAM](https://github.com/FZJ-IEK3-VSA/tsam). Suitable for large models. + +--- + ## 📦 Installation Install flixOpt via pip. diff --git a/docs/index.md b/docs/index.md index 8101c04ba..0a89dff2b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,36 +1,9 @@ # flixOpt: Energy and Material Flow Optimization Framework -**flixOpt** is a Python-based optimization framework designed to tackle energy and material flow problems using mixed-integer linear programming (MILP). Combining flexibility and efficiency, it provides a powerful platform for both dispatch and investment optimization challenges. +**flixOpt** is a Python-based optimization framework designed to tackle energy and material flow problems using mixed-integer linear programming (MILP). -## 🚀 Introduction +It bridges the gap between **high-level energy systems models** like [FINE](https://github.com/FZJ-IEK3-VSA/FINE) used for design and (multi-period) investment decisions and **low-level dispatch optimization tools** used for operation decisions. -flixOpt was developed by [TU Dresden](https://github.com/gewv-tu-dresden) as part of the SMARTBIOGRID project, funded by the German Federal Ministry for Economic Affairs and Energy. Building on the Matlab-based flixOptMat framework, flixOpt also incorporates concepts from [oemof/solph](https://github.com/oemof/oemof-solph). - -Although flixOpt is in its early stages, it is fully functional and ready for experimentation. Feedback and collaboration are highly encouraged to help shape its future. - -## 🌟 Key Features - -- **High-level Interface** with low-level control - - User-friendly interface for defining flow systems - - Pre-defined components like CHP, Heat Pump, Cooling Tower, etc. - - Fine-grained control for advanced configurations - -- **Investment Optimization** - - Combined dispatch and investment optimization - - Size optimization and discrete investment decisions - - Combined with On/Off variables and constraints - -- **Effects, not only Costs --> Multi-criteria Optimization** - - flixopt abstracts costs as so called 'Effects'. This allows to model costs, CO2-emissions, primary-energy-demand or area-demand at the same time. - - Effects can interact with each other(e.g., specific CO2 costs) - - Any of these `Effects` can be used as the optimization objective. - - A **Weigted Sum**of Effects can be used as the optimization objective. - - Every Effect can be constrained ($\epsilon$-constraint method). - -- **Calculation Modes** - - **Full** - Solve the model with highest accuracy and computational requirements. - - **Segmented** - Speed up solving by using a rolling horizon. - - **Aggregated** - Speed up solving by identifying typical periods using [TSAM](https://github.com/FZJ-IEK3-VSA/tsam). Suitable for large models. ## 🛠️ Getting Started @@ -46,11 +19,11 @@ See our [Concepts & Math](concepts-and-math/index.md) to understand the core con flixOpt works with various solvers: -- HiGHS (installed by default) -- CBC -- GLPK -- Gurobi -- CPLEX +- [HiGHS](https://highs.dev/) (installed by default) +- [Gurobi](https://www.gurobi.com/) +- [CBC](https://github.com/coin-or/Cbc) +- [GLPK](https://www.gnu.org/software/glpk/) +- [CPLEX](https://www.ibm.com/analytics/cplex-optimizer) ## 📝 Citation From cead48176087e2b52c8e507781966b0155a85d01 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 23 Mar 2025 19:57:54 +0100 Subject: [PATCH 422/507] Update docs --- docs/concepts-and-math/index.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/concepts-and-math/index.md b/docs/concepts-and-math/index.md index 84cf37500..df26cf084 100644 --- a/docs/concepts-and-math/index.md +++ b/docs/concepts-and-math/index.md @@ -70,6 +70,14 @@ flixOpt offers different calculation approaches: - [`SegmentedCalculation`][flixOpt.calculation.SegmentedCalculation] - Solves the problem in segments (with optioinal overlap), improving performance for large problems - [`AggregatedCalculation`][flixOpt.calculation.AggregatedCalculation] - Uses typical periods to reduce computational requirements +### Results + +The results of a calculation are stored in a [`CalculationResults`][flixOpt.results.CalculationResults] object. +This object contains the solutions of the optimization as well as all information about the [`Calculation`][flixOpt.calculation.Calculation] and the [`FlowSystem`][flixOpt.flow_system.FlowSystem] it was created from. +The solutions is stored as an `xarray.Dataset`, but can be accessed through their assotiated Component, Bus or Effect. + +This [`CalculationResults`][flixOpt.results.CalculationResults] object can be saved to file and reloaded from file, allowing you to analyze the results anytime after the solve. + ## How These Concepts Work Together 1. You create a `FlowSystem` with a specified time series From 25fb34368bc7474823dc1be7cfe281ca59afe7ff Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 23 Mar 2025 20:57:15 +0100 Subject: [PATCH 423/507] Update docs --- docs/concepts-and-math/index.md | 34 ++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/docs/concepts-and-math/index.md b/docs/concepts-and-math/index.md index df26cf084..56e716236 100644 --- a/docs/concepts-and-math/index.md +++ b/docs/concepts-and-math/index.md @@ -80,27 +80,27 @@ This [`CalculationResults`][flixOpt.results.CalculationResults] object can be sa ## How These Concepts Work Together -1. You create a `FlowSystem` with a specified time series -2. You add elements to the FLowSystem: - - `Bus` objects as connection points - - `Component` objects like Boilers, Storages, etc.. They include `Flow` which define the connection to a Bus. - - `Effect` objects to represent costs, emissions, etc. -3.You choose a calculation mode and solver -4.flixOpt converts your model into a mathematical optimization problem -5.The solver finds the optimal solution -6.You analyze the results with built-in or external tools +The process of woring with flixOpt can be devided into 3 steps: +1. Create a [`FlowSystem`][flixOpt.flow_system.FlowSystem], containing all the elements and data of your system + - Define the time series of your system + - Add [`Components`][flixOpt.components] like [`Boilers`][flixOpt.linear_converters.Boiler], [`HeatPumps`][flixOpt.linear_converters.HeatPump], [`CHPs`][flixOpt.linear_converters.CHP], etc. + - Add [`Buses`][flixOpt.elements.Bus] as connection points in your system + - Add [`Effects`][flixOpt.effects.Effect] to represent costs, emissions, etc. + - *This [`FlowSystem`][flixOpt.flow_system.FlowSystem] can also be loaded from a netCDF file* +2. Translate the model to a mathematical optimization problem + - Create a [`Calculation`][flixOpt.calculation.Calculation] from your FlowSystem and choose a Solver + - ...the model is translated to a mathematical optimization problem... +3. Analyze the results + - The results are stored in a [`CalculationResults`][flixOpt.results.CalculationResults] object + - This object can be saved to file and reloaded from file + - As it contains the used [`FlowSystem`][flixOpt.flow_system.FlowSystem], it can be used to start a new calculation + +![flixOpt Concept and Usage](../images/architecture_flixOpt.png) ## Advanced Usage -flixOpt uses [linopy](https://github.com/PyPSA/linopy) to model the mathematical optimization problem. -Any model created with flixOpt can be extended or modified using the great [linopy API](https://linopy.readthedocs.io/en/latest/api.html). +As flixopt is build on [linopy](https://github.com/PyPSA/linopy), any model created with flixOpt can be extended or modified using the great [linopy API](https://linopy.readthedocs.io/en/latest/api.html). This allows to adjust your model to very specific requirements without loosing the convenience of flixOpt. - - -## Architechture (outdated) -![Architecture](../images/architecture_flixOpt.png) - - From ca17378fc185239962f26c68696f9bc1fd253d8a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 23 Mar 2025 21:48:22 +0100 Subject: [PATCH 424/507] Update docs --- .../Mathematical Description.md | 9 ++++ docs/concepts-and-math/index.md | 52 ++++++++----------- 2 files changed, 32 insertions(+), 29 deletions(-) diff --git a/docs/concepts-and-math/Mathematical Description.md b/docs/concepts-and-math/Mathematical Description.md index 34549a2b9..339aea39e 100644 --- a/docs/concepts-and-math/Mathematical Description.md +++ b/docs/concepts-and-math/Mathematical Description.md @@ -12,6 +12,15 @@ flixOpt uses the following naming conventions: - The letter $i$ is used to denote an index (e.g., $i=1,\dots,\text n$) - All time steps are denoted by the letter $\text{t}$ (e.g., $\text{t}_0$, $\text{t}_1$, $\text{t}_i$) +## Timesteps +Time steps are defined as a sequence of discrete time steps $\text{t}_i \in \mathcal{T} \quad \text{for} \quad i \in \{1, 2, \dots, \text{n}\}$ (left-aligned in its timespan). +From this sequence, the corresponding time intervals $\Delta \text{t}_i \in \Delta \mathcal{T}$ are derived as + +$$\Delta \text{t}_i = \text{t}_{i+1} - \text{t}_i \quad \text{for} \quad i \in \{1, 2, \dots, \text{n}-1\}$$ + +The final time interval $\Delta \text{t}_\text n$ defaults to $\Delta \text{t}_\text n = \Delta \text{t}_{\text n-1}$, but is of course customizable. +Non-equidistant time steps are also supported. + ## Buses The balance equation for a bus is: diff --git a/docs/concepts-and-math/index.md b/docs/concepts-and-math/index.md index 56e716236..b274f69a1 100644 --- a/docs/concepts-and-math/index.md +++ b/docs/concepts-and-math/index.md @@ -13,14 +13,15 @@ Every flixOpt model starts with creating a FlowSystem. It: - Contains and connects [components](#components), [buses](#buses), and [flows](#flows) - Manages the [effects](#effects) (objectives and constraints) -### Timesteps -Time steps are defined as a sequence of discrete time steps $\text{t}_i \in \mathcal{T} \quad \text{for} \quad i \in \{1, 2, \dots, \text{n}\}$ (left-aligned in its timespan). -From this sequence, the corresponding time intervals $\Delta \text{t}_i \in \Delta \mathcal{T}$ are derived as +### Flows -$$\Delta \text{t}_i = \text{t}_{i+1} - \text{t}_i \quad \text{for} \quad i \in \{1, 2, \dots, \text{n}-1\}$$ +[`Flow`][flixOpt.elements.Flow] objects represent the movement of energy or material between a [Bus](#buses) and a [Component](#components) in a predefined direction. -The final time interval $\Delta \text{t}_\text n$ defaults to $\Delta \text{t}_\text n = \Delta \text{t}_{\text n-1}$, but is of course customizable. -Non-equidistant time steps are also supported. +- Have a `flow_rate`, which is the main optimization variable of a Flow +- Have a `size` which defines how much energy or material can be moved (fixed or part of an investment decision) +- Have constraints to limit the flow-rate (min/max, total flow hours, on/off etc.) +- Can have fixed profiles (for demands or renewable generation) +- Can have [Effects](#effects) associated by their use (operation, investment, on/off, ...) ### Buses @@ -30,16 +31,6 @@ Non-equidistant time steps are also supported. - Can represent physical networks like heat, electricity, or gas - Handle infeasible balances gently by allowing the balance to be closed in return for a big Penalty (optional) -### Flows - -[`Flow`][flixOpt.elements.Flow] objects represent the movement of energy or material between a [Bus](#buses) and a [Component](#components) in a predefined direction. - -- Have a `flow_rate`, which is the main optimization variable of a Flow -- Have a `size` which defines how much energy or material can be moved (fixed or part of an investment decision) -- Have constraints to limit the flow-rate (min/max, total flow hours, on/off etc.) -- Can have fixed profiles (for demands or renewable generation) -- Can have [Effects](#effects) associated by their use (operation, investment, on/off, ...) - ### Components [`Component`][flixOpt.elements.Component] objects usually represent physical entities in your system that interact with [`Flows`][flixOpt.elements.Flow]. They include: @@ -62,9 +53,11 @@ These can be freely defined and crosslink to each other (`CO₂` ──[specific One effect is designated as the **optimization objective** (typically Costs), while others can have constraints. This effect can incorporate several other effects, which woul result in a weighted objective from multiple effects. -### Calculation Modes +### Calculation -flixOpt offers different calculation approaches: +A [`FlowSystem`][flixOpt.flow_system.FlowSystem] can be converted to a Model and optimized by creating a [`Calculation`][flixOpt.calculation.Calculation] from it. + +flixOpt offers different calculation modes: - [`FullCalculation`][flixOpt.calculation.FullCalculation] - Solves the entire problem at once - [`SegmentedCalculation`][flixOpt.calculation.SegmentedCalculation] - Solves the problem in segments (with optioinal overlap), improving performance for large problems @@ -80,20 +73,21 @@ This [`CalculationResults`][flixOpt.results.CalculationResults] object can be sa ## How These Concepts Work Together -The process of woring with flixOpt can be devided into 3 steps: +The process of working with flixOpt can be divided into 3 steps: + 1. Create a [`FlowSystem`][flixOpt.flow_system.FlowSystem], containing all the elements and data of your system - - Define the time series of your system - - Add [`Components`][flixOpt.components] like [`Boilers`][flixOpt.linear_converters.Boiler], [`HeatPumps`][flixOpt.linear_converters.HeatPump], [`CHPs`][flixOpt.linear_converters.CHP], etc. - - Add [`Buses`][flixOpt.elements.Bus] as connection points in your system - - Add [`Effects`][flixOpt.effects.Effect] to represent costs, emissions, etc. - - *This [`FlowSystem`][flixOpt.flow_system.FlowSystem] can also be loaded from a netCDF file* + - Define the time series of your system + - Add [`Components`][flixOpt.components] like [`Boilers`][flixOpt.linear_converters.Boiler], [`HeatPumps`][flixOpt.linear_converters.HeatPump], [`CHPs`][flixOpt.linear_converters.CHP], etc. + - Add [`Buses`][flixOpt.elements.Bus] as connection points in your system + - Add [`Effects`][flixOpt.effects.Effect] to represent costs, emissions, etc. + - *This [`FlowSystem`][flixOpt.flow_system.FlowSystem] can also be loaded from a netCDF file* 2. Translate the model to a mathematical optimization problem - - Create a [`Calculation`][flixOpt.calculation.Calculation] from your FlowSystem and choose a Solver - - ...the model is translated to a mathematical optimization problem... + - Create a [`Calculation`][flixOpt.calculation.Calculation] from your FlowSystem and choose a Solver + - ...the model is translated to a mathematical optimization problem... 3. Analyze the results - - The results are stored in a [`CalculationResults`][flixOpt.results.CalculationResults] object - - This object can be saved to file and reloaded from file - - As it contains the used [`FlowSystem`][flixOpt.flow_system.FlowSystem], it can be used to start a new calculation + - The results are stored in a [`CalculationResults`][flixOpt.results.CalculationResults] object + - This object can be saved to file and reloaded from file + - As it contains the used [`FlowSystem`][flixOpt.flow_system.FlowSystem], it can be used to start a new calculation ![flixOpt Concept and Usage](../images/architecture_flixOpt.png) From 94498629915d25582678c613482e345b1ddabfd6 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 23 Mar 2025 22:14:50 +0100 Subject: [PATCH 425/507] Add versioning to docs --- .github/workflows/deploy-docs.yaml | 6 ++++-- mkdocs.yml | 7 +++++++ pyproject.toml | 3 ++- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy-docs.yaml b/.github/workflows/deploy-docs.yaml index 326552f7c..5aa05733d 100644 --- a/.github/workflows/deploy-docs.yaml +++ b/.github/workflows/deploy-docs.yaml @@ -28,5 +28,7 @@ jobs: # Install all documentation dependencies directly instead of using -e .[docs] pip install mkdocs-material mkdocstrings-python mkdocs-table-reader-plugin mkdocs-include-markdown-plugin mkdocs-gen-files mkdocs-literate-nav markdown-include pymdown-extensions pygments - - name: Deploy documentation - run: mkdocs gh-deploy --force \ No newline at end of file + - name: Deploy docs + run: | + VERSION=${GITHUB_REF#refs/tags/v} + mike deploy --push --update-aliases $VERSION latest \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index c5bff3175..cb1cc1442 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -50,6 +50,7 @@ theme: - content.code.annotate - content.tooltips - content.code.copy + - navigation.footer.version markdown_extensions: - admonition @@ -83,6 +84,7 @@ plugins: - search # Enables the search functionality in the documentation - table-reader # Allows including tables from external files - include-markdown + - mike - gen-files: scripts: - scripts/gen_ref_pages.py @@ -120,6 +122,11 @@ plugins: extra: infer_type_annotations: true # Uses Python type hints to supplement docstring information +extra: + version: + provider: mike + default: latest + extra_javascript: - javascripts/mathjax.js # Custom MathJax 3 CDN Configuration - https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js #MathJax 3 CDN diff --git a/pyproject.toml b/pyproject.toml index fa253e26f..b52a769c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,7 +70,8 @@ docs = [ "mkdocs-literate-nav", "markdown-include", "pymdown-extensions", - "pygments" + "pygments", + "mike", ] [project.urls] From 6eba0505e08e72b6b0337b16a3cb178cf2339935 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 23 Mar 2025 22:14:50 +0100 Subject: [PATCH 426/507] Add versioning to docs --- .github/workflows/deploy-docs.yaml | 6 ++++-- mkdocs.yml | 7 +++++++ pyproject.toml | 3 ++- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy-docs.yaml b/.github/workflows/deploy-docs.yaml index 326552f7c..5aa05733d 100644 --- a/.github/workflows/deploy-docs.yaml +++ b/.github/workflows/deploy-docs.yaml @@ -28,5 +28,7 @@ jobs: # Install all documentation dependencies directly instead of using -e .[docs] pip install mkdocs-material mkdocstrings-python mkdocs-table-reader-plugin mkdocs-include-markdown-plugin mkdocs-gen-files mkdocs-literate-nav markdown-include pymdown-extensions pygments - - name: Deploy documentation - run: mkdocs gh-deploy --force \ No newline at end of file + - name: Deploy docs + run: | + VERSION=${GITHUB_REF#refs/tags/v} + mike deploy --push --update-aliases $VERSION latest \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index a257aca50..f508b6e00 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -50,6 +50,7 @@ theme: - content.code.annotate - content.tooltips - content.code.copy + - navigation.footer.version markdown_extensions: - admonition @@ -83,6 +84,7 @@ plugins: - search # Enables the search functionality in the documentation - table-reader # Allows including tables from external files - include-markdown + - mike - gen-files: scripts: - scripts/gen_ref_pages.py @@ -120,6 +122,11 @@ plugins: extra: infer_type_annotations: true # Uses Python type hints to supplement docstring information +extra: + version: + provider: mike + default: latest + extra_javascript: - javascripts/mathjax.js # Custom MathJax 3 CDN Configuration - https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js #MathJax 3 CDN diff --git a/pyproject.toml b/pyproject.toml index f386a060c..5938592a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,7 +65,8 @@ docs = [ "mkdocs-literate-nav", "markdown-include", "pymdown-extensions", - "pygments" + "pygments", + "mike", ] [project.urls] From ec789831a6b5e3d7bc04e6e37d9fb7ec59827df8 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 24 Mar 2025 08:38:52 +0100 Subject: [PATCH 427/507] Improve docs --- docs/concepts-and-math/index.md | 10 +++++++--- docs/index.md | 6 +++++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/docs/concepts-and-math/index.md b/docs/concepts-and-math/index.md index b274f69a1..de671c30c 100644 --- a/docs/concepts-and-math/index.md +++ b/docs/concepts-and-math/index.md @@ -83,13 +83,17 @@ The process of working with flixOpt can be divided into 3 steps: - *This [`FlowSystem`][flixOpt.flow_system.FlowSystem] can also be loaded from a netCDF file* 2. Translate the model to a mathematical optimization problem - Create a [`Calculation`][flixOpt.calculation.Calculation] from your FlowSystem and choose a Solver - - ...the model is translated to a mathematical optimization problem... + - ...The Calculation is translated internaly to a mathematical optimization problem... + - ...and solved by the chosen solver. 3. Analyze the results - The results are stored in a [`CalculationResults`][flixOpt.results.CalculationResults] object - - This object can be saved to file and reloaded from file + - This object can be saved to file and reloaded from file, retaining all information about the calculation - As it contains the used [`FlowSystem`][flixOpt.flow_system.FlowSystem], it can be used to start a new calculation -![flixOpt Concept and Usage](../images/architecture_flixOpt.png) +
    + ![flixOpt Conceptual Usage](../images/architecture_flixOpt.png) +
    Conceptual Usage and IO operations of flixOpt
    +
    ## Advanced Usage As flixopt is build on [linopy](https://github.com/PyPSA/linopy), any model created with flixOpt can be extended or modified using the great [linopy API](https://linopy.readthedocs.io/en/latest/api.html). diff --git a/docs/index.md b/docs/index.md index 0a89dff2b..9b8ab5209 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,8 +4,12 @@ It bridges the gap between **high-level energy systems models** like [FINE](https://github.com/FZJ-IEK3-VSA/FINE) used for design and (multi-period) investment decisions and **low-level dispatch optimization tools** used for operation decisions. +
    + ![flixOpt Conceptual Usage](./images/architecture_flixOpt.png) +
    Conceptual Usage and IO operations of flixOpt
    +
    -## 🛠️ Getting Started +## 🚀️ Getting Started See the [Getting Started Guide](getting-started.md) to start using flixOpt. From 6eedd538984efd1639e4615f94d0252cd0da3ee7 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 24 Mar 2025 20:04:22 +0100 Subject: [PATCH 428/507] Remove network infos from results and from export files --- flixOpt/plotting.py | 2 +- flixOpt/results.py | 40 +++++++++++++++++++++++++--------------- 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/flixOpt/plotting.py b/flixOpt/plotting.py index 9a90c664d..9567f18f3 100644 --- a/flixOpt/plotting.py +++ b/flixOpt/plotting.py @@ -581,7 +581,7 @@ def plot_network( try: from pyvis.network import Network except ImportError: - logger.warning("Please install pyvis to visualize the network: 'pip install pyvis'") + logger.critical("Plotting the flow system network was not possible. Please install pyvis: 'pip install pyvis'") return None net = Network(directed=True, height='100%' if controls is False else '800px', font_color='white') diff --git a/flixOpt/results.py b/flixOpt/results.py index 631b1d4de..0e7d4f4db 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -17,6 +17,7 @@ if TYPE_CHECKING: from .calculation import Calculation, SegmentedCalculation + import pyvis logger = logging.getLogger('flixOpt') @@ -36,7 +37,6 @@ class CalculationResults: solution (xr.Dataset): Dataset containing optimization results. flow_system (xr.Dataset): Dataset containing the flow system. summary (Dict): Information about the calculation. - network_infos (Dict): Information about the network structure. name (str): Name identifier for the calculation. model (linopy.Model): The optimization model (if available). folder (pathlib.Path): Path to the results directory. @@ -87,16 +87,12 @@ def from_file(cls, folder: Union[str, pathlib.Path], name: str): with open(paths.summary, 'r', encoding='utf-8') as f: summary = yaml.load(f, Loader=yaml.FullLoader) - with open(paths.network, 'r', encoding='utf-8') as f: - network_infos = json.load(f) - return cls(solution=fx_io.load_dataset_from_netcdf(paths.solution), flow_system=fx_io.load_dataset_from_netcdf(paths.flow_system), name=name, folder=folder, model=model, - summary=summary, - network_infos=network_infos) + summary=summary) @classmethod def from_calculation(cls, calculation: 'Calculation'): @@ -119,7 +115,6 @@ def from_calculation(cls, calculation: 'Calculation'): solution=calculation.model.solution, flow_system=calculation.flow_system.as_dataset(constants_in_dataset=True), summary=calculation.summary, - network_infos=calculation.flow_system.network_infos(), model=calculation.model, name=calculation.name, folder=calculation.folder, @@ -131,7 +126,6 @@ def __init__( flow_system: xr.Dataset, name: str, summary: Dict, - network_infos: Dict, folder: Optional[pathlib.Path] = None, model: Optional[linopy.Model] = None, ): @@ -139,17 +133,14 @@ def __init__( Args: solution: The solution of the optimization. flow_system: The flow_system that was used to create the calculation as a datatset. - results_structure: The structure of the flow_system that was used to solve the calculation. name: The name of the calculation. - infos: Information about the calculation, - network_infos: Information about the network. + summary: Information about the calculation, folder: The folder where the results are saved. model: The linopy model that was used to solve the calculation. """ self.solution = solution self.flow_system = flow_system self.summary = summary - self.network_infos = network_infos self.name = name self.model = model self.folder = pathlib.Path(folder) if folder is not None else pathlib.Path.cwd() / 'results' @@ -231,6 +222,28 @@ def plot_heatmap(self, save=save, show=show) + def plot_network( + self, + controls: Union[ + bool, + List[ + Literal['nodes', 'edges', 'layout', 'interaction', 'manipulation', 'physics', 'selection', 'renderer'] + ], + ] = True, + path: Optional[pathlib.Path] = None, + show: bool = False + ) -> 'pyvis.network.Network': + """ See flixOpt.flow_system.FlowSystem.plot_network """ + try: + from .flow_system import FlowSystem + flow_system = FlowSystem.from_dataset(self.flow_system) + except Exception as e: + logger.critical(f'Could not reconstruct the flow_system from dataset: {e}') + return None + if path is None: + path = self.folder / f'{self.name}--network.html' + return flow_system.plot_network(controls=controls, path=path, show=show) + def to_file( self, folder: Optional[Union[str, pathlib.Path]] = None, @@ -265,9 +278,6 @@ def to_file( with open(paths.summary, 'w', encoding='utf-8') as f: yaml.dump(self.summary, f, allow_unicode=True, sort_keys=False, indent=4, width=1000) - with open(paths.network, 'w', encoding='utf-8') as f: - json.dump(self.network_infos, f, indent=4, ensure_ascii=False) - if save_linopy_model: if self.model is None: logger.critical('No model in the CalculationResults. Saving the model is not possible.') From 7ae77aeb972fbf721e8f43d37fa4e684325fa714 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 24 Mar 2025 20:09:05 +0100 Subject: [PATCH 429/507] Update example --- examples/02_Complex/complex_example_results.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/02_Complex/complex_example_results.py b/examples/02_Complex/complex_example_results.py index e52fb0920..edb6c0618 100644 --- a/examples/02_Complex/complex_example_results.py +++ b/examples/02_Complex/complex_example_results.py @@ -19,7 +19,7 @@ ) from e # --- Basic overview --- - fx.plotting.plot_network(*results.network_infos, show=True) + results.plot_network(show=True) results['Fernwärme'].plot_node_balance() # --- Detailed Plots --- From a2981cb8850184be52a4b008bb96c17e865fa575 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 24 Mar 2025 20:09:50 +0100 Subject: [PATCH 430/507] ruff check --- flixOpt/io.py | 2 +- flixOpt/results.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/flixOpt/io.py b/flixOpt/io.py index 1ba289e6d..89c37d158 100644 --- a/flixOpt/io.py +++ b/flixOpt/io.py @@ -3,8 +3,8 @@ import logging import pathlib import re -from typing import Dict, Literal, Tuple, Union, Optional from dataclasses import dataclass +from typing import Dict, Literal, Optional, Tuple, Union import linopy import xarray as xr diff --git a/flixOpt/results.py b/flixOpt/results.py index 0e7d4f4db..c93a6e3db 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -16,9 +16,10 @@ from .core import TimeSeriesCollection if TYPE_CHECKING: - from .calculation import Calculation, SegmentedCalculation import pyvis + from .calculation import Calculation, SegmentedCalculation + logger = logging.getLogger('flixOpt') From 03a5c75c9225851f5e8d44c7c874dd4e55ffd296 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 24 Mar 2025 20:11:59 +0100 Subject: [PATCH 431/507] ruff check --- flixOpt/io.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixOpt/io.py b/flixOpt/io.py index 1ba289e6d..89c37d158 100644 --- a/flixOpt/io.py +++ b/flixOpt/io.py @@ -3,8 +3,8 @@ import logging import pathlib import re -from typing import Dict, Literal, Tuple, Union, Optional from dataclasses import dataclass +from typing import Dict, Literal, Optional, Tuple, Union import linopy import xarray as xr From 66c82916e12c92a9c2797dbf0e1007674a70bc6d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 24 Mar 2025 20:15:03 +0100 Subject: [PATCH 432/507] Create copy to ensure no changes in the original data --- flixOpt/plotting.py | 1 + 1 file changed, 1 insertion(+) diff --git a/flixOpt/plotting.py b/flixOpt/plotting.py index 9a90c664d..77c1a9662 100644 --- a/flixOpt/plotting.py +++ b/flixOpt/plotting.py @@ -101,6 +101,7 @@ def with_plotly( ) ) elif mode == 'area': + data = data.copy() data[(data > -1e-5) & (data < 1e-5)] = 0 # Preventing issues with plotting # Split columns into positive, negative, and mixed categories positive_columns = list(data.columns[(data >= 0).where(~np.isnan(data), True).all()]) From 791e9d7eae2cbe2b1bfb422987d756b9082d28de Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 24 Mar 2025 20:41:26 +0100 Subject: [PATCH 433/507] Add pie plots --- flixOpt/plotting.py | 310 ++++++++++++++++++++++++++++++++++++++++++++ flixOpt/results.py | 25 +++- 2 files changed, 333 insertions(+), 2 deletions(-) diff --git a/flixOpt/plotting.py b/flixOpt/plotting.py index 77c1a9662..83176c29e 100644 --- a/flixOpt/plotting.py +++ b/flixOpt/plotting.py @@ -634,3 +634,313 @@ def plot_network( logger.warning( f'Showing the network in the Browser went wrong. Open it manually. Its saved under {path}: {e}' ) + + +def pie_with_plotly( + data: pd.DataFrame, + colors: Union[List[str], str] = 'viridis', + title: str = '', + legend_title: str = '', + hole: float = 0.0, + fig: Optional[go.Figure] = None, + show: bool = False, + save: bool = False, + path: Union[str, pathlib.Path] = 'temp-plot.html', +) -> go.Figure: + """ + Create a pie chart with Plotly to visualize the proportion of values in a DataFrame. + + Args: + data: A DataFrame containing the data to plot. If multiple rows exist, + they will be summed unless a specific index value is passed. + colors: A List of colors (as str) or a name of a colorscale (e.g., 'viridis', 'plasma') + to use for coloring the pie segments. + title: The title of the plot. + legend_title: The title for the legend. + hole: Size of the hole in the center for creating a donut chart (0.0 to 1.0). + fig: A Plotly figure object to plot on. If not provided, a new figure will be created. + show: Whether to show the figure after creation. + save: Whether to save the figure after creation (without showing). + path: Path to save the figure. + + Returns: + A Plotly figure object containing the generated pie chart. + + Notes: + - Negative values are not appropriate for pie charts and will be converted to absolute values with a warning. + - If the data contains very small values (less than 1% of the total), they can be grouped into an "Other" category + for better readability. + - By default, the sum of all columns is used for the pie chart. For time series data, consider preprocessing. + + Examples: + >>> fig = pie_with_plotly(data, colorscale='Pastel') + >>> fig.show() + """ + if data.empty: + logger.warning("Empty DataFrame provided for pie chart. Returning empty figure.") + return go.Figure() + + # Create a copy to avoid modifying the original DataFrame + data_copy = data.copy() + + # Check if any negative values and warn + if (data_copy < 0).any().any(): + logger.warning("Negative values detected in data. Using absolute values for pie chart.") + data_copy = data_copy.abs() + + # If data has multiple rows, sum them to get total for each column + if len(data_copy) > 1: + data_sum = data_copy.sum() + else: + data_sum = data_copy.iloc[0] + + # Get labels (column names) and values + labels = data_sum.index.tolist() + values = data_sum.values.tolist() + + # Apply color mapping + if isinstance(colors, str): + colorscale = px.colors.get_colorscale(colors) + colors = px.colors.sample_colorscale( + colorscale, + [i / (len(labels) - 1) for i in range(len(labels))] if len(labels) > 1 else [0], + ) + + # Create figure if not provided + fig = fig if fig is not None else go.Figure() + + # Add pie trace + fig.add_trace( + go.Pie( + labels=labels, + values=values, + hole=hole, + marker=dict(colors=colors), + textinfo='percent+label+value', + textposition='inside', + insidetextorientation='radial', + ) + ) + + # Update layout for better aesthetics + fig.update_layout( + title=title, + legend_title=legend_title, + plot_bgcolor='rgba(0,0,0,0)', # Transparent background + paper_bgcolor='rgba(0,0,0,0)', # Transparent paper background + font=dict(size=14), # Increase font size for better readability + ) + + if isinstance(path, pathlib.Path): + path = path.as_posix() + if show: + plotly.offline.plot(fig, filename=path) + elif save: # If show, the file is saved anyway + fig.write_html(path) + + return fig + + +def pie_with_matplotlib( + data: pd.DataFrame, + colors: Union[List[str], str] = 'viridis', + title: str = '', + figsize: Tuple[int, int] = (10, 8), + autopct: str = '%1.1f%%', + startangle: int = 90, + shadow: bool = False, + is_donut: bool = False, + fig: Optional[plt.Figure] = None, + ax: Optional[plt.Axes] = None, + show: bool = False, + path: Optional[Union[str, pathlib.Path]] = None, +) -> Tuple[plt.Figure, plt.Axes]: + """ + Create a pie chart with Matplotlib to visualize the proportion of values in a DataFrame. + + Args: + data: A DataFrame containing the data to plot. If multiple rows exist, + they will be summed unless a specific index value is passed. + colors: A List of colors (as str) or a name of a colorscale (e.g., 'viridis', 'plasma') + to use for coloring the pie segments. + title: The title of the plot. + figsize: The size of the figure (width, height) in inches. + autopct: String format for the percentage display on wedges. + startangle: Starting angle for the first wedge in degrees. + shadow: Whether to draw the pie with a shadow beneath it. + is_donut: If True, creates a donut chart by adding a white circle in the center. + fig: A Matplotlib figure object to plot on. If not provided, a new figure will be created. + ax: A Matplotlib axes object to plot on. If not provided, a new axes will be created. + show: Whether to show the figure after creation. + path: Path to save the figure to. + + Returns: + A tuple containing the Matplotlib figure and axes objects used for the plot. + + Notes: + - Negative values are not appropriate for pie charts and will be converted to absolute values with a warning. + - If the data contains very small values (less than 1% of the total), they can be grouped into an "Other" category + for better readability. + - By default, the sum of all columns is used for the pie chart. For time series data, consider preprocessing. + + Examples: + >>> fig, ax = pie_with_matplotlib(data, colorscale='viridis', is_donut=True) + >>> plt.show() + """ + if data.empty: + logger.warning("Empty DataFrame provided for pie chart. Returning empty figure.") + if fig is None or ax is None: + fig, ax = plt.subplots(figsize=figsize) + return fig, ax + + # Create a copy to avoid modifying the original DataFrame + data_copy = data.copy() + + # Check if any negative values and warn + if (data_copy < 0).any().any(): + logger.warning("Negative values detected in data. Using absolute values for pie chart.") + data_copy = data_copy.abs() + + # If data has multiple rows, sum them to get total for each column + if len(data_copy) > 1: + data_sum = data_copy.sum() + else: + data_sum = data_copy.iloc[0] + + # Get labels (column names) and values + labels = data_sum.index.tolist() + values = data_sum.values.tolist() + + # Apply color mapping + if isinstance(colors, str): + cmap = plt.get_cmap(colors, len(labels)) + colors = [cmap(i) for i in range(len(labels))] + + # Create figure and axis if not provided + if fig is None or ax is None: + fig, ax = plt.subplots(figsize=figsize) + + # Draw the pie chart + wedges, texts, autotexts = ax.pie( + values, + labels=labels, + colors=colors, + autopct=autopct, + startangle=startangle, + shadow=shadow, + wedgeprops=dict(width=0.5) if is_donut else None, # Set width for donut + ) + + # Customize the appearance + # Make autopct text more visible + for autotext in autotexts: + autotext.set_fontsize(10) + autotext.set_color('white') + + # Set aspect ratio to be equal to ensure a circular pie + ax.set_aspect('equal') + + # Add title + if title: + ax.set_title(title, fontsize=16) + + # Create a legend if there are many segments + if len(labels) > 6: + ax.legend( + wedges, + labels, + title="Categories", + loc="center left", + bbox_to_anchor=(1, 0, 0.5, 1) + ) + + # Apply tight layout + fig.tight_layout() + + # Show or save + if show: + plt.show() + if path is not None: + fig.savefig(path, dpi=300, bbox_inches='tight') + + return fig, ax + + +def dual_pie_with_plotly( + data_left: pd.DataFrame, + data_right: pd.DataFrame, + colors: Union[List[str], str] = 'viridis', + title: str = '', + subtitles: Tuple[str, str] = ('Left Chart', 'Right Chart'), + legend_title: str = '', + hole: float = 0.3, + show: bool = False, + save: bool = False, + path: Union[str, pathlib.Path] = 'temp-plot.html', +) -> go.Figure: + """ + Create two pie charts side by side with Plotly, leveraging the existing pie_with_plotly function. + + Args: + data_left: DataFrame for the left pie chart. + data_right: DataFrame for the right pie chart. + colors: A List of colors (as str) or a name of a colorscale (e.g., 'viridis', 'plasma') + to use for coloring the pie segments. + title: The main title of the plot. + subtitles: Tuple containing the subtitles for (left, right) charts. + legend_title: The title for the legend. + hole: Size of the hole in the center for creating donut charts (0.0 to 1.0). + show: Whether to show the figure after creation. + save: Whether to save the figure after creation (without showing). + path: Path to save the figure. + + Returns: + A Plotly figure object containing the generated dual pie chart. + """ + from plotly.subplots import make_subplots + + # Create a subplot figure + fig = make_subplots(rows=1, cols=2, specs=[[{'type': 'pie'}, {'type': 'pie'}]], subplot_titles=subtitles) + + # Create a function to get a trace from pie_with_plotly + def get_pie_trace(data): + if data.empty: + return None + temp_fig = go.Figure() + pie_with_plotly(data=data, colors=colors, hole=hole, fig=temp_fig) + if len(temp_fig.data) > 0: + return temp_fig.data[0] + return None + + # Add left pie trace + left_trace = get_pie_trace(data_left) + if left_trace: + left_trace.domain = dict(x=[0, 0.45]) + fig.add_trace(left_trace) + + # Add right pie trace + right_trace = get_pie_trace(data_right) + if right_trace: + right_trace.domain = dict(x=[0.55, 1]) + fig.add_trace(right_trace) + + # Update layout + fig.update_layout( + title=title, + legend_title=legend_title, + plot_bgcolor='rgba(0,0,0,0)', + paper_bgcolor='rgba(0,0,0,0)', + font=dict(size=14), + margin=dict(t=60, b=30, l=30, r=30), + legend=dict(orientation='h', yanchor='bottom', y=-0.2, xanchor='center', x=0.5), + ) + + # Handle file saving and display + if isinstance(path, pathlib.Path): + path = path.as_posix() + if show: + plotly.offline.plot(fig, filename=path) + elif save: + fig.write_html(path) + + return fig diff --git a/flixOpt/results.py b/flixOpt/results.py index 631b1d4de..8f470f55e 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -371,14 +371,35 @@ def plot_node_balance(self, show=show, save=True if save else False) + def plot_node_balance_pie(self, + save: Union[bool, pathlib.Path] = False, + show: bool = True) -> plotly.graph_objects.Figure: + node_balance = self.node_balance(with_last_timestep=True, + negate_inputs=False, + negate_outputs=False).to_dataframe() + fig = plotting.dual_pie_with_plotly( + node_balance[[col for col in self.inputs if col in node_balance.columns]], + node_balance[[col for col in self.outputs if col in node_balance.columns]], + colors='viridis', + title=f'Flow rates of {self.label} (total)', + subtitles=('Inputs', 'Outputs'), + legend_title='Flows', + ) + + return plotly_save_and_show( + fig, + self._calculation_results.folder / f'{self.label} (flow rates total).html', + user_filename=None if isinstance(save, bool) else pathlib.Path(save), + show=show, + save=True if save else False) + def node_balance(self, negate_inputs: bool = True, negate_outputs: bool = False, threshold: Optional[float] = 1e-5, with_last_timestep: bool = False) -> xr.Dataset: - variable_names = [name for name in self._variable_names if name.endswith(('|flow_rate', '|excess_input', '|excess_output'))] return sanitize_dataset( - ds=self.solution[variable_names], + ds=self.solution[self.inputs + self.outputs], threshold=threshold, timesteps=self._calculation_results.timesteps_extra if with_last_timestep else None, negate=( From d583ec2ba512fc2258b792e914e9839e9d05df28 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 24 Mar 2025 20:49:16 +0100 Subject: [PATCH 434/507] Improve santize_dataset() to be able to set certain values to 0 if below a threshold --- flixOpt/results.py | 47 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/flixOpt/results.py b/flixOpt/results.py index 8f470f55e..22a37137c 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -640,32 +640,61 @@ def plot_heatmap( def sanitize_dataset( - ds: xr.Dataset, - timesteps: Optional[pd.DatetimeIndex] = None, - threshold: Optional[float] = 1e-5, - negate: Optional[List[str]] = None, + ds: xr.Dataset, + timesteps: Optional[pd.DatetimeIndex] = None, + threshold: Optional[float] = 1e-5, + negate: Optional[List[str]] = None, + drop_small_vars: bool = True, + zero_small_values: bool = False, ) -> xr.Dataset: """ - Sanitizes a dataset by dropping variables with small values and optionally reindexing the time axis. + Sanitizes a dataset by handling small values (dropping or zeroing) and optionally reindexing the time axis. Args: ds: The dataset to sanitize. timesteps: The timesteps to reindex the dataset to. If None, the original timesteps are kept. - threshold: The threshold for dropping variables. If None, no variables are dropped. + threshold: The threshold for small values processing. If None, no processing is done. negate: The variables to negate. If None, no variables are negated. + drop_small_vars: If True, drops variables where all values are below threshold. + zero_small_values: If True, sets values below threshold to zero. Returns: xr.Dataset: The sanitized dataset. """ + # Create a copy to avoid modifying the original + ds = ds.copy() + + # Step 1: Negate specified variables if negate is not None: for var in negate: - ds[var] = -ds[var] + if var in ds: + ds[var] = -ds[var] + + # Step 2: Handle small values if threshold is not None: abs_ds = xr.apply_ufunc(np.abs, ds) - vars_to_drop = [var for var in ds.data_vars if (abs_ds[var] <= threshold).all()] - ds = ds.drop_vars(vars_to_drop) + + # Option 1: Drop variables where all values are below threshold + if drop_small_vars: + vars_to_drop = [var for var in ds.data_vars if (abs_ds[var] <= threshold).all()] + ds = ds.drop_vars(vars_to_drop) + + # Option 2: Set small values to zero + if zero_small_values: + for var in ds.data_vars: + # Create a boolean mask of values below threshold + mask = abs_ds[var] <= threshold + # Only proceed if there are values to zero out + if mask.any(): + # Create a copy to ensure we don't modify data with views + ds[var] = ds[var].copy() + # Set values below threshold to zero + ds[var] = ds[var].where(~mask, 0) + + # Step 3: Reindex to specified timesteps if needed if timesteps is not None and not ds.indexes['time'].equals(timesteps): ds = ds.reindex({'time': timesteps}, fill_value=np.nan) + return ds From 72c8a03defa97726e8b057a012cd29b88cb470dc Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 24 Mar 2025 20:58:27 +0100 Subject: [PATCH 435/507] Improve pie plot --- flixOpt/plotting.py | 62 ++++++++++++++++++++++++++++++++------------- flixOpt/results.py | 19 ++++++++++---- 2 files changed, 59 insertions(+), 22 deletions(-) diff --git a/flixOpt/plotting.py b/flixOpt/plotting.py index 83176c29e..ab0aad90b 100644 --- a/flixOpt/plotting.py +++ b/flixOpt/plotting.py @@ -867,8 +867,8 @@ def pie_with_matplotlib( def dual_pie_with_plotly( - data_left: pd.DataFrame, - data_right: pd.DataFrame, + data_left: pd.Series, + data_right: pd.Series, colors: Union[List[str], str] = 'viridis', title: str = '', subtitles: Tuple[str, str] = ('Left Chart', 'Right Chart'), @@ -879,11 +879,11 @@ def dual_pie_with_plotly( path: Union[str, pathlib.Path] = 'temp-plot.html', ) -> go.Figure: """ - Create two pie charts side by side with Plotly, leveraging the existing pie_with_plotly function. + Create two pie charts side by side with Plotly, with consistent coloring across both charts. Args: - data_left: DataFrame for the left pie chart. - data_right: DataFrame for the right pie chart. + data_left: Series for the left pie chart. + data_right: Series for the right pie chart. colors: A List of colors (as str) or a name of a colorscale (e.g., 'viridis', 'plasma') to use for coloring the pie segments. title: The main title of the plot. @@ -898,28 +898,56 @@ def dual_pie_with_plotly( A Plotly figure object containing the generated dual pie chart. """ from plotly.subplots import make_subplots + import itertools # Create a subplot figure fig = make_subplots(rows=1, cols=2, specs=[[{'type': 'pie'}, {'type': 'pie'}]], subplot_titles=subtitles) - # Create a function to get a trace from pie_with_plotly - def get_pie_trace(data): - if data.empty: + all_labels = data_left.index.tolist() + data_right.index.tolist() + + # Generate consistent color mapping + if isinstance(colors, str): + colorscale = px.colors.get_colorscale(colors) + color_list = px.colors.sample_colorscale( + colorscale, + [i / (len(all_labels) - 1) for i in range(len(all_labels))] if len(all_labels) > 1 else [0], + ) + color_map = {label: color_list[i] for i, label in enumerate(all_labels)} + else: + # If colors is a list, create a cycling iterator + color_iter = itertools.cycle(colors) + color_map = {label: next(color_iter) for label in all_labels} + + # Function to create a pie trace with consistently mapped colors + def create_pie_trace(data_series, side): + # Filter out zero or negative values + data_series = data_series[data_series > 0] + if data_series.empty: return None - temp_fig = go.Figure() - pie_with_plotly(data=data, colors=colors, hole=hole, fig=temp_fig) - if len(temp_fig.data) > 0: - return temp_fig.data[0] - return None - # Add left pie trace - left_trace = get_pie_trace(data_left) + labels = data_series.index.tolist() + values = data_series.values.tolist() + trace_colors = [color_map[label] for label in labels] + + return go.Pie( + labels=labels, + values=values, + name=side, + marker_colors=trace_colors, + hole=hole, + textinfo='percent+label', + textposition='inside', + insidetextorientation='radial', + ) + + # Add left pie if data exists + left_trace = create_pie_trace(data_left, subtitles[0]) if left_trace: left_trace.domain = dict(x=[0, 0.45]) fig.add_trace(left_trace) - # Add right pie trace - right_trace = get_pie_trace(data_right) + # Add right pie if data exists + right_trace = create_pie_trace(data_right, subtitles[1]) if right_trace: right_trace.domain = dict(x=[0.55, 1]) fig.add_trace(right_trace) diff --git a/flixOpt/results.py b/flixOpt/results.py index 22a37137c..807956a81 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -374,12 +374,21 @@ def plot_node_balance(self, def plot_node_balance_pie(self, save: Union[bool, pathlib.Path] = False, show: bool = True) -> plotly.graph_objects.Figure: - node_balance = self.node_balance(with_last_timestep=True, - negate_inputs=False, - negate_outputs=False).to_dataframe() + inputs = sanitize_dataset( + ds=self.solution[self.inputs], + threshold=1e-5, + drop_small_vars=True, + zero_small_values=True, + ) + outputs = sanitize_dataset( + ds=self.solution[self.outputs], + threshold=1e-5, + drop_small_vars=True, + zero_small_values=True, + ) fig = plotting.dual_pie_with_plotly( - node_balance[[col for col in self.inputs if col in node_balance.columns]], - node_balance[[col for col in self.outputs if col in node_balance.columns]], + inputs.to_dataframe().sum(), + outputs.to_dataframe().sum(), colors='viridis', title=f'Flow rates of {self.label} (total)', subtitles=('Inputs', 'Outputs'), From 7ac28270330b93d5279369c4f7a46a64a9557a78 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 24 Mar 2025 21:22:51 +0100 Subject: [PATCH 436/507] Improve pie plot --- flixOpt/plotting.py | 83 +++++++++++++++++++++++++++++++++++---------- flixOpt/results.py | 3 ++ 2 files changed, 68 insertions(+), 18 deletions(-) diff --git a/flixOpt/plotting.py b/flixOpt/plotting.py index ab0aad90b..0a424a50a 100644 --- a/flixOpt/plotting.py +++ b/flixOpt/plotting.py @@ -873,7 +873,11 @@ def dual_pie_with_plotly( title: str = '', subtitles: Tuple[str, str] = ('Left Chart', 'Right Chart'), legend_title: str = '', - hole: float = 0.3, + hole: float = 0.2, + group_below_percentage: float = 5.0, + hover_template: str = '%{label}: %{value} (%{percent})', + text_info: str = 'percent+label', + text_position: str = 'inside', show: bool = False, save: bool = False, path: Union[str, pathlib.Path] = 'temp-plot.html', @@ -889,7 +893,12 @@ def dual_pie_with_plotly( title: The main title of the plot. subtitles: Tuple containing the subtitles for (left, right) charts. legend_title: The title for the legend. - hole: Size of the hole in the center for creating donut charts (0.0 to 1.0). + hole: Size of the hole in the center for creating donut charts (0.0 to 100). + group_below_percentage: Whether to group small segments (below percentage (0...1)) into an "Other" category. + hover_template: Template for hover text. Use %{label}, %{value}, %{percent}. + text_info: What to show on pie segments: 'label', 'percent', 'value', 'label+percent', + 'label+value', 'percent+value', 'label+percent+value', or 'none'. + text_position: Position of text: 'inside', 'outside', 'auto', or 'none'. show: Whether to show the figure after creation. save: Whether to save the figure after creation (without showing). path: Path to save the figure. @@ -900,10 +909,48 @@ def dual_pie_with_plotly( from plotly.subplots import make_subplots import itertools + # Check for empty data + if data_left.empty and data_right.empty: + logger.warning('Both datasets are empty. Returning empty figure.') + return go.Figure() + # Create a subplot figure - fig = make_subplots(rows=1, cols=2, specs=[[{'type': 'pie'}, {'type': 'pie'}]], subplot_titles=subtitles) + fig = make_subplots( + rows=1, cols=2, specs=[[{'type': 'pie'}, {'type': 'pie'}]], subplot_titles=subtitles, horizontal_spacing=0.05 + ) + + # Process series to handle negative values and apply minimum percentage threshold + def preprocess_series(series): + # Handle negative values + if (series < 0).any(): + logger.warning(f'Negative values detected in data. Using absolute values for pie chart.') + series = series.abs() + + # Remove zeros + series = series[series > 0] + + # Apply minimum percentage threshold if needed + if group_below_percentage and not series.empty: + total = series.sum() + if total > 0: + percentages = (series / total) * 100 + small_indices = percentages < group_below_percentage - all_labels = data_left.index.tolist() + data_right.index.tolist() + if small_indices.any(): + # Create "Other" category for small values + other_sum = series[small_indices].sum() + # Remove small values and add Other + series = series[~small_indices] + if other_sum > 0: + series['Other'] = other_sum + + return series + + data_left_processed = preprocess_series(data_left) + data_right_processed = preprocess_series(data_right) + + # Get unique set of all labels for consistent coloring + all_labels = sorted(set(data_left_processed.index) | set(data_right_processed.index)) # Generate consistent color mapping if isinstance(colors, str): @@ -920,8 +967,6 @@ def dual_pie_with_plotly( # Function to create a pie trace with consistently mapped colors def create_pie_trace(data_series, side): - # Filter out zero or negative values - data_series = data_series[data_series > 0] if data_series.empty: return None @@ -935,32 +980,34 @@ def create_pie_trace(data_series, side): name=side, marker_colors=trace_colors, hole=hole, - textinfo='percent+label', - textposition='inside', + textinfo=text_info, + textposition=text_position, insidetextorientation='radial', + hovertemplate=hover_template, + sort=True, # Sort values by default (largest first) ) # Add left pie if data exists - left_trace = create_pie_trace(data_left, subtitles[0]) + left_trace = create_pie_trace(data_left_processed, subtitles[0]) if left_trace: - left_trace.domain = dict(x=[0, 0.45]) - fig.add_trace(left_trace) + left_trace.domain = dict(x=[0, 0.48]) + fig.add_trace(left_trace, row=1, col=1) # Add right pie if data exists - right_trace = create_pie_trace(data_right, subtitles[1]) + right_trace = create_pie_trace(data_right_processed, subtitles[1]) if right_trace: - right_trace.domain = dict(x=[0.55, 1]) - fig.add_trace(right_trace) + right_trace.domain = dict(x=[0.52, 1]) + fig.add_trace(right_trace, row=1, col=2) # Update layout fig.update_layout( title=title, legend_title=legend_title, - plot_bgcolor='rgba(0,0,0,0)', - paper_bgcolor='rgba(0,0,0,0)', + plot_bgcolor='rgba(0,0,0,0)', # Transparent background + paper_bgcolor='rgba(0,0,0,0)', # Transparent paper background font=dict(size=14), - margin=dict(t=60, b=30, l=30, r=30), - legend=dict(orientation='h', yanchor='bottom', y=-0.2, xanchor='center', x=0.5), + margin=dict(t=80, b=50, l=30, r=30), + legend=dict(orientation='h', yanchor='bottom', y=-0.2, xanchor='center', x=0.5, font=dict(size=12)), ) # Handle file saving and display diff --git a/flixOpt/results.py b/flixOpt/results.py index 807956a81..31e1aff4a 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -372,6 +372,7 @@ def plot_node_balance(self, save=True if save else False) def plot_node_balance_pie(self, + group_below_percentage: float = 5, save: Union[bool, pathlib.Path] = False, show: bool = True) -> plotly.graph_objects.Figure: inputs = sanitize_dataset( @@ -391,8 +392,10 @@ def plot_node_balance_pie(self, outputs.to_dataframe().sum(), colors='viridis', title=f'Flow rates of {self.label} (total)', + text_info='label+percent', subtitles=('Inputs', 'Outputs'), legend_title='Flows', + group_below_percentage=group_below_percentage, ) return plotly_save_and_show( From 4f8972e471146e0a63e6cca70d40e656910e9230 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 25 Mar 2025 08:16:51 +0100 Subject: [PATCH 437/507] Update pie plot to show flow hours --- flixOpt/results.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/flixOpt/results.py b/flixOpt/results.py index 31e1aff4a..a4226793c 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -380,18 +380,18 @@ def plot_node_balance_pie(self, threshold=1e-5, drop_small_vars=True, zero_small_values=True, - ) + ) * self._calculation_results.hours_per_timestep outputs = sanitize_dataset( ds=self.solution[self.outputs], threshold=1e-5, drop_small_vars=True, zero_small_values=True, - ) + ) * self._calculation_results.hours_per_timestep fig = plotting.dual_pie_with_plotly( inputs.to_dataframe().sum(), outputs.to_dataframe().sum(), colors='viridis', - title=f'Flow rates of {self.label} (total)', + title=f'Flow hours of {self.label}', text_info='label+percent', subtitles=('Inputs', 'Outputs'), legend_title='Flows', @@ -400,7 +400,7 @@ def plot_node_balance_pie(self, return plotly_save_and_show( fig, - self._calculation_results.folder / f'{self.label} (flow rates total).html', + self._calculation_results.folder / f'{self.label} (flow hours).html', user_filename=None if isinstance(save, bool) else pathlib.Path(save), show=show, save=True if save else False) From 9a9743932f2f4dab27af631ccc92eea56904b4ca Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 25 Mar 2025 08:32:08 +0100 Subject: [PATCH 438/507] Improve grouping of pie plot --- flixOpt/plotting.py | 42 ++++++++++++++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/flixOpt/plotting.py b/flixOpt/plotting.py index 0a424a50a..b0e8221d5 100644 --- a/flixOpt/plotting.py +++ b/flixOpt/plotting.py @@ -920,7 +920,18 @@ def dual_pie_with_plotly( ) # Process series to handle negative values and apply minimum percentage threshold - def preprocess_series(series): + def preprocess_series(series: pd.Series): + """ + Preprocess a series for pie chart display by handling negative values + and grouping the smallest parts together if they collectively represent + less than the specified percentage threshold. + + Args: + series: The series to preprocess + + Returns: + A preprocessed pandas Series + """ # Handle negative values if (series < 0).any(): logger.warning(f'Negative values detected in data. Using absolute values for pie chart.') @@ -933,16 +944,27 @@ def preprocess_series(series): if group_below_percentage and not series.empty: total = series.sum() if total > 0: - percentages = (series / total) * 100 - small_indices = percentages < group_below_percentage - - if small_indices.any(): - # Create "Other" category for small values - other_sum = series[small_indices].sum() - # Remove small values and add Other - series = series[~small_indices] + # Sort series by value (ascending) + sorted_series = series.sort_values() + + # Calculate cumulative percentage contribution + cumulative_percent = (sorted_series.cumsum() / total) * 100 + + # Find entries that collectively make up less than group_below_percentage + to_group = cumulative_percent <= group_below_percentage + + if to_group.any(): + # Create "Other" category for the smallest values that together are < threshold + other_sum = sorted_series[to_group].sum() + + # Keep only values that aren't in the "Other" group + result_series = series[~series.index.isin(sorted_series[to_group].index)] + + # Add the "Other" category if it has a value if other_sum > 0: - series['Other'] = other_sum + result_series['Other'] = other_sum + + return result_series return series From e38ef5e7f32d527b8c370267f128c7b015dd1738 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 25 Mar 2025 08:37:19 +0100 Subject: [PATCH 439/507] Improve grouping of pie plot --- flixOpt/plotting.py | 12 ++++++------ flixOpt/results.py | 12 ++++++++++-- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/flixOpt/plotting.py b/flixOpt/plotting.py index b0e8221d5..6a514631e 100644 --- a/flixOpt/plotting.py +++ b/flixOpt/plotting.py @@ -874,7 +874,7 @@ def dual_pie_with_plotly( subtitles: Tuple[str, str] = ('Left Chart', 'Right Chart'), legend_title: str = '', hole: float = 0.2, - group_below_percentage: float = 5.0, + lower_percentage_group: float = 5.0, hover_template: str = '%{label}: %{value} (%{percent})', text_info: str = 'percent+label', text_position: str = 'inside', @@ -894,7 +894,7 @@ def dual_pie_with_plotly( subtitles: Tuple containing the subtitles for (left, right) charts. legend_title: The title for the legend. hole: Size of the hole in the center for creating donut charts (0.0 to 100). - group_below_percentage: Whether to group small segments (below percentage (0...1)) into an "Other" category. + lower_percentage_group: Whether to group small segments (below percentage (0...1)) into an "Other" category. hover_template: Template for hover text. Use %{label}, %{value}, %{percent}. text_info: What to show on pie segments: 'label', 'percent', 'value', 'label+percent', 'label+value', 'percent+value', 'label+percent+value', or 'none'. @@ -941,7 +941,7 @@ def preprocess_series(series: pd.Series): series = series[series > 0] # Apply minimum percentage threshold if needed - if group_below_percentage and not series.empty: + if lower_percentage_group and not series.empty: total = series.sum() if total > 0: # Sort series by value (ascending) @@ -950,10 +950,10 @@ def preprocess_series(series: pd.Series): # Calculate cumulative percentage contribution cumulative_percent = (sorted_series.cumsum() / total) * 100 - # Find entries that collectively make up less than group_below_percentage - to_group = cumulative_percent <= group_below_percentage + # Find entries that collectively make up less than lower_percentage_group + to_group = cumulative_percent <= lower_percentage_group - if to_group.any(): + if to_group.sum() > 1: # Create "Other" category for the smallest values that together are < threshold other_sum = sorted_series[to_group].sum() diff --git a/flixOpt/results.py b/flixOpt/results.py index a4226793c..46346be55 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -372,9 +372,17 @@ def plot_node_balance(self, save=True if save else False) def plot_node_balance_pie(self, - group_below_percentage: float = 5, + lower_percentage_group: float = 5, save: Union[bool, pathlib.Path] = False, show: bool = True) -> plotly.graph_objects.Figure: + """ + Plots a pie chart of the flow hours of the inputs and outputs of the component. + + Args: + lower_percentage_group: The percentage (0...100) of the total flow hours is grouped together. + save: Whether to save the figure. + show: Whether to show the figure. + """ inputs = sanitize_dataset( ds=self.solution[self.inputs], threshold=1e-5, @@ -395,7 +403,7 @@ def plot_node_balance_pie(self, text_info='label+percent', subtitles=('Inputs', 'Outputs'), legend_title='Flows', - group_below_percentage=group_below_percentage, + lower_percentage_group=lower_percentage_group, ) return plotly_save_and_show( From 03119422e8b9bd0afcdfb460fc252c2ae6cfeaeb Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 25 Mar 2025 08:38:00 +0100 Subject: [PATCH 440/507] Improve grouping of pie plot --- flixOpt/results.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixOpt/results.py b/flixOpt/results.py index 46346be55..cb63c8397 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -379,7 +379,7 @@ def plot_node_balance_pie(self, Plots a pie chart of the flow hours of the inputs and outputs of the component. Args: - lower_percentage_group: The percentage (0...100) of the total flow hours is grouped together. + lower_percentage_group: The percentage of flow_hours that is grouped in "Others" (0...100) save: Whether to save the figure. show: Whether to show the figure. """ From b9b2e64c4ab34a3ca6a7f328af69189e31a6056a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 25 Mar 2025 13:04:21 +0100 Subject: [PATCH 441/507] Add options to pie plot and add pie plots to examples --- examples/00_Minmal/minimal_example.py | 1 + examples/01_Simple/simple_example.py | 1 + examples/02_Complex/complex_example.py | 1 + flixOpt/results.py | 18 ++++++++++++------ 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/examples/00_Minmal/minimal_example.py b/examples/00_Minmal/minimal_example.py index f58d44e64..3de5d4358 100644 --- a/examples/00_Minmal/minimal_example.py +++ b/examples/00_Minmal/minimal_example.py @@ -61,6 +61,7 @@ df1 = calculation.results['costs'].filter_solution('time').to_dataframe() # Plot the results of a specific element + calculation.results['District Heating'].plot_node_balance_pie() calculation.results['District Heating'].plot_node_balance() # Save results to a file diff --git a/examples/01_Simple/simple_example.py b/examples/01_Simple/simple_example.py index 0e87c5611..ab4a2e747 100644 --- a/examples/01_Simple/simple_example.py +++ b/examples/01_Simple/simple_example.py @@ -107,6 +107,7 @@ calculation.solve(fx.solvers.HighsSolver(mip_gap=0, time_limit_seconds=30)) # --- Analyze Results --- + calculation.results['Fernwärme'].plot_node_balance_pie() calculation.results['Fernwärme'].plot_node_balance() calculation.results['Storage'].plot_node_balance() calculation.results.plot_heatmap('CHP(Q_th)|flow_rate') diff --git a/examples/02_Complex/complex_example.py b/examples/02_Complex/complex_example.py index 9429d4c45..f124d96ab 100644 --- a/examples/02_Complex/complex_example.py +++ b/examples/02_Complex/complex_example.py @@ -193,3 +193,4 @@ calculation.results.plot_heatmap('BHKW2(Q_th)|flow_rate') calculation.results['BHKW2'].plot_node_balance() calculation.results['Speicher'].plot_charge_state() + calculation.results['Fernwärme'].plot_node_balance_pie() diff --git a/flixOpt/results.py b/flixOpt/results.py index cb63c8397..2ea509e66 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -371,15 +371,21 @@ def plot_node_balance(self, show=show, save=True if save else False) - def plot_node_balance_pie(self, - lower_percentage_group: float = 5, - save: Union[bool, pathlib.Path] = False, - show: bool = True) -> plotly.graph_objects.Figure: + def plot_node_balance_pie( + self, + lower_percentage_group: float = 5, + colors: Union[str, List[str]] = 'viridis', + text_info: str = 'percent+label+value', + save: Union[bool, pathlib.Path] = False, + show: bool = True, + ) -> plotly.graph_objects.Figure: """ Plots a pie chart of the flow hours of the inputs and outputs of the component. Args: + colors: a colorscale or a list of colors to use for the plot lower_percentage_group: The percentage of flow_hours that is grouped in "Others" (0...100) + text_info: What information to display on the pie plot save: Whether to save the figure. show: Whether to show the figure. """ @@ -398,9 +404,9 @@ def plot_node_balance_pie(self, fig = plotting.dual_pie_with_plotly( inputs.to_dataframe().sum(), outputs.to_dataframe().sum(), - colors='viridis', + colors=colors, title=f'Flow hours of {self.label}', - text_info='label+percent', + text_info=text_info, subtitles=('Inputs', 'Outputs'), legend_title='Flows', lower_percentage_group=lower_percentage_group, From 5be3fdddee386710a1608a4188c043c600f63c94 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 25 Mar 2025 14:53:16 +0100 Subject: [PATCH 442/507] BUGFIX: Always use timesteps_extra as the time index of the solution --- flixOpt/structure.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/flixOpt/structure.py b/flixOpt/structure.py index 38368a8ec..6e8f13818 100644 --- a/flixOpt/structure.py +++ b/flixOpt/structure.py @@ -85,8 +85,7 @@ def solution(self): for effect in sorted(self.flow_system.effects, key=lambda effect: effect.label_full.upper()) }, } - solution.reindex(time=self.time_series_collection.timesteps_extra) - return solution + return solution.reindex(time=self.time_series_collection.timesteps_extra) @property def hours_per_step(self): From ee850bf37483e4eba22ff8078c2659dc3593c714 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 25 Mar 2025 14:53:54 +0100 Subject: [PATCH 443/507] BUGFIX: Treat nan as 0 when deciding which columns to drop in sanitize_dataset() --- flixOpt/results.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flixOpt/results.py b/flixOpt/results.py index c93a6e3db..b64a8a703 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -651,8 +651,8 @@ def sanitize_dataset( for var in negate: ds[var] = -ds[var] if threshold is not None: - abs_ds = xr.apply_ufunc(np.abs, ds) - vars_to_drop = [var for var in ds.data_vars if (abs_ds[var] <= threshold).all()] + ds_no_nan_abs = xr.apply_ufunc(np.abs, ds).fillna(0) # Replace NaN with 0 (below thres ds_without_na = ds.fillna(0) # Replace NaN with 0 (below threshold) for the comparison + vars_to_drop = [var for var in ds.data_vars if (ds_no_nan_abs[var] <= threshold).all()] ds = ds.drop_vars(vars_to_drop) if timesteps is not None and not ds.indexes['time'].equals(timesteps): ds = ds.reindex({'time': timesteps}, fill_value=np.nan) From 11ace5e4dd9c40f2b1e09321e4edec307e954860 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 25 Mar 2025 15:07:56 +0100 Subject: [PATCH 444/507] Improve Exception --- flixOpt/flow_system.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixOpt/flow_system.py b/flixOpt/flow_system.py index 6adcf8d4b..fa7e9f673 100644 --- a/flixOpt/flow_system.py +++ b/flixOpt/flow_system.py @@ -140,7 +140,7 @@ def add_elements(self, *elements: Element) -> None: elif isinstance(new_element, Bus): self._add_buses(new_element) else: - raise Exception('argument is not instance of a modeling Element (Element)') + raise TypeError(f'Tried to add incompatible object to FlowSystem: {type(new_element)=}: {new_element=} ') def to_json(self, path: Union[str, pathlib.Path]): """ From b9e3645b2af031f339e17a2b2fe08c89ade29fc0 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 25 Mar 2025 15:17:02 +0100 Subject: [PATCH 445/507] Improves Exception types all over the place --- flixOpt/calculation.py | 2 +- flixOpt/components.py | 14 +++++++------- flixOpt/core.py | 6 +++++- flixOpt/effects.py | 4 ++-- flixOpt/elements.py | 4 ++-- flixOpt/flow_system.py | 4 ++-- 6 files changed, 19 insertions(+), 15 deletions(-) diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py index b00bc6120..31fb5e663 100644 --- a/flixOpt/calculation.py +++ b/flixOpt/calculation.py @@ -239,7 +239,7 @@ def _perform_aggregation(self): steps_per_period = self.aggregation_parameters.hours_per_period / self.flow_system.time_series_collection.hours_per_timestep.max() is_integer = (self.aggregation_parameters.hours_per_period % self.flow_system.time_series_collection.hours_per_timestep.max()).item() == 0 if not (steps_per_period.size == 1 and is_integer): - raise Exception( + 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.' ) diff --git a/flixOpt/components.py b/flixOpt/components.py index c71c37000..6a5adc697 100644 --- a/flixOpt/components.py +++ b/flixOpt/components.py @@ -10,7 +10,7 @@ import pandas as pd from . import utils -from .core import NumericData, NumericDataTS, Scalar, TimeSeries, TimeSeriesCollection +from .core import NumericData, NumericDataTS, Scalar, TimeSeries, TimeSeriesCollection, PlausibilityError from .elements import Component, ComponentModel, Flow from .features import InvestmentModel, MultipleSegmentsModel, OnOffModel from .interface import InvestParameters, OnOffParameters @@ -66,13 +66,13 @@ def create_model(self, model: SystemModel) -> 'LinearConverterModel': def _plausibility_checks(self) -> None: if not self.conversion_factors and not self.segmented_conversion_factors: - raise Exception('Either conversion_factors or segmented_conversion_factors must be defined!') + raise PlausibilityError('Either conversion_factors or segmented_conversion_factors must be defined!') if self.conversion_factors and self.segmented_conversion_factors: - raise Exception('Only one of conversion_factors or segmented_conversion_factors can be defined, not both!') + raise PlausibilityError('Only one of conversion_factors or segmented_conversion_factors can be defined, not both!') if self.conversion_factors: if self.degrees_of_freedom <= 0: - raise Exception( + raise PlausibilityError( f'Too Many conversion_factors_specified. Care that you use less conversion_factors ' f'then inputs + outputs!! With {len(self.inputs + self.outputs)} inputs and outputs, ' f'use not more than {len(self.inputs + self.outputs) - 1} conversion_factors!' @@ -81,13 +81,13 @@ def _plausibility_checks(self) -> None: for conversion_factor in self.conversion_factors: for flow in conversion_factor: if flow not in self.flows: - raise Exception( + raise PlausibilityError( f'{self.label}: Flow {flow} in conversion_factors is not in inputs/outputs' ) if self.segmented_conversion_factors: for flow in self.flows.values(): if isinstance(flow.size, InvestParameters) and flow.size.fixed_size is None: - raise Exception( + raise PlausibilityError( f'segmented_conversion_factors (in {self.label_full}) and variable size ' f'(in flow {flow.label_full}) do not make sense together!' ) @@ -566,7 +566,7 @@ def _initial_and_final_charge_state(self): name_short ) else: # TODO: Validation in Storage Class, not in Model - raise Exception(f'initial_charge_state has undefined value: {self.element.initial_charge_state}') + raise PlausibilityError(f'initial_charge_state has undefined value: {self.element.initial_charge_state}') if self.element.maximal_final_charge_state is not None: self.add(self._model.add_constraints( diff --git a/flixOpt/core.py b/flixOpt/core.py index 960b2b1f4..a4aa279fa 100644 --- a/flixOpt/core.py +++ b/flixOpt/core.py @@ -26,6 +26,10 @@ """Represents either standard numeric data or TimeSeriesData.""" +class PlausibilityError(Exception): + """Error for a failing Plausibility check.""" + pass + class ConversionError(Exception): """Base exception for data conversion errors.""" pass @@ -110,7 +114,7 @@ def __init__(self, data: NumericData, agg_group: Optional[str] = None, agg_weigh self.agg_group = agg_group self.agg_weight = agg_weight if (agg_group is not None) and (agg_weight is not None): - raise Exception('Either or explicit can be used. Not both!') + raise ValueError('Either or explicit can be used. Not both!') self.label: Optional[str] = None def __repr__(self): diff --git a/flixOpt/effects.py b/flixOpt/effects.py index 2e6c4fefc..7680e964f 100644 --- a/flixOpt/effects.py +++ b/flixOpt/effects.py @@ -200,7 +200,7 @@ def create_model(self, model: SystemModel) -> 'EffectCollectionModel': def add_effects(self, *effects: Effect) -> None: for effect in list(effects): if effect in self: - raise Exception(f'Effect with label "{effect.label=}" already added!') + raise ValueError(f'Effect with label "{effect.label=}" already added!') if effect.is_standard: self.standard_effect = effect if effect.is_objective: @@ -354,7 +354,7 @@ def add_share_to_effects( def add_share_to_penalty(self, name: str, expression: linopy.LinearExpression) -> None: if expression.ndim != 0: - raise Exception(f'Penalty shares must be scalar expressions! ({expression.ndim=})') + raise TypeError(f'Penalty shares must be scalar expressions! ({expression.ndim=})') self.penalty.add_share(name, expression) def do_modeling(self): diff --git a/flixOpt/elements.py b/flixOpt/elements.py index 031db9830..abe3e2e8c 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -10,7 +10,7 @@ import numpy as np from .config import CONFIG -from .core import NumericData, NumericDataTS, Scalar, TimeSeriesCollection +from .core import NumericData, NumericDataTS, Scalar, TimeSeriesCollection, PlausibilityError from .effects import EffectValuesUser from .features import InvestmentModel, OnOffModel, PreventSimultaneousUsageModel from .interface import InvestParameters, OnOffParameters @@ -283,7 +283,7 @@ def to_dict(self) -> Dict: def _plausibility_checks(self) -> None: # TODO: Incorporate into Variable? (Lower_bound can not be greater than upper bound if np.any(self.relative_minimum > self.relative_maximum): - raise Exception(self.label_full + ': Take care, that relative_minimum <= relative_maximum!') + raise PlausibilityError(self.label_full + ': Take care, that relative_minimum <= relative_maximum!') if ( self.size == CONFIG.modeling.BIG and self.fixed_relative_profile is not None diff --git a/flixOpt/flow_system.py b/flixOpt/flow_system.py index fa7e9f673..290e1a1a3 100644 --- a/flixOpt/flow_system.py +++ b/flixOpt/flow_system.py @@ -341,10 +341,10 @@ def _check_if_element_is_unique(self, element: Element) -> None: element: new element to check """ if element in self.all_elements.values(): - raise Exception(f'Element {element.label} already added to FlowSystem!') + raise ValueError(f'Element {element.label} already added to FlowSystem!') # check if name is already used: if element.label_full in self.all_elements: - raise Exception(f'Label of Element {element.label} already used in another element!') + raise ValueError(f'Label of Element {element.label} already used in another element!') def _add_effects(self, *args: Effect) -> None: self.effects.add_effects(*args) From 4ad50c8e1c3025e50bdf76a1003079a8c879debd Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 25 Mar 2025 15:20:40 +0100 Subject: [PATCH 446/507] ruff check --- flixOpt/components.py | 3 +-- flixOpt/elements.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/flixOpt/components.py b/flixOpt/components.py index 6a5adc697..ec4336e43 100644 --- a/flixOpt/components.py +++ b/flixOpt/components.py @@ -7,10 +7,9 @@ import linopy import numpy as np -import pandas as pd from . import utils -from .core import NumericData, NumericDataTS, Scalar, TimeSeries, TimeSeriesCollection, PlausibilityError +from .core import NumericData, NumericDataTS, PlausibilityError, Scalar, TimeSeries from .elements import Component, ComponentModel, Flow from .features import InvestmentModel, MultipleSegmentsModel, OnOffModel from .interface import InvestParameters, OnOffParameters diff --git a/flixOpt/elements.py b/flixOpt/elements.py index abe3e2e8c..6c8e806f2 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -10,7 +10,7 @@ import numpy as np from .config import CONFIG -from .core import NumericData, NumericDataTS, Scalar, TimeSeriesCollection, PlausibilityError +from .core import NumericData, NumericDataTS, PlausibilityError, Scalar, TimeSeriesCollection from .effects import EffectValuesUser from .features import InvestmentModel, OnOffModel, PreventSimultaneousUsageModel from .interface import InvestParameters, OnOffParameters From b8d743041d43fb001351de0b09c4f86574c1b0ff Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 26 Mar 2025 10:36:26 +0100 Subject: [PATCH 447/507] Improve docstring --- flixOpt/results.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixOpt/results.py b/flixOpt/results.py index 2ea509e66..12eee9e5e 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -380,7 +380,7 @@ def plot_node_balance_pie( show: bool = True, ) -> plotly.graph_objects.Figure: """ - Plots a pie chart of the flow hours of the inputs and outputs of the component. + Plots a pie chart of the flow hours of the inputs and outputs of buses or components. Args: colors: a colorscale or a list of colors to use for the plot From a472e1c10c1cb3c894b1563cab9442d3e832dfa0 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 26 Mar 2025 10:48:36 +0100 Subject: [PATCH 448/507] Improve options for matplotlib --- flixOpt/plotting.py | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/flixOpt/plotting.py b/flixOpt/plotting.py index 6a514631e..aeac3bcf0 100644 --- a/flixOpt/plotting.py +++ b/flixOpt/plotting.py @@ -27,6 +27,7 @@ def with_plotly( colors: Union[List[str], str] = 'viridis', title: str = '', ylabel: str = '', + xlabel: str = 'Time in h', fig: Optional[go.Figure] = None, show: bool = False, save: bool = False, @@ -150,7 +151,7 @@ def with_plotly( gridwidth=0.5, # Customize grid line width ), xaxis=dict( - title='Time in h', + title=xlabel, showgrid=True, # Enable grid lines on the x-axis gridcolor='lightgrey', # Customize grid line color gridwidth=0.5, # Customize grid line width @@ -181,6 +182,9 @@ def with_matplotlib( data: pd.DataFrame, mode: Literal['bar', 'line'] = 'bar', colors: Union[List[str], str] = 'viridis', + title: str = '', + ylabel: str = '', + xlabel: str = 'Time in h', figsize: Tuple[int, int] = (12, 6), fig: Optional[plt.Figure] = None, ax: Optional[plt.Axes] = None, @@ -261,7 +265,9 @@ def with_matplotlib( ax.step(data.index, data[column], where='post', color=colors[i], label=column) # Aesthetics - ax.set_xlabel('Time in h', fontsize=14) + ax.set_xlabel(xlabel, ha='center') + ax.set_ylabel(ylabel, va='center') + ax.set_title(title) ax.grid(color='lightgrey', linestyle='-', linewidth=0.5) ax.legend( loc='upper center', # Place legend at the bottom center @@ -282,6 +288,9 @@ def with_matplotlib( def heat_map_matplotlib( data: pd.DataFrame, color_map: str = 'viridis', + title: str = '', + xlabel: str = 'Period', + ylabel: str = 'Step', figsize: Tuple[float, float] = (12, 6), show: bool = False, path: Optional[Union[str, pathlib.Path]] = None, @@ -324,8 +333,9 @@ def heat_map_matplotlib( ax.set_yticklabels(data.index, va='center') # Add labels to the axes - ax.set_xlabel('Period', ha='center') - ax.set_ylabel('Step', va='center') + ax.set_xlabel(xlabel, ha='center') + ax.set_ylabel(ylabel, va='center') + ax.set_title(title) # Position x-axis labels at the top ax.xaxis.set_label_position('top') @@ -349,7 +359,7 @@ def heat_map_plotly( data: pd.DataFrame, color_map: str = 'viridis', title: str = '', - xlabel: str = 'Periods', + xlabel: str = 'Period', ylabel: str = 'Step', categorical_labels: bool = True, show: bool = False, From 50b04f2b18222beed63ebc5d78a6c37daf31cc68 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 26 Mar 2025 11:10:36 +0100 Subject: [PATCH 449/507] Add options to plot with matplotlib --- flixOpt/results.py | 217 ++++++++++++++++++++++++++++++++++++--------- 1 file changed, 173 insertions(+), 44 deletions(-) diff --git a/flixOpt/results.py b/flixOpt/results.py index 12eee9e5e..bb5f6dcfe 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -8,6 +8,7 @@ import numpy as np import pandas as pd import plotly +import matplotlib.pyplot as plt import xarray as xr import yaml @@ -213,14 +214,16 @@ def filter_solution(self, return filter_dataset(self[element].solution, variable_dims) return filter_dataset(self.solution, variable_dims) - def plot_heatmap(self, - variable_name: str, - heatmap_timeframes: Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'] = 'D', - heatmap_timesteps_per_frame: Literal['W', 'D', 'h', '15min', 'min'] = 'h', - color_map: str = 'portland', - save: Union[bool, pathlib.Path] = False, - show: bool = True - ) -> plotly.graph_objs.Figure: + def plot_heatmap( + self, + variable_name: str, + heatmap_timeframes: Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'] = 'D', + heatmap_timesteps_per_frame: Literal['W', 'D', 'h', '15min', 'min'] = 'h', + color_map: str = 'portland', + save: Union[bool, pathlib.Path] = False, + show: bool = True, + engine: Literal['plotly', 'matplotlib'] = 'plotly' + ) -> Union[plotly.graph_objs.Figure, Tuple[plt.Figure, plt.Axes]]: return plot_heatmap( dataarray=self.solution[variable_name], name=variable_name, @@ -229,7 +232,9 @@ def plot_heatmap(self, heatmap_timesteps_per_frame=heatmap_timesteps_per_frame, color_map=color_map, save=save, - show=show) + show=show, + engine=engine, + ) def to_file( self, @@ -358,18 +363,43 @@ def __init__(self, self.inputs = inputs self.outputs = outputs - def plot_node_balance(self, - save: Union[bool, pathlib.Path] = False, - show: bool = True): - fig = plotting.with_plotly( - self.node_balance(with_last_timestep=True).to_dataframe(), mode='area', title=f'Flow rates of {self.label}' - ) - return plotly_save_and_show( - fig, - self._calculation_results.folder / f'{self.label} (flow rates).html', - user_filename=None if isinstance(save, bool) else pathlib.Path(save), - show=show, - save=True if save else False) + def plot_node_balance( + self, + save: Union[bool, pathlib.Path] = False, + show: bool = True, + engine: Literal['plotly', 'matplotlib'] = 'plotly' + ) -> Union[plotly.graph_objs.Figure, Tuple[plt.Figure, plt.Axes]]: + """ + Plots the node balance of the Component or Bus. + Args: + 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'. + """ + if engine == 'plotly': + fig = plotting.with_plotly( + self.node_balance(with_last_timestep=True).to_dataframe(), mode='area', title=f'Flow rates of {self.label}' + ) + return plotly_save_and_show( + fig, + self._calculation_results.folder / f'{self.label} (flow rates).html', + user_filename=None if isinstance(save, bool) else pathlib.Path(save), + show=show, + save=True if save else False) + elif engine == 'matplotlib': + fig, ax = plotting.with_matplotlib( + self.node_balance(with_last_timestep=True).to_dataframe(), mode='bar', title=f'Flow rates of {self.label}' + ) + return matplotlib_save_and_show( + fig, + ax, + self._calculation_results.folder / f'{self.label} (flow rates).html', + user_filename=None if isinstance(save, bool) else pathlib.Path(save), + show=show, + save=True if save else False + ) + else: + raise ValueError(f'Engine "{engine}" not supported. Use "plotly" or "matplotlib"') def plot_node_balance_pie( self, @@ -419,11 +449,13 @@ def plot_node_balance_pie( show=show, save=True if save else False) - def node_balance(self, - negate_inputs: bool = True, - negate_outputs: bool = False, - threshold: Optional[float] = 1e-5, - with_last_timestep: bool = False) -> xr.Dataset: + def node_balance( + self, + negate_inputs: bool = True, + negate_outputs: bool = False, + threshold: Optional[float] = 1e-5, + with_last_timestep: bool = False + ) -> xr.Dataset: return sanitize_dataset( ds=self.solution[self.inputs + self.outputs], threshold=threshold, @@ -453,13 +485,25 @@ def _charge_state(self) -> str: @property def charge_state(self) -> xr.DataArray: + """ Get the solution of the charge state of the Storage. """ if not self.is_storage: raise ValueError(f'Cant get charge_state. "{self.label}" is not a storage') return self.solution[self._charge_state] - def plot_charge_state(self, - save: Union[bool, pathlib.Path] = False, - show: bool = True) -> plotly.graph_objs._figure.Figure: + def plot_charge_state( + self, + save: Union[bool, pathlib.Path] = False, + show: bool = True + ) -> plotly.graph_objs.Figure: + """ + Plots the charge state of a Storage. + Args: + 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. + + Raises: + ValueError: If the Component is not a Storage. + """ if not self.is_storage: raise ValueError(f'Cant plot charge_state. "{self.label}" is not a storage') fig = plotting.with_plotly(self.node_balance(with_last_timestep=True).to_dataframe(), @@ -482,6 +526,16 @@ def node_balance_with_charge_state( negate_inputs: bool = True, negate_outputs: bool = False, threshold: Optional[float] = 1e-5) -> xr.Dataset: + """ + Returns a dataset with the node balance of the Storage including its charge state. + Args: + negate_inputs: Whether to negate the inputs of the Storage. + negate_outputs: Whether to negate the outputs of the Storage. + threshold: The threshold for small values. + + Raises: + ValueError: If the Component is not a Storage. + """ if not self.is_storage: raise ValueError(f'Cant get charge_state. "{self.label}" is not a storage') variable_names = self.inputs + self.outputs + [self._charge_state] @@ -566,7 +620,7 @@ def segment_names(self) -> List[str]: return [segment.name for segment in self.segment_results] def solution_without_overlap(self, variable_name: str) -> xr.DataArray: - """Returns the solution of a variable without overlap""" + """Returns the solution of a variable without overlapping timesteps""" dataarrays = [result.solution[variable_name].isel(time=slice(None, self.timesteps_per_segment)) for result in self.segment_results[:-1] ] + [self.segment_results[-1].solution[variable_name]] @@ -580,8 +634,21 @@ def plot_heatmap( heatmap_timesteps_per_frame: Literal['W', 'D', 'h', '15min', 'min'] = 'h', color_map: str = 'portland', save: Union[bool, pathlib.Path] = False, - show: bool = True - ) -> plotly.graph_objs.Figure: + show: bool = True, + engine: Literal['plotly', 'matplotlib'] = 'plotly', + ) -> Union[plotly.graph_objs.Figure, Tuple[plt.Figure, plt.Axes]]: + """ + Plots a heatmap of the solution of a variable. + + Args: + variable_name: The name of the variable to plot. + heatmap_timeframes: The timeframes to use for the heatmap. + heatmap_timesteps_per_frame: The timesteps per frame to use for the heatmap. + color_map: The color map to use for the 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'. + """ return plot_heatmap( dataarray=self.solution_without_overlap(variable_name), name=variable_name, @@ -590,7 +657,9 @@ def plot_heatmap( heatmap_timesteps_per_frame=heatmap_timesteps_per_frame, color_map=color_map, save=save, - show=show) + show=show, + engine=engine, + ) def to_file(self, folder: Optional[Union[str, pathlib.Path]] = None, @@ -641,6 +710,32 @@ def plotly_save_and_show(fig: plotly.graph_objs.Figure, return fig +def matplotlib_save_and_show(fig: plt.Figure, + ax: plt.Axes, + default_filename: pathlib.Path, + user_filename: Optional[pathlib.Path] = None, + show: bool = True, + save: bool = False) -> Tuple[plt.Figure, plt.Axes]: + """ + Optionally saves and/or displays a Matplotlib figure. + + Args: + fig: The Matplotlib figure to display or save. + default_filename: The default file path if no user filename is provided. + user_filename: An optional user-specified file path. + show: Whether to display the figure (default: True). + save: Whether to save the figure (default: False). + + Returns: + plt.Figure: The input figure. + """ + filename = user_filename or default_filename + if show: + fig.show() + if save: + fig.savefig(str(filename), dpi=300) + return fig, ax + def plot_heatmap( dataarray: xr.DataArray, name: str, @@ -649,20 +744,54 @@ def plot_heatmap( heatmap_timesteps_per_frame: Literal['W', 'D', 'h', '15min', 'min'] = 'h', color_map: str = 'portland', save: Union[bool, pathlib.Path] = False, - show: bool = True + show: bool = True, + engine: Literal['plotly', 'matplotlib'] = 'plotly' ): + """ + Plots a heatmap of the solution of a variable. + + Args: + dataarray: The dataarray to plot. + name: The name of the variable to plot. + folder: The folder to save the plot to. + heatmap_timeframes: The timeframes to use for the heatmap. + heatmap_timesteps_per_frame: The timesteps per frame to use for the heatmap. + color_map: The color map to use for the 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'. + """ heatmap_data = plotting.heat_map_data_from_df( dataarray.to_dataframe(name), heatmap_timeframes, heatmap_timesteps_per_frame, 'ffill') - fig = plotting.heat_map_plotly( - heatmap_data, title=name, color_map=color_map, - xlabel=f'timeframe [{heatmap_timeframes}]', ylabel=f'timesteps [{heatmap_timesteps_per_frame}]' - ) - return plotly_save_and_show( - fig, - folder / f'{name} ({heatmap_timeframes}-{heatmap_timesteps_per_frame}).html', - user_filename=None if isinstance(save, bool) else pathlib.Path(save), - show=show, - save=True if save else False) + + xlabel, ylabel = f'timeframe [{heatmap_timeframes}]', f'timesteps [{heatmap_timesteps_per_frame}]' + + if engine == 'plotly': + fig = plotting.heat_map_plotly( + heatmap_data, title=name, color_map=color_map, + xlabel=xlabel, ylabel=ylabel + ) + return plotly_save_and_show( + fig, + folder / f'{name} ({heatmap_timeframes}-{heatmap_timesteps_per_frame}).html', + user_filename=None if isinstance(save, bool) else pathlib.Path(save), + show=show, + save=True if save else False) + elif engine == 'matplotlib': + fig, ax = plotting.heat_map_matplotlib( + heatmap_data, title=name, color_map=color_map, + xlabel=xlabel, ylabel=ylabel + ) + return matplotlib_save_and_show( + fig, + ax, + folder / f'{name} ({heatmap_timeframes}-{heatmap_timesteps_per_frame}).html', + user_filename=None if isinstance(save, bool) else pathlib.Path(save), + show=show, + save=True if save else False + ) + else: + raise ValueError(f'Engine "{engine}" not supported. Use "plotly" or "matplotlib"') def sanitize_dataset( From 557c2739ed2974a41f8a4ccfc68c1853f7aa9f14 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 26 Mar 2025 11:22:58 +0100 Subject: [PATCH 450/507] Bugfix png export --- flixOpt/plotting.py | 3 +++ flixOpt/results.py | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/flixOpt/plotting.py b/flixOpt/plotting.py index b13174e54..7e28db01c 100644 --- a/flixOpt/plotting.py +++ b/flixOpt/plotting.py @@ -198,6 +198,9 @@ def with_matplotlib( data: A DataFrame containing the data to plot. The index should represent time (e.g., hours), and each column represents a separate data series. mode: Plotting mode. Use 'bar' for stacked bar charts or 'line' for stepped lines. colors: A List of colors (as str) or a name of a colorscale (e.g., 'viridis', 'plasma') to use for coloring the data series. + title: The title of the plot. + ylabel: The ylabel of the plot. + xlabel: The xlabel of the plot. figsize: Specify the size of the figure fig: A Matplotlib figure object to plot on. If not provided, a new figure will be created. ax: A Matplotlib axes object to plot on. If not provided, a new axes will be created. diff --git a/flixOpt/results.py b/flixOpt/results.py index 15b6bb1f0..637ebaf18 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -404,7 +404,7 @@ def plot_node_balance( return matplotlib_save_and_show( fig, ax, - self._calculation_results.folder / f'{self.label} (flow rates).html', + self._calculation_results.folder / f'{self.label} (flow rates).png', user_filename=None if isinstance(save, bool) else pathlib.Path(save), show=show, save=True if save else False @@ -796,7 +796,7 @@ def plot_heatmap( return matplotlib_save_and_show( fig, ax, - folder / f'{name} ({heatmap_timeframes}-{heatmap_timesteps_per_frame}).html', + folder / f'{name} ({heatmap_timeframes}-{heatmap_timesteps_per_frame}).png', user_filename=None if isinstance(save, bool) else pathlib.Path(save), show=show, save=True if save else False From 914a12219c7163a43cb3ba3cba38b35bd8260bee Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 26 Mar 2025 11:23:11 +0100 Subject: [PATCH 451/507] Rename colormap in case of matplotlib --- flixOpt/results.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/flixOpt/results.py b/flixOpt/results.py index 637ebaf18..5eed531a0 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -789,6 +789,9 @@ def plot_heatmap( show=show, save=True if save else False) elif engine == 'matplotlib': + if color_map == 'portland': + logger.debug('Rename colormap "portland" to "coolwarm", as portland is not availlable in matplotlib') + color_map = 'coolwarm' fig, ax = plotting.heat_map_matplotlib( heatmap_data, title=name, color_map=color_map, xlabel=xlabel, ylabel=ylabel From 13ed0959f5850edd49eb1be00d984be84ac0050c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 26 Mar 2025 11:48:18 +0100 Subject: [PATCH 452/507] Add engine options but raise Exceptions --- flixOpt/results.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/flixOpt/results.py b/flixOpt/results.py index 5eed531a0..ef0713b75 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -419,6 +419,7 @@ def plot_node_balance_pie( text_info: str = 'percent+label+value', save: Union[bool, pathlib.Path] = False, show: bool = True, + engine: Literal['plotly'] = 'plotly' ) -> plotly.graph_objects.Figure: """ Plots a pie chart of the flow hours of the inputs and outputs of buses or components. @@ -429,7 +430,10 @@ def plot_node_balance_pie( text_info: What information to display on the pie plot save: Whether to save the figure. show: Whether to show the figure. + engine: Plotting engine to use. Only 'plotly' is implemented atm. """ + if engine != 'plotly': + raise NotImplementedError(f'Plotting engine "{engine}" not implemented for Component.plot_node_balance_pie.') inputs = sanitize_dataset( ds=self.solution[self.inputs], threshold=1e-5, @@ -504,19 +508,25 @@ def charge_state(self) -> xr.DataArray: def plot_charge_state( self, save: Union[bool, pathlib.Path] = False, - show: bool = True + show: bool = True, + engine: Literal['plotly'] = 'plotly' ) -> plotly.graph_objs.Figure: """ Plots the charge state of a Storage. Args: 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: Plotting engine to use. Only 'plotly' is implemented atm. Raises: ValueError: If the Component is not a Storage. """ + if engine != 'plotly': + raise NotImplementedError(f'Plotting engine "{engine}" not implemented for ComponentResults.plot_charge_state.') + if not self.is_storage: raise ValueError(f'Cant plot charge_state. "{self.label}" is not a storage') + fig = plotting.with_plotly(self.node_balance(with_last_timestep=True).to_dataframe(), mode='area', title=f'Operation Balance of {self.label}', From 0b30ff3c964e6f949955d718c1c1f492e7ce1964 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 26 Mar 2025 11:48:24 +0100 Subject: [PATCH 453/507] Add tests --- tests/test_results_plots.py | 57 +++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 tests/test_results_plots.py diff --git a/tests/test_results_plots.py b/tests/test_results_plots.py new file mode 100644 index 000000000..717931a66 --- /dev/null +++ b/tests/test_results_plots.py @@ -0,0 +1,57 @@ +import pytest + +import flixOpt as fx + +from .conftest import create_calculation_and_solve, simple_flow_system + + +@pytest.fixture(params=[True, False]) +def show(request): + return request.param + + +@pytest.fixture(params=[True, False]) +def save(request): + return request.param + + +def test_results_plots_matplotlib(simple_flow_system, show, save): + calculation = create_calculation_and_solve(simple_flow_system, fx.solvers.HighsSolver(0.01, 30), 'test_results_plots') + results = calculation.results + + results['Boiler'].plot_node_balance(engine='matplotlib', save=save, show=show) + + results.plot_heatmap('Speicher(Q_th_load)|flow_rate', + heatmap_timeframes='D', + heatmap_timesteps_per_frame='h', + color_map='viridis', + save=show, + show=save, + engine='matplotlib') + + with pytest.raises(NotImplementedError): + results['Speicher'].plot_charge_state(engine='matplotlib') + + with pytest.raises(NotImplementedError): + results['Speicher'].plot_node_balance_pie(engine='matplotlib', save=save, show=show) + + +def test_results_plots_plotly(simple_flow_system, save, show): + calculation = create_calculation_and_solve(simple_flow_system, fx.solvers.HighsSolver(0.01, 30), 'test_results_plots') + results = calculation.results + + results['Boiler'].plot_node_balance(engine='plotly', save=save, show=show) + + results.plot_heatmap('Speicher(Q_th_load)|flow_rate', + heatmap_timeframes='D', + heatmap_timesteps_per_frame='h', + color_map='viridis', + save=show, + show=save, + engine='matplotlib') + + results['Speicher'].plot_charge_state(engine='plotly') + + results['Speicher'].plot_node_balance_pie(engine='plotly', save=save, show=show) + + From 2b87e8f6fb0053275d7fb25d2922d1a8374d53f4 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 26 Mar 2025 11:50:55 +0100 Subject: [PATCH 454/507] ruff check --- flixOpt/plotting.py | 5 +++-- flixOpt/results.py | 2 +- tests/test_results_plots.py | 12 ++++++++---- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/flixOpt/plotting.py b/flixOpt/plotting.py index 7e28db01c..e13274ad3 100644 --- a/flixOpt/plotting.py +++ b/flixOpt/plotting.py @@ -919,9 +919,10 @@ def dual_pie_with_plotly( Returns: A Plotly figure object containing the generated dual pie chart. """ - from plotly.subplots import make_subplots import itertools + from plotly.subplots import make_subplots + # Check for empty data if data_left.empty and data_right.empty: logger.warning('Both datasets are empty. Returning empty figure.') @@ -947,7 +948,7 @@ def preprocess_series(series: pd.Series): """ # Handle negative values if (series < 0).any(): - logger.warning(f'Negative values detected in data. Using absolute values for pie chart.') + logger.warning('Negative values detected in data. Using absolute values for pie chart.') series = series.abs() # Remove zeros diff --git a/flixOpt/results.py b/flixOpt/results.py index ef0713b75..d57d49740 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -5,10 +5,10 @@ from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Tuple, Union import linopy +import matplotlib.pyplot as plt import numpy as np import pandas as pd import plotly -import matplotlib.pyplot as plt import xarray as xr import yaml diff --git a/tests/test_results_plots.py b/tests/test_results_plots.py index 717931a66..685bc0885 100644 --- a/tests/test_results_plots.py +++ b/tests/test_results_plots.py @@ -9,14 +9,18 @@ def show(request): return request.param +@pytest.fixture(params=[simple_flow_system]) +def flow_system(request): + return request.getfixturevalue(request.param.__name__) + @pytest.fixture(params=[True, False]) def save(request): return request.param -def test_results_plots_matplotlib(simple_flow_system, show, save): - calculation = create_calculation_and_solve(simple_flow_system, fx.solvers.HighsSolver(0.01, 30), 'test_results_plots') +def test_results_plots_matplotlib(flow_system, show, save): + calculation = create_calculation_and_solve(flow_system, fx.solvers.HighsSolver(0.01, 30), 'test_results_plots') results = calculation.results results['Boiler'].plot_node_balance(engine='matplotlib', save=save, show=show) @@ -36,8 +40,8 @@ def test_results_plots_matplotlib(simple_flow_system, show, save): results['Speicher'].plot_node_balance_pie(engine='matplotlib', save=save, show=show) -def test_results_plots_plotly(simple_flow_system, save, show): - calculation = create_calculation_and_solve(simple_flow_system, fx.solvers.HighsSolver(0.01, 30), 'test_results_plots') +def test_results_plots_plotly(flow_system, save, show): + calculation = create_calculation_and_solve(flow_system, fx.solvers.HighsSolver(0.01, 30), 'test_results_plots') results = calculation.results results['Boiler'].plot_node_balance(engine='plotly', save=save, show=show) From 7c81f9336cf7e9b7abb2c20cdb285a23ad3eed3d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 26 Mar 2025 13:03:24 +0100 Subject: [PATCH 455/507] Add custom colormap for "portland" --- flixOpt/plotting.py | 12 ++++++++++++ flixOpt/results.py | 3 --- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/flixOpt/plotting.py b/flixOpt/plotting.py index e13274ad3..e61c36ac5 100644 --- a/flixOpt/plotting.py +++ b/flixOpt/plotting.py @@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, List, Literal, Optional, Tuple, Union import matplotlib.pyplot as plt +import matplotlib.colors as mcolors import numpy as np import pandas as pd import plotly.express as px @@ -20,6 +21,17 @@ logger = logging.getLogger('flixOpt') +# Define the colors for the 'portland' colormap in matplotlib +_portland_colors = [ + [12/255, 51/255, 131/255], # Dark blue + [10/255, 136/255, 186/255], # Light blue + [242/255, 211/255, 56/255], # Yellow + [242/255, 143/255, 56/255], # Orange + [217/255, 30/255, 30/255] # Red +] + +plt.colormaps.register(mcolors.LinearSegmentedColormap.from_list('portland', _portland_colors)) + def with_plotly( data: pd.DataFrame, diff --git a/flixOpt/results.py b/flixOpt/results.py index d57d49740..25774d834 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -799,9 +799,6 @@ def plot_heatmap( show=show, save=True if save else False) elif engine == 'matplotlib': - if color_map == 'portland': - logger.debug('Rename colormap "portland" to "coolwarm", as portland is not availlable in matplotlib') - color_map = 'coolwarm' fig, ax = plotting.heat_map_matplotlib( heatmap_data, title=name, color_map=color_map, xlabel=xlabel, ylabel=ylabel From 17bb2ab4593ec09e1ea6f00240ce5b0091322cc7 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 26 Mar 2025 13:25:19 +0100 Subject: [PATCH 456/507] Feature/linopy/no periods plotting (#197) * Add pie plots * Improve santize_dataset() to be able to set certain values to 0 if below a threshold * Update pie plot to show flow hours * Improve grouping of pie plot * Add options to pie plot and add pie plots to examples * Improve docstring * Improve options for matplotlib * Add options to plot with matplotlib * Bugfix png export * Rename colormap in case of matplotlib * Add engine options but raise Exceptions * Add custom colormap for "portland" --- examples/00_Minmal/minimal_example.py | 1 + examples/01_Simple/simple_example.py | 1 + examples/02_Complex/complex_example.py | 1 + flixOpt/plotting.py | 444 ++++++++++++++++++++++++- flixOpt/results.py | 323 +++++++++++++++--- tests/test_results_plots.py | 61 ++++ 6 files changed, 772 insertions(+), 59 deletions(-) create mode 100644 tests/test_results_plots.py diff --git a/examples/00_Minmal/minimal_example.py b/examples/00_Minmal/minimal_example.py index f58d44e64..3de5d4358 100644 --- a/examples/00_Minmal/minimal_example.py +++ b/examples/00_Minmal/minimal_example.py @@ -61,6 +61,7 @@ df1 = calculation.results['costs'].filter_solution('time').to_dataframe() # Plot the results of a specific element + calculation.results['District Heating'].plot_node_balance_pie() calculation.results['District Heating'].plot_node_balance() # Save results to a file diff --git a/examples/01_Simple/simple_example.py b/examples/01_Simple/simple_example.py index 0e87c5611..ab4a2e747 100644 --- a/examples/01_Simple/simple_example.py +++ b/examples/01_Simple/simple_example.py @@ -107,6 +107,7 @@ calculation.solve(fx.solvers.HighsSolver(mip_gap=0, time_limit_seconds=30)) # --- Analyze Results --- + calculation.results['Fernwärme'].plot_node_balance_pie() calculation.results['Fernwärme'].plot_node_balance() calculation.results['Storage'].plot_node_balance() calculation.results.plot_heatmap('CHP(Q_th)|flow_rate') diff --git a/examples/02_Complex/complex_example.py b/examples/02_Complex/complex_example.py index 9429d4c45..f124d96ab 100644 --- a/examples/02_Complex/complex_example.py +++ b/examples/02_Complex/complex_example.py @@ -193,3 +193,4 @@ calculation.results.plot_heatmap('BHKW2(Q_th)|flow_rate') calculation.results['BHKW2'].plot_node_balance() calculation.results['Speicher'].plot_charge_state() + calculation.results['Fernwärme'].plot_node_balance_pie() diff --git a/flixOpt/plotting.py b/flixOpt/plotting.py index 9567f18f3..e61c36ac5 100644 --- a/flixOpt/plotting.py +++ b/flixOpt/plotting.py @@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, List, Literal, Optional, Tuple, Union import matplotlib.pyplot as plt +import matplotlib.colors as mcolors import numpy as np import pandas as pd import plotly.express as px @@ -20,6 +21,17 @@ logger = logging.getLogger('flixOpt') +# Define the colors for the 'portland' colormap in matplotlib +_portland_colors = [ + [12/255, 51/255, 131/255], # Dark blue + [10/255, 136/255, 186/255], # Light blue + [242/255, 211/255, 56/255], # Yellow + [242/255, 143/255, 56/255], # Orange + [217/255, 30/255, 30/255] # Red +] + +plt.colormaps.register(mcolors.LinearSegmentedColormap.from_list('portland', _portland_colors)) + def with_plotly( data: pd.DataFrame, @@ -27,6 +39,7 @@ def with_plotly( colors: Union[List[str], str] = 'viridis', title: str = '', ylabel: str = '', + xlabel: str = 'Time in h', fig: Optional[go.Figure] = None, show: bool = False, save: bool = False, @@ -101,6 +114,7 @@ def with_plotly( ) ) elif mode == 'area': + data = data.copy() data[(data > -1e-5) & (data < 1e-5)] = 0 # Preventing issues with plotting # Split columns into positive, negative, and mixed categories positive_columns = list(data.columns[(data >= 0).where(~np.isnan(data), True).all()]) @@ -149,7 +163,7 @@ def with_plotly( gridwidth=0.5, # Customize grid line width ), xaxis=dict( - title='Time in h', + title=xlabel, showgrid=True, # Enable grid lines on the x-axis gridcolor='lightgrey', # Customize grid line color gridwidth=0.5, # Customize grid line width @@ -180,6 +194,9 @@ def with_matplotlib( data: pd.DataFrame, mode: Literal['bar', 'line'] = 'bar', colors: Union[List[str], str] = 'viridis', + title: str = '', + ylabel: str = '', + xlabel: str = 'Time in h', figsize: Tuple[int, int] = (12, 6), fig: Optional[plt.Figure] = None, ax: Optional[plt.Axes] = None, @@ -193,6 +210,9 @@ def with_matplotlib( data: A DataFrame containing the data to plot. The index should represent time (e.g., hours), and each column represents a separate data series. mode: Plotting mode. Use 'bar' for stacked bar charts or 'line' for stepped lines. colors: A List of colors (as str) or a name of a colorscale (e.g., 'viridis', 'plasma') to use for coloring the data series. + title: The title of the plot. + ylabel: The ylabel of the plot. + xlabel: The xlabel of the plot. figsize: Specify the size of the figure fig: A Matplotlib figure object to plot on. If not provided, a new figure will be created. ax: A Matplotlib axes object to plot on. If not provided, a new axes will be created. @@ -260,7 +280,9 @@ def with_matplotlib( ax.step(data.index, data[column], where='post', color=colors[i], label=column) # Aesthetics - ax.set_xlabel('Time in h', fontsize=14) + ax.set_xlabel(xlabel, ha='center') + ax.set_ylabel(ylabel, va='center') + ax.set_title(title) ax.grid(color='lightgrey', linestyle='-', linewidth=0.5) ax.legend( loc='upper center', # Place legend at the bottom center @@ -281,6 +303,9 @@ def with_matplotlib( def heat_map_matplotlib( data: pd.DataFrame, color_map: str = 'viridis', + title: str = '', + xlabel: str = 'Period', + ylabel: str = 'Step', figsize: Tuple[float, float] = (12, 6), show: bool = False, path: Optional[Union[str, pathlib.Path]] = None, @@ -323,8 +348,9 @@ def heat_map_matplotlib( ax.set_yticklabels(data.index, va='center') # Add labels to the axes - ax.set_xlabel('Period', ha='center') - ax.set_ylabel('Step', va='center') + ax.set_xlabel(xlabel, ha='center') + ax.set_ylabel(ylabel, va='center') + ax.set_title(title) # Position x-axis labels at the top ax.xaxis.set_label_position('top') @@ -348,7 +374,7 @@ def heat_map_plotly( data: pd.DataFrame, color_map: str = 'viridis', title: str = '', - xlabel: str = 'Periods', + xlabel: str = 'Period', ylabel: str = 'Step', categorical_labels: bool = True, show: bool = False, @@ -633,3 +659,411 @@ def plot_network( logger.warning( f'Showing the network in the Browser went wrong. Open it manually. Its saved under {path}: {e}' ) + + +def pie_with_plotly( + data: pd.DataFrame, + colors: Union[List[str], str] = 'viridis', + title: str = '', + legend_title: str = '', + hole: float = 0.0, + fig: Optional[go.Figure] = None, + show: bool = False, + save: bool = False, + path: Union[str, pathlib.Path] = 'temp-plot.html', +) -> go.Figure: + """ + Create a pie chart with Plotly to visualize the proportion of values in a DataFrame. + + Args: + data: A DataFrame containing the data to plot. If multiple rows exist, + they will be summed unless a specific index value is passed. + colors: A List of colors (as str) or a name of a colorscale (e.g., 'viridis', 'plasma') + to use for coloring the pie segments. + title: The title of the plot. + legend_title: The title for the legend. + hole: Size of the hole in the center for creating a donut chart (0.0 to 1.0). + fig: A Plotly figure object to plot on. If not provided, a new figure will be created. + show: Whether to show the figure after creation. + save: Whether to save the figure after creation (without showing). + path: Path to save the figure. + + Returns: + A Plotly figure object containing the generated pie chart. + + Notes: + - Negative values are not appropriate for pie charts and will be converted to absolute values with a warning. + - If the data contains very small values (less than 1% of the total), they can be grouped into an "Other" category + for better readability. + - By default, the sum of all columns is used for the pie chart. For time series data, consider preprocessing. + + Examples: + >>> fig = pie_with_plotly(data, colorscale='Pastel') + >>> fig.show() + """ + if data.empty: + logger.warning("Empty DataFrame provided for pie chart. Returning empty figure.") + return go.Figure() + + # Create a copy to avoid modifying the original DataFrame + data_copy = data.copy() + + # Check if any negative values and warn + if (data_copy < 0).any().any(): + logger.warning("Negative values detected in data. Using absolute values for pie chart.") + data_copy = data_copy.abs() + + # If data has multiple rows, sum them to get total for each column + if len(data_copy) > 1: + data_sum = data_copy.sum() + else: + data_sum = data_copy.iloc[0] + + # Get labels (column names) and values + labels = data_sum.index.tolist() + values = data_sum.values.tolist() + + # Apply color mapping + if isinstance(colors, str): + colorscale = px.colors.get_colorscale(colors) + colors = px.colors.sample_colorscale( + colorscale, + [i / (len(labels) - 1) for i in range(len(labels))] if len(labels) > 1 else [0], + ) + + # Create figure if not provided + fig = fig if fig is not None else go.Figure() + + # Add pie trace + fig.add_trace( + go.Pie( + labels=labels, + values=values, + hole=hole, + marker=dict(colors=colors), + textinfo='percent+label+value', + textposition='inside', + insidetextorientation='radial', + ) + ) + + # Update layout for better aesthetics + fig.update_layout( + title=title, + legend_title=legend_title, + plot_bgcolor='rgba(0,0,0,0)', # Transparent background + paper_bgcolor='rgba(0,0,0,0)', # Transparent paper background + font=dict(size=14), # Increase font size for better readability + ) + + if isinstance(path, pathlib.Path): + path = path.as_posix() + if show: + plotly.offline.plot(fig, filename=path) + elif save: # If show, the file is saved anyway + fig.write_html(path) + + return fig + + +def pie_with_matplotlib( + data: pd.DataFrame, + colors: Union[List[str], str] = 'viridis', + title: str = '', + figsize: Tuple[int, int] = (10, 8), + autopct: str = '%1.1f%%', + startangle: int = 90, + shadow: bool = False, + is_donut: bool = False, + fig: Optional[plt.Figure] = None, + ax: Optional[plt.Axes] = None, + show: bool = False, + path: Optional[Union[str, pathlib.Path]] = None, +) -> Tuple[plt.Figure, plt.Axes]: + """ + Create a pie chart with Matplotlib to visualize the proportion of values in a DataFrame. + + Args: + data: A DataFrame containing the data to plot. If multiple rows exist, + they will be summed unless a specific index value is passed. + colors: A List of colors (as str) or a name of a colorscale (e.g., 'viridis', 'plasma') + to use for coloring the pie segments. + title: The title of the plot. + figsize: The size of the figure (width, height) in inches. + autopct: String format for the percentage display on wedges. + startangle: Starting angle for the first wedge in degrees. + shadow: Whether to draw the pie with a shadow beneath it. + is_donut: If True, creates a donut chart by adding a white circle in the center. + fig: A Matplotlib figure object to plot on. If not provided, a new figure will be created. + ax: A Matplotlib axes object to plot on. If not provided, a new axes will be created. + show: Whether to show the figure after creation. + path: Path to save the figure to. + + Returns: + A tuple containing the Matplotlib figure and axes objects used for the plot. + + Notes: + - Negative values are not appropriate for pie charts and will be converted to absolute values with a warning. + - If the data contains very small values (less than 1% of the total), they can be grouped into an "Other" category + for better readability. + - By default, the sum of all columns is used for the pie chart. For time series data, consider preprocessing. + + Examples: + >>> fig, ax = pie_with_matplotlib(data, colorscale='viridis', is_donut=True) + >>> plt.show() + """ + if data.empty: + logger.warning("Empty DataFrame provided for pie chart. Returning empty figure.") + if fig is None or ax is None: + fig, ax = plt.subplots(figsize=figsize) + return fig, ax + + # Create a copy to avoid modifying the original DataFrame + data_copy = data.copy() + + # Check if any negative values and warn + if (data_copy < 0).any().any(): + logger.warning("Negative values detected in data. Using absolute values for pie chart.") + data_copy = data_copy.abs() + + # If data has multiple rows, sum them to get total for each column + if len(data_copy) > 1: + data_sum = data_copy.sum() + else: + data_sum = data_copy.iloc[0] + + # Get labels (column names) and values + labels = data_sum.index.tolist() + values = data_sum.values.tolist() + + # Apply color mapping + if isinstance(colors, str): + cmap = plt.get_cmap(colors, len(labels)) + colors = [cmap(i) for i in range(len(labels))] + + # Create figure and axis if not provided + if fig is None or ax is None: + fig, ax = plt.subplots(figsize=figsize) + + # Draw the pie chart + wedges, texts, autotexts = ax.pie( + values, + labels=labels, + colors=colors, + autopct=autopct, + startangle=startangle, + shadow=shadow, + wedgeprops=dict(width=0.5) if is_donut else None, # Set width for donut + ) + + # Customize the appearance + # Make autopct text more visible + for autotext in autotexts: + autotext.set_fontsize(10) + autotext.set_color('white') + + # Set aspect ratio to be equal to ensure a circular pie + ax.set_aspect('equal') + + # Add title + if title: + ax.set_title(title, fontsize=16) + + # Create a legend if there are many segments + if len(labels) > 6: + ax.legend( + wedges, + labels, + title="Categories", + loc="center left", + bbox_to_anchor=(1, 0, 0.5, 1) + ) + + # Apply tight layout + fig.tight_layout() + + # Show or save + if show: + plt.show() + if path is not None: + fig.savefig(path, dpi=300, bbox_inches='tight') + + return fig, ax + + +def dual_pie_with_plotly( + data_left: pd.Series, + data_right: pd.Series, + colors: Union[List[str], str] = 'viridis', + title: str = '', + subtitles: Tuple[str, str] = ('Left Chart', 'Right Chart'), + legend_title: str = '', + hole: float = 0.2, + lower_percentage_group: float = 5.0, + hover_template: str = '%{label}: %{value} (%{percent})', + text_info: str = 'percent+label', + text_position: str = 'inside', + show: bool = False, + save: bool = False, + path: Union[str, pathlib.Path] = 'temp-plot.html', +) -> go.Figure: + """ + Create two pie charts side by side with Plotly, with consistent coloring across both charts. + + Args: + data_left: Series for the left pie chart. + data_right: Series for the right pie chart. + colors: A List of colors (as str) or a name of a colorscale (e.g., 'viridis', 'plasma') + to use for coloring the pie segments. + title: The main title of the plot. + subtitles: Tuple containing the subtitles for (left, right) charts. + legend_title: The title for the legend. + hole: Size of the hole in the center for creating donut charts (0.0 to 100). + lower_percentage_group: Whether to group small segments (below percentage (0...1)) into an "Other" category. + hover_template: Template for hover text. Use %{label}, %{value}, %{percent}. + text_info: What to show on pie segments: 'label', 'percent', 'value', 'label+percent', + 'label+value', 'percent+value', 'label+percent+value', or 'none'. + text_position: Position of text: 'inside', 'outside', 'auto', or 'none'. + show: Whether to show the figure after creation. + save: Whether to save the figure after creation (without showing). + path: Path to save the figure. + + Returns: + A Plotly figure object containing the generated dual pie chart. + """ + import itertools + + from plotly.subplots import make_subplots + + # Check for empty data + if data_left.empty and data_right.empty: + logger.warning('Both datasets are empty. Returning empty figure.') + return go.Figure() + + # Create a subplot figure + fig = make_subplots( + rows=1, cols=2, specs=[[{'type': 'pie'}, {'type': 'pie'}]], subplot_titles=subtitles, horizontal_spacing=0.05 + ) + + # Process series to handle negative values and apply minimum percentage threshold + def preprocess_series(series: pd.Series): + """ + Preprocess a series for pie chart display by handling negative values + and grouping the smallest parts together if they collectively represent + less than the specified percentage threshold. + + Args: + series: The series to preprocess + + Returns: + A preprocessed pandas Series + """ + # Handle negative values + if (series < 0).any(): + logger.warning('Negative values detected in data. Using absolute values for pie chart.') + series = series.abs() + + # Remove zeros + series = series[series > 0] + + # Apply minimum percentage threshold if needed + if lower_percentage_group and not series.empty: + total = series.sum() + if total > 0: + # Sort series by value (ascending) + sorted_series = series.sort_values() + + # Calculate cumulative percentage contribution + cumulative_percent = (sorted_series.cumsum() / total) * 100 + + # Find entries that collectively make up less than lower_percentage_group + to_group = cumulative_percent <= lower_percentage_group + + if to_group.sum() > 1: + # Create "Other" category for the smallest values that together are < threshold + other_sum = sorted_series[to_group].sum() + + # Keep only values that aren't in the "Other" group + result_series = series[~series.index.isin(sorted_series[to_group].index)] + + # Add the "Other" category if it has a value + if other_sum > 0: + result_series['Other'] = other_sum + + return result_series + + return series + + data_left_processed = preprocess_series(data_left) + data_right_processed = preprocess_series(data_right) + + # Get unique set of all labels for consistent coloring + all_labels = sorted(set(data_left_processed.index) | set(data_right_processed.index)) + + # Generate consistent color mapping + if isinstance(colors, str): + colorscale = px.colors.get_colorscale(colors) + color_list = px.colors.sample_colorscale( + colorscale, + [i / (len(all_labels) - 1) for i in range(len(all_labels))] if len(all_labels) > 1 else [0], + ) + color_map = {label: color_list[i] for i, label in enumerate(all_labels)} + else: + # If colors is a list, create a cycling iterator + color_iter = itertools.cycle(colors) + color_map = {label: next(color_iter) for label in all_labels} + + # Function to create a pie trace with consistently mapped colors + def create_pie_trace(data_series, side): + if data_series.empty: + return None + + labels = data_series.index.tolist() + values = data_series.values.tolist() + trace_colors = [color_map[label] for label in labels] + + return go.Pie( + labels=labels, + values=values, + name=side, + marker_colors=trace_colors, + hole=hole, + textinfo=text_info, + textposition=text_position, + insidetextorientation='radial', + hovertemplate=hover_template, + sort=True, # Sort values by default (largest first) + ) + + # Add left pie if data exists + left_trace = create_pie_trace(data_left_processed, subtitles[0]) + if left_trace: + left_trace.domain = dict(x=[0, 0.48]) + fig.add_trace(left_trace, row=1, col=1) + + # Add right pie if data exists + right_trace = create_pie_trace(data_right_processed, subtitles[1]) + if right_trace: + right_trace.domain = dict(x=[0.52, 1]) + fig.add_trace(right_trace, row=1, col=2) + + # Update layout + fig.update_layout( + title=title, + legend_title=legend_title, + plot_bgcolor='rgba(0,0,0,0)', # Transparent background + paper_bgcolor='rgba(0,0,0,0)', # Transparent paper background + font=dict(size=14), + margin=dict(t=80, b=50, l=30, r=30), + legend=dict(orientation='h', yanchor='bottom', y=-0.2, xanchor='center', x=0.5, font=dict(size=12)), + ) + + # Handle file saving and display + if isinstance(path, pathlib.Path): + path = path.as_posix() + if show: + plotly.offline.plot(fig, filename=path) + elif save: + fig.write_html(path) + + return fig diff --git a/flixOpt/results.py b/flixOpt/results.py index b64a8a703..25774d834 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Tuple, Union import linopy +import matplotlib.pyplot as plt import numpy as np import pandas as pd import plotly @@ -205,14 +206,16 @@ def filter_solution(self, return filter_dataset(self[element].solution, variable_dims) return filter_dataset(self.solution, variable_dims) - def plot_heatmap(self, - variable_name: str, - heatmap_timeframes: Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'] = 'D', - heatmap_timesteps_per_frame: Literal['W', 'D', 'h', '15min', 'min'] = 'h', - color_map: str = 'portland', - save: Union[bool, pathlib.Path] = False, - show: bool = True - ) -> plotly.graph_objs.Figure: + def plot_heatmap( + self, + variable_name: str, + heatmap_timeframes: Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'] = 'D', + heatmap_timesteps_per_frame: Literal['W', 'D', 'h', '15min', 'min'] = 'h', + color_map: str = 'portland', + save: Union[bool, pathlib.Path] = False, + show: bool = True, + engine: Literal['plotly', 'matplotlib'] = 'plotly' + ) -> Union[plotly.graph_objs.Figure, Tuple[plt.Figure, plt.Axes]]: return plot_heatmap( dataarray=self.solution[variable_name], name=variable_name, @@ -221,7 +224,9 @@ def plot_heatmap(self, heatmap_timesteps_per_frame=heatmap_timesteps_per_frame, color_map=color_map, save=save, - show=show) + show=show, + engine=engine, + ) def plot_network( self, @@ -369,27 +374,105 @@ def __init__(self, self.inputs = inputs self.outputs = outputs - def plot_node_balance(self, - save: Union[bool, pathlib.Path] = False, - show: bool = True): - fig = plotting.with_plotly( - self.node_balance(with_last_timestep=True).to_dataframe(), mode='area', title=f'Flow rates of {self.label}' + def plot_node_balance( + self, + save: Union[bool, pathlib.Path] = False, + show: bool = True, + engine: Literal['plotly', 'matplotlib'] = 'plotly' + ) -> Union[plotly.graph_objs.Figure, Tuple[plt.Figure, plt.Axes]]: + """ + Plots the node balance of the Component or Bus. + Args: + 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'. + """ + if engine == 'plotly': + fig = plotting.with_plotly( + self.node_balance(with_last_timestep=True).to_dataframe(), mode='area', title=f'Flow rates of {self.label}' + ) + return plotly_save_and_show( + fig, + self._calculation_results.folder / f'{self.label} (flow rates).html', + user_filename=None if isinstance(save, bool) else pathlib.Path(save), + show=show, + save=True if save else False) + elif engine == 'matplotlib': + fig, ax = plotting.with_matplotlib( + self.node_balance(with_last_timestep=True).to_dataframe(), mode='bar', title=f'Flow rates of {self.label}' + ) + return matplotlib_save_and_show( + fig, + ax, + self._calculation_results.folder / f'{self.label} (flow rates).png', + user_filename=None if isinstance(save, bool) else pathlib.Path(save), + show=show, + save=True if save else False + ) + else: + raise ValueError(f'Engine "{engine}" not supported. Use "plotly" or "matplotlib"') + + def plot_node_balance_pie( + self, + lower_percentage_group: float = 5, + colors: Union[str, List[str]] = 'viridis', + text_info: str = 'percent+label+value', + save: Union[bool, pathlib.Path] = False, + show: bool = True, + engine: Literal['plotly'] = 'plotly' + ) -> plotly.graph_objects.Figure: + """ + Plots a pie chart of the flow hours of the inputs and outputs of buses or components. + + Args: + colors: a colorscale or a list of colors to use for the plot + lower_percentage_group: The percentage of flow_hours that is grouped in "Others" (0...100) + text_info: What information to display on the pie plot + save: Whether to save the figure. + show: Whether to show the figure. + engine: Plotting engine to use. Only 'plotly' is implemented atm. + """ + if engine != 'plotly': + raise NotImplementedError(f'Plotting engine "{engine}" not implemented for Component.plot_node_balance_pie.') + inputs = sanitize_dataset( + ds=self.solution[self.inputs], + threshold=1e-5, + drop_small_vars=True, + zero_small_values=True, + ) * self._calculation_results.hours_per_timestep + outputs = sanitize_dataset( + ds=self.solution[self.outputs], + threshold=1e-5, + drop_small_vars=True, + zero_small_values=True, + ) * self._calculation_results.hours_per_timestep + fig = plotting.dual_pie_with_plotly( + inputs.to_dataframe().sum(), + outputs.to_dataframe().sum(), + colors=colors, + title=f'Flow hours of {self.label}', + text_info=text_info, + subtitles=('Inputs', 'Outputs'), + legend_title='Flows', + lower_percentage_group=lower_percentage_group, ) + return plotly_save_and_show( - fig, - self._calculation_results.folder / f'{self.label} (flow rates).html', - user_filename=None if isinstance(save, bool) else pathlib.Path(save), - show=show, - save=True if save else False) + fig, + self._calculation_results.folder / f'{self.label} (flow hours).html', + user_filename=None if isinstance(save, bool) else pathlib.Path(save), + show=show, + save=True if save else False) - def node_balance(self, - negate_inputs: bool = True, - negate_outputs: bool = False, - threshold: Optional[float] = 1e-5, - with_last_timestep: bool = False) -> xr.Dataset: - variable_names = [name for name in self._variable_names if name.endswith(('|flow_rate', '|excess_input', '|excess_output'))] + def node_balance( + self, + negate_inputs: bool = True, + negate_outputs: bool = False, + threshold: Optional[float] = 1e-5, + with_last_timestep: bool = False + ) -> xr.Dataset: return sanitize_dataset( - ds=self.solution[variable_names], + ds=self.solution[self.inputs + self.outputs], threshold=threshold, timesteps=self._calculation_results.timesteps_extra if with_last_timestep else None, negate=( @@ -417,15 +500,33 @@ def _charge_state(self) -> str: @property def charge_state(self) -> xr.DataArray: + """ Get the solution of the charge state of the Storage. """ if not self.is_storage: raise ValueError(f'Cant get charge_state. "{self.label}" is not a storage') return self.solution[self._charge_state] - def plot_charge_state(self, - save: Union[bool, pathlib.Path] = False, - show: bool = True) -> plotly.graph_objs._figure.Figure: + def plot_charge_state( + self, + save: Union[bool, pathlib.Path] = False, + show: bool = True, + engine: Literal['plotly'] = 'plotly' + ) -> plotly.graph_objs.Figure: + """ + Plots the charge state of a Storage. + Args: + 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: Plotting engine to use. Only 'plotly' is implemented atm. + + Raises: + ValueError: If the Component is not a Storage. + """ + if engine != 'plotly': + raise NotImplementedError(f'Plotting engine "{engine}" not implemented for ComponentResults.plot_charge_state.') + if not self.is_storage: raise ValueError(f'Cant plot charge_state. "{self.label}" is not a storage') + fig = plotting.with_plotly(self.node_balance(with_last_timestep=True).to_dataframe(), mode='area', title=f'Operation Balance of {self.label}', @@ -446,6 +547,16 @@ def node_balance_with_charge_state( negate_inputs: bool = True, negate_outputs: bool = False, threshold: Optional[float] = 1e-5) -> xr.Dataset: + """ + Returns a dataset with the node balance of the Storage including its charge state. + Args: + negate_inputs: Whether to negate the inputs of the Storage. + negate_outputs: Whether to negate the outputs of the Storage. + threshold: The threshold for small values. + + Raises: + ValueError: If the Component is not a Storage. + """ if not self.is_storage: raise ValueError(f'Cant get charge_state. "{self.label}" is not a storage') variable_names = self.inputs + self.outputs + [self._charge_state] @@ -530,7 +641,7 @@ def segment_names(self) -> List[str]: return [segment.name for segment in self.segment_results] def solution_without_overlap(self, variable_name: str) -> xr.DataArray: - """Returns the solution of a variable without overlap""" + """Returns the solution of a variable without overlapping timesteps""" dataarrays = [result.solution[variable_name].isel(time=slice(None, self.timesteps_per_segment)) for result in self.segment_results[:-1] ] + [self.segment_results[-1].solution[variable_name]] @@ -544,8 +655,21 @@ def plot_heatmap( heatmap_timesteps_per_frame: Literal['W', 'D', 'h', '15min', 'min'] = 'h', color_map: str = 'portland', save: Union[bool, pathlib.Path] = False, - show: bool = True - ) -> plotly.graph_objs.Figure: + show: bool = True, + engine: Literal['plotly', 'matplotlib'] = 'plotly', + ) -> Union[plotly.graph_objs.Figure, Tuple[plt.Figure, plt.Axes]]: + """ + Plots a heatmap of the solution of a variable. + + Args: + variable_name: The name of the variable to plot. + heatmap_timeframes: The timeframes to use for the heatmap. + heatmap_timesteps_per_frame: The timesteps per frame to use for the heatmap. + color_map: The color map to use for the 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'. + """ return plot_heatmap( dataarray=self.solution_without_overlap(variable_name), name=variable_name, @@ -554,7 +678,9 @@ def plot_heatmap( heatmap_timesteps_per_frame=heatmap_timesteps_per_frame, color_map=color_map, save=save, - show=show) + show=show, + engine=engine, + ) def to_file(self, folder: Optional[Union[str, pathlib.Path]] = None, @@ -605,6 +731,32 @@ def plotly_save_and_show(fig: plotly.graph_objs.Figure, return fig +def matplotlib_save_and_show(fig: plt.Figure, + ax: plt.Axes, + default_filename: pathlib.Path, + user_filename: Optional[pathlib.Path] = None, + show: bool = True, + save: bool = False) -> Tuple[plt.Figure, plt.Axes]: + """ + Optionally saves and/or displays a Matplotlib figure. + + Args: + fig: The Matplotlib figure to display or save. + default_filename: The default file path if no user filename is provided. + user_filename: An optional user-specified file path. + show: Whether to display the figure (default: True). + save: Whether to save the figure (default: False). + + Returns: + plt.Figure: The input figure. + """ + filename = user_filename or default_filename + if show: + fig.show() + if save: + fig.savefig(str(filename), dpi=300) + return fig, ax + def plot_heatmap( dataarray: xr.DataArray, name: str, @@ -613,49 +765,112 @@ def plot_heatmap( heatmap_timesteps_per_frame: Literal['W', 'D', 'h', '15min', 'min'] = 'h', color_map: str = 'portland', save: Union[bool, pathlib.Path] = False, - show: bool = True + show: bool = True, + engine: Literal['plotly', 'matplotlib'] = 'plotly' ): + """ + Plots a heatmap of the solution of a variable. + + Args: + dataarray: The dataarray to plot. + name: The name of the variable to plot. + folder: The folder to save the plot to. + heatmap_timeframes: The timeframes to use for the heatmap. + heatmap_timesteps_per_frame: The timesteps per frame to use for the heatmap. + color_map: The color map to use for the 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'. + """ heatmap_data = plotting.heat_map_data_from_df( dataarray.to_dataframe(name), heatmap_timeframes, heatmap_timesteps_per_frame, 'ffill') - fig = plotting.heat_map_plotly( - heatmap_data, title=name, color_map=color_map, - xlabel=f'timeframe [{heatmap_timeframes}]', ylabel=f'timesteps [{heatmap_timesteps_per_frame}]' - ) - return plotly_save_and_show( - fig, - folder / f'{name} ({heatmap_timeframes}-{heatmap_timesteps_per_frame}).html', - user_filename=None if isinstance(save, bool) else pathlib.Path(save), - show=show, - save=True if save else False) + + xlabel, ylabel = f'timeframe [{heatmap_timeframes}]', f'timesteps [{heatmap_timesteps_per_frame}]' + + if engine == 'plotly': + fig = plotting.heat_map_plotly( + heatmap_data, title=name, color_map=color_map, + xlabel=xlabel, ylabel=ylabel + ) + return plotly_save_and_show( + fig, + folder / f'{name} ({heatmap_timeframes}-{heatmap_timesteps_per_frame}).html', + user_filename=None if isinstance(save, bool) else pathlib.Path(save), + show=show, + save=True if save else False) + elif engine == 'matplotlib': + fig, ax = plotting.heat_map_matplotlib( + heatmap_data, title=name, color_map=color_map, + xlabel=xlabel, ylabel=ylabel + ) + return matplotlib_save_and_show( + fig, + ax, + folder / f'{name} ({heatmap_timeframes}-{heatmap_timesteps_per_frame}).png', + user_filename=None if isinstance(save, bool) else pathlib.Path(save), + show=show, + save=True if save else False + ) + else: + raise ValueError(f'Engine "{engine}" not supported. Use "plotly" or "matplotlib"') def sanitize_dataset( - ds: xr.Dataset, - timesteps: Optional[pd.DatetimeIndex] = None, - threshold: Optional[float] = 1e-5, - negate: Optional[List[str]] = None, + ds: xr.Dataset, + timesteps: Optional[pd.DatetimeIndex] = None, + threshold: Optional[float] = 1e-5, + negate: Optional[List[str]] = None, + drop_small_vars: bool = True, + zero_small_values: bool = False, ) -> xr.Dataset: """ - Sanitizes a dataset by dropping variables with small values and optionally reindexing the time axis. + Sanitizes a dataset by handling small values (dropping or zeroing) and optionally reindexing the time axis. Args: ds: The dataset to sanitize. timesteps: The timesteps to reindex the dataset to. If None, the original timesteps are kept. - threshold: The threshold for dropping variables. If None, no variables are dropped. + threshold: The threshold for small values processing. If None, no processing is done. negate: The variables to negate. If None, no variables are negated. + drop_small_vars: If True, drops variables where all values are below threshold. + zero_small_values: If True, sets values below threshold to zero. Returns: xr.Dataset: The sanitized dataset. """ + # Create a copy to avoid modifying the original + ds = ds.copy() + + # Step 1: Negate specified variables if negate is not None: for var in negate: - ds[var] = -ds[var] + if var in ds: + ds[var] = -ds[var] + + # Step 2: Handle small values if threshold is not None: - ds_no_nan_abs = xr.apply_ufunc(np.abs, ds).fillna(0) # Replace NaN with 0 (below thres ds_without_na = ds.fillna(0) # Replace NaN with 0 (below threshold) for the comparison - vars_to_drop = [var for var in ds.data_vars if (ds_no_nan_abs[var] <= threshold).all()] - ds = ds.drop_vars(vars_to_drop) + ds_no_nan_abs = xr.apply_ufunc(np.abs, ds).fillna(0) # Replace NaN with 0 (below threshold) for the comparison + + # Option 1: Drop variables where all values are below threshold + if drop_small_vars: + vars_to_drop = [var for var in ds.data_vars if (ds_no_nan_abs[var] <= threshold).all()] + ds = ds.drop_vars(vars_to_drop) + + # Option 2: Set small values to zero + if zero_small_values: + for var in ds.data_vars: + # Create a boolean mask of values below threshold + mask = ds_no_nan_abs[var] <= threshold + # Only proceed if there are values to zero out + if mask.any(): + # Create a copy to ensure we don't modify data with views + ds[var] = ds[var].copy() + # Set values below threshold to zero + ds[var] = ds[var].where(~mask, 0) + + # Step 3: Reindex to specified timesteps if needed if timesteps is not None and not ds.indexes['time'].equals(timesteps): ds = ds.reindex({'time': timesteps}, fill_value=np.nan) + return ds diff --git a/tests/test_results_plots.py b/tests/test_results_plots.py new file mode 100644 index 000000000..685bc0885 --- /dev/null +++ b/tests/test_results_plots.py @@ -0,0 +1,61 @@ +import pytest + +import flixOpt as fx + +from .conftest import create_calculation_and_solve, simple_flow_system + + +@pytest.fixture(params=[True, False]) +def show(request): + return request.param + +@pytest.fixture(params=[simple_flow_system]) +def flow_system(request): + return request.getfixturevalue(request.param.__name__) + + +@pytest.fixture(params=[True, False]) +def save(request): + return request.param + + +def test_results_plots_matplotlib(flow_system, show, save): + calculation = create_calculation_and_solve(flow_system, fx.solvers.HighsSolver(0.01, 30), 'test_results_plots') + results = calculation.results + + results['Boiler'].plot_node_balance(engine='matplotlib', save=save, show=show) + + results.plot_heatmap('Speicher(Q_th_load)|flow_rate', + heatmap_timeframes='D', + heatmap_timesteps_per_frame='h', + color_map='viridis', + save=show, + show=save, + engine='matplotlib') + + with pytest.raises(NotImplementedError): + results['Speicher'].plot_charge_state(engine='matplotlib') + + with pytest.raises(NotImplementedError): + results['Speicher'].plot_node_balance_pie(engine='matplotlib', save=save, show=show) + + +def test_results_plots_plotly(flow_system, save, show): + calculation = create_calculation_and_solve(flow_system, fx.solvers.HighsSolver(0.01, 30), 'test_results_plots') + results = calculation.results + + results['Boiler'].plot_node_balance(engine='plotly', save=save, show=show) + + results.plot_heatmap('Speicher(Q_th_load)|flow_rate', + heatmap_timeframes='D', + heatmap_timesteps_per_frame='h', + color_map='viridis', + save=show, + show=save, + engine='matplotlib') + + results['Speicher'].plot_charge_state(engine='plotly') + + results['Speicher'].plot_node_balance_pie(engine='plotly', save=save, show=show) + + From 6bb4d793cc6f11301a6a544e2dce1f9b54532c8d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 26 Mar 2025 13:27:29 +0100 Subject: [PATCH 457/507] Moving functions from results to plotting.py --- flixOpt/plotting.py | 59 ++++++++++++++++++++++++++++++++++++++++ flixOpt/results.py | 66 +++++---------------------------------------- 2 files changed, 65 insertions(+), 60 deletions(-) diff --git a/flixOpt/plotting.py b/flixOpt/plotting.py index e61c36ac5..2a4bd1b8f 100644 --- a/flixOpt/plotting.py +++ b/flixOpt/plotting.py @@ -1067,3 +1067,62 @@ def create_pie_trace(data_series, side): fig.write_html(path) return fig + + +def plotly_save_and_show( + fig: plotly.graph_objs.Figure, + default_filename: pathlib.Path, + user_filename: Optional[pathlib.Path] = None, + show: bool = True, + save: bool = False, +) -> plotly.graph_objs.Figure: + """ + Optionally saves and/or displays a Plotly figure. + + Args: + fig: The Plotly figure to display or save. + default_filename: The default file path if no user filename is provided. + user_filename: An optional user-specified file path. + show: Whether to display the figure (default: True). + save: Whether to save the figure (default: False). + + Returns: + go.Figure: The input figure. + """ + filename = user_filename or default_filename + if show and not save: + fig.show() + elif save and show: + plotly.offline.plot(fig, filename=str(filename)) + elif save and not show: + fig.write_html(filename) + return fig + + +def matplotlib_save_and_show( + fig: plt.Figure, + ax: plt.Axes, + default_filename: pathlib.Path, + user_filename: Optional[pathlib.Path] = None, + show: bool = True, + save: bool = False +) -> Tuple[plt.Figure, plt.Axes]: + """ + Optionally saves and/or displays a Matplotlib figure. + + Args: + fig: The Matplotlib figure to display or save. + default_filename: The default file path if no user filename is provided. + user_filename: An optional user-specified file path. + show: Whether to display the figure (default: True). + save: Whether to save the figure (default: False). + + Returns: + plt.Figure: The input figure. + """ + filename = user_filename or default_filename + if show: + fig.show() + if save: + fig.savefig(str(filename), dpi=300) + return fig, ax diff --git a/flixOpt/results.py b/flixOpt/results.py index 25774d834..18069e4fb 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -391,7 +391,7 @@ def plot_node_balance( fig = plotting.with_plotly( self.node_balance(with_last_timestep=True).to_dataframe(), mode='area', title=f'Flow rates of {self.label}' ) - return plotly_save_and_show( + return plotting.plotly_save_and_show( fig, self._calculation_results.folder / f'{self.label} (flow rates).html', user_filename=None if isinstance(save, bool) else pathlib.Path(save), @@ -401,7 +401,7 @@ def plot_node_balance( fig, ax = plotting.with_matplotlib( self.node_balance(with_last_timestep=True).to_dataframe(), mode='bar', title=f'Flow rates of {self.label}' ) - return matplotlib_save_and_show( + return plotting.matplotlib_save_and_show( fig, ax, self._calculation_results.folder / f'{self.label} (flow rates).png', @@ -457,7 +457,7 @@ def plot_node_balance_pie( lower_percentage_group=lower_percentage_group, ) - return plotly_save_and_show( + return plotting.plotly_save_and_show( fig, self._calculation_results.folder / f'{self.label} (flow hours).html', user_filename=None if isinstance(save, bool) else pathlib.Path(save), @@ -535,7 +535,7 @@ def plot_charge_state( fig.add_trace(plotly.graph_objs.Scatter( x=charge_state.index, y=charge_state.values.flatten(), mode='lines', name=self._charge_state)) - return plotly_save_and_show( + return plotting.plotly_save_and_show( fig, self._calculation_results.folder / f'{self.label} (charge state).html', user_filename=None if isinstance(save, bool) else pathlib.Path(save), @@ -703,60 +703,6 @@ def to_file(self, logger.info(f'Saved calculation "{name}" to {path}') -def plotly_save_and_show(fig: plotly.graph_objs.Figure, - default_filename: pathlib.Path, - user_filename: Optional[pathlib.Path] = None, - show: bool = True, - save: bool = False) -> plotly.graph_objs.Figure: - """ - Optionally saves and/or displays a Plotly figure. - - Args: - fig: The Plotly figure to display or save. - default_filename: The default file path if no user filename is provided. - user_filename: An optional user-specified file path. - show: Whether to display the figure (default: True). - save: Whether to save the figure (default: False). - - Returns: - go.Figure: The input figure. - """ - filename = user_filename or default_filename - if show and not save: - fig.show() - elif save and show: - plotly.offline.plot(fig, filename=str(filename)) - elif save and not show: - fig.write_html(filename) - return fig - - -def matplotlib_save_and_show(fig: plt.Figure, - ax: plt.Axes, - default_filename: pathlib.Path, - user_filename: Optional[pathlib.Path] = None, - show: bool = True, - save: bool = False) -> Tuple[plt.Figure, plt.Axes]: - """ - Optionally saves and/or displays a Matplotlib figure. - - Args: - fig: The Matplotlib figure to display or save. - default_filename: The default file path if no user filename is provided. - user_filename: An optional user-specified file path. - show: Whether to display the figure (default: True). - save: Whether to save the figure (default: False). - - Returns: - plt.Figure: The input figure. - """ - filename = user_filename or default_filename - if show: - fig.show() - if save: - fig.savefig(str(filename), dpi=300) - return fig, ax - def plot_heatmap( dataarray: xr.DataArray, name: str, @@ -792,7 +738,7 @@ def plot_heatmap( heatmap_data, title=name, color_map=color_map, xlabel=xlabel, ylabel=ylabel ) - return plotly_save_and_show( + return plotting.plotly_save_and_show( fig, folder / f'{name} ({heatmap_timeframes}-{heatmap_timesteps_per_frame}).html', user_filename=None if isinstance(save, bool) else pathlib.Path(save), @@ -803,7 +749,7 @@ def plot_heatmap( heatmap_data, title=name, color_map=color_map, xlabel=xlabel, ylabel=ylabel ) - return matplotlib_save_and_show( + return plotting.matplotlib_save_and_show( fig, ax, folder / f'{name} ({heatmap_timeframes}-{heatmap_timesteps_per_frame}).png', From 69dcff9ee07895c75448d2d757826e6e6d6e94a5 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 26 Mar 2025 13:28:06 +0100 Subject: [PATCH 458/507] formating --- flixOpt/plotting.py | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/flixOpt/plotting.py b/flixOpt/plotting.py index 2a4bd1b8f..6de8fac83 100644 --- a/flixOpt/plotting.py +++ b/flixOpt/plotting.py @@ -662,15 +662,15 @@ def plot_network( def pie_with_plotly( - data: pd.DataFrame, - colors: Union[List[str], str] = 'viridis', - title: str = '', - legend_title: str = '', - hole: float = 0.0, - fig: Optional[go.Figure] = None, - show: bool = False, - save: bool = False, - path: Union[str, pathlib.Path] = 'temp-plot.html', + data: pd.DataFrame, + colors: Union[List[str], str] = 'viridis', + title: str = '', + legend_title: str = '', + hole: float = 0.0, + fig: Optional[go.Figure] = None, + show: bool = False, + save: bool = False, + path: Union[str, pathlib.Path] = 'temp-plot.html', ) -> go.Figure: """ Create a pie chart with Plotly to visualize the proportion of values in a DataFrame. @@ -767,18 +767,18 @@ def pie_with_plotly( def pie_with_matplotlib( - data: pd.DataFrame, - colors: Union[List[str], str] = 'viridis', - title: str = '', - figsize: Tuple[int, int] = (10, 8), - autopct: str = '%1.1f%%', - startangle: int = 90, - shadow: bool = False, - is_donut: bool = False, - fig: Optional[plt.Figure] = None, - ax: Optional[plt.Axes] = None, - show: bool = False, - path: Optional[Union[str, pathlib.Path]] = None, + data: pd.DataFrame, + colors: Union[List[str], str] = 'viridis', + title: str = '', + figsize: Tuple[int, int] = (10, 8), + autopct: str = '%1.1f%%', + startangle: int = 90, + shadow: bool = False, + is_donut: bool = False, + fig: Optional[plt.Figure] = None, + ax: Optional[plt.Axes] = None, + show: bool = False, + path: Optional[Union[str, pathlib.Path]] = None, ) -> Tuple[plt.Figure, plt.Axes]: """ Create a pie chart with Matplotlib to visualize the proportion of values in a DataFrame. From 3a96fff46a6ae03772800d5b4c8cc9e5b78a9acc Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 26 Mar 2025 13:33:57 +0100 Subject: [PATCH 459/507] Remove save and show options from plotting methods. --- flixOpt/plotting.py | 76 --------------------------------------------- flixOpt/results.py | 9 +++--- 2 files changed, 5 insertions(+), 80 deletions(-) diff --git a/flixOpt/plotting.py b/flixOpt/plotting.py index 6de8fac83..aff682f8f 100644 --- a/flixOpt/plotting.py +++ b/flixOpt/plotting.py @@ -41,9 +41,6 @@ def with_plotly( ylabel: str = '', xlabel: str = 'Time in h', fig: Optional[go.Figure] = None, - show: bool = False, - save: bool = False, - path: Union[str, pathlib.Path] = 'temp-plot.html', ) -> go.Figure: """ Plot a DataFrame with Plotly, using either stacked bars or stepped lines. @@ -55,9 +52,6 @@ def with_plotly( title: The title of the plot. ylabel: The label for the y-axis. fig: A Plotly figure object to plot on. If not provided, a new figure will be created. - show: Wether to show the figure after creation. (This includes saving the figure) - save: Wether to save the figure after creation (without showing) - path: Path to save the figure. Returns: A Plotly figure object containing the generated plot. @@ -181,12 +175,6 @@ def with_plotly( ), ) - if isinstance(path, pathlib.Path): - path = path.as_posix() - if show: - plotly.offline.plot(fig, filename=path) - elif save: # If show, the file is saved anyway - fig.write_html(path) return fig @@ -200,8 +188,6 @@ def with_matplotlib( figsize: Tuple[int, int] = (12, 6), fig: Optional[plt.Figure] = None, ax: Optional[plt.Axes] = None, - show: bool = False, - path: Optional[Union[str, pathlib.Path]] = None, ) -> Tuple[plt.Figure, plt.Axes]: """ Plot a DataFrame with Matplotlib using stacked bars or stepped lines. @@ -216,8 +202,6 @@ def with_matplotlib( figsize: Specify the size of the figure fig: A Matplotlib figure object to plot on. If not provided, a new figure will be created. ax: A Matplotlib axes object to plot on. If not provided, a new axes will be created. - show: Wether to show the figure after creation. - path: Path to save the figure to. Returns: A tuple containing the Matplotlib figure and axes objects used for the plot. @@ -292,11 +276,6 @@ def with_matplotlib( ) fig.tight_layout() - if show: - plt.show() - if path is not None: - fig.savefig(path, dpi=300) - return fig, ax @@ -307,8 +286,6 @@ def heat_map_matplotlib( xlabel: str = 'Period', ylabel: str = 'Step', figsize: Tuple[float, float] = (12, 6), - show: bool = False, - path: Optional[Union[str, pathlib.Path]] = None, ) -> Tuple[plt.Figure, plt.Axes]: """ Plots a DataFrame as a heatmap using Matplotlib. The columns of the DataFrame will be displayed on the x-axis, @@ -319,8 +296,6 @@ def heat_map_matplotlib( The values in the DataFrame will be represented as colors in the heatmap. color_map: The colormap to use for the heatmap. Default is 'viridis'. Matplotlib supports various colormaps like 'plasma', 'inferno', 'cividis', etc. figsize: The size of the figure to create. Default is (12, 6), which results in a width of 12 inches and a height of 6 inches. - show: Wether to show the figure after creation. - path: Path to save the figure to. Returns: A tuple containing the Matplotlib `Figure` and `Axes` objects. The `Figure` contains the overall plot, while the `Axes` is the area @@ -362,10 +337,6 @@ def heat_map_matplotlib( fig.colorbar(sm1, ax=ax, pad=0.12, aspect=15, fraction=0.2, orientation='horizontal') fig.tight_layout() - if show: - plt.show() - if path is not None: - fig.savefig(path, dpi=300) return fig, ax @@ -377,9 +348,6 @@ def heat_map_plotly( xlabel: str = 'Period', ylabel: str = 'Step', categorical_labels: bool = True, - show: bool = False, - save: bool = False, - path: Union[str, pathlib.Path] = 'temp-plot.html', ) -> go.Figure: """ Plots a DataFrame as a heatmap using Plotly. The columns of the DataFrame will be mapped to the x-axis, @@ -433,13 +401,6 @@ def heat_map_plotly( yaxis=dict(title=ylabel, autorange='reversed', type='category' if categorical_labels else None), ) - if isinstance(path, pathlib.Path): - path = path.as_posix() - if show: - plotly.offline.plot(fig, filename=path) - elif save: # If show, the file is saved anyway - fig.write_html(path) - return fig @@ -668,9 +629,6 @@ def pie_with_plotly( legend_title: str = '', hole: float = 0.0, fig: Optional[go.Figure] = None, - show: bool = False, - save: bool = False, - path: Union[str, pathlib.Path] = 'temp-plot.html', ) -> go.Figure: """ Create a pie chart with Plotly to visualize the proportion of values in a DataFrame. @@ -684,9 +642,6 @@ def pie_with_plotly( legend_title: The title for the legend. hole: Size of the hole in the center for creating a donut chart (0.0 to 1.0). fig: A Plotly figure object to plot on. If not provided, a new figure will be created. - show: Whether to show the figure after creation. - save: Whether to save the figure after creation (without showing). - path: Path to save the figure. Returns: A Plotly figure object containing the generated pie chart. @@ -756,13 +711,6 @@ def pie_with_plotly( font=dict(size=14), # Increase font size for better readability ) - if isinstance(path, pathlib.Path): - path = path.as_posix() - if show: - plotly.offline.plot(fig, filename=path) - elif save: # If show, the file is saved anyway - fig.write_html(path) - return fig @@ -777,8 +725,6 @@ def pie_with_matplotlib( is_donut: bool = False, fig: Optional[plt.Figure] = None, ax: Optional[plt.Axes] = None, - show: bool = False, - path: Optional[Union[str, pathlib.Path]] = None, ) -> Tuple[plt.Figure, plt.Axes]: """ Create a pie chart with Matplotlib to visualize the proportion of values in a DataFrame. @@ -796,8 +742,6 @@ def pie_with_matplotlib( is_donut: If True, creates a donut chart by adding a white circle in the center. fig: A Matplotlib figure object to plot on. If not provided, a new figure will be created. ax: A Matplotlib axes object to plot on. If not provided, a new axes will be created. - show: Whether to show the figure after creation. - path: Path to save the figure to. Returns: A tuple containing the Matplotlib figure and axes objects used for the plot. @@ -882,12 +826,6 @@ def pie_with_matplotlib( # Apply tight layout fig.tight_layout() - # Show or save - if show: - plt.show() - if path is not None: - fig.savefig(path, dpi=300, bbox_inches='tight') - return fig, ax @@ -903,9 +841,6 @@ def dual_pie_with_plotly( hover_template: str = '%{label}: %{value} (%{percent})', text_info: str = 'percent+label', text_position: str = 'inside', - show: bool = False, - save: bool = False, - path: Union[str, pathlib.Path] = 'temp-plot.html', ) -> go.Figure: """ Create two pie charts side by side with Plotly, with consistent coloring across both charts. @@ -924,9 +859,6 @@ def dual_pie_with_plotly( text_info: What to show on pie segments: 'label', 'percent', 'value', 'label+percent', 'label+value', 'percent+value', 'label+percent+value', or 'none'. text_position: Position of text: 'inside', 'outside', 'auto', or 'none'. - show: Whether to show the figure after creation. - save: Whether to save the figure after creation (without showing). - path: Path to save the figure. Returns: A Plotly figure object containing the generated dual pie chart. @@ -1058,14 +990,6 @@ def create_pie_trace(data_series, side): legend=dict(orientation='h', yanchor='bottom', y=-0.2, xanchor='center', x=0.5, font=dict(size=12)), ) - # Handle file saving and display - if isinstance(path, pathlib.Path): - path = path.as_posix() - if show: - plotly.offline.plot(fig, filename=path) - elif save: - fig.write_html(path) - return fig diff --git a/flixOpt/results.py b/flixOpt/results.py index 18069e4fb..2c9d43274 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -527,10 +527,11 @@ def plot_charge_state( if not self.is_storage: raise ValueError(f'Cant plot charge_state. "{self.label}" is not a storage') - fig = plotting.with_plotly(self.node_balance(with_last_timestep=True).to_dataframe(), - mode='area', - title=f'Operation Balance of {self.label}', - show=False) + fig = plotting.with_plotly( + self.node_balance(with_last_timestep=True).to_dataframe(), + mode='area', + title=f'Operation Balance of {self.label}', + ) charge_state = self.charge_state.to_dataframe() fig.add_trace(plotly.graph_objs.Scatter( x=charge_state.index, y=charge_state.values.flatten(), mode='lines', name=self._charge_state)) From cea9a4507d24e6f9a825bf3fa4cc6b8dec8a7a76 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 26 Mar 2025 13:45:10 +0100 Subject: [PATCH 460/507] Unify export of figures for both plotly and matplotlib --- flixOpt/plotting.py | 76 ++++++++++++++++++--------------------------- 1 file changed, 30 insertions(+), 46 deletions(-) diff --git a/flixOpt/plotting.py b/flixOpt/plotting.py index aff682f8f..fa4a6e7e2 100644 --- a/flixOpt/plotting.py +++ b/flixOpt/plotting.py @@ -8,8 +8,8 @@ import pathlib from typing import TYPE_CHECKING, List, Literal, Optional, Tuple, Union -import matplotlib.pyplot as plt import matplotlib.colors as mcolors +import matplotlib.pyplot as plt import numpy as np import pandas as pd import plotly.express as px @@ -993,60 +993,44 @@ def create_pie_trace(data_series, side): return fig -def plotly_save_and_show( - fig: plotly.graph_objs.Figure, - default_filename: pathlib.Path, - user_filename: Optional[pathlib.Path] = None, - show: bool = True, - save: bool = False, -) -> plotly.graph_objs.Figure: - """ - Optionally saves and/or displays a Plotly figure. - - Args: - fig: The Plotly figure to display or save. - default_filename: The default file path if no user filename is provided. - user_filename: An optional user-specified file path. - show: Whether to display the figure (default: True). - save: Whether to save the figure (default: False). - - Returns: - go.Figure: The input figure. - """ - filename = user_filename or default_filename - if show and not save: - fig.show() - elif save and show: - plotly.offline.plot(fig, filename=str(filename)) - elif save and not show: - fig.write_html(filename) - return fig - - -def matplotlib_save_and_show( - fig: plt.Figure, - ax: plt.Axes, +def export_figure( + figure_like: Union[plotly.graph_objs.Figure, Tuple[plt.Figure, plt.Axes]], default_filename: pathlib.Path, user_filename: Optional[pathlib.Path] = None, show: bool = True, save: bool = False -) -> Tuple[plt.Figure, plt.Axes]: +) -> Union[plotly.graph_objs.Figure, Tuple[plt.Figure, plt.Axes]]: """ - Optionally saves and/or displays a Matplotlib figure. + Export a figure to a file and or show it. Args: - fig: The Matplotlib figure to display or save. + figure_like: The figure to export. Can be a Plotly figure or a tuple of Matplotlib figure and axes. default_filename: The default file path if no user filename is provided. user_filename: An optional user-specified file path. show: Whether to display the figure (default: True). save: Whether to save the figure (default: False). - - Returns: - plt.Figure: The input figure. """ - filename = user_filename or default_filename - if show: - fig.show() - if save: - fig.savefig(str(filename), dpi=300) - return fig, ax + + if isinstance(figure_like, plotly.graph_objs.Figure): + fig = figure_like + filename = user_filename or default_filename + if not filename.suffix == '.html': + logger.debug(f'To save a plotly figure, the filename should end with ".html". Got {filename}') + if show and not save: + fig.show() + elif save and show: + plotly.offline.plot(fig, filename=str(filename)) + elif save and not show: + fig.write_html(filename) + return figure_like + + elif isinstance(figure_like, tuple): + fig, ax = figure_like + filename = user_filename or default_filename + if show: + fig.show() + if save: + fig.savefig(str(filename), dpi=300) + return fig, ax + else: + raise TypeError(f'Figure type not supported: {type(figure_like)}') From 2b9f2fc2b36e14094518fd5631ca628f74d74187 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 26 Mar 2025 13:50:41 +0100 Subject: [PATCH 461/507] Improve path and filetype hanldling in export --- flixOpt/plotting.py | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/flixOpt/plotting.py b/flixOpt/plotting.py index fa4a6e7e2..0f469fe11 100644 --- a/flixOpt/plotting.py +++ b/flixOpt/plotting.py @@ -995,8 +995,9 @@ def create_pie_trace(data_series, side): def export_figure( figure_like: Union[plotly.graph_objs.Figure, Tuple[plt.Figure, plt.Axes]], - default_filename: pathlib.Path, - user_filename: Optional[pathlib.Path] = None, + default_path: pathlib.Path, + default_filetype: Optional[str] = None, + user_path: Optional[pathlib.Path] = None, show: bool = True, save: bool = False ) -> Union[plotly.graph_objs.Figure, Tuple[plt.Figure, plt.Axes]]: @@ -1005,15 +1006,24 @@ def export_figure( Args: figure_like: The figure to export. Can be a Plotly figure or a tuple of Matplotlib figure and axes. - default_filename: The default file path if no user filename is provided. - user_filename: An optional user-specified file path. + default_path: The default file path if no user filename is provided. + default_filetype: The default filetype if the path doesnt end with a filetype. + user_path: An optional user-specified file path. show: Whether to display the figure (default: True). save: Whether to save the figure (default: False). + + Raises: + ValueError: If no default filetype is provided and the path doesn't specify a filetype. + TypeError: If the figure type is not supported. """ + filename = user_path or default_path + if filename.suffix == '': + if default_filetype is None: + raise ValueError('No default filetype provided') + filename = filename.with_suffix(default_filetype) if isinstance(figure_like, plotly.graph_objs.Figure): fig = figure_like - filename = user_filename or default_filename if not filename.suffix == '.html': logger.debug(f'To save a plotly figure, the filename should end with ".html". Got {filename}') if show and not save: @@ -1026,11 +1036,10 @@ def export_figure( elif isinstance(figure_like, tuple): fig, ax = figure_like - filename = user_filename or default_filename if show: fig.show() if save: fig.savefig(str(filename), dpi=300) return fig, ax - else: - raise TypeError(f'Figure type not supported: {type(figure_like)}') + + raise TypeError(f'Figure type not supported: {type(figure_like)}') From 50983cbd924945d94b3def927d0a42d45255dfd8 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 26 Mar 2025 13:56:50 +0100 Subject: [PATCH 462/507] Use unified export possibilities for figures --- flixOpt/results.py | 82 ++++++++++++++++++++++------------------------ 1 file changed, 40 insertions(+), 42 deletions(-) diff --git a/flixOpt/results.py b/flixOpt/results.py index 2c9d43274..6352751c6 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -388,30 +388,27 @@ def plot_node_balance( engine: The engine to use for plotting. Can be either 'plotly' or 'matplotlib'. """ if engine == 'plotly': - fig = plotting.with_plotly( + figure_like = plotting.with_plotly( self.node_balance(with_last_timestep=True).to_dataframe(), mode='area', title=f'Flow rates of {self.label}' ) - return plotting.plotly_save_and_show( - fig, - self._calculation_results.folder / f'{self.label} (flow rates).html', - user_filename=None if isinstance(save, bool) else pathlib.Path(save), - show=show, - save=True if save else False) + default_filetype = '.html' elif engine == 'matplotlib': - fig, ax = plotting.with_matplotlib( + figure_like = plotting.with_matplotlib( self.node_balance(with_last_timestep=True).to_dataframe(), mode='bar', title=f'Flow rates of {self.label}' ) - return plotting.matplotlib_save_and_show( - fig, - ax, - self._calculation_results.folder / f'{self.label} (flow rates).png', - user_filename=None if isinstance(save, bool) else pathlib.Path(save), - show=show, - save=True if save else False - ) + default_filetype = '.png' else: raise ValueError(f'Engine "{engine}" not supported. Use "plotly" or "matplotlib"') + return plotting.export_figure( + figure_like=figure_like, + default_path=self._calculation_results.folder / f'{self.label} (flow rates)', + default_filetype=default_filetype, + user_path=None if isinstance(save, bool) else pathlib.Path(save), + show=show, + save=True if save else False, + ) + def plot_node_balance_pie( self, lower_percentage_group: float = 5, @@ -446,7 +443,8 @@ def plot_node_balance_pie( drop_small_vars=True, zero_small_values=True, ) * self._calculation_results.hours_per_timestep - fig = plotting.dual_pie_with_plotly( + + figure_like = plotting.dual_pie_with_plotly( inputs.to_dataframe().sum(), outputs.to_dataframe().sum(), colors=colors, @@ -457,12 +455,14 @@ def plot_node_balance_pie( lower_percentage_group=lower_percentage_group, ) - return plotting.plotly_save_and_show( - fig, - self._calculation_results.folder / f'{self.label} (flow hours).html', - user_filename=None if isinstance(save, bool) else pathlib.Path(save), - show=show, - save=True if save else False) + return plotting.export_figure( + figure_like=figure_like, + default_path=self._calculation_results.folder / f'{self.label} (flow hours)', + default_filetype='.html', + user_path=None if isinstance(save, bool) else pathlib.Path(save), + show=show, + save=True if save else False, + ) def node_balance( self, @@ -536,10 +536,11 @@ def plot_charge_state( fig.add_trace(plotly.graph_objs.Scatter( x=charge_state.index, y=charge_state.values.flatten(), mode='lines', name=self._charge_state)) - return plotting.plotly_save_and_show( + return plotting.export_figure( fig, - self._calculation_results.folder / f'{self.label} (charge state).html', - user_filename=None if isinstance(save, bool) else pathlib.Path(save), + default_path=self._calculation_results.folder / f'{self.label} (charge state)', + default_filetype='.html', + user_path=None if isinstance(save, bool) else pathlib.Path(save), show=show, save=True if save else False) @@ -735,32 +736,29 @@ def plot_heatmap( xlabel, ylabel = f'timeframe [{heatmap_timeframes}]', f'timesteps [{heatmap_timesteps_per_frame}]' if engine == 'plotly': - fig = plotting.heat_map_plotly( + figure_like = plotting.heat_map_plotly( heatmap_data, title=name, color_map=color_map, xlabel=xlabel, ylabel=ylabel ) - return plotting.plotly_save_and_show( - fig, - folder / f'{name} ({heatmap_timeframes}-{heatmap_timesteps_per_frame}).html', - user_filename=None if isinstance(save, bool) else pathlib.Path(save), - show=show, - save=True if save else False) + default_filetype = '.html' elif engine == 'matplotlib': - fig, ax = plotting.heat_map_matplotlib( + figure_like = plotting.heat_map_matplotlib( heatmap_data, title=name, color_map=color_map, xlabel=xlabel, ylabel=ylabel ) - return plotting.matplotlib_save_and_show( - fig, - ax, - folder / f'{name} ({heatmap_timeframes}-{heatmap_timesteps_per_frame}).png', - user_filename=None if isinstance(save, bool) else pathlib.Path(save), - show=show, - save=True if save else False - ) + default_filetype = '.png' else: raise ValueError(f'Engine "{engine}" not supported. Use "plotly" or "matplotlib"') + return plotting.export_figure( + figure_like=figure_like, + default_path=folder / f'{name} ({heatmap_timeframes}-{heatmap_timesteps_per_frame})', + default_filetype=default_filetype, + user_path=None if isinstance(save, bool) else pathlib.Path(save), + show=show, + save=True if save else False, + ) + def sanitize_dataset( ds: xr.Dataset, From 0eb4764e904fd0508dbc9df161305d57cc7766f6 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 26 Mar 2025 13:57:20 +0100 Subject: [PATCH 463/507] improve test --- tests/test_results_plots.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_results_plots.py b/tests/test_results_plots.py index 685bc0885..5843920ad 100644 --- a/tests/test_results_plots.py +++ b/tests/test_results_plots.py @@ -1,3 +1,4 @@ +import matplotlib.pyplot as plt import pytest import flixOpt as fx @@ -38,6 +39,7 @@ def test_results_plots_matplotlib(flow_system, show, save): with pytest.raises(NotImplementedError): results['Speicher'].plot_node_balance_pie(engine='matplotlib', save=save, show=show) + plt.close('all') def test_results_plots_plotly(flow_system, save, show): From 21ec33d886b5b8bb94a31529777b1792a051595c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 26 Mar 2025 14:11:21 +0100 Subject: [PATCH 464/507] Adjust pie_with_matplotlib() --- flixOpt/plotting.py | 35 +++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/flixOpt/plotting.py b/flixOpt/plotting.py index 0f469fe11..16f7286ee 100644 --- a/flixOpt/plotting.py +++ b/flixOpt/plotting.py @@ -718,11 +718,9 @@ def pie_with_matplotlib( data: pd.DataFrame, colors: Union[List[str], str] = 'viridis', title: str = '', + legend_title: str = 'Categories', + hole: float = 0.0, figsize: Tuple[int, int] = (10, 8), - autopct: str = '%1.1f%%', - startangle: int = 90, - shadow: bool = False, - is_donut: bool = False, fig: Optional[plt.Figure] = None, ax: Optional[plt.Axes] = None, ) -> Tuple[plt.Figure, plt.Axes]: @@ -735,11 +733,9 @@ def pie_with_matplotlib( colors: A List of colors (as str) or a name of a colorscale (e.g., 'viridis', 'plasma') to use for coloring the pie segments. title: The title of the plot. + legend_title: The title for the legend. + hole: Size of the hole in the center for creating a donut chart (0.0 to 1.0). figsize: The size of the figure (width, height) in inches. - autopct: String format for the percentage display on wedges. - startangle: Starting angle for the first wedge in degrees. - shadow: Whether to draw the pie with a shadow beneath it. - is_donut: If True, creates a donut chart by adding a white circle in the center. fig: A Matplotlib figure object to plot on. If not provided, a new figure will be created. ax: A Matplotlib axes object to plot on. If not provided, a new axes will be created. @@ -753,7 +749,7 @@ def pie_with_matplotlib( - By default, the sum of all columns is used for the pie chart. For time series data, consider preprocessing. Examples: - >>> fig, ax = pie_with_matplotlib(data, colorscale='viridis', is_donut=True) + >>> fig, ax = pie_with_matplotlib(data, colors='viridis', hole=0.3) >>> plt.show() """ if data.empty: @@ -794,12 +790,23 @@ def pie_with_matplotlib( values, labels=labels, colors=colors, - autopct=autopct, - startangle=startangle, - shadow=shadow, - wedgeprops=dict(width=0.5) if is_donut else None, # Set width for donut + autopct='%1.1f%%', + startangle=90, + shadow=False, + wedgeprops=dict(width=0.5) if hole > 0 else None, # Set width for donut ) + # Adjust the wedgeprops to make donut hole size consistent with plotly + # For matplotlib, the hole size is determined by the wedge width + # Convert hole parameter to wedge width + if hole > 0: + # Adjust hole size to match plotly's hole parameter + # In matplotlib, wedge width is relative to the radius (which is 1) + # For plotly, hole is a fraction of the radius + wedge_width = 1 - hole + for wedge in wedges: + wedge.set_width(wedge_width) + # Customize the appearance # Make autopct text more visible for autotext in autotexts: @@ -818,7 +825,7 @@ def pie_with_matplotlib( ax.legend( wedges, labels, - title="Categories", + title=legend_title, loc="center left", bbox_to_anchor=(1, 0, 0.5, 1) ) From fe604ce0a61cef52ebdcce05f259b3836e5389b2 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 26 Mar 2025 14:17:51 +0100 Subject: [PATCH 465/507] Add dual pie with matplotlib method --- flixOpt/plotting.py | 171 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 171 insertions(+) diff --git a/flixOpt/plotting.py b/flixOpt/plotting.py index 16f7286ee..3dd2c6461 100644 --- a/flixOpt/plotting.py +++ b/flixOpt/plotting.py @@ -1000,6 +1000,177 @@ def create_pie_trace(data_series, side): return fig +def dual_pie_with_matplotlib( + data_left: pd.Series, + data_right: pd.Series, + colors: Union[List[str], str] = 'viridis', + title: str = '', + subtitles: Tuple[str, str] = ('Left Chart', 'Right Chart'), + legend_title: str = '', + hole: float = 0.2, + lower_percentage_group: float = 5.0, + figsize: Tuple[int, int] = (14, 7), + fig: Optional[plt.Figure] = None, + axes: Optional[List[plt.Axes]] = None, +) -> Tuple[plt.Figure, List[plt.Axes]]: + """ + Create two pie charts side by side with Matplotlib, with consistent coloring across both charts. + Leverages the existing pie_with_matplotlib function. + + Args: + data_left: Series for the left pie chart. + data_right: Series for the right pie chart. + colors: A List of colors (as str) or a name of a colorscale (e.g., 'viridis', 'plasma') + to use for coloring the pie segments. + title: The main title of the plot. + subtitles: Tuple containing the subtitles for (left, right) charts. + legend_title: The title for the legend. + hole: Size of the hole in the center for creating donut charts (0.0 to 1.0). + lower_percentage_group: Whether to group small segments (below percentage) into an "Other" category. + figsize: The size of the figure (width, height) in inches. + fig: A Matplotlib figure object to plot on. If not provided, a new figure will be created. + axes: A list of Matplotlib axes objects to plot on. If not provided, new axes will be created. + + Returns: + A tuple containing the Matplotlib figure and list of axes objects used for the plot. + """ + import itertools + + # Check for empty data + if data_left.empty and data_right.empty: + logger.warning('Both datasets are empty. Returning empty figure.') + if fig is None: + fig, axes = plt.subplots(1, 2, figsize=figsize) + return fig, axes + + # Create figure and axes if not provided + if fig is None or axes is None: + fig, axes = plt.subplots(1, 2, figsize=figsize) + + # Process series to handle negative values and apply minimum percentage threshold + def preprocess_series(series: pd.Series): + """ + Preprocess a series for pie chart display by handling negative values + and grouping the smallest parts together if they collectively represent + less than the specified percentage threshold. + """ + # Handle negative values + if (series < 0).any(): + logger.warning('Negative values detected in data. Using absolute values for pie chart.') + series = series.abs() + + # Remove zeros + series = series[series > 0] + + # Apply minimum percentage threshold if needed + if lower_percentage_group and not series.empty: + total = series.sum() + if total > 0: + # Sort series by value (ascending) + sorted_series = series.sort_values() + + # Calculate cumulative percentage contribution + cumulative_percent = (sorted_series.cumsum() / total) * 100 + + # Find entries that collectively make up less than lower_percentage_group + to_group = cumulative_percent <= lower_percentage_group + + if to_group.sum() > 1: + # Create "Other" category for the smallest values that together are < threshold + other_sum = sorted_series[to_group].sum() + + # Keep only values that aren't in the "Other" group + result_series = series[~series.index.isin(sorted_series[to_group].index)] + + # Add the "Other" category if it has a value + if other_sum > 0: + result_series['Other'] = other_sum + + return result_series + + return series + + # Preprocess data + data_left_processed = preprocess_series(data_left) + data_right_processed = preprocess_series(data_right) + + # Convert Series to DataFrames for pie_with_matplotlib + df_left = pd.DataFrame(data_left_processed).T if not data_left_processed.empty else pd.DataFrame() + df_right = pd.DataFrame(data_right_processed).T if not data_right_processed.empty else pd.DataFrame() + + # Get unique set of all labels for consistent coloring + all_labels = sorted(set(data_left_processed.index) | set(data_right_processed.index)) + + # Generate a consistent color mapping for both charts + if isinstance(colors, str): + cmap = plt.get_cmap(colors, len(all_labels)) + color_list = [cmap(i) for i in range(len(all_labels))] + else: + # If colors is a list, create a cycling iterator + color_iter = itertools.cycle(colors) + color_list = [next(color_iter) for _ in range(len(all_labels))] + + # Create a mapping from label to color + color_map = {label: color_list[i] for i, label in enumerate(all_labels)} + + # Configure colors for each DataFrame based on the consistent mapping + left_colors = [color_map[col] for col in df_left.columns] if not df_left.empty else [] + right_colors = [color_map[col] for col in df_right.columns] if not df_right.empty else [] + + # Create left pie chart + if not df_left.empty: + pie_with_matplotlib(data=df_left, colors=left_colors, title=subtitles[0], hole=hole, fig=fig, ax=axes[0]) + else: + axes[0].set_title(subtitles[0]) + axes[0].axis('off') + + # Create right pie chart + if not df_right.empty: + pie_with_matplotlib(data=df_right, colors=right_colors, title=subtitles[1], hole=hole, fig=fig, ax=axes[1]) + else: + axes[1].set_title(subtitles[1]) + axes[1].axis('off') + + # Add main title + if title: + fig.suptitle(title, fontsize=16, y=0.98) + + # Adjust layout + fig.tight_layout() + + # Create a unified legend if both charts have data + if not df_left.empty and not df_right.empty: + # Remove individual legends + for ax in axes: + if ax.get_legend(): + ax.get_legend().remove() + + # Create handles for the unified legend + handles = [] + labels_for_legend = [] + + for label in all_labels: + color = color_map[label] + patch = plt.Line2D([0], [0], marker='o', color='w', markerfacecolor=color, markersize=10, label=label) + handles.append(patch) + labels_for_legend.append(label) + + # Add unified legend + fig.legend( + handles=handles, + labels=labels_for_legend, + title=legend_title, + loc='lower center', + bbox_to_anchor=(0.5, 0), + ncol=min(len(all_labels), 5), # Limit columns to 5 for readability + ) + + # Add padding at the bottom for the legend + fig.subplots_adjust(bottom=0.2) + + return fig, axes + + def export_figure( figure_like: Union[plotly.graph_objs.Figure, Tuple[plt.Figure, plt.Axes]], default_path: pathlib.Path, From 6a4917d2641776c40d6fe80f901d14cada75db2a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 26 Mar 2025 14:47:10 +0100 Subject: [PATCH 466/507] plot pie with matplotlib --- flixOpt/results.py | 49 ++++++++++++++++++++++++------------- tests/test_results_plots.py | 39 ++++++++++------------------- 2 files changed, 45 insertions(+), 43 deletions(-) diff --git a/flixOpt/results.py b/flixOpt/results.py index 6352751c6..6ff9ffb2b 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -429,8 +429,6 @@ def plot_node_balance_pie( show: Whether to show the figure. engine: Plotting engine to use. Only 'plotly' is implemented atm. """ - if engine != 'plotly': - raise NotImplementedError(f'Plotting engine "{engine}" not implemented for Component.plot_node_balance_pie.') inputs = sanitize_dataset( ds=self.solution[self.inputs], threshold=1e-5, @@ -444,21 +442,37 @@ def plot_node_balance_pie( zero_small_values=True, ) * self._calculation_results.hours_per_timestep - figure_like = plotting.dual_pie_with_plotly( - inputs.to_dataframe().sum(), - outputs.to_dataframe().sum(), - colors=colors, - title=f'Flow hours of {self.label}', - text_info=text_info, - subtitles=('Inputs', 'Outputs'), - legend_title='Flows', - lower_percentage_group=lower_percentage_group, - ) + if engine == 'plotly': + figure_like = plotting.dual_pie_with_plotly( + inputs.to_dataframe().sum(), + outputs.to_dataframe().sum(), + colors=colors, + title=f'Flow hours of {self.label}', + text_info=text_info, + subtitles=('Inputs', 'Outputs'), + legend_title='Flows', + lower_percentage_group=lower_percentage_group, + ) + default_filetype = '.html' + elif engine == 'matplotlib': + logger.debug('Parameter text_info is not supported for matplotlib') + figure_like = plotting.dual_pie_with_matplotlib( + inputs.to_dataframe().sum(), + outputs.to_dataframe().sum(), + colors=colors, + title=f'Flow hours of {self.label}', + subtitles=('Inputs', 'Outputs'), + legend_title='Flows', + lower_percentage_group=lower_percentage_group, + ) + default_filetype = '.png' + else: + raise ValueError(f'Engine "{engine}" not supported. Use "plotly" or "matplotlib"') return plotting.export_figure( figure_like=figure_like, default_path=self._calculation_results.folder / f'{self.label} (flow hours)', - default_filetype='.html', + default_filetype=default_filetype, user_path=None if isinstance(save, bool) else pathlib.Path(save), show=show, save=True if save else False, @@ -545,10 +559,11 @@ def plot_charge_state( save=True if save else False) def node_balance_with_charge_state( - self, - negate_inputs: bool = True, - negate_outputs: bool = False, - threshold: Optional[float] = 1e-5) -> xr.Dataset: + self, + negate_inputs: bool = True, + negate_outputs: bool = False, + threshold: Optional[float] = 1e-5 + ) -> xr.Dataset: """ Returns a dataset with the node balance of the Storage including its charge state. Args: diff --git a/tests/test_results_plots.py b/tests/test_results_plots.py index 5843920ad..59840cf1a 100644 --- a/tests/test_results_plots.py +++ b/tests/test_results_plots.py @@ -20,33 +20,16 @@ def save(request): return request.param -def test_results_plots_matplotlib(flow_system, show, save): - calculation = create_calculation_and_solve(flow_system, fx.solvers.HighsSolver(0.01, 30), 'test_results_plots') - results = calculation.results - - results['Boiler'].plot_node_balance(engine='matplotlib', save=save, show=show) - - results.plot_heatmap('Speicher(Q_th_load)|flow_rate', - heatmap_timeframes='D', - heatmap_timesteps_per_frame='h', - color_map='viridis', - save=show, - show=save, - engine='matplotlib') - - with pytest.raises(NotImplementedError): - results['Speicher'].plot_charge_state(engine='matplotlib') - - with pytest.raises(NotImplementedError): - results['Speicher'].plot_node_balance_pie(engine='matplotlib', save=save, show=show) - plt.close('all') +@pytest.fixture(params=['plotly', 'matplotlib']) +def plotting_engine(request): + return request.param -def test_results_plots_plotly(flow_system, save, show): +def test_results_plots(flow_system, plotting_engine, show, save): calculation = create_calculation_and_solve(flow_system, fx.solvers.HighsSolver(0.01, 30), 'test_results_plots') results = calculation.results - results['Boiler'].plot_node_balance(engine='plotly', save=save, show=show) + results['Boiler'].plot_node_balance(engine=plotting_engine, save=save, show=show) results.plot_heatmap('Speicher(Q_th_load)|flow_rate', heatmap_timeframes='D', @@ -54,10 +37,14 @@ def test_results_plots_plotly(flow_system, save, show): color_map='viridis', save=show, show=save, - engine='matplotlib') + engine=plotting_engine) - results['Speicher'].plot_charge_state(engine='plotly') - - results['Speicher'].plot_node_balance_pie(engine='plotly', save=save, show=show) + results['Speicher'].plot_node_balance_pie(engine=plotting_engine, save=save, show=show) + if plotting_engine == 'matplotlib': + with pytest.raises(NotImplementedError): + results['Speicher'].plot_charge_state(engine=plotting_engine) + else: + results['Speicher'].plot_charge_state(engine=plotting_engine) + plt.close('all') From 79fcf6384d1ece42c49319c290c99a7208a28dad Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 26 Mar 2025 14:48:15 +0100 Subject: [PATCH 467/507] Fix title --- flixOpt/results.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flixOpt/results.py b/flixOpt/results.py index 6ff9ffb2b..ed6d1ded8 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -460,7 +460,7 @@ def plot_node_balance_pie( inputs.to_dataframe().sum(), outputs.to_dataframe().sum(), colors=colors, - title=f'Flow hours of {self.label}', + title=f'Total flow hours of {self.label}', subtitles=('Inputs', 'Outputs'), legend_title='Flows', lower_percentage_group=lower_percentage_group, @@ -471,7 +471,7 @@ def plot_node_balance_pie( return plotting.export_figure( figure_like=figure_like, - default_path=self._calculation_results.folder / f'{self.label} (flow hours)', + default_path=self._calculation_results.folder / f'{self.label} (total flow hours)', default_filetype=default_filetype, user_path=None if isinstance(save, bool) else pathlib.Path(save), show=show, From e7fc586c6822cefeeaf61197b66e7265b6ce250b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 26 Mar 2025 14:55:59 +0100 Subject: [PATCH 468/507] ruff check --- flixOpt/plotting.py | 1 - 1 file changed, 1 deletion(-) diff --git a/flixOpt/plotting.py b/flixOpt/plotting.py index d9c2a0858..3dd2c6461 100644 --- a/flixOpt/plotting.py +++ b/flixOpt/plotting.py @@ -10,7 +10,6 @@ import matplotlib.colors as mcolors import matplotlib.pyplot as plt -import matplotlib.colors as mcolors import numpy as np import pandas as pd import plotly.express as px From 98a3410f426d3ba9de3912140760f40e4f7e1bf6 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 26 Mar 2025 15:21:33 +0100 Subject: [PATCH 469/507] Update example_calculation_types.py --- .../example_calculation_types.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/examples/03_Calculation_types/example_calculation_types.py b/examples/03_Calculation_types/example_calculation_types.py index 673b22f34..51ea03c68 100644 --- a/examples/03_Calculation_types/example_calculation_types.py +++ b/examples/03_Calculation_types/example_calculation_types.py @@ -190,18 +190,18 @@ def get_solutions(calcs: List, variable: str) -> xr.Dataset: # --- Plotting for comparison --- fx.plotting.with_plotly( get_solutions(calculations, 'Speicher|charge_state').to_dataframe(), - mode='line', title='Charge State Comparison', ylabel='Charge state', path='results/Charge State.html', save=True - ) + mode='line', title='Charge State Comparison', ylabel='Charge state', + ).write_html('results/Charge State.html') fx.plotting.with_plotly( get_solutions(calculations, 'BHKW2(Q_th)|flow_rate').to_dataframe(), - mode='line', title='BHKW2(Q_th) Flow Rate Comparison', ylabel='Flow rate', path='results/BHKW2 Thermal Power.html', save=True - ) + mode='line', title='BHKW2(Q_th) Flow Rate Comparison', ylabel='Flow rate', + ).write_html('results/BHKW2 Thermal Power.html') fx.plotting.with_plotly( get_solutions(calculations, 'costs(operation)|total_per_timestep').to_dataframe(), - mode='line', title='Operation Cost Comparison', ylabel='Costs [€]', path='results/Operation Costs.html', save=True - ) + mode='line', title='Operation Cost Comparison', ylabel='Costs [€]' + ).write_html('results/Operation Costs.html') fx.plotting.with_plotly( pd.DataFrame(get_solutions(calculations, 'costs(operation)|total_per_timestep').to_dataframe().sum()).T, From 80a62b82bb5c8457712780d32ceb442fb5a4b5b4 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 26 Mar 2025 15:45:18 +0100 Subject: [PATCH 470/507] Improve io of aggregation.py --- flixOpt/aggregation.py | 23 +++++++++++++++++++---- flixOpt/calculation.py | 2 +- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/flixOpt/aggregation.py b/flixOpt/aggregation.py index 7f6648c86..31ed59d9f 100644 --- a/flixOpt/aggregation.py +++ b/flixOpt/aggregation.py @@ -7,8 +7,8 @@ import logging import timeit import warnings -from collections import Counter -from typing import TYPE_CHECKING, Dict, List, Optional, Set, Tuple +import pathlib +from typing import TYPE_CHECKING, Dict, List, Optional, Set, Tuple, Union import linopy import numpy as np @@ -140,7 +140,12 @@ def describe_clusters(self) -> str: def use_extreme_periods(self): return self.time_series_for_high_peaks or self.time_series_for_low_peaks - def plot(self, colormap: str = 'viridis', show: bool = True) -> 'go.Figure': + def plot( + self, + colormap: str = 'viridis', + show: bool = True, + save: Optional[pathlib.Path] = None + ) -> 'go.Figure': from . import plotting df_org = self.original_data.copy().rename( @@ -152,11 +157,21 @@ def plot(self, colormap: str = 'viridis', show: bool = True) -> 'go.Figure': fig = plotting.with_plotly(df_org, 'line', colors=colormap) for trace in fig.data: trace.update(dict(line=dict(dash='dash'))) - fig = plotting.with_plotly(df_agg, 'line', colors=colormap, show=show, fig=fig) + fig = plotting.with_plotly(df_agg, 'line', colors=colormap, fig=fig) fig.update_layout( title='Original vs Aggregated Data (original = ---)', xaxis_title='Index', yaxis_title='Value' ) + + plotting.export_figure( + figure_like=fig, + default_path=pathlib.Path('aggregated data.html'), + default_filetype='.html', + user_path=None if isinstance(save, bool) else pathlib.Path(save), + show=show, + save=True if save else False, + ) + return fig def get_cluster_indices(self) -> Dict[str, List[np.ndarray]]: diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py index 31fb5e663..1717a33f5 100644 --- a/flixOpt/calculation.py +++ b/flixOpt/calculation.py @@ -259,7 +259,7 @@ def _perform_aggregation(self): ) self.aggregation.cluster() - self.aggregation.plot() + self.aggregation.plot(show=True, save= self.folder / 'aggregation.html') if self.aggregation_parameters.aggregate_data_and_fix_non_binary_vars: self.flow_system.time_series_collection.insert_new_data(self.aggregation.aggregated_data, include_extra_timestep=False) self.durations['aggregation'] = round(timeit.default_timer() - t_start_agg, 2) From e409281d06928b8f921e611051e8edf0ef4af0d0 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 26 Mar 2025 16:04:08 +0100 Subject: [PATCH 471/507] ruff check --- flixOpt/aggregation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixOpt/aggregation.py b/flixOpt/aggregation.py index 31ed59d9f..1b33d0d06 100644 --- a/flixOpt/aggregation.py +++ b/flixOpt/aggregation.py @@ -5,9 +5,9 @@ import copy import logging +import pathlib import timeit import warnings -import pathlib from typing import TYPE_CHECKING, Dict, List, Optional, Set, Tuple, Union import linopy From 18b76b68cf7088beea90813653312e5af0a4fe25 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 27 Mar 2025 11:48:43 +0100 Subject: [PATCH 472/507] Improved color handling in plots * Add color handling options for plotting.py * Use improved color processing * Use improved color processing to pie * Use improved color processing to pie * Fix imports * ruff check * Update colors in results.py * Improving color handling * Add more tests for plotting * Add docstrings to types * Update type hints * Use a ColorProcessor class * Use a ColorProcessor class --- flixOpt/plotting.py | 364 +++++++++++++++++++++++++----------- flixOpt/results.py | 31 ++- tests/test_results_plots.py | 38 +++- 3 files changed, 309 insertions(+), 124 deletions(-) diff --git a/flixOpt/plotting.py b/flixOpt/plotting.py index 3dd2c6461..30df69126 100644 --- a/flixOpt/plotting.py +++ b/flixOpt/plotting.py @@ -4,9 +4,10 @@ It's meant to be used in results.py, but is designed to be used by the end user as well. """ +import itertools import logging import pathlib -from typing import TYPE_CHECKING, List, Literal, Optional, Tuple, Union +from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, Union import matplotlib.colors as mcolors import matplotlib.pyplot as plt @@ -15,6 +16,7 @@ import plotly.express as px import plotly.graph_objects as go import plotly.offline +from plotly.exceptions import PlotlyError if TYPE_CHECKING: import pyvis @@ -33,10 +35,200 @@ plt.colormaps.register(mcolors.LinearSegmentedColormap.from_list('portland', _portland_colors)) +ColorType = Union[str, List[str], Dict[str, str]] +"""Identifier for the colors to use. +Use the name of a colorscale, a list of colors or a dictionary of labels to colors. +The colors must be valid color strings (HEX or names). Depending on the Engine used, other formats are possible. +See also: +- https://htmlcolorcodes.com/color-names/ +- https://matplotlib.org/stable/tutorials/colors/colormaps.html +- https://plotly.com/python/builtin-colorscales/ +""" + +PlottingEngine = Literal['plotly', 'matplotlib'] +"""Identifier for the plotting engine to use.""" + + +class ColorProcessor: + """Class to handle color processing for different visualization engines.""" + + def __init__(self, engine: PlottingEngine = 'plotly', default_colormap: str = 'viridis'): + """ + Initialize the color processor. + + Args: + engine: The plotting engine to use ('plotly' or 'matplotlib') + default_colormap: Default colormap to use if none is specified + """ + if engine not in ["plotly", "matplotlib"]: + raise TypeError(f'engine must be "plotly" or "matplotlib", but is {engine}') + self.engine = engine + self.default_colormap = default_colormap + + def _generate_colors_from_colormap(self, colormap_name: str, num_colors: int) -> List[Any]: + """ + Generate colors from a named colormap. + + Args: + colormap_name: Name of the colormap + num_colors: Number of colors to generate + + Returns: + List of colors in the format appropriate for the engine + """ + if self.engine == 'plotly': + try: + colorscale = px.colors.get_colorscale(colormap_name) + except PlotlyError as e: + logger.warning( + f"Colorscale '{colormap_name}' not found in Plotly. Using {self.default_colormap}: {e}" + ) + colorscale = px.colors.get_colorscale(self.default_colormap) + + # Generate evenly spaced points + color_points = [i / (num_colors - 1) for i in range(num_colors)] if num_colors > 1 else [0] + return px.colors.sample_colorscale(colorscale, color_points) + + else: # matplotlib + try: + cmap = plt.get_cmap(colormap_name, num_colors) + except ValueError as e: + logger.warning( + f"Colormap '{colormap_name}' not found in Matplotlib. Using {self.default_colormap}: {e}" + ) + cmap = plt.get_cmap(self.default_colormap, num_colors) + + return [cmap(i) for i in range(num_colors)] + + def _handle_color_list(self, colors: List[str], num_labels: int) -> List[str]: + """ + Handle a list of colors, cycling if necessary. + + Args: + colors: List of color strings + num_labels: Number of labels that need colors + + Returns: + List of colors matching the number of labels + """ + if len(colors) == 0: + logger.warning(f'Empty color list provided. Using {self.default_colormap} instead.') + return self._generate_colors_from_colormap(self.default_colormap, num_labels) + + if len(colors) < num_labels: + logger.warning( + f'Not enough colors provided ({len(colors)}) for all labels ({num_labels}). Colors will cycle.' + ) + # Cycle through the colors + color_iter = itertools.cycle(colors) + return [next(color_iter) for _ in range(num_labels)] + else: + # Trim if necessary + if len(colors) > num_labels: + logger.warning( + f'More colors provided ({len(colors)}) than labels ({num_labels}). Extra colors will be ignored.' + ) + return colors[:num_labels] + + def _handle_color_dict(self, colors: Dict[str, str], labels: List[str]) -> List[str]: + """ + Handle a dictionary mapping labels to colors. + + Args: + colors: Dictionary mapping labels to colors + labels: List of labels that need colors + + Returns: + List of colors in the same order as labels + """ + if len(colors) == 0: + logger.warning(f'Empty color dictionary provided. Using {self.default_colormap} instead.') + return self._generate_colors_from_colormap(self.default_colormap, len(labels)) + + # Find missing labels + missing_labels = set(labels) - set(colors.keys()) + if missing_labels: + logger.warning( + f'Some labels have no color specified: {missing_labels}. Using {self.default_colormap} for these.' + ) + + # Generate colors for missing labels + missing_colors = self._generate_colors_from_colormap(self.default_colormap, len(missing_labels)) + + # Create a copy to avoid modifying the original + colors_copy = colors.copy() + for i, label in enumerate(missing_labels): + colors_copy[label] = missing_colors[i] + else: + colors_copy = colors + + # Create color list in the same order as labels + return [colors_copy[label] for label in labels] + + def process_colors( + self, + colors: ColorType, + labels: List[str], + return_mapping: bool = False, + ) -> Union[List[Any], Dict[str, Any]]: + """ + Process colors for the specified labels. + + Args: + colors: Color specification (colormap name, list of colors, or label-to-color mapping) + labels: List of data labels that need colors assigned + return_mapping: If True, returns a dictionary mapping labels to colors; + if False, returns a list of colors in the same order as labels + + Returns: + Either a list of colors or a dictionary mapping labels to colors + """ + if len(labels) == 0: + logger.warning('No labels provided for color assignment.') + return {} if return_mapping else [] + + # Process based on type of colors input + if isinstance(colors, str): + color_list = self._generate_colors_from_colormap(colors, len(labels)) + elif isinstance(colors, list): + color_list = self._handle_color_list(colors, len(labels)) + elif isinstance(colors, dict): + color_list = self._handle_color_dict(colors, labels) + else: + logger.warning( + f'Unsupported color specification type: {type(colors)}. Using {self.default_colormap} instead.' + ) + color_list = self._generate_colors_from_colormap(self.default_colormap, len(labels)) + + # Return either a list or a mapping + if return_mapping: + return {label: color_list[i] for i, label in enumerate(labels)} + else: + return color_list + + +def get_categorical_colormap( + category_names: List[str], colormap: str = 'tab10', engine: PlottingEngine = 'plotly' +) -> Dict[str, Any]: + """ + Creates a consistent mapping of categories to colors from a colormap. + + Args: + category_names: List of category names to assign colors to + colormap: Name of the colormap to use + engine: The plotting engine ('plotly' or 'matplotlib') + + Returns: + Dictionary mapping category names to colors in the format required by the specified engine + """ + processor = ColorProcessor(engine=engine, default_colormap=colormap) + return processor.process_colors(colormap, category_names, return_mapping=True) + + def with_plotly( data: pd.DataFrame, mode: Literal['bar', 'line', 'area'] = 'area', - colors: Union[List[str], str] = 'viridis', + colors: ColorType = 'viridis', title: str = '', ylabel: str = '', xlabel: str = 'Time in h', @@ -46,38 +238,27 @@ def with_plotly( Plot a DataFrame with Plotly, using either stacked bars or stepped lines. Args: - data: A DataFrame containing the data to plot, where the index represents time (e.g., hours), and each column represents a separate data series. - mode: The plotting mode. Use 'bar' for stacked bar charts or 'line' for stepped lines. - colors: A List of colors (as str) or a name of a colorscale (e.g., 'viridis', 'plasma') to use for coloring the data series. + data: A DataFrame containing the data to plot, where the index represents time (e.g., hours), + and each column represents a separate data series. + mode: The plotting mode. Use 'bar' for stacked bar charts, 'line' for stepped lines, + or 'area' for stacked area charts. + colors: Color specification, can be: + - A string with a colorscale name (e.g., 'viridis', 'plasma') + - A list of color strings (e.g., ['#ff0000', '#00ff00']) + - A dictionary mapping column names to colors (e.g., {'Column1': '#ff0000'}) title: The title of the plot. ylabel: The label for the y-axis. fig: A Plotly figure object to plot on. If not provided, a new figure will be created. Returns: A Plotly figure object containing the generated plot. - - Notes: - - If `mode` is 'bar', bars are stacked for each data series. - - If `mode` is 'line', a stepped line is drawn for each data series. - - The legend is positioned below the plot for a cleaner layout when many data series are present. - - Examples: - >>> fig = with_plotly(data, mode='bar', colorscale='plasma') - >>> fig.show() """ assert mode in ['bar', 'line', 'area'], f"'mode' must be one of {['bar', 'line', 'area']}" if data.empty: return go.Figure() - if isinstance(colors, str): - colorscale = px.colors.get_colorscale(colors) - colors = px.colors.sample_colorscale( - colorscale, - [i / (len(data.columns) - 1) for i in range(len(data.columns))] if len(data.columns) > 1 else [0], - ) - assert len(colors) == len(data.columns), ( - f'The number of colors does not match the provided data columns. {len(colors)=}; {len(colors)=}' - ) + processed_colors = ColorProcessor(engine='plotly').process_colors(colors, list(data.columns)) + fig = fig if fig is not None else go.Figure() if mode == 'bar': @@ -87,7 +268,7 @@ def with_plotly( x=data.index, y=data[column], name=column, - marker=dict(color=colors[i]), + marker=dict(color=processed_colors[i]), ) ) @@ -104,7 +285,7 @@ def with_plotly( y=data[column], mode='lines', name=column, - line=dict(shape='hv', color=colors[i]), + line=dict(shape='hv', color=processed_colors[i]), ) ) elif mode == 'area': @@ -115,13 +296,15 @@ def with_plotly( negative_columns = list(data.columns[(data <= 0).where(~np.isnan(data), True).all()]) negative_columns = [column for column in negative_columns if column not in positive_columns] mixed_columns = list(set(data.columns) - set(positive_columns + negative_columns)) + if mixed_columns: logger.warning( f'Data for plotting stacked lines contains columns with both positive and negative values:' f' {mixed_columns}. These can not be stacked, and are printed as simple lines' ) - colors_stacked = {column: colors[i] for i, column in enumerate(data.columns)} + # Get color mapping for all columns + colors_stacked = {column: processed_colors[i] for i, column in enumerate(data.columns)} for column in positive_columns + negative_columns: fig.add_trace( @@ -181,7 +364,7 @@ def with_plotly( def with_matplotlib( data: pd.DataFrame, mode: Literal['bar', 'line'] = 'bar', - colors: Union[List[str], str] = 'viridis', + colors: ColorType = 'viridis', title: str = '', ylabel: str = '', xlabel: str = 'Time in h', @@ -193,9 +376,13 @@ def with_matplotlib( Plot a DataFrame with Matplotlib using stacked bars or stepped lines. Args: - data: A DataFrame containing the data to plot. The index should represent time (e.g., hours), and each column represents a separate data series. + data: A DataFrame containing the data to plot. The index should represent time (e.g., hours), + and each column represents a separate data series. mode: Plotting mode. Use 'bar' for stacked bar charts or 'line' for stepped lines. - colors: A List of colors (as str) or a name of a colorscale (e.g., 'viridis', 'plasma') to use for coloring the data series. + colors: Color specification, can be: + - A string with a colormap name (e.g., 'viridis', 'plasma') + - A list of color strings (e.g., ['#ff0000', '#00ff00']) + - A dictionary mapping column names to colors (e.g., {'Column1': '#ff0000'}) title: The title of the plot. ylabel: The ylabel of the plot. xlabel: The xlabel of the plot. @@ -211,22 +398,13 @@ def with_matplotlib( Negative values are stacked separately without extra labels in the legend. - If `mode` is 'line', stepped lines are drawn for each data series. - The legend is placed below the plot to accommodate multiple data series. - - Examples: - >>> fig, ax = with_matplotlib(data, mode='bar', colorscale='plasma') - >>> plt.show() """ assert mode in ['bar', 'line'], f"'mode' must be one of {['bar', 'line']} for matplotlib" if fig is None or ax is None: fig, ax = plt.subplots(figsize=figsize) - if isinstance(colors, str): - cmap = plt.get_cmap(colors, len(data.columns)) - colors = [cmap(i) for i in range(len(data.columns))] - assert len(colors) == len(data.columns), ( - f'The number of colors does not match the provided data columns. {len(colors)=}; {len(colors)=}' - ) + processed_colors = ColorProcessor(engine='matplotlib').process_colors(colors, list(data.columns)) if mode == 'bar': cumulative_positive = np.zeros(len(data)) @@ -241,7 +419,7 @@ def with_matplotlib( data.index, positive_values, bottom=cumulative_positive, - color=colors[i], + color=processed_colors[i], label=column, width=width, align='center', @@ -252,7 +430,7 @@ def with_matplotlib( data.index, negative_values, bottom=cumulative_negative, - color=colors[i], + color=processed_colors[i], label='', # No label for negative bars width=width, align='center', @@ -261,7 +439,7 @@ def with_matplotlib( elif mode == 'line': for i, column in enumerate(data.columns): - ax.step(data.index, data[column], where='post', color=colors[i], label=column) + ax.step(data.index, data[column], where='post', color=processed_colors[i], label=column) # Aesthetics ax.set_xlabel(xlabel, ha='center') @@ -551,16 +729,6 @@ def plot_network( Returns: The `Network` instance representing the visualization, or `None` if `pyvis` is not installed. - Usage: - - Visualize and open the network with default options: - >>> self.plot_network() - - - Save the visualization with opening: - >>> self.plot_network(show=True) - - - Visualize with custom controls and path: - >>> self.plot_network(path='output/custom_network.html', controls=['nodes', 'layout']) - Notes: - This function requires `pyvis`. If not installed, the function prints a warning and returns `None`. - Nodes are styled based on type (e.g., circles for buses, boxes for components) and annotated with node information. @@ -624,7 +792,7 @@ def plot_network( def pie_with_plotly( data: pd.DataFrame, - colors: Union[List[str], str] = 'viridis', + colors: ColorType = 'viridis', title: str = '', legend_title: str = '', hole: float = 0.0, @@ -636,8 +804,10 @@ def pie_with_plotly( Args: data: A DataFrame containing the data to plot. If multiple rows exist, they will be summed unless a specific index value is passed. - colors: A List of colors (as str) or a name of a colorscale (e.g., 'viridis', 'plasma') - to use for coloring the pie segments. + colors: Color specification, can be: + - A string with a colorscale name (e.g., 'viridis', 'plasma') + - A list of color strings (e.g., ['#ff0000', '#00ff00']) + - A dictionary mapping column names to colors (e.g., {'Column1': '#ff0000'}) title: The title of the plot. legend_title: The title for the legend. hole: Size of the hole in the center for creating a donut chart (0.0 to 1.0). @@ -652,9 +822,6 @@ def pie_with_plotly( for better readability. - By default, the sum of all columns is used for the pie chart. For time series data, consider preprocessing. - Examples: - >>> fig = pie_with_plotly(data, colorscale='Pastel') - >>> fig.show() """ if data.empty: logger.warning("Empty DataFrame provided for pie chart. Returning empty figure.") @@ -678,13 +845,8 @@ def pie_with_plotly( labels = data_sum.index.tolist() values = data_sum.values.tolist() - # Apply color mapping - if isinstance(colors, str): - colorscale = px.colors.get_colorscale(colors) - colors = px.colors.sample_colorscale( - colorscale, - [i / (len(labels) - 1) for i in range(len(labels))] if len(labels) > 1 else [0], - ) + # Apply color mapping using the unified color processor + processed_colors = ColorProcessor(engine='plotly').process_colors(colors, list(data.columns)) # Create figure if not provided fig = fig if fig is not None else go.Figure() @@ -695,7 +857,7 @@ def pie_with_plotly( labels=labels, values=values, hole=hole, - marker=dict(colors=colors), + marker=dict(colors=processed_colors), textinfo='percent+label+value', textposition='inside', insidetextorientation='radial', @@ -716,7 +878,7 @@ def pie_with_plotly( def pie_with_matplotlib( data: pd.DataFrame, - colors: Union[List[str], str] = 'viridis', + colors: ColorType = 'viridis', title: str = '', legend_title: str = 'Categories', hole: float = 0.0, @@ -730,8 +892,10 @@ def pie_with_matplotlib( Args: data: A DataFrame containing the data to plot. If multiple rows exist, they will be summed unless a specific index value is passed. - colors: A List of colors (as str) or a name of a colorscale (e.g., 'viridis', 'plasma') - to use for coloring the pie segments. + colors: Color specification, can be: + - A string with a colormap name (e.g., 'viridis', 'plasma') + - A list of color strings (e.g., ['#ff0000', '#00ff00']) + - A dictionary mapping column names to colors (e.g., {'Column1': '#ff0000'}) title: The title of the plot. legend_title: The title for the legend. hole: Size of the hole in the center for creating a donut chart (0.0 to 1.0). @@ -748,9 +912,6 @@ def pie_with_matplotlib( for better readability. - By default, the sum of all columns is used for the pie chart. For time series data, consider preprocessing. - Examples: - >>> fig, ax = pie_with_matplotlib(data, colors='viridis', hole=0.3) - >>> plt.show() """ if data.empty: logger.warning("Empty DataFrame provided for pie chart. Returning empty figure.") @@ -776,10 +937,8 @@ def pie_with_matplotlib( labels = data_sum.index.tolist() values = data_sum.values.tolist() - # Apply color mapping - if isinstance(colors, str): - cmap = plt.get_cmap(colors, len(labels)) - colors = [cmap(i) for i in range(len(labels))] + # Apply color mapping using the unified color processor + processed_colors = ColorProcessor(engine='matplotlib').process_colors(colors, labels) # Create figure and axis if not provided if fig is None or ax is None: @@ -789,7 +948,7 @@ def pie_with_matplotlib( wedges, texts, autotexts = ax.pie( values, labels=labels, - colors=colors, + colors=processed_colors, autopct='%1.1f%%', startangle=90, shadow=False, @@ -839,7 +998,7 @@ def pie_with_matplotlib( def dual_pie_with_plotly( data_left: pd.Series, data_right: pd.Series, - colors: Union[List[str], str] = 'viridis', + colors: ColorType = 'viridis', title: str = '', subtitles: Tuple[str, str] = ('Left Chart', 'Right Chart'), legend_title: str = '', @@ -855,8 +1014,10 @@ def dual_pie_with_plotly( Args: data_left: Series for the left pie chart. data_right: Series for the right pie chart. - colors: A List of colors (as str) or a name of a colorscale (e.g., 'viridis', 'plasma') - to use for coloring the pie segments. + colors: Color specification, can be: + - A string with a colorscale name (e.g., 'viridis', 'plasma') + - A list of color strings (e.g., ['#ff0000', '#00ff00']) + - A dictionary mapping category names to colors (e.g., {'Category1': '#ff0000'}) title: The main title of the plot. subtitles: Tuple containing the subtitles for (left, right) charts. legend_title: The title for the legend. @@ -870,8 +1031,6 @@ def dual_pie_with_plotly( Returns: A Plotly figure object containing the generated dual pie chart. """ - import itertools - from plotly.subplots import make_subplots # Check for empty data @@ -881,7 +1040,9 @@ def dual_pie_with_plotly( # Create a subplot figure fig = make_subplots( - rows=1, cols=2, specs=[[{'type': 'pie'}, {'type': 'pie'}]], subplot_titles=subtitles, horizontal_spacing=0.05 + rows=1, cols=2, specs=[[{'type': 'pie'}, {'type': 'pie'}]], + subplot_titles=subtitles, + horizontal_spacing=0.05 ) # Process series to handle negative values and apply minimum percentage threshold @@ -939,18 +1100,8 @@ def preprocess_series(series: pd.Series): # Get unique set of all labels for consistent coloring all_labels = sorted(set(data_left_processed.index) | set(data_right_processed.index)) - # Generate consistent color mapping - if isinstance(colors, str): - colorscale = px.colors.get_colorscale(colors) - color_list = px.colors.sample_colorscale( - colorscale, - [i / (len(all_labels) - 1) for i in range(len(all_labels))] if len(all_labels) > 1 else [0], - ) - color_map = {label: color_list[i] for i, label in enumerate(all_labels)} - else: - # If colors is a list, create a cycling iterator - color_iter = itertools.cycle(colors) - color_map = {label: next(color_iter) for label in all_labels} + # Get consistent color mapping for both charts using our unified function + color_map = ColorProcessor(engine='plotly').process_colors(colors, all_labels, return_mapping=True) # Function to create a pie trace with consistently mapped colors def create_pie_trace(data_series, side): @@ -1003,7 +1154,7 @@ def create_pie_trace(data_series, side): def dual_pie_with_matplotlib( data_left: pd.Series, data_right: pd.Series, - colors: Union[List[str], str] = 'viridis', + colors: ColorType = 'viridis', title: str = '', subtitles: Tuple[str, str] = ('Left Chart', 'Right Chart'), legend_title: str = '', @@ -1020,8 +1171,10 @@ def dual_pie_with_matplotlib( Args: data_left: Series for the left pie chart. data_right: Series for the right pie chart. - colors: A List of colors (as str) or a name of a colorscale (e.g., 'viridis', 'plasma') - to use for coloring the pie segments. + colors: Color specification, can be: + - A string with a colormap name (e.g., 'viridis', 'plasma') + - A list of color strings (e.g., ['#ff0000', '#00ff00']) + - A dictionary mapping category names to colors (e.g., {'Category1': '#ff0000'}) title: The main title of the plot. subtitles: Tuple containing the subtitles for (left, right) charts. legend_title: The title for the legend. @@ -1034,8 +1187,6 @@ def dual_pie_with_matplotlib( Returns: A tuple containing the Matplotlib figure and list of axes objects used for the plot. """ - import itertools - # Check for empty data if data_left.empty and data_right.empty: logger.warning('Both datasets are empty. Returning empty figure.') @@ -1101,17 +1252,8 @@ def preprocess_series(series: pd.Series): # Get unique set of all labels for consistent coloring all_labels = sorted(set(data_left_processed.index) | set(data_right_processed.index)) - # Generate a consistent color mapping for both charts - if isinstance(colors, str): - cmap = plt.get_cmap(colors, len(all_labels)) - color_list = [cmap(i) for i in range(len(all_labels))] - else: - # If colors is a list, create a cycling iterator - color_iter = itertools.cycle(colors) - color_list = [next(color_iter) for _ in range(len(all_labels))] - - # Create a mapping from label to color - color_map = {label: color_list[i] for i, label in enumerate(all_labels)} + # Get consistent color mapping for both charts using our unified function + color_map = ColorProcessor(engine='plotly').process_colors(colors, all_labels, return_mapping=True) # Configure colors for each DataFrame based on the consistent mapping left_colors = [color_map[col] for col in df_left.columns] if not df_left.empty else [] diff --git a/flixOpt/results.py b/flixOpt/results.py index ed6d1ded8..5dc4a93ed 100644 --- a/flixOpt/results.py +++ b/flixOpt/results.py @@ -214,7 +214,7 @@ def plot_heatmap( color_map: str = 'portland', save: Union[bool, pathlib.Path] = False, show: bool = True, - engine: Literal['plotly', 'matplotlib'] = 'plotly' + engine: plotting.PlottingEngine = 'plotly' ) -> Union[plotly.graph_objs.Figure, Tuple[plt.Figure, plt.Axes]]: return plot_heatmap( dataarray=self.solution[variable_name], @@ -378,7 +378,8 @@ def plot_node_balance( self, save: Union[bool, pathlib.Path] = False, show: bool = True, - engine: Literal['plotly', 'matplotlib'] = 'plotly' + colors: plotting.ColorType = 'viridis', + engine: plotting.PlottingEngine = 'plotly', ) -> Union[plotly.graph_objs.Figure, Tuple[plt.Figure, plt.Axes]]: """ Plots the node balance of the Component or Bus. @@ -389,12 +390,18 @@ def plot_node_balance( """ if engine == 'plotly': figure_like = plotting.with_plotly( - self.node_balance(with_last_timestep=True).to_dataframe(), mode='area', title=f'Flow rates of {self.label}' + self.node_balance(with_last_timestep=True).to_dataframe(), + colors=colors, + mode='area', + title=f'Flow rates of {self.label}' ) default_filetype = '.html' elif engine == 'matplotlib': figure_like = plotting.with_matplotlib( - self.node_balance(with_last_timestep=True).to_dataframe(), mode='bar', title=f'Flow rates of {self.label}' + self.node_balance(with_last_timestep=True).to_dataframe(), + colors=colors, + mode='bar', + title=f'Flow rates of {self.label}', ) default_filetype = '.png' else: @@ -412,11 +419,11 @@ def plot_node_balance( def plot_node_balance_pie( self, lower_percentage_group: float = 5, - colors: Union[str, List[str]] = 'viridis', + colors: plotting.ColorType = 'viridis', text_info: str = 'percent+label+value', save: Union[bool, pathlib.Path] = False, show: bool = True, - engine: Literal['plotly'] = 'plotly' + engine: plotting.PlottingEngine = 'plotly' ) -> plotly.graph_objects.Figure: """ Plots a pie chart of the flow hours of the inputs and outputs of buses or components. @@ -523,13 +530,15 @@ def plot_charge_state( self, save: Union[bool, pathlib.Path] = False, show: bool = True, - engine: Literal['plotly'] = 'plotly' + colors: plotting.ColorType = 'viridis', + engine: plotting.PlottingEngine = 'plotly' ) -> plotly.graph_objs.Figure: """ Plots the charge state of a Storage. Args: 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. + colors: The c engine: Plotting engine to use. Only 'plotly' is implemented atm. Raises: @@ -543,9 +552,13 @@ def plot_charge_state( fig = plotting.with_plotly( self.node_balance(with_last_timestep=True).to_dataframe(), + colors=colors, mode='area', title=f'Operation Balance of {self.label}', ) + + # TODO: Use colors for charge state? + charge_state = self.charge_state.to_dataframe() fig.add_trace(plotly.graph_objs.Scatter( x=charge_state.index, y=charge_state.values.flatten(), mode='lines', name=self._charge_state)) @@ -673,7 +686,7 @@ def plot_heatmap( color_map: str = 'portland', save: Union[bool, pathlib.Path] = False, show: bool = True, - engine: Literal['plotly', 'matplotlib'] = 'plotly', + engine: plotting.PlottingEngine = 'plotly', ) -> Union[plotly.graph_objs.Figure, Tuple[plt.Figure, plt.Axes]]: """ Plots a heatmap of the solution of a variable. @@ -729,7 +742,7 @@ def plot_heatmap( color_map: str = 'portland', save: Union[bool, pathlib.Path] = False, show: bool = True, - engine: Literal['plotly', 'matplotlib'] = 'plotly' + engine: plotting.PlottingEngine = 'plotly' ): """ Plots a heatmap of the solution of a variable. diff --git a/tests/test_results_plots.py b/tests/test_results_plots.py index 59840cf1a..3aa2e10e5 100644 --- a/tests/test_results_plots.py +++ b/tests/test_results_plots.py @@ -25,21 +25,30 @@ def plotting_engine(request): return request.param -def test_results_plots(flow_system, plotting_engine, show, save): +@pytest.fixture(params=[ + 'viridis', # Test string colormap + ['#ff0000', '#00ff00', '#0000ff', '#ffff00', '#ff00ff', '#00ffff'], # Test color list + {'Boiler(Q_th)|flow_rate': '#ff0000', 'Heat Demand(Q_th)|flow_rate': '#00ff00', 'Speicher(Q_th_load)|flow_rate': '#0000ff'} # Test color dict +]) +def color_spec(request): + return request.param + + +def test_results_plots(flow_system, plotting_engine, show, save, color_spec): calculation = create_calculation_and_solve(flow_system, fx.solvers.HighsSolver(0.01, 30), 'test_results_plots') results = calculation.results - results['Boiler'].plot_node_balance(engine=plotting_engine, save=save, show=show) + results['Boiler'].plot_node_balance(engine=plotting_engine, save=save, show=show, colors=color_spec) results.plot_heatmap('Speicher(Q_th_load)|flow_rate', heatmap_timeframes='D', heatmap_timesteps_per_frame='h', - color_map='viridis', + color_map='viridis', # Note: heatmap only accepts string colormap save=show, show=save, engine=plotting_engine) - results['Speicher'].plot_node_balance_pie(engine=plotting_engine, save=save, show=show) + results['Speicher'].plot_node_balance_pie(engine=plotting_engine, save=save, show=show, colors=color_spec) if plotting_engine == 'matplotlib': with pytest.raises(NotImplementedError): @@ -48,3 +57,24 @@ def test_results_plots(flow_system, plotting_engine, show, save): results['Speicher'].plot_charge_state(engine=plotting_engine) plt.close('all') + + +def test_color_handling_edge_cases(flow_system, plotting_engine, show, save): + """Test edge cases for color handling""" + calculation = create_calculation_and_solve(flow_system, fx.solvers.HighsSolver(0.01, 30), 'test_color_edge_cases') + results = calculation.results + + # Test with empty color list (should fall back to default) + results['Boiler'].plot_node_balance(engine=plotting_engine, save=save, show=show, colors=[]) + + # Test with invalid colormap name (should use default and log warning) + results['Boiler'].plot_node_balance(engine=plotting_engine, save=save, show=show, colors='nonexistent_colormap') + + # Test with insufficient colors for elements (should cycle colors) + results['Boiler'].plot_node_balance(engine=plotting_engine, save=save, show=show, colors=['#ff0000', '#00ff00']) + + # Test with color dict missing some elements (should use default for missing) + partial_color_dict = {'Boiler(Q_th)|flow_rate': '#ff0000'} # Missing other elements + results['Boiler'].plot_node_balance(engine=plotting_engine, save=save, show=show, colors=partial_color_dict) + + plt.close('all') From b25f9bc2378c1c4cb1fd552e6f7ce74fe0972264 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 27 Mar 2025 12:01:08 +0100 Subject: [PATCH 473/507] ruff check --- flixOpt/plotting.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flixOpt/plotting.py b/flixOpt/plotting.py index 30df69126..eace345c0 100644 --- a/flixOpt/plotting.py +++ b/flixOpt/plotting.py @@ -36,10 +36,10 @@ ColorType = Union[str, List[str], Dict[str, str]] -"""Identifier for the colors to use. +"""Identifier for the colors to use. Use the name of a colorscale, a list of colors or a dictionary of labels to colors. The colors must be valid color strings (HEX or names). Depending on the Engine used, other formats are possible. -See also: +See also: - https://htmlcolorcodes.com/color-names/ - https://matplotlib.org/stable/tutorials/colors/colormaps.html - https://plotly.com/python/builtin-colorscales/ From 1a8652c3813e2e2eb2d8c7d4fae4de6bef524ad2 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 27 Mar 2025 12:55:25 +0100 Subject: [PATCH 474/507] Bugfix in plotting.with_matplotlib() colors --- flixOpt/plotting.py | 20 +------------------- 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/flixOpt/plotting.py b/flixOpt/plotting.py index eace345c0..87bd90532 100644 --- a/flixOpt/plotting.py +++ b/flixOpt/plotting.py @@ -207,24 +207,6 @@ def process_colors( return color_list -def get_categorical_colormap( - category_names: List[str], colormap: str = 'tab10', engine: PlottingEngine = 'plotly' -) -> Dict[str, Any]: - """ - Creates a consistent mapping of categories to colors from a colormap. - - Args: - category_names: List of category names to assign colors to - colormap: Name of the colormap to use - engine: The plotting engine ('plotly' or 'matplotlib') - - Returns: - Dictionary mapping category names to colors in the format required by the specified engine - """ - processor = ColorProcessor(engine=engine, default_colormap=colormap) - return processor.process_colors(colormap, category_names, return_mapping=True) - - def with_plotly( data: pd.DataFrame, mode: Literal['bar', 'line', 'area'] = 'area', @@ -1253,7 +1235,7 @@ def preprocess_series(series: pd.Series): all_labels = sorted(set(data_left_processed.index) | set(data_right_processed.index)) # Get consistent color mapping for both charts using our unified function - color_map = ColorProcessor(engine='plotly').process_colors(colors, all_labels, return_mapping=True) + color_map = ColorProcessor(engine='matplotlib').process_colors(colors, all_labels, return_mapping=True) # Configure colors for each DataFrame based on the consistent mapping left_colors = [color_map[col] for col in df_left.columns] if not df_left.empty else [] From 8e26acad17581cd8aabd5dc0b36ec6fa9219aada Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 27 Mar 2025 15:12:55 +0100 Subject: [PATCH 475/507] Add netcdf to dependencies --- pyproject.toml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b52a769c0..45dda3924 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ dependencies = [ "numpy >= 1.21.5, < 2", "PyYAML >= 6.0", "linopy >= 0.5.1", + "netcdf4 >= 1.6.1", "rich >= 13.0.1", "highspy >= 1.5.3", # Default solver "pandas >= 2, < 3", # Used in post-processing @@ -50,7 +51,6 @@ dev = [ "pyvis == 0.3.1", # Used for visualizing the FLowSystem "tsam >= 2.3.1", # Used for time series aggregation "scipy >= 1.15.1", # Used by tsam. Prior versions have conflict with highspy. See https://github.com/scipy/scipy/issues/22257 - "netcdf4 >= 1.6.1", # Used for saving and loading the FlowSystem with compression "gurobipy >= 10.0", ] @@ -58,7 +58,8 @@ full = [ "pyvis == 0.3.1", # Used for visualizing the FLowSystem "tsam >= 2.3.1", # Used for time series aggregation "scipy >= 1.15.1", # Used by tsam. Prior versions have conflict with highspy. See https://github.com/scipy/scipy/issues/22257 - "netcdf4 >= 1.6.1", # Used for saving and loading the FlowSystem with compression + "streamlit >= 1.44.0", + "gurobipy >= 10.0", ] docs = [ From f1e6c21207594cf3825c165bd1eb90dd606cbd93 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 27 Mar 2025 22:24:05 +0100 Subject: [PATCH 476/507] Improve Documentation (#195) * Move documentation of Storage to docs * Move docu away from Bus * Add docstrings to types * Add documentation of math of effects * Restructure mathematical Documentation * Improve docs * Add template for release notes to docs --- README.md | 2 +- .../Mathematical Description.md | 117 ---------------- .../Mathematical Notation/Bus.md | 33 +++++ .../Effects, Penalty & Objective.md | 132 ++++++++++++++++++ .../Mathematical Notation/Flow.md | 26 ++++ .../Mathematical Notation/LinearConverter.md | 21 +++ .../Mathematical Notation/Storage.md | 44 ++++++ .../Mathematical Notation/index.md | 22 +++ .../Mathematical Notation/others.md | 3 + docs/concepts-and-math/index.md | 9 +- flixOpt/components.py | 53 +------ flixOpt/effects.py | 3 + flixOpt/elements.py | 30 +--- site/release-notes/_template.txt | 32 +++++ 14 files changed, 328 insertions(+), 199 deletions(-) delete mode 100644 docs/concepts-and-math/Mathematical Description.md create mode 100644 docs/concepts-and-math/Mathematical Notation/Bus.md create mode 100644 docs/concepts-and-math/Mathematical Notation/Effects, Penalty & Objective.md create mode 100644 docs/concepts-and-math/Mathematical Notation/Flow.md create mode 100644 docs/concepts-and-math/Mathematical Notation/LinearConverter.md create mode 100644 docs/concepts-and-math/Mathematical Notation/Storage.md create mode 100644 docs/concepts-and-math/Mathematical Notation/index.md create mode 100644 docs/concepts-and-math/Mathematical Notation/others.md create mode 100644 site/release-notes/_template.txt diff --git a/README.md b/README.md index 89b286ec5..f0ebc370a 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ It was originally developed by [TU Dresden](https://github.com/gewv-tu-dresden) - flixopt abstracts costs as so called 'Effects'. This allows to model costs, CO2-emissions, primary-energy-demand or area-demand at the same time. - Effects can interact with each other(e.g., specific CO2 costs) - Any of these `Effects` can be used as the optimization objective. - - A **Weigted Sum**of Effects can be used as the optimization objective. + - A **Weigted Sum** of Effects can be used as the optimization objective. - Every Effect can be constrained ($\epsilon$-constraint method). - **Calculation Modes** diff --git a/docs/concepts-and-math/Mathematical Description.md b/docs/concepts-and-math/Mathematical Description.md deleted file mode 100644 index 339aea39e..000000000 --- a/docs/concepts-and-math/Mathematical Description.md +++ /dev/null @@ -1,117 +0,0 @@ - -# Mathematical Notation - -## Naming Conventions - -flixOpt uses the following naming conventions: - -- All optimization variables are denoted by italic letters (e.g., $x$, $y$, $z$) -- All parameters and constants are denoted by non italic small letters (e.g., $\text{a}$, $\text{b}$, $\text{c}$) -- All Sets are denoted by greek capital letters (e.g., $\mathcal{F}$, $\mathcal{E}$) -- All units of a set are denoted by greek small letters (e.g., $\mathcal{f}$, $\mathcal{e}$) -- The letter $i$ is used to denote an index (e.g., $i=1,\dots,\text n$) -- All time steps are denoted by the letter $\text{t}$ (e.g., $\text{t}_0$, $\text{t}_1$, $\text{t}_i$) - -## Timesteps -Time steps are defined as a sequence of discrete time steps $\text{t}_i \in \mathcal{T} \quad \text{for} \quad i \in \{1, 2, \dots, \text{n}\}$ (left-aligned in its timespan). -From this sequence, the corresponding time intervals $\Delta \text{t}_i \in \Delta \mathcal{T}$ are derived as - -$$\Delta \text{t}_i = \text{t}_{i+1} - \text{t}_i \quad \text{for} \quad i \in \{1, 2, \dots, \text{n}-1\}$$ - -The final time interval $\Delta \text{t}_\text n$ defaults to $\Delta \text{t}_\text n = \Delta \text{t}_{\text n-1}$, but is of course customizable. -Non-equidistant time steps are also supported. - -## Buses - -The balance equation for a bus is: - -$$ \label{eq:bus_balance} - \sum_{f_\text{in} \in \mathcal{F}_\text{in}} p_{f_\text{in}}(\text{t}_i) = - \sum_{f_\text{out} \in \mathcal{F}_\text{out}} p_{f_\text{out}}(\text{t}_i) -$$ - -Optionally, a Bus can have a `excess_penalty_per_flow_hour` parameter, which allows to penalize the balance for missing or excess flow-rates. -This is usefull as it handles a possible ifeasiblity gently. - -This changes the balance to - -$$ \label{eq:bus_balance-excess} - \sum_{f_\text{in} \in \mathcal{F}_\text{in}} p_{f_ \text{in}}(\text{t}_i) + \phi_\text{in}(\text{t}_i) = - \sum_{f_\text{out} \in \mathcal{F}_\text{out}} p_{f_\text{out}}(\text{t}_i) + \phi_\text{out}(\text{t}_i) -$$ - -The penalty term is defined as - -$$ \label{eq:bus_penalty} - s_{b \rightarrow \Phi}(\text{t}_i) = - \text a_{b \rightarrow \Phi}(\text{t}_i) \cdot \Delta \text{t}_i - \cdot [ \phi_\text{in}(\text{t}_i) + \phi_\text{out}(\text{t}_i) ] -$$ - -With: - -- $\mathcal{F}_\text{in}$ and $\mathcal{F}_\text{out}$ being the set of all incoming and outgoing flows -- $p_{f_\text{in}}(\text{t}_i)$ and $p_{f_\text{out}}(\text{t}_i)$ being the flow-rate at time $\text{t}_i$ for flow $f_\text{in}$ and $f_\text{out}$, respectively -- $\phi_\text{in}(\text{t}_i)$ and $\phi_\text{out}(\text{t}_i)$ being the missing or excess flow-rate at time $\text{t}_i$, respectively -- $\text{t}_i$ being the time step -- $s_{b \rightarrow \Phi}(\text{t}_i)$ being the penalty term -- $\text a_{b \rightarrow \Phi}(\text{t}_i)$ being the penalty coefficient (`excess_penalty_per_flow_hour`) - -## Flows - -The flow-rate is the main optimization variable of the Flow. It's limited by the size of the Flow and relative bounds \eqref{eq:flow_rate}. - -$$ \label{eq:flow_rate} - \text P \cdot \text p^{\text{L}}_{\text{rel}}(\text{t}_{i}) - \leq p(\text{t}_{i}) \leq - \text P \cdot \text p^{\text{U}}_{\text{rel}}(\text{t}_{i}) -$$ - -With: - -- $\text P$ being the size of the Flow -- $p(\text{t}_{i})$ being the flow-rate at time $\text{t}_{i}$ -- $\text p^{\text{L}}_{\text{rel}}(\text{t}_{i})$ being the relative lower bound (typically 0) -- $\text p^{\text{U}}_{\text{rel}}(\text{t}_{i})$ being the relative upper bound (typically 1) - -With $\text p^{\text{L}}_{\text{rel}}(\text{t}_{i}) = 0$ and $\text p^{\text{U}}_{\text{rel}}(\text{t}_{i}) = 1$, -equation \eqref{eq:flow_rate} simplifies to - -$$ - 0 \leq p(\text{t}_{i}) \leq \text P -$$ - - -This mathematical Formulation can be extended or changed when using [OnOffParameters](#omoffparameters) -to define the On/Off state of the Flow, or [InvestParameters](#investments), -which changes the size of the Flow from a constant to an optimization variable. - -## LinearConverters -[`LinearConverters`][flixOpt.components.LinearConverter] define a ratio between incoming and outgoing [Flows](#flows). - -$$ \label{eq:Linear-Transformer-Ratio} - \sum_{f_{\text{in}} \in \mathcal F_{in}} \text a_{f_{\text{in}}}(\text{t}_i) \cdot p_{f_\text{in}}(\text{t}_i) = \sum_{f_{\text{out}} \in \mathcal F_{out}} \text b_{f_\text{out}}(\text{t}_i) \cdot p_{f_\text{out}}(\text{t}_i) -$$ - -With: - -- $\mathcal F_{in}$ and $\mathcal F_{out}$ being the set of all incoming and outgoing flows -- $p_{f_\text{in}}(\text{t}_i)$ and $p_{f_\text{out}}(\text{t}_i)$ being the flow-rate at time $\text{t}_i$ for flow $f_\text{in}$ and $f_\text{out}$, respectively -- $\text a_{f_\text{in}}(\text{t}_i)$ and $\text b_{f_\text{out}}(\text{t}_i)$ being the ratio of the flow-rate at time $\text{t}_i$ for flow $f_\text{in}$ and $f_\text{out}$, respectively - -With one incoming **Flow** and one outgoing **Flow**, this can be simplified to: - -$$ \label{eq:Linear-Transformer-Ratio-simple} - \text a(\text{t}_i) \cdot p_{f_\text{in}}(\text{t}_i) = p_{f_\text{out}}(\text{t}_i) -$$ - -where $\text a$ can be interpreted as the conversion efficiency of the **LinearTransformer**. -#### Piecewise Concersion factors -The conversion efficiency can be defined as a piecewise function. - -## Effects -## Features -### InvestParameters -### OnOffParameters - -## Calculation Modes \ No newline at end of file diff --git a/docs/concepts-and-math/Mathematical Notation/Bus.md b/docs/concepts-and-math/Mathematical Notation/Bus.md new file mode 100644 index 000000000..840c90a08 --- /dev/null +++ b/docs/concepts-and-math/Mathematical Notation/Bus.md @@ -0,0 +1,33 @@ +A Bus is a simple nodal balance between its incoming and outgoing flow rates. + +$$ \label{eq:bus_balance} + \sum_{f_\text{in} \in \mathcal{F}_\text{in}} p_{f_\text{in}}(\text{t}_i) = + \sum_{f_\text{out} \in \mathcal{F}_\text{out}} p_{f_\text{out}}(\text{t}_i) +$$ + +Optionally, a Bus can have a `excess_penalty_per_flow_hour` parameter, which allows to penaltize the balance for missing or excess flow-rates. +This is usefull as it handles a possible ifeasiblity gently. + +This changes the balance to + +$$ \label{eq:bus_balance-excess} + \sum_{f_\text{in} \in \mathcal{F}_\text{in}} p_{f_ \text{in}}(\text{t}_i) + \phi_\text{in}(\text{t}_i) = + \sum_{f_\text{out} \in \mathcal{F}_\text{out}} p_{f_\text{out}}(\text{t}_i) + \phi_\text{out}(\text{t}_i) +$$ + +The penalty term is defined as + +$$ \label{eq:bus_penalty} + s_{b \rightarrow \Phi}(\text{t}_i) = + \text a_{b \rightarrow \Phi}(\text{t}_i) \cdot \Delta \text{t}_i + \cdot [ \phi_\text{in}(\text{t}_i) + \phi_\text{out}(\text{t}_i) ] +$$ + +With: + +- $\mathcal{F}_\text{in}$ and $\mathcal{F}_\text{out}$ being the set of all incoming and outgoing flows +- $p_{f_\text{in}}(\text{t}_i)$ and $p_{f_\text{out}}(\text{t}_i)$ being the flow-rate at time $\text{t}_i$ for flow $f_\text{in}$ and $f_\text{out}$, respectively +- $\phi_\text{in}(\text{t}_i)$ and $\phi_\text{out}(\text{t}_i)$ being the missing or excess flow-rate at time $\text{t}_i$, respectively +- $\text{t}_i$ being the time step +- $s_{b \rightarrow \Phi}(\text{t}_i)$ being the penalty term +- $\text a_{b \rightarrow \Phi}(\text{t}_i)$ being the penalty coefficient (`excess_penalty_per_flow_hour`) \ No newline at end of file diff --git a/docs/concepts-and-math/Mathematical Notation/Effects, Penalty & Objective.md b/docs/concepts-and-math/Mathematical Notation/Effects, Penalty & Objective.md new file mode 100644 index 000000000..07f9fb8de --- /dev/null +++ b/docs/concepts-and-math/Mathematical Notation/Effects, Penalty & Objective.md @@ -0,0 +1,132 @@ +## Effects +[`Effects`][flixOpt.effects.Effect] are used to allocate things like costs, emissions, or other "effects" occuring in the system. +These arise from so called **Shares**, which originate from **Elements** like [Flows](Flow.md). + +**Example:** + +[`Flows`][flixOpt.elements.Flow] have an attribute called `effects_per_flow_hour`, defining the effect amount of per flow hour. +Assiziated effects could be: +- costs - given in [€/kWh]... +- ...or emissions - given in [kg/kWh]. +- +Effects are allocated seperatly for investments and operation. + +### Shares to Effects + +$$ \label{eq:Share_invest} +s_{l \rightarrow e, \text{inv}} = \sum_{v \in \mathcal{V}_{l, \text{inv}}} v \cdot \text a_{v \rightarrow e} +$$ + +$$ \label{eq:Share_operation} +s_{l \rightarrow e, \text{op}}(\text{t}_i) = \sum_{v \in \mathcal{V}_{l,\text{op}}} v(\text{t}_i) \cdot \text a_{v \rightarrow e}(\text{t}_i) +$$ + +With: + +- $\text{t}_i$ being the time step +- $\mathcal{V_l}$ being the set of all optimization variables of element $e$ +- $\mathcal{V}_{l, \text{inv}}$ being the set of all optimization variables of element $e$ related to investment +- $\mathcal{V}_{l, \text{op}}$ being the set of all optimization variables of element $e$ related to operation +- $v$ being an optimization variable of the element $l$ +- $v(\text{t}_i)$ being an optimization variable of the element $l$ at timestep $\text{t}_i$ +- $\text a_{v \rightarrow e}$ being the factor between the optimization variable $v$ to effect $e$ +- $\text a_{v \rightarrow e}(\text{t}_i)$ being the factor between the optimization variable $v$ to effect $e$ for timestep $\text{t}_i$ +- $s_{l \rightarrow e, \text{inv}}$ being the share of element $l$ to the investment part of effect $e$ +- $s_{l \rightarrow e, \text{op}}(\text{t}_i)$ being the share of element $l$ to the operation part of effect $e$ + +### Shares between different Effects + +Furthermore, the Effect $x$ can contribute a share to another Effect ${e} \in \mathcal{E}\backslash x$. +This share is defined by the factor $\text r_{x \rightarrow e}$. + +For example, the Effect "CO$_2$ emissions" (unit: kg) +can cause an additional share to Effect "monetary costs" (unit: €). +In this case, the factor $\text a_{x \rightarrow e}$ is the specific CO$_2$ price in €/kg. However, circular references have to be avoided. + +The overall sum of investment shares of an Effect $e$ is given by $\eqref{Effect_invest}$ + +$$ \label{eq:Effect_invest} +E_{e, \text{inv}} = +\sum_{l \in \mathcal{L}} s_{l \rightarrow e,\text{inv}} + +\sum_{x \in \mathcal{E}\backslash e} E_{x, \text{inv}} \cdot \text{r}_{x \rightarrow e,\text{inv}} +$$ + +The overall sum of operation shares is given by $\eqref{eq:Effect_Operation}$ + +$$ \label{eq:Effect_Operation} +E_{e, \text{op}}(\text{t}_{i}) = +\sum_{l \in \mathcal{L}} s_{l \rightarrow e, \text{op}}(\text{t}_i) + +\sum_{x \in \mathcal{E}\backslash e} E_{x, \text{op}}(\text{t}_i) \cdot \text{r}_{x \rightarrow {e},\text{op}}(\text{t}_i) +$$ + +and totals to $\eqref{eq:Effect_Operation_total}$ +$$\label{eq:Effect_Operation_total} +E_{e,\text{op},\text{tot}} = \sum_{i=1}^n E_{e,\text{op}}(\text{t}_{i}) +$$ + +With: + +- $\mathcal{L}$ being the set of all elements in the FlowSystem +- $\mathcal{E}$ being the set of all effects in the FlowSystem +- $\text r_{x \rightarrow e, \text{inv}}$ being the factor between the operation part of Effect $x$ and Effect $e$ +- $\text r_{x \rightarrow e, \text{op}}(\text{t}_i)$ being the factor between the invest part of Effect $x$ and Effect $e$ + +- $\text{t}_i$ being the time step +- $s_{l \rightarrow e, \text{inv}}$ being the share of element $l$ to the investment part of effect $e$ +- $s_{l \rightarrow e, \text{op}}(\text{t}_i)$ being the share of element $l$ to the operation part of effect $e$ + + +The total of an effect $E_{e}$ is given as $\eqref{eq:Effect_Total}$ + +$$ \label{eq:Effect_Total} +E_{e} = E_{\text{inv},e} +E_{\text{op},\text{tot},e} +$$ + +### Constraining Effects + +For each variable $v \in \{ E_{e,\text{inv}}, E_{e,\text{op},\text{tot}}, E_e\}$, a lower bound $v^\text{L}$ and upper bound $v^\text{U}$ can be defined as + +$$ \label{eq:Bounds_Single} +\text v^\text{L} \leq v \leq \text v^\text{U} +$$ + +Furthermore, bounds for the operational shares can be set for each time step + +$$ \label{eq:Bounds_Time_Steps} +\text E_{e,\text{op}}^\text{L}(\text{t}_i) \leq E_{e,\text{op}}(\text{t}_i) \leq \text E_{e,\text{op}}^\text{U}(\text{t}_i) +$$ + +## Penalty + +Additionally to the user defined [Effects](#effects), a Penalty $\Phi$ is part of every flixOpt Model. +Its used to prevent unsolvable problems and simplify troubleshooting. +Shares to the penalty can originate from every Element and are constructed similarly to +$\eqref{Share_invest}$ and $\eqref{Share_operation}$. + +$$ \label{eq:Penalty} +\Phi = \sum_{l \in \mathcal{L}} \left( s_{l \rightarrow \Phi} +\sum_{\text{t}_i \in \mathcal{T}} s_{l \rightarrow \Phi}(\text{t}_{i}) \right) +$$ + +With: + +- $\mathcal{L}$ being the set of all elements in the FlowSystem +- $\mathcal{T}$ being the set of all timesteps +- $s_{l \rightarrow \Phi}$ being the share of element $l$ to the penalty + +At the moment, penalties only occur in [Buses](#buses) + +## Objective + +The optimization objective of a flixOpt Model is defined as $\eqref{eq:Objective}$ +$$ \label{eq:Objective} +\min(E_{\Omega} + \Phi) +$$ + +With: + +- $\Omega$ being the chosen **Objective [Effect](#effects)** (see $\eqref{eq:Effect_Total}$) +- $\Phi$ being the [Penalty](#penalty) + +This approach allows for a multi-criteria optimization using both... + - ... the **Weigted Sum**Method, as the chosen **Objective Effect** can incorporate other Effects. + - ... the ($\epsilon$-constraint method) by constraining effects. \ No newline at end of file diff --git a/docs/concepts-and-math/Mathematical Notation/Flow.md b/docs/concepts-and-math/Mathematical Notation/Flow.md new file mode 100644 index 000000000..27d51292f --- /dev/null +++ b/docs/concepts-and-math/Mathematical Notation/Flow.md @@ -0,0 +1,26 @@ +The flow-rate is the main optimization variable of the Flow. It's limited by the size of the Flow and relative bounds \eqref{eq:flow_rate}. + +$$ \label{eq:flow_rate} + \text P \cdot \text p^{\text{L}}_{\text{rel}}(\text{t}_{i}) + \leq p(\text{t}_{i}) \leq + \text P \cdot \text p^{\text{U}}_{\text{rel}}(\text{t}_{i}) +$$ + +With: + +- $\text P$ being the size of the Flow +- $p(\text{t}_{i})$ being the flow-rate at time $\text{t}_{i}$ +- $\text p^{\text{L}}_{\text{rel}}(\text{t}_{i})$ being the relative lower bound (typically 0) +- $\text p^{\text{U}}_{\text{rel}}(\text{t}_{i})$ being the relative upper bound (typically 1) + +With $\text p^{\text{L}}_{\text{rel}}(\text{t}_{i}) = 0$ and $\text p^{\text{U}}_{\text{rel}}(\text{t}_{i}) = 1$, +equation \eqref{eq:flow_rate} simplifies to + +$$ + 0 \leq p(\text{t}_{i}) \leq \text P +$$ + + +This mathematical Formulation can be extended or changed when using [OnOffParameters](#onoffparameters) +to define the On/Off state of the Flow, or [InvestParameters](#investments), +which changes the size of the Flow from a constant to an optimization variable. \ No newline at end of file diff --git a/docs/concepts-and-math/Mathematical Notation/LinearConverter.md b/docs/concepts-and-math/Mathematical Notation/LinearConverter.md new file mode 100644 index 000000000..cf3a6b4e5 --- /dev/null +++ b/docs/concepts-and-math/Mathematical Notation/LinearConverter.md @@ -0,0 +1,21 @@ +[`LinearConverters`][flixOpt.components.LinearConverter] define a ratio between incoming and outgoing [Flows](#flows). + +$$ \label{eq:Linear-Transformer-Ratio} + \sum_{f_{\text{in}} \in \mathcal F_{in}} \text a_{f_{\text{in}}}(\text{t}_i) \cdot p_{f_\text{in}}(\text{t}_i) = \sum_{f_{\text{out}} \in \mathcal F_{out}} \text b_{f_\text{out}}(\text{t}_i) \cdot p_{f_\text{out}}(\text{t}_i) +$$ + +With: + +- $\mathcal F_{in}$ and $\mathcal F_{out}$ being the set of all incoming and outgoing flows +- $p_{f_\text{in}}(\text{t}_i)$ and $p_{f_\text{out}}(\text{t}_i)$ being the flow-rate at time $\text{t}_i$ for flow $f_\text{in}$ and $f_\text{out}$, respectively +- $\text a_{f_\text{in}}(\text{t}_i)$ and $\text b_{f_\text{out}}(\text{t}_i)$ being the ratio of the flow-rate at time $\text{t}_i$ for flow $f_\text{in}$ and $f_\text{out}$, respectively + +With one incoming **Flow** and one outgoing **Flow**, this can be simplified to: + +$$ \label{eq:Linear-Transformer-Ratio-simple} + \text a(\text{t}_i) \cdot p_{f_\text{in}}(\text{t}_i) = p_{f_\text{out}}(\text{t}_i) +$$ + +where $\text a$ can be interpreted as the conversion efficiency of the **LinearTransformer**. +#### Piecewise Concersion factors +The conversion efficiency can be defined as a piecewise function. \ No newline at end of file diff --git a/docs/concepts-and-math/Mathematical Notation/Storage.md b/docs/concepts-and-math/Mathematical Notation/Storage.md new file mode 100644 index 000000000..db78b6ab3 --- /dev/null +++ b/docs/concepts-and-math/Mathematical Notation/Storage.md @@ -0,0 +1,44 @@ +# Storages +**Storages** have one incoming and one outgoing **[Flow](#flows)** with a charging and discharging efficiency. +A storage has a state of charge $c(\text{t}_i)$ which is limited by its `size` $\text C$ and relative bounds $\eqref{eq:Storage_Bounds}$. + +$$ \label{eq:Storage_Bounds} + \text C \cdot \text c^{\text{L}}_{\text{rel}}(\text t_{i}) + \leq c(\text{t}_i) \leq + \text C \cdot \text c^{\text{U}}_{\text{rel}}(\text t_{i}) +$$ + +Where: + +- $\text C$ is the size of the storage +- $c(\text{t}_i)$ is the state of charge at time $\text{t}_i$ +- $\text c^{\text{L}}_{\text{rel}}(\text t_{i})$ is the relative lower bound (typically 0) +- $\text c^{\text{U}}_{\text{rel}}(\text t_{i})$ is the relative upper bound (typically 1) + +With $\text c^{\text{L}}_{\text{rel}}(\text t_{i}) = 0$ and $\text c^{\text{U}}_{\text{rel}}(\text t_{i}) = 1$, +Equation $\eqref{eq:Storage_Bounds}$ simplifies to + +$$ 0 \leq c(\text t_{i}) \leq \text C $$ + +The state of charge $c(\text{t}_i)$ decreases by a fraction of the prior state of charge. The belonging parameter +$ \dot{ \text c}_\text{rel, loss}(\text{t}_i)$ expresses the "loss fraction per hour". The storage balance from $\text{t}_i$ to $\text t_{i+1}$ is + +$$ +\begin{align*} + c(\text{t}_{i+1}) &= c(\text{t}_{i}) \cdot (1-\dot{\text{c}}_\text{rel,loss}(\text{t}_i) \cdot \Delta \text{t}_{i}) \\ + &\quad + p_{f_\text{in}}(\text{t}_i) \cdot \Delta \text{t}_i \cdot \eta_\text{in}(\text{t}_i) \\ + &\quad - \frac{p_{f_\text{out}}(\text{t}_i) \cdot \Delta \text{t}_i}{\eta_\text{out}(\text{t}_i)} + \tag{3} +\end{align*} +$$ + +Where: + +- $c(\text{t}_{i+1})$ is the state of charge at time $\text{t}_{i+1}$ +- $c(\text{t}_{i})$ is the state of charge at time $\text{t}_{i}$ +- $\dot{\text{c}}_\text{rel,loss}(\text{t}_i)$ is the relative loss rate (self-discharge) per hour +- $\Delta \text{t}_{i}$ is the time step duration in hours +- $p_{f_\text{in}}(\text{t}_i)$ is the input flow rate at time $\text{t}_i$ +- $\eta_\text{in}(\text{t}_i)$ is the charging efficiency at time $\text{t}_i$ +- $p_{f_\text{out}}(\text{t}_i)$ is the output flow rate at time $\text{t}_i$ +- $\eta_\text{out}(\text{t}_i)$ is the discharging efficiency at time $\text{t}_i$ \ No newline at end of file diff --git a/docs/concepts-and-math/Mathematical Notation/index.md b/docs/concepts-and-math/Mathematical Notation/index.md new file mode 100644 index 000000000..7d7f67b13 --- /dev/null +++ b/docs/concepts-and-math/Mathematical Notation/index.md @@ -0,0 +1,22 @@ + +# Mathematical Notation + +## Naming Conventions + +flixOpt uses the following naming conventions: + +- All optimization variables are denoted by italic letters (e.g., $x$, $y$, $z$) +- All parameters and constants are denoted by non italic small letters (e.g., $\text{a}$, $\text{b}$, $\text{c}$) +- All Sets are denoted by greek capital letters (e.g., $\mathcal{F}$, $\mathcal{E}$) +- All units of a set are denoted by greek small letters (e.g., $\mathcal{f}$, $\mathcal{e}$) +- The letter $i$ is used to denote an index (e.g., $i=1,\dots,\text n$) +- All time steps are denoted by the letter $\text{t}$ (e.g., $\text{t}_0$, $\text{t}_1$, $\text{t}_i$) + +## Timesteps +Time steps are defined as a sequence of discrete time steps $\text{t}_i \in \mathcal{T} \quad \text{for} \quad i \in \{1, 2, \dots, \text{n}\}$ (left-aligned in its timespan). +From this sequence, the corresponding time intervals $\Delta \text{t}_i \in \Delta \mathcal{T}$ are derived as + +$$\Delta \text{t}_i = \text{t}_{i+1} - \text{t}_i \quad \text{for} \quad i \in \{1, 2, \dots, \text{n}-1\}$$ + +The final time interval $\Delta \text{t}_\text n$ defaults to $\Delta \text{t}_\text n = \Delta \text{t}_{\text n-1}$, but is of course customizable. +Non-equidistant time steps are also supported. diff --git a/docs/concepts-and-math/Mathematical Notation/others.md b/docs/concepts-and-math/Mathematical Notation/others.md new file mode 100644 index 000000000..0cd82de94 --- /dev/null +++ b/docs/concepts-and-math/Mathematical Notation/others.md @@ -0,0 +1,3 @@ +# Work in Progress + +This is a work in progress. \ No newline at end of file diff --git a/docs/concepts-and-math/index.md b/docs/concepts-and-math/index.md index de671c30c..46df65857 100644 --- a/docs/concepts-and-math/index.md +++ b/docs/concepts-and-math/index.md @@ -1,4 +1,4 @@ -# flixOpt Concepts & Mathematical Description +# flixOpt Concepts flixOpt is built around a set of core concepts that work together to represent and optimize energy and material flow systems. This page provides a high-level overview of these concepts and how they interact. @@ -48,10 +48,13 @@ Every flixOpt model starts with creating a FlowSystem. It: - Costs (investment, operation) - Emissions (CO₂, NOx, etc.) - Resource consumption +- Area demand These can be freely defined and crosslink to each other (`CO₂` ──[specific CO₂-costs]─→ `Costs`). -One effect is designated as the **optimization objective** (typically Costs), while others can have constraints. -This effect can incorporate several other effects, which woul result in a weighted objective from multiple effects. +One effect is designated as the **optimization objective** (typically Costs), while others can be constrained. +This approach allows for a multi-criteria optimization using both... + - ... the **Weigted Sum**Method, by Optimizing a theoretical Effect which other Effects crosslink to. + - ... the ($\epsilon$-constraint method) by constraining effects. ### Calculation diff --git a/flixOpt/components.py b/flixOpt/components.py index ec4336e43..fd263a326 100644 --- a/flixOpt/components.py +++ b/flixOpt/components.py @@ -127,57 +127,10 @@ def degrees_of_freedom(self): @register_class_for_io class Storage(Component): - r""" - **Storages** have one incoming and one outgoing **Flow** - $f_\text{in}$ and $f_\text{out}$ - - each with an efficiency $\eta_\text{in}$ and $\eta_\text{out}$. - Further, storages have a `size` $\text C$ and a state of charge $c(\text{t}_i)$. - Similarly to the flow-rate $p(\text{t}_i)$ of a [`Flow`][flixOpt.elements.Flow], - the `size` $\text C$ combined with a relative upper bound - $\text c^{\text{U}}_\text{rel}(\text t_{i})$ and lower bound $\text c^{\text{L}}_\text{rel}(\text t_{i})$ - limits the state of charge $c(\text{t}_i)$ by $\eqref{eq:Storage_Bounds}$. - - $$ \label{eq:Storage_Bounds} - \text C \cdot \text c^{\text{L}}_{\text{rel}}(\text t_{i}) - \leq c(\text{t}_i) \leq - \text C \cdot \text c^{\text{U}}_{\text{rel}}(\text t_{i}) - $$ - - Where: - - - $\text C$ is the storage capacity - - $c(\text{t}_i)$ is the state of charge at time $\text{t}_i$ - - $\text c^{\text{L}}_{\text{rel}}(\text t_{i})$ is the relative lower bound (typically 0) - - $\text c^{\text{U}}_{\text{rel}}(\text t_{i})$ is the relative upper bound (typically 1) - - With $\text c^{\text{L}}_{\text{rel}}(\text t_{i}) = 0$ and $\text c^{\text{U}}_{\text{rel}}(\text t_{i}) = 1$, - Equation $\eqref{eq:Storage_Bounds}$ simplifies to - - $$ 0 \leq c(\text t_{i}) \leq \text C $$ - - The state of charge $c(\text{t}_i)$ decreases by a fraction of the prior state of charge. The belonging parameter - $ \dot{ \text c}_\text{rel, loss}(\text{t}_i)$ expresses the "loss fraction per hour". The storage balance from $\text{t}_i$ to $\text t_{i+1}$ is - - $$ - \begin{align*} - c(\text{t}_{i+1}) &= c(\text{t}_{i}) \cdot (1-\dot{\text{c}}_\text{rel,loss}(\text{t}_i) \cdot \Delta \text{t}_{i}) \\ - &\quad + p_{f_\text{in}}(\text{t}_i) \cdot \Delta \text{t}_i \cdot \eta_\text{in}(\text{t}_i) \\ - &\quad - \frac{p_{f_\text{out}}(\text{t}_i) \cdot \Delta \text{t}_i}{\eta_\text{out}(\text{t}_i)} - \tag{3} - \end{align*} - $$ - - Where: - - - $c(\text{t}_{i+1})$ is the state of charge at time $\text{t}_{i+1}$ - - $c(\text{t}_{i})$ is the state of charge at time $\text{t}_{i}$ - - $\dot{\text{c}}_\text{rel,loss}(\text{t}_i)$ is the relative loss rate (self-discharge) per hour - - $\Delta \text{t}_{i}$ is the time step duration in hours - - $p_{f_\text{in}}(\text{t}_i)$ is the input flow rate at time $\text{t}_i$ - - $\eta_\text{in}(\text{t}_i)$ is the charging efficiency at time $\text{t}_i$ - - $p_{f_\text{out}}(\text{t}_i)$ is the output flow rate at time $\text{t}_i$ - - $\eta_\text{out}(\text{t}_i)$ is the discharging efficiency at time $\text{t}_i$ - """ + Used to model the storage of energy or material. + """ + def __init__( self, label: str, diff --git a/flixOpt/effects.py b/flixOpt/effects.py index 7680e964f..4513e2d66 100644 --- a/flixOpt/effects.py +++ b/flixOpt/effects.py @@ -176,7 +176,10 @@ def do_modeling(self): EffectTimeSeries = Dict[str, TimeSeries] # Used internally to index values EffectValuesDict = Dict[str, NumericDataTS] # How effect values are stored EffectValuesUser = Union[NumericDataTS, Dict[str, NumericDataTS]] # User-specified Shares to Effects +""" This datatype is used to define the share to an effect by a certain attribute. """ + EffectValuesUserScalar = Union[Scalar, Dict[str, Scalar]] # User-specified Shares to Effects +""" This datatype is used to define the share to an effect by a certain attribute. Only scalars are allowed. """ class EffectCollection: diff --git a/flixOpt/elements.py b/flixOpt/elements.py index 6c8e806f2..c0fa77728 100644 --- a/flixOpt/elements.py +++ b/flixOpt/elements.py @@ -84,34 +84,8 @@ def _plausibility_checks(self) -> None: @register_class_for_io class Bus(Element): - r""" - A Bus represents a nodal balance between the flow rates of its incoming and outgoing [Flows][flixOpt.elements.Flow] - ($\mathcal{F}_\text{in}$ and $\mathcal{F}_\text{out}$), - which must hold for every time step $\text{t}_i \in \mathcal{T}$. - - $$ - \sum_{f_\text{in} \in \mathcal{F}_\text{in}} p_{f_\text{in}}(\text{t}_i) = - \sum_{f_\text{out} \in \mathcal{F}_\text{out}} p_{f_\text{out}}(\text{t}_i) - $$ - - To handle ifeasiblities gently, 2 variables $\phi_\text{in}(\text{t}_i)\geq0$ and - $\phi_\text{out}(\text{t}_i)\geq0$ might be introduced. - These represent the missing or excess flow_rate in Bus. E certain amount of penalty occurs for each missing or - excess flow_rate in the balance (`excess_penalty_per_flow_hour`), so they usually dont affect the Optimization. - The penalty term is defined as - - $$ - s_{b \rightarrow \Phi}(\text{t}_i) = - \text a_{b \rightarrow \Phi}(\text{t}_i) \cdot \Delta \text{t}_i - \cdot [ \phi_\text{in}(\text{t}_i) + \phi_\text{out}(\text{t}_i) ] - $$ - - Which changes the balance to - - $$ - \sum_{f_\text{in} \in \mathcal{F}_\text{in}} p_{f_ \text{in}}(\text{t}_i) + \phi_\text{in}(\text{t}_i) = - \sum_{f_\text{out} \in \mathcal{F}_\text{out}} p_{f_\text{out}}(\text{t}_i) + \phi_\text{out}(\text{t}_i) - $$ + """ + A Bus represents a nodal balance between the flow rates of its incoming and outgoing Flows. """ def __init__( diff --git a/site/release-notes/_template.txt b/site/release-notes/_template.txt new file mode 100644 index 000000000..fe85a0554 --- /dev/null +++ b/site/release-notes/_template.txt @@ -0,0 +1,32 @@ +# Release v{version} + +**Release Date:** YYYY-MM-DD + +## What's New + +* Feature 1 - Description +* Feature 2 - Description + +## Improvements + +* Improvement 1 - Description +* Improvement 2 - Description + +## Bug Fixes + +* Fixed issue with X +* Resolved problem with Y + +## Breaking Changes + +* Change 1 - Migration instructions +* Change 2 - Migration instructions + +## Deprecations + +* Feature X will be removed in v{next_version} + +## Dependencies + +* Added dependency X v1.2.3 +* Updated dependency Y to v2.0.0 \ No newline at end of file From 3a7121e4bab1536eb8d00e63cc7fd0d540334ca7 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 28 Mar 2025 13:08:59 +0100 Subject: [PATCH 477/507] Piecewise fully remake (#204) * Add Segment and Piecewise to Interfaces * Add Segment and Piecewise to commons * Update example * Improve Piecewise * Improve types * Bugfix LinearConverterModel * Change signature of Piecewise * Add new test flow system * Add new interfaces * Temp * Temp * Simplify Piecewise Models * Simplify Piecewise Models * Heavx renaming * Remove as_series * Bugfix * update test * Rename PiecewiseShares to PiecewiseEffects * ruff check * bugfix * Bugfix TimeSeries naming * Update PiecewiseEffects to check for no Scalar data * Add costrings * Rename segmented_conversion_factors to piecewise_conversion * Update names * Update names * Add docstring * update names * Add attribute * Bugfix * Update names * Update example * Remove some functions * Bugfix * Bugfix test * Remove unused and non intuitive functions * Bugfix test * Update names * Update names * Update names --- examples/02_Complex/complex_example.py | 36 +++-- flixOpt/__init__.py | 4 + flixOpt/commons.py | 6 +- flixOpt/components.py | 70 ++++---- flixOpt/features.py | 213 +++++++++++-------------- flixOpt/interface.py | 104 ++++++++++-- tests/conftest.py | 45 ++++-- tests/run_all_tests.py | 2 +- tests/test_integration.py | 10 +- tests/test_io.py | 4 +- 10 files changed, 291 insertions(+), 203 deletions(-) diff --git a/examples/02_Complex/complex_example.py b/examples/02_Complex/complex_example.py index f124d96ab..38da18db3 100644 --- a/examples/02_Complex/complex_example.py +++ b/examples/02_Complex/complex_example.py @@ -13,7 +13,7 @@ # Configure options for testing various parameters and behaviors check_penalty = False excess_penalty = 1e5 - use_chp_with_segments = False + use_chp_with_piecewise_conversion = True time_indices = None # Define specific time steps for custom calculations, or use the entire series # --- Define Demand and Price Profiles --- @@ -92,32 +92,34 @@ Q_fu=fx.Flow('Q_fu', bus='Gas', size=1e3, previous_flow_rate=20), # The CHP was ON previously ) - # 3. Define CHP with Linear Segments - # This CHP unit uses linear segments for more dynamic behavior over time + # 3. Define CHP with Piecewise Conversion + # This CHP unit uses piecewise conversion for more dynamic behavior over time P_el = fx.Flow('P_el', bus='Strom', size=60, previous_flow_rate=20) Q_th = fx.Flow('Q_th', bus='Fernwärme') Q_fu = fx.Flow('Q_fu', bus='Gas') - segmented_conversion_factors = { - P_el.label: [(5, 30), (40, 60)], # Similar to eta_th, each factor here can be an array - Q_th.label: [(6, 35), (45, 100)], - Q_fu.label: [(12, 70), (90, 200)], - } + piecewise_conversion = fx.PiecewiseConversion( + { + P_el.label: fx.Piecewise([fx.Piece(5, 30), fx.Piece(40, 60)]), + Q_th.label: fx.Piecewise([fx.Piece(6, 35), fx.Piece(45, 100)]), + Q_fu.label: fx.Piecewise([fx.Piece(12, 70), fx.Piece(90, 200)]), + } + ) bhkw_2 = fx.LinearConverter( 'BHKW2', inputs=[Q_fu], outputs=[P_el, Q_th], - segmented_conversion_factors=segmented_conversion_factors, + piecewise_conversion=piecewise_conversion, on_off_parameters=fx.OnOffParameters(effects_per_switch_on=0.01), ) # 4. Define Storage Component - # Storage with variable size and segmented investment effects - segmented_investment_effects = ( - [(5, 25), (25, 100)], # Investment size - { - Costs.label: [(50, 250), (250, 800)], # Investment costs - PE.label: [(5, 25), (25, 100)], # Primary energy costs + # Storage with variable size and piecewise investment effects + segmented_investment_effects = fx.PiecewiseEffects( + piecewise_origin=fx.Piecewise([fx.Piece(5, 25), fx.Piece(25, 100)]), + piecewise_shares={ + Costs.label: fx.Piecewise([fx.Piece(50, 250), fx.Piece(250, 800)]), + PE.label: fx.Piecewise([fx.Piece(5, 25), fx.Piece(25, 100)]), }, ) @@ -126,7 +128,7 @@ charging=fx.Flow('Q_th_load', bus='Fernwärme', size=1e4), discharging=fx.Flow('Q_th_unload', bus='Fernwärme', size=1e4), capacity_in_flow_hours=fx.InvestParameters( - effects_in_segments=segmented_investment_effects, # Investment effects + piecewise_effects=segmented_investment_effects, # Investment effects optional=False, # Forced investment minimum_size=0, maximum_size=1000, # Optimizing between 0 and 1000 kWh @@ -175,7 +177,7 @@ # --- Build FlowSystem --- # Select components to be included in the flow system flow_system.add_elements(Costs, CO2, PE, Gaskessel, Waermelast, Gasbezug, Stromverkauf, speicher) - flow_system.add_elements(bhkw_2) if use_chp_with_segments else flow_system.add_elements(bhkw) + flow_system.add_elements(bhkw_2) if use_chp_with_piecewise_conversion else flow_system.add_elements(bhkw) pprint(flow_system) # Get a string representation of the FlowSystem diff --git a/flixOpt/__init__.py b/flixOpt/__init__.py index 9550d89f8..b100389f0 100644 --- a/flixOpt/__init__.py +++ b/flixOpt/__init__.py @@ -14,6 +14,10 @@ InvestParameters, LinearConverter, OnOffParameters, + Piece, + Piecewise, + PiecewiseConversion, + PiecewiseEffects, SegmentedCalculation, Sink, Source, diff --git a/flixOpt/commons.py b/flixOpt/commons.py index d88a882ed..e6e2aa4ce 100644 --- a/flixOpt/commons.py +++ b/flixOpt/commons.py @@ -18,7 +18,7 @@ from .effects import Effect from .elements import Bus, Flow from .flow_system import FlowSystem -from .interface import InvestParameters, OnOffParameters +from .interface import InvestParameters, OnOffParameters, Piece, Piecewise, PiecewiseConversion, PiecewiseEffects __all__ = [ 'TimeSeriesData', @@ -39,6 +39,10 @@ 'AggregatedCalculation', 'InvestParameters', 'OnOffParameters', + 'Piece', + 'Piecewise', + 'PiecewiseConversion', + 'PiecewiseEffects', 'AggregationParameters', 'plotting', 'results', diff --git a/flixOpt/components.py b/flixOpt/components.py index fd263a326..f3c9a6f7d 100644 --- a/flixOpt/components.py +++ b/flixOpt/components.py @@ -11,8 +11,8 @@ from . import utils from .core import NumericData, NumericDataTS, PlausibilityError, Scalar, TimeSeries from .elements import Component, ComponentModel, Flow -from .features import InvestmentModel, MultipleSegmentsModel, OnOffModel -from .interface import InvestParameters, OnOffParameters +from .features import InvestmentModel, OnOffModel, PiecewiseModel +from .interface import InvestParameters, OnOffParameters, PiecewiseConversion from .structure import SystemModel, register_class_for_io if TYPE_CHECKING: @@ -35,7 +35,7 @@ def __init__( outputs: List[Flow], on_off_parameters: OnOffParameters = None, conversion_factors: List[Dict[str, NumericDataTS]] = None, - segmented_conversion_factors: Dict[str, List[Tuple[NumericDataTS, NumericDataTS]]] = None, + piecewise_conversion: Optional[PiecewiseConversion] = None, meta_data: Optional[Dict] = None, ): """ @@ -45,18 +45,14 @@ def __init__( outputs: The output Flows on_off_parameters: Information about on and off states. See class OnOffParameters. conversion_factors: linear relation between flows. - Either 'conversion_factors' or 'segmented_conversion_factors' can be used! - segmented_conversion_factors: Segmented linear relation between flows. - Each Flow gets a List of Segments assigned. - If FLows need to be 0 (or Off), include a "Zero-Segment" "(0, 0)", or use on_off_parameters - Either 'segmented_conversion_factors' or 'conversion_factors' can be used! - --> "gaps" can be expressed by a segment not starting at the end of the prior segment: [(1,3), (4,5)] - --> "points" can expressed as segment with same begin and end: [(3,3), (4,4)] + Either 'conversion_factors' or 'piecewise_conversion' can be used! + piecewise_conversion: Define a piecewise linear relation between flow rates of different flows. + Either 'conversion_factors' or 'piecewise_conversion' can be used! meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types. """ super().__init__(label, inputs, outputs, on_off_parameters, meta_data=meta_data) self.conversion_factors = conversion_factors or [] - self.segmented_conversion_factors = segmented_conversion_factors or {} + self.piecewise_conversion = piecewise_conversion def create_model(self, model: SystemModel) -> 'LinearConverterModel': self._plausibility_checks() @@ -64,10 +60,10 @@ def create_model(self, model: SystemModel) -> 'LinearConverterModel': return self.model def _plausibility_checks(self) -> None: - if not self.conversion_factors and not self.segmented_conversion_factors: - raise PlausibilityError('Either conversion_factors or segmented_conversion_factors must be defined!') - if self.conversion_factors and self.segmented_conversion_factors: - raise PlausibilityError('Only one of conversion_factors or segmented_conversion_factors can be defined, not both!') + if not self.conversion_factors and not self.piecewise_conversion: + raise PlausibilityError('Either conversion_factors or piecewise_conversion must be defined!') + if self.conversion_factors and self.piecewise_conversion: + raise PlausibilityError('Only one of conversion_factors or piecewise_conversion can be defined, not both!') if self.conversion_factors: if self.degrees_of_freedom <= 0: @@ -83,11 +79,11 @@ def _plausibility_checks(self) -> None: raise PlausibilityError( f'{self.label}: Flow {flow} in conversion_factors is not in inputs/outputs' ) - if self.segmented_conversion_factors: + if self.piecewise_conversion: for flow in self.flows.values(): if isinstance(flow.size, InvestParameters) and flow.size.fixed_size is None: raise PlausibilityError( - f'segmented_conversion_factors (in {self.label_full}) and variable size ' + f'piecewise_conversion (in {self.label_full}) and variable size ' f'(in flow {flow.label_full}) do not make sense together!' ) @@ -95,17 +91,8 @@ def transform_data(self, flow_system: 'FlowSystem'): super().transform_data(flow_system) if self.conversion_factors: self.conversion_factors = self._transform_conversion_factors(flow_system) - else: - segmented_conversion_factors = {} - for flow, segments in self.segmented_conversion_factors.items(): - segmented_conversion_factors[flow] = [ - ( - flow_system.create_time_series(f'{self.flows[flow].label_full}|Stützstelle|{idx}a', segment[0]), - flow_system.create_time_series(f'{self.flows[flow].label_full}|Stützstelle|{idx}b', segment[1]), - ) - for idx, segment in enumerate(segments) - ] - self.segmented_conversion_factors = segmented_conversion_factors + if self.piecewise_conversion: + self.piecewise_conversion.transform_data(flow_system, f'{self.label_full}|PiecewiseConversion') def _transform_conversion_factors(self, flow_system: 'FlowSystem') -> List[Dict[str, TimeSeries]]: """macht alle Faktoren, die nicht TimeSeries sind, zu TimeSeries""" @@ -421,20 +408,23 @@ def do_modeling(self): ) ) - # (linear) segments: else: - # TODO: Improve Inclusion of OnOffParameters. Instead of creating a Binary in every flow, the binary could only be part of the Segment itself - segments: Dict[str, List[Tuple[NumericData, NumericData]]] = { - self.element.flows[flow].model.flow_rate.name: [ - (ts1.active_data, ts2.active_data) for ts1, ts2 in self.element.segmented_conversion_factors[flow] - ] - for flow in self.element.flows + # TODO: Improve Inclusion of OnOffParameters. Instead of creating a Binary in every flow, the binary could only be part of the Piece itself + piecewise_conversion = { + self.element.flows[flow].model.flow_rate.name: piecewise + for flow, piecewise in self.element.piecewise_conversion.items() } - linear_segments = MultipleSegmentsModel( - self._model, self.label_of_element, segments, self.on_off.on if self.on_off is not None else None - ) # TODO: Add Outside_segments Variable (On) - linear_segments.do_modeling() - self.sub_models.append(linear_segments) + + piecewise_conversion = PiecewiseModel( + model=self._model, + label_of_element=self.label_of_element, + label=self.label_full, + piecewise_variables=piecewise_conversion, + zero_point=self.on_off.on if self.on_off is not None else False, + as_time_series=True + ) + piecewise_conversion.do_modeling() + self.sub_models.append(piecewise_conversion) class StorageModel(ComponentModel): diff --git a/flixOpt/features.py b/flixOpt/features.py index 2e4754463..2569be61a 100644 --- a/flixOpt/features.py +++ b/flixOpt/features.py @@ -12,13 +12,9 @@ from . import utils from .config import CONFIG from .core import NumericData, Scalar, TimeSeries -from .interface import InvestParameters, OnOffParameters +from .interface import InvestParameters, OnOffParameters, Piece, Piecewise, PiecewiseConversion, PiecewiseEffects from .structure import Model, SystemModel -if TYPE_CHECKING: # for type checking and preventing circular imports - from .effects import Effect - - logger = logging.getLogger('flixOpt') @@ -39,7 +35,7 @@ def __init__( self.size: Optional[Union[Scalar, linopy.Variable]] = None self.is_invested: Optional[linopy.Variable] = None - self._segments: Optional[SegmentedSharesModel] = None + self.piecewise_effects: Optional[PiecewiseEffectsModel] = None self._on_variable = on_variable self._defining_variable = defining_variable @@ -101,17 +97,17 @@ def _create_shares(self): target='invest', ) - if self.parameters.effects_in_segments: - self._segments = self.add( - SegmentedSharesModel( + if self.parameters.piecewise_effects: + self.piecewise_effects = self.add( + PiecewiseEffectsModel( model=self._model, label_of_element=self.label_of_element, - variable_segments=(self.size, self.parameters.effects_in_segments[0]), - share_segments=self.parameters.effects_in_segments[1], - can_be_outside_segments=self.is_invested), + piecewise_origin=(self.size.name, self.parameters.piecewise_effects.piecewise_origin), + piecewise_shares=self.parameters.piecewise_effects.piecewise_shares, + zero_point=self.is_invested), 'segments' ) - self._segments.do_modeling() + self.piecewise_effects.do_modeling() def _create_bounds_for_optional_investment(self): if self.parameters.fixed_size: @@ -303,7 +299,7 @@ def _add_on_constraints(self): # % Bedingungen 1) und 2) müssen erfüllt sein: # % Anmerkung: Falls "abschnittsweise linear" gewählt, dann ist eigentlich nur Bedingung 1) noch notwendig - # % (und dann auch nur wenn erstes Segment bei Q_th=0 beginnt. Dann soll bei Q_th=0 (d.h. die Maschine ist Aus) On = 0 und segment1.onSeg = 0):) + # % (und dann auch nur wenn erstes Piece bei Q_th=0 beginnt. Dann soll bei Q_th=0 (d.h. die Maschine ist Aus) On = 0 und segment1.onSeg = 0):) # % Fazit: Wenn kein Performance-Verlust durch mehr Gleichungen, dann egal! nr_of_def_vars = len(self._defining_variables) @@ -660,32 +656,28 @@ def compute_consecutive_duration( ) -class SegmentModel(Model): - """Class for modeling a linear segment of one or more variables in parallel""" +class PieceModel(Model): + """Class for modeling a linear piece of one or more variables in parallel""" def __init__( self, model: SystemModel, label_of_element: str, - segment_index: Union[int, str], - sample_points: Dict[str, Tuple[Union[NumericData, TimeSeries], Union[NumericData, TimeSeries]]], + label: str, as_time_series: bool = True, ): - super().__init__(model, label_of_element, f'Segment{segment_index}') - self.in_segment: Optional[linopy.Variable] = None + super().__init__(model, label_of_element, label) + self.inside_piece: Optional[linopy.Variable] = None self.lambda0: Optional[linopy.Variable] = None self.lambda1: Optional[linopy.Variable] = None - - self._segment_index = segment_index self._as_time_series = as_time_series - self.sample_points = sample_points def do_modeling(self): - self.in_segment = self.add(self._model.add_variables( + self.inside_piece = self.add(self._model.add_variables( binary=True, - name=f'{self.label_full}|in_segment', + name=f'{self.label_full}|inside_piece', coords=self._model.coords if self._as_time_series else None), - 'in_segment' + 'inside_piece' ) self.lambda0 = self.add(self._model.add_variables( @@ -702,102 +694,91 @@ def do_modeling(self): 'lambda1' ) - # eq: lambda0(t) + lambda1(t) = in_segment(t) + # eq: lambda0(t) + lambda1(t) = inside_piece(t) self.add(self._model.add_constraints( - self.in_segment == self.lambda0 + self.lambda1, - name=f'{self.label_full}|in_segment'), - 'in_segment' + self.inside_piece == self.lambda0 + self.lambda1, + name=f'{self.label_full}|inside_piece'), + 'inside_piece' ) -class MultipleSegmentsModel(Model): - # TODO: Length... +class PiecewiseModel(Model): + def __init__( self, model: SystemModel, label_of_element: str, - sample_points: Dict[str, List[Tuple[NumericData, NumericData]]], - can_be_outside_segments: Optional[Union[bool, linopy.Variable]], - as_time_series: bool = True, - label: str = 'MultipleSegments', + label: str, + piecewise_variables: Dict[str, Piecewise], + zero_point: Optional[Union[bool, linopy.Variable]], + as_time_series: bool, ): """ + Modeling a Piecewise relation between miultiple variables. + The relation is defined by a list of Pieces, which are assigned to the variables. + Each Piece is a tuple of (start, end). + Args: - model: Model to which the segmented variable belongs. - label_of_element: Name of the parent variable. - sample_points: Dictionary mapping variables (names) to their sample points for each segment. - The sample points are tuples of the form (start, end). - can_be_outside_segments: Whether the variable can be outside the segments. If True, a variable is created. - If False or None, no variable is created. If a Variable is passed, it is used. - as_time_series: Whether to create a scalar or time series variable. - label: Name of the Model. + model: The SystemModel 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. + 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. """ super().__init__(model, label_of_element, label) - self.outside_segments: Optional[linopy.Variable] = None - + self._piecewise_variables = piecewise_variables + self._zero_point = zero_point self._as_time_series = as_time_series - self._can_be_outside_segments = can_be_outside_segments - self._sample_points = sample_points - self._segment_models: List[SegmentModel] = [] + + self.pieces: List[PieceModel] = [] + self.zero_point: Optional[linopy.Variable] = None def do_modeling(self): - restructured_variables_with_segments: List[Dict[str, Tuple[NumericData, NumericData]]] = [ - {key: values[i] for key, values in self._sample_points.items()} for i in range(self._nr_of_segments) - ] + for i in range(len(list(self._piecewise_variables.values())[0])): + new_piece = self.add( + PieceModel( + model=self._model, + label_of_element=self.label_of_element, + label=f'Piece_{i}', + as_time_series=self._as_time_series, + ) + ) + self.pieces.append(new_piece) + new_piece.do_modeling() - self._segment_models = [ - self.add( - SegmentModel( - self._model, - label_of_element=self.label_of_element, - segment_index=i, - sample_points=sample_points, - as_time_series=self._as_time_series), - f'Segment_{i}') - for i, sample_points in enumerate(restructured_variables_with_segments) - ] - - for segment_model in self._segment_models: - segment_model.do_modeling() - - # eq: - v(t) + (v_0_0 * lambda_0_0 + v_0_1 * lambda_0_1) + (v_1_0 * lambda_1_0 + v_1_1 * lambda_1_1) ... = 0 - # -> v_0_0, v_0_1 = Stützstellen des Segments 0 - for var_name in self._sample_points.keys(): + for var_name in self._piecewise_variables: variable = self._model.variables[var_name] self.add(self._model.add_constraints( - variable == sum([segment.lambda0 * segment.sample_points[var_name][0] - + segment.lambda1 * segment.sample_points[var_name][1] - for segment in self._segment_models]), + variable == sum([piece_model.lambda0 * piece_bounds.start + + piece_model.lambda1 * piece_bounds.end + for piece_model, piece_bounds in zip(self.pieces, self._piecewise_variables[var_name], strict=False)]), name=f'{self.label_full}|{var_name}_lambda'), f'{var_name}_lambda' ) # a) eq: Segment1.onSeg(t) + Segment2.onSeg(t) + ... = 1 Aufenthalt nur in Segmenten erlaubt # b) eq: -On(t) + Segment1.onSeg(t) + Segment2.onSeg(t) + ... = 0 zusätzlich kann alles auch Null sein - if isinstance(self._can_be_outside_segments, linopy.Variable): - self.outside_segments = self._can_be_outside_segments - rhs = self.outside_segments - elif self._can_be_outside_segments is True: - self.outside_segments = self.add(self._model.add_variables( + if isinstance(self._zero_point, linopy.Variable): + self.zero_point = self._zero_point + rhs = self.zero_point + elif self._zero_point is True: + self.zero_point = self.add(self._model.add_variables( coords=self._model.coords, binary=True, - name=f'{self.label_full}|outside_segments'), - 'outside_segments' + name=f'{self.label_full}|zero_point'), + 'zero_point' ) - rhs = self.outside_segments + rhs = self.zero_point else: rhs = 1 self.add(self._model.add_constraints( - sum([segment.in_segment for segment in self._segment_models]) <= rhs, + sum([piece.inside_piece for piece in self.pieces]) <= rhs, name=f'{self.label_full}|{variable.name}_single_segment'), 'single_segment' ) - @property - def _nr_of_segments(self): - return len(next(iter(self._sample_points.values()))) - class ShareAllocationModel(Model): def __init__( @@ -898,59 +879,59 @@ def add_share( self._eq_total_per_timestep.lhs -= self.shares[name] -class SegmentedSharesModel(Model): - # TODO: Length... +class PiecewiseEffectsModel(Model): def __init__( self, model: SystemModel, label_of_element: str, - variable_segments: Tuple[linopy.Variable, List[Tuple[Scalar, Scalar]]], - share_segments: Dict[str, List[Tuple[Scalar, Scalar]]], - can_be_outside_segments: Optional[Union[bool, linopy.Variable]], - label: str = 'SegmentedShares', + piecewise_origin: Tuple[str, Piecewise], + piecewise_shares: Dict[str, Piecewise], + zero_point: Optional[Union[bool, linopy.Variable]], + label: str = 'PiecewiseEffects', ): super().__init__(model, label_of_element, label) - assert len(variable_segments[1]) == len(list(share_segments.values())[0]), ( - 'Segment length of variable_segments and share_segments must be equal' + assert len(piecewise_origin[1]) == len(list(piecewise_shares.values())[0]), ( + 'Piece length of variable_segments and share_segments must be equal' ) - self._can_be_outside_segments = can_be_outside_segments - self._variable_segments = variable_segments - self._share_segments = share_segments - self._shares: Dict['Effect', linopy.Variable] = {} - self._segments_model: Optional[MultipleSegmentsModel] = None - self._as_tme_series: bool = 'time' in self._variable_segments[0].indexes + self._zero_point = zero_point + self._piecewise_origin = piecewise_origin + self._piecewise_shares = piecewise_shares + self.shares: Dict[str, linopy.Variable] = {} + + self.piecewise_model: Optional[PiecewiseModel] = None def do_modeling(self): - self._shares = { + self.shares = { effect: self.add(self._model.add_variables( - coords=self._model.coords if self._as_tme_series else None, + coords=None, name=f'{self.label_full}|{effect}'), f'{effect}' - ) for effect in self._share_segments + ) for effect in self._piecewise_shares } - # Mapping variable names to segments - segments: Dict[str, List[Tuple[Scalar, Scalar]]] = { - **{self._shares[effect].name: segment for effect, segment in self._share_segments.items()}, - **{self._variable_segments[0].name: self._variable_segments[1]}, + piecewise_variables = { + self._piecewise_origin[0]: self._piecewise_origin[1], + **{self.shares[effect_label].name: self._piecewise_shares[effect_label] for effect_label in self._piecewise_shares} } - self._segments_model = self.add( - MultipleSegmentsModel( + self.piecewise_model = self.add( + PiecewiseModel( model=self._model, label_of_element=self.label_of_element, - sample_points=segments, - can_be_outside_segments=self._can_be_outside_segments, - as_time_series=self._as_tme_series), - 'segments' + label=f'{self.label_full}|PiecewiseModel', + piecewise_variables=piecewise_variables, + zero_point=self._zero_point, + as_time_series=False, + ) ) - self._segments_model.do_modeling() + + self.piecewise_model.do_modeling() # Shares self._model.effects.add_share_to_effects( name=self.label_of_element, - expressions={effect: variable*1 for effect, variable in self._shares.items()}, - target='operation' if self._as_tme_series else 'invest', + expressions={effect: variable*1 for effect, variable in self.shares.items()}, + target='invest', ) diff --git a/flixOpt/interface.py b/flixOpt/interface.py index a285a3032..bd2147573 100644 --- a/flixOpt/interface.py +++ b/flixOpt/interface.py @@ -4,23 +4,104 @@ """ import logging -from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union - -from flixOpt.core import TimeSeriesCollection +from typing import TYPE_CHECKING, Dict, Iterator, List, Optional, Union from .config import CONFIG from .core import NumericData, NumericDataTS, Scalar -from .structure import Element, Interface, register_class_for_io +from .structure import Interface, register_class_for_io if TYPE_CHECKING: # for type checking and preventing circular imports + from .effects import EffectValuesUser, EffectValuesUserScalar from .flow_system import FlowSystem -if TYPE_CHECKING: - from .effects import Effect, EffectValuesUser, EffectValuesUserScalar logger = logging.getLogger('flixOpt') +@register_class_for_io +class Piece(Interface): + def __init__(self, start: NumericData, end: NumericData): + """ + Define a Piece, which is part of a Piecewise object. + + Args: + start: The x-values of the piece. + end: The end of the piece. + """ + self.start = start + self.end = end + + def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): + self.start = flow_system.create_time_series(f'{name_prefix}|start', self.start) + self.end = flow_system.create_time_series(f'{name_prefix}|end', self.end) + + +@register_class_for_io +class Piecewise(Interface): + def __init__(self, pieces: List[Piece]): + """ + Define a Piecewise, consisting of a list of Pieces. + + Args: + pieces: The pieces of the piecewise. + """ + self.pieces = pieces + + def __len__(self): + return len(self.pieces) + + def __getitem__(self, index) -> Piece: + return self.pieces[index] # Enables indexing like piecewise[i] + + def __iter__(self) -> Iterator[Piece]: + return iter(self.pieces) # Enables iteration like for piece in piecewise: ... + + def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): + for i, piece in enumerate(self.pieces): + piece.transform_data(flow_system, f'{name_prefix}|Piece{i}') + + +@register_class_for_io +class PiecewiseConversion(Interface): + def __init__(self, piecewises: Dict[str, Piecewise]): + """ + Define a piecewise conversion between multiple Flows. + --> "gaps" can be expressed by a piece not starting at the end of the prior piece: [(1,3), (4,5)] + --> "points" can expressed as piece with same begin and end: [(3,3), (4,4)] + + Args: + piecewises: Dict of Piecewises defining the conversion factors. flow labels as keys, piecewise as values + """ + self.piecewises = piecewises + + def items(self): + return self.piecewises.items() + + def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): + for name, piecewise in self.piecewises.items(): + piecewise.transform_data(flow_system, f'{name_prefix}|{name}') + + +@register_class_for_io +class PiecewiseEffects(Interface): + def __init__(self, piecewise_origin: Piecewise, piecewise_shares: Dict[str, Piecewise]): + """ + Define piecewise effects related to a variable. + + Args: + piecewise_origin: Piecewise of the related variable + piecewise_shares: Piecewise defining the shares to different Effects + """ + self.piecewise_origin = piecewise_origin + self.piecewise_shares = piecewise_shares + + def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): + raise NotImplementedError('PiecewiseEffects is not yet implemented for non scalar shares') + #self.piecewise_origin.transform_data(flow_system, f'{name_prefix}|PiecewiseEffects|origin') + #for name, piecewise in self.piecewise_shares.items(): + # piecewise.transform_data(flow_system, f'{name_prefix}|PiecewiseEffects|{name}') + + @register_class_for_io class InvestParameters(Interface): """ @@ -35,9 +116,7 @@ def __init__( optional: bool = True, # Investition ist weglassbar fix_effects: Optional['EffectValuesUserScalar'] = None, specific_effects: Optional['EffectValuesUserScalar'] = None, # costs per Flow-Unit/Storage-Size/... - effects_in_segments: Optional[ - Tuple[List[Tuple[Scalar, Scalar]], Dict['str', List[Tuple[Scalar, Scalar]]]] - ] = None, + piecewise_effects: Optional[PiecewiseEffects] = None, divest_effects: Optional['EffectValuesUserScalar'] = None, ): """ @@ -49,7 +128,7 @@ def __init__( specific_effects: Specific costs, e.g., in €/kW_nominal or €/m²_nominal. Example: {costs: 3, CO2: 0.3} with costs and CO2 representing an Object of class Effect (Attention: Annualize costs to chosen period!) - effects_in_segments: Linear relation in segments [invest_segments, cost_segments]. + piecewise_effects: Linear piecewise relation [invest_pieces, cost_pieces]. Example 1: [ [5, 25, 25, 100], # size in kW {costs: [50,250,250,800], # € @@ -61,7 +140,7 @@ def __init__( [50,250,250,800] # value for standart effect, typically € ] # € (Attention: Annualize costs to chosen period!) - (Args 'specific_effects' and 'fix_effects' can be used in parallel to InvestsizeSegments) + (Args 'specific_effects' and 'fix_effects' can be used in parallel to Investsizepieces) minimum_size: Min nominal value (only if: size_is_fixed = False). maximum_size: Max nominal value (only if: size_is_fixed = False). """ @@ -70,7 +149,7 @@ def __init__( self.fixed_size = fixed_size self.optional = optional self.specific_effects: EffectValuesUser = specific_effects or {} - self.effects_in_segments = effects_in_segments + self.piecewise_effects = piecewise_effects self._minimum_size = minimum_size self._maximum_size = maximum_size or CONFIG.modeling.BIG # default maximum @@ -87,6 +166,7 @@ def minimum_size(self): def maximum_size(self): return self.fixed_size or self._maximum_size + @register_class_for_io class OnOffParameters(Interface): def __init__( diff --git a/tests/conftest.py b/tests/conftest.py index 1505794cd..7b639c91a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -208,9 +208,13 @@ def flow_system_complex() -> fx.FlowSystem: invest_speicher = fx.InvestParameters( fix_effects=0, - effects_in_segments=([(5, 25), (25, 100)], - {'costs': [(50, 250), (250, 800)], 'PE': [(5, 25), (25, 100)]} - ), + piecewise_effects=fx.PiecewiseEffects( + piecewise_origin=fx.Piecewise([fx.Piece(5, 25), fx.Piece(25, 100)]), + piecewise_shares={ + 'costs': fx.Piecewise([fx.Piece(50, 250), fx.Piece(250, 800)]), + 'PE': fx.Piecewise([fx.Piece(5, 25), fx.Piece(25, 100)]) + } + ), optional=False, specific_effects={'costs': 0.01, 'CO2': 0.01}, minimum_size=0, @@ -255,7 +259,30 @@ def flow_system_base(flow_system_complex) -> fx.FlowSystem: @pytest.fixture -def flow_system_segments_of_flows(flow_system_complex) -> fx.FlowSystem: +def flow_system_piecewise_conversion(flow_system_complex) -> fx.FlowSystem: + flow_system = flow_system_complex + + flow_system.add_elements(fx.LinearConverter( + 'KWK', + inputs=[fx.Flow('Q_fu', bus='Gas')], + outputs=[fx.Flow('P_el', bus='Strom', size=60, relative_maximum=55, previous_flow_rate=10), + fx.Flow('Q_th', bus='Fernwärme')], + piecewise_conversion= fx.PiecewiseConversion({ + 'P_el': fx.Piecewise([fx.Piece(5, 30), fx.Piece(40, 60)]), + 'Q_th': fx.Piecewise([fx.Piece(6, 35), fx.Piece(45, 100)]), + 'Q_fu': fx.Piecewise([fx.Piece(12, 70), fx.Piece(90, 200)]), + }), + on_off_parameters=fx.OnOffParameters(effects_per_switch_on=0.01), + )) + + return flow_system + + +@pytest.fixture +def flow_system_segments_of_flows_2(flow_system_complex) -> fx.FlowSystem: + """ + Use segments/Piecewise with numeric data + """ flow_system = flow_system_complex flow_system.add_elements(fx.LinearConverter( @@ -263,11 +290,11 @@ def flow_system_segments_of_flows(flow_system_complex) -> fx.FlowSystem: inputs=[fx.Flow('Q_fu', bus='Gas')], outputs=[fx.Flow('P_el', bus='Strom', size=60, relative_maximum=55, previous_flow_rate=10), fx.Flow('Q_th', bus='Fernwärme')], - segmented_conversion_factors={ - 'P_el': [(5, 30), (40, 60)], - 'Q_th': [(6, 35), (45, 100)], - 'Q_fu': [(12, 70), (90, 200)], - }, + piecewise_conversion= fx.PiecewiseConversion({ + 'P_el': fx.Piecewise([fx.Piece(np.linspace(5, 6, len(flow_system.time_series_collection.timesteps)), 30), fx.Piece(40, np.linspace(60, 70, len(flow_system.time_series_collection.timesteps)))]), + 'Q_th': fx.Piecewise([fx.Piece(6, 35), fx.Piece(45, 100)]), + 'Q_fu': fx.Piecewise([fx.Piece(12, 70), fx.Piece(90, 200)]), + }), on_off_parameters=fx.OnOffParameters(effects_per_switch_on=0.01), )) diff --git a/tests/run_all_tests.py b/tests/run_all_tests.py index 6e5774214..2f0434f4f 100644 --- a/tests/run_all_tests.py +++ b/tests/run_all_tests.py @@ -7,4 +7,4 @@ import pytest if __name__ == '__main__': - pytest.main(['--disable-warnings']) + pytest.main(['test_io.py', '--disable-warnings']) diff --git a/tests/test_integration.py b/tests/test_integration.py index c192154a2..90bbae3b2 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -318,13 +318,13 @@ def test_basic_flow_system(self, flow_system_base, highs_solver): ) assert_almost_equal_numeric( - calculation.results.model['Speicher|SegmentedShares|costs'].solution.values, + calculation.results.model['Speicher|PiecewiseEffects|costs'].solution.values, 800, - 'Speicher investCosts_segmented_costs doesnt match expected value', + 'Speicher|PiecewiseEffects|costs doesnt match expected value', ) - def test_segments_of_flows(self, flow_system_segments_of_flows, highs_solver): - calculation = create_calculation_and_solve(flow_system_segments_of_flows, highs_solver, 'test_segments_of_flows') + def test_piecewise_conversion(self, flow_system_piecewise_conversion, highs_solver): + calculation = create_calculation_and_solve(flow_system_piecewise_conversion, highs_solver, 'test_piecewise_conversion') effects = calculation.flow_system.effects comps = calculation.flow_system.components @@ -360,7 +360,7 @@ def test_segments_of_flows(self, flow_system_segments_of_flows, highs_solver): ) assert_almost_equal_numeric( - comps['Speicher'].model.variables['Speicher|SegmentedShares|costs'].solution.values, + comps['Speicher'].model.variables['Speicher|PiecewiseEffects|costs'].solution.values, 454.74666666666667, 'Speicher investCosts_segmented_costs doesnt match expected value', ) diff --git a/tests/test_io.py b/tests/test_io.py index 08176f980..4f2cedd0b 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -9,12 +9,12 @@ assert_almost_equal_numeric, flow_system_base, flow_system_long, - flow_system_segments_of_flows, + flow_system_segments_of_flows_2, simple_flow_system, ) -@pytest.fixture(params=[flow_system_base, flow_system_segments_of_flows, simple_flow_system, flow_system_long]) +@pytest.fixture(params=[flow_system_base, flow_system_segments_of_flows_2, simple_flow_system, flow_system_long]) def flow_system(request): fs = request.getfixturevalue(request.param.__name__) if isinstance(fs, fx.FlowSystem): From 363807cce043badfee123f7950b86d6fae0a2110 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 29 Mar 2025 13:54:13 +0100 Subject: [PATCH 478/507] Bugfix in document_linopy_model --- flixOpt/io.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flixOpt/io.py b/flixOpt/io.py index 89c37d158..ac077c086 100644 --- a/flixOpt/io.py +++ b/flixOpt/io.py @@ -158,8 +158,8 @@ def document_linopy_model(model: linopy.Model, path: pathlib.Path = None) -> Dic 'termination_condition': model.termination_condition, 'status': model.status, 'nvars': model.nvars, - 'nvarsbin': model.binaries.nvars, - 'nvarscont': model.continuous.nvars, + 'nvarsbin': model.binaries.nvars if len(model.binaries) > 0 else 0, #Temporary, waiting for linopy to fix + 'nvarscont': model.continuous.nvars if len(model.continuous) > 0 else 0, #Temporary, waiting for linopy to fix 'ncons': model.ncons, 'variables': {variable_name: variable.__repr__() for variable_name, variable in model.variables.items()}, 'constraints': { From c9ed88674ff80bd5d2b2495d5f00da481ec39d2b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 29 Mar 2025 17:37:18 +0100 Subject: [PATCH 479/507] Feature/docs2 (#207) * Improve docs --- docs/SUMMARY.md | 6 +-- docs/examples/index.md | 5 ++ docs/faq/contribute.md | 49 ++++++++++++++++++ docs/faq/index.md | 3 ++ docs/getting-started.md | 2 +- docs/index.md | 50 +++++++++++-------- .../Mathematical Notation/Bus.md | 0 .../Effects, Penalty & Objective.md | 0 .../Mathematical Notation/Flow.md | 2 +- .../Mathematical Notation/LinearConverter.md | 2 +- .../Mathematical Notation/Piecewise.md | 49 ++++++++++++++++++ .../Mathematical Notation/Storage.md | 0 .../Mathematical Notation/index.md | 0 .../Mathematical Notation/others.md | 0 .../index.md | 16 +++++- 15 files changed, 156 insertions(+), 28 deletions(-) create mode 100644 docs/examples/index.md create mode 100644 docs/faq/contribute.md create mode 100644 docs/faq/index.md rename docs/{concepts-and-math => user-guide}/Mathematical Notation/Bus.md (100%) rename docs/{concepts-and-math => user-guide}/Mathematical Notation/Effects, Penalty & Objective.md (100%) rename docs/{concepts-and-math => user-guide}/Mathematical Notation/Flow.md (94%) rename docs/{concepts-and-math => user-guide}/Mathematical Notation/LinearConverter.md (90%) create mode 100644 docs/user-guide/Mathematical Notation/Piecewise.md rename docs/{concepts-and-math => user-guide}/Mathematical Notation/Storage.md (100%) rename docs/{concepts-and-math => user-guide}/Mathematical Notation/index.md (100%) rename docs/{concepts-and-math => user-guide}/Mathematical Notation/others.md (100%) rename docs/{concepts-and-math => user-guide}/index.md (90%) diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index d2a5654b2..7afca119d 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -1,7 +1,7 @@ - [Home](index.md) - [Getting Started](getting-started.md) -- [Concepts & Math](concepts-and-math/) +- [User Guide](user-guide/) - [Examples](examples/) +- [FAQ](faq/) - [API-Reference](api-reference/) -- [Release Notes](release-notes/) -- [Contribute](contribute.md) \ No newline at end of file +- [Release Notes](release-notes/) \ No newline at end of file diff --git a/docs/examples/index.md b/docs/examples/index.md new file mode 100644 index 000000000..d2b0664e1 --- /dev/null +++ b/docs/examples/index.md @@ -0,0 +1,5 @@ +# Examples + +Here you can find a collection of examples that demonstrate how to use flixOpt. + +We work on improving this gallery. If you have something to share, please contact us! \ No newline at end of file diff --git a/docs/faq/contribute.md b/docs/faq/contribute.md new file mode 100644 index 000000000..d4535c0c0 --- /dev/null +++ b/docs/faq/contribute.md @@ -0,0 +1,49 @@ +# Contributing to the Project + +We warmly welcome contributions from the community! This guide will help you get started with contributing to our project. + +## Development Setup +1. Clone the repository `git clone https://github.com/flixOpt/flixopt.git` +2. Install the development dependencies `pip install -editable .[dev, docs]` +3. Run `pytest` and `ruff check .` to ensure your code passes all tests + +## Documentation +flixOpt uses [mkdocs](https://www.mkdocs.org/) to generate documentation. To preview the documentation locally, run `mkdocs serve` in the root directory. + +## Helpful Commands +- `mkdocs serve` to preview the documentation locally. Navigate to `http://127.0.0.1:8000/` to view the documentation. +- `pytest` to run the test suite (You can also run the provided python script `run_all_test.py`) +- `ruff check .` to run the linter +- `ruff check . --fix` to automatically fix linting issues + +--- +# Best practices + +## Coding Guidelines + +- Follow PEP 8 style guidelines +- Write clear, commented code +- Include type hints +- Create or update tests for new functionality +- Ensure 100% test coverage for new code + +## Branches +As we start to think flixOpt in **Releases**, we decided to introduce multiple **dev**-branches instead of only one: +Following the **Semantic Versioning** guidelines, we introduced: +- `next/patch`: This is where all pull requests for the next patch release (1.0.x) go. +- `next/minor`: This is where all pull requests for the next minor release (1.x.0) go. +- `next/major`: This is where all pull requests for the next major release (x.0.0) go. + +Everything else remains in `feature/...`-branches. + +## Pull requests +Every feature or bugfix should be merged into one of the 3 [release branches](#branches), using **Squash and merge** or a regular **single commit**. +At some point, `next/minor` or `next/major` will get merged into `main` using a regular **Merge** (not squash). +*This ensures that Features are kept separate, and the `next/...`branches stay in synch with ``main`.* + +## Releases +As stated, we follow **Semantic Versioning**. +Right after one of the 3 [release branches](#branches) is merged into main, a **Tag** should be added to the merge commit and pushed to the main branch. The tag has the form `v1.2.3`. +With this tag, a release with **Release Notes** must be created. + +*This is our current best practice* diff --git a/docs/faq/index.md b/docs/faq/index.md new file mode 100644 index 000000000..85d44e6af --- /dev/null +++ b/docs/faq/index.md @@ -0,0 +1,3 @@ +# Frequently Asked Questions + +## Work in progress \ No newline at end of file diff --git a/docs/getting-started.md b/docs/getting-started.md index 702a8171b..f1c3080b6 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -37,6 +37,6 @@ Working with flixOpt follows a general pattern: Now that you've installed flixOpt and understand the basic workflow, you can: -- Learn about the [core concepts of flixOpt](concepts-and-math/index.md) +- Learn about the [core concepts of flixOpt](user-guide/index.md) - Explore some [examples](examples/) - Check the [API reference](api-reference/index.md) for detailed documentation diff --git a/docs/index.md b/docs/index.md index 9b8ab5209..1c54da8e7 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,37 +1,47 @@ -# flixOpt: Energy and Material Flow Optimization Framework +# FlixOpt -**flixOpt** is a Python-based optimization framework designed to tackle energy and material flow problems using mixed-integer linear programming (MILP). +**FlixOpt** is a Python-based optimization framework designed to tackle energy and material flow problems using mixed-integer linear programming (MILP). -It bridges the gap between **high-level energy systems models** like [FINE](https://github.com/FZJ-IEK3-VSA/FINE) used for design and (multi-period) investment decisions and **low-level dispatch optimization tools** used for operation decisions. +It borrows concepts from both [FINE](https://github.com/FZJ-IEK3-VSA/FINE) and [oemof.solph](https://github.com/oemof/oemof-solph). + +## Why FlixOpt? + +FlixOpt is designed as a general-purpose optimization framework to get your model running quickly, without sacrificing flexibility down the road: + +- **Easy to Use API**: FlixOpt provides a Pythonic, object-oriented interface that makes mathematical optimization more accessible to Python developers. + +- **Approachable Learning Curve**: Designed to be accessible from the start, with options for more detailed models down the road. + +- **Domain Independence**: While frameworks like oemof and FINE excel at energy system modeling with domain-specific components, FlixOpt offers a more general mathematical approach that can be applied across different fields. + +- **Extensibility**: Easily add custom constraints or variables to any FlixOpt Model using [linopy](https://github.com/PyPSA/linopy). Tailor any FlixOpt model to your specific needs without loosing the convenience of the framework. + +- **Solver Agnostic**: Work with different solvers through a consistent interface. + +- **Results File I/O**: Built to analyze results independent of running the optimization.
    ![flixOpt Conceptual Usage](./images/architecture_flixOpt.png)
    Conceptual Usage and IO operations of flixOpt
    -## 🚀️ Getting Started - -See the [Getting Started Guide](getting-started.md) to start using flixOpt. +## Installation -See the [Examples](examples/) section for detailed examples. +```bash +pip install flixopt +``` -## ⚙️ How It Works +For more detailed installation options, see the [Getting Started](getting-started.md) guide. -See our [Concepts & Math](concepts-and-math/index.md) to understand the core concepts of flixOpt. +## License -## 🛠️ Compatible Solvers +FlixOpt is released under the MIT License. See [LICENSE](https://github.com/flixopt/flixopt/blob/main/LICENSE) for details. -flixOpt works with various solvers: +## Citation -- [HiGHS](https://highs.dev/) (installed by default) -- [Gurobi](https://www.gurobi.com/) -- [CBC](https://github.com/coin-or/Cbc) -- [GLPK](https://www.gnu.org/software/glpk/) -- [CPLEX](https://www.ibm.com/analytics/cplex-optimizer) - -## 📝 Citation - -If you use flixOpt in your research or project, please cite: +If you use FlixOpt in your research or project, please cite: - **Main Citation:** [DOI:10.18086/eurosun.2022.04.07](https://doi.org/10.18086/eurosun.2022.04.07) - **Short Overview:** [DOI:10.13140/RG.2.2.14948.24969](https://doi.org/10.13140/RG.2.2.14948.24969) + +*A more sophisticated paper is in progress* diff --git a/docs/concepts-and-math/Mathematical Notation/Bus.md b/docs/user-guide/Mathematical Notation/Bus.md similarity index 100% rename from docs/concepts-and-math/Mathematical Notation/Bus.md rename to docs/user-guide/Mathematical Notation/Bus.md diff --git a/docs/concepts-and-math/Mathematical Notation/Effects, Penalty & Objective.md b/docs/user-guide/Mathematical Notation/Effects, Penalty & Objective.md similarity index 100% rename from docs/concepts-and-math/Mathematical Notation/Effects, Penalty & Objective.md rename to docs/user-guide/Mathematical Notation/Effects, Penalty & Objective.md diff --git a/docs/concepts-and-math/Mathematical Notation/Flow.md b/docs/user-guide/Mathematical Notation/Flow.md similarity index 94% rename from docs/concepts-and-math/Mathematical Notation/Flow.md rename to docs/user-guide/Mathematical Notation/Flow.md index 27d51292f..4b755d005 100644 --- a/docs/concepts-and-math/Mathematical Notation/Flow.md +++ b/docs/user-guide/Mathematical Notation/Flow.md @@ -1,4 +1,4 @@ -The flow-rate is the main optimization variable of the Flow. It's limited by the size of the Flow and relative bounds \eqref{eq:flow_rate}. +The flow_rate is the main optimization variable of the Flow. It's limited by the size of the Flow and relative bounds \eqref{eq:flow_rate}. $$ \label{eq:flow_rate} \text P \cdot \text p^{\text{L}}_{\text{rel}}(\text{t}_{i}) diff --git a/docs/concepts-and-math/Mathematical Notation/LinearConverter.md b/docs/user-guide/Mathematical Notation/LinearConverter.md similarity index 90% rename from docs/concepts-and-math/Mathematical Notation/LinearConverter.md rename to docs/user-guide/Mathematical Notation/LinearConverter.md index cf3a6b4e5..3b9250944 100644 --- a/docs/concepts-and-math/Mathematical Notation/LinearConverter.md +++ b/docs/user-guide/Mathematical Notation/LinearConverter.md @@ -18,4 +18,4 @@ $$ where $\text a$ can be interpreted as the conversion efficiency of the **LinearTransformer**. #### Piecewise Concersion factors -The conversion efficiency can be defined as a piecewise function. \ No newline at end of file +The conversion efficiency can be defined as a piecewise linear approximation. See [Piecewise](Piecewise.md) for more details. \ No newline at end of file diff --git a/docs/user-guide/Mathematical Notation/Piecewise.md b/docs/user-guide/Mathematical Notation/Piecewise.md new file mode 100644 index 000000000..e327a0e04 --- /dev/null +++ b/docs/user-guide/Mathematical Notation/Piecewise.md @@ -0,0 +1,49 @@ +# Piecewise + +A Piecewise is a collection of [`Pieces`][flixOpt.interface.Piece], which each define a valid range for a variable $v$ + +$$ \label{eq:active_piece} + \beta_\text{k} = \lambda_\text{0, k} + \lambda_\text{1, k} +$$ + +$$ \label{eq:piece} + v_\text{k} = \lambda_\text{0, k} * \text{v}_{\text{start,k}} + \lambda_\text{1,k} * \text{v}_{\text{end,k}} +$$ + +$$ \label{eq:piecewise_in_pieces} +\sum_{k=1}^k \beta_{k} = 1 +$$ + +With: + +- $v$: The variable to be defined by the Piecewise +- $\text{v}_{\text{start,k}}$: the start point of the piece for variable $v$ +- $\text{v}_{\text{end,k}}$: the end point of the piece for variable $v$ +- $\beta_\text{k} \in \{0, 1\}$: defining wether the Piece $k$ is active +- $\lambda_\text{0,k} \in [0, 1]$: A variable defining the fraction of $\text{v}_{\text{start,k}}$ that is active +- $\lambda_\text{1,k} \in [0, 1]$: A variable defining the fraction of $\text{v}_{\text{end,k}}$ that is active + +Which can also be described as $v \in 0 \cup [\text{v}_\text{start}, \text{v}_\text{end}]$. + +Instead of \eqref{eq:piecewise_in_pieces}, the following constraint is used to also allow all variables to be zero: + +$$ \label{eq:piecewise_in_pieces_zero} +\sum_{k=1}^k \beta_{k} = \beta_\text{zero} +$$ + +With: + +- $\beta_\text{zero} \in \{0, 1\}$. + +Which can also be described as $v \in \{0\} \cup [\text{v}_{\text{start_k}}, \text{v}_{\text{end_k}}]$ + + +## Combining multiple Piecewises + +Piecewise allows representing non-linear relationships. +This is a powerful technique in linear optimization to model non-linear behaviors while maintaining the problem's linearity. + +Therefore, each Piecewise must have the same number of Pieces $k$. + +The variables described in [Piecewise](#piecewise) are created for each Piece, but nor for each Piecewise. +Rather, \eqref{eq:piece} is the only constraint that is created for each Piecewise, using the start and endpoints $\text{v}_{\text{start,k}}$ and $\text{v}_{\text{end,k}}$ of each Piece for the corresponding variable $v$ diff --git a/docs/concepts-and-math/Mathematical Notation/Storage.md b/docs/user-guide/Mathematical Notation/Storage.md similarity index 100% rename from docs/concepts-and-math/Mathematical Notation/Storage.md rename to docs/user-guide/Mathematical Notation/Storage.md diff --git a/docs/concepts-and-math/Mathematical Notation/index.md b/docs/user-guide/Mathematical Notation/index.md similarity index 100% rename from docs/concepts-and-math/Mathematical Notation/index.md rename to docs/user-guide/Mathematical Notation/index.md diff --git a/docs/concepts-and-math/Mathematical Notation/others.md b/docs/user-guide/Mathematical Notation/others.md similarity index 100% rename from docs/concepts-and-math/Mathematical Notation/others.md rename to docs/user-guide/Mathematical Notation/others.md diff --git a/docs/concepts-and-math/index.md b/docs/user-guide/index.md similarity index 90% rename from docs/concepts-and-math/index.md rename to docs/user-guide/index.md index 46df65857..3dadd2ffc 100644 --- a/docs/concepts-and-math/index.md +++ b/docs/user-guide/index.md @@ -17,12 +17,24 @@ Every flixOpt model starts with creating a FlowSystem. It: [`Flow`][flixOpt.elements.Flow] objects represent the movement of energy or material between a [Bus](#buses) and a [Component](#components) in a predefined direction. -- Have a `flow_rate`, which is the main optimization variable of a Flow -- Have a `size` which defines how much energy or material can be moved (fixed or part of an investment decision) +- Have a `size` which, generally speaking, defines how fast energy or material can be moved. Usually measured in MW, kW, m³/h, etc. +- Have a `flow_rate`, which is defines how fast energy or material is transported. Usually measured in MW, kW, m³/h, etc. - Have constraints to limit the flow-rate (min/max, total flow hours, on/off etc.) - Can have fixed profiles (for demands or renewable generation) - Can have [Effects](#effects) associated by their use (operation, investment, on/off, ...) +#### Flow Hours +While the **Flow Rate** defines the rate in which energy or material is transported, the **Flow Hours** define the amount of energy or material that is transported. +Its defined by the flow_rate times the duration of the timestep in hours. + +Examples: + +| Flow Rate | Timestep | Flow Hours | +|-----------|----------|------------| +| 10 (MW) | 1 hour | 10 (MWh) | +| 10 (MW) | 6 minutes | 0.1 (MWh) | +| 10 (kg/h) | 1 hour | 10 (kg) | + ### Buses [`Bus`][flixOpt.elements.Bus] objects represent nodes or connection points in a FlowSystem. They: From e53b047b0243adfed5a2814e62d24d79aa0675a9 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 29 Mar 2025 17:37:34 +0100 Subject: [PATCH 480/507] Update release notes (#205) * Update release notes --- docs/release-notes/index.md | 6 +----- docs/release-notes/v2.0.0.md | 19 +++++++++---------- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/docs/release-notes/index.md b/docs/release-notes/index.md index 3dfb50c7f..fecb3d61b 100644 --- a/docs/release-notes/index.md +++ b/docs/release-notes/index.md @@ -4,8 +4,4 @@ This page provides links to release notes for all versions of flixopt. ## Latest Release -* [v2.0.0](v2.0.0.md) - ????- Migration from pyomo to linopy, improving performance and usability - -## Previous Releases - -None \ No newline at end of file +* [v2.0.0](v2.0.0.md) - 29.03.2025 - Migration from pyomo to linopy. Alround improvements in performance and usability diff --git a/docs/release-notes/v2.0.0.md b/docs/release-notes/v2.0.0.md index 44656d962..2d67ceb54 100644 --- a/docs/release-notes/v2.0.0.md +++ b/docs/release-notes/v2.0.0.md @@ -6,8 +6,7 @@ - **Migration from Pyomo to Linopy**: Completely rebuilt the optimization foundation to use Linopy instead of Pyomo - Significant performance improvements, especially for large models - - Internal useage of linopys own mathematical modeling language - - use a flixOpt model as a baseline and extend it with custom constraints or variables using linopys own modeling language + - Internal useage of linopys mathematical modeling language - **xarray-Based Data Architecture**: Redesigned data handling to rely on xarray.Dataset throughout the package for: - Improved solution representation and analysis - Enhanced time series management @@ -21,7 +20,8 @@ ### Model Handling -- **Full Model Export/Import**: As a result of the migration to Linopy, the linopy.Model can be exported before or after the solve. +- **Extend every flixopt model with the linopy language**: Add any additional constraint or variable to your flixopt model by using the linopy language. +- **Full Model Export/Import**: As a result of the migration to Linopy, the linopy.Model can be exported before or after the solve. - **Improved Infeasible Model Handling**: Added better detection and reporting for infeasible optimization models - **Improved Model Documentation**: Every model can be documented in a .yaml file, containing human readable mathematical formulations of all variables and constraints. THis is used to document every Calculation. @@ -31,9 +31,9 @@ Accessing the results of a Calculation is now as simple as: ```python fx.FullCalculation('Sim1', flow_system) calculation.solve(fx.solvers.HighsSolver()) -calculation.results +calculation.results # This object can be entirely saved and reloaded to file without any information loss ``` -This access doesn`t change if you save and load the results to a file or use them in your script directly! +This access doesn't change if you save and load the results to a file or use them in your script directly! - **Improved Documentation**: The FlowSystem as well as a model Documentation is created for every model run. - **Results without saving to file**: The results of a Calculation can now be properly accessed without saving the results to a file first. @@ -56,18 +56,17 @@ This access doesn`t change if you save and load the results to a file or use the ## 📚 Documentation +- Imporved documentation of the flixOpt API and mathematical formulations - **Google Style Docstrings**: Updated all docstrings to Google style format -## 🐛 Bug Fixes - ## 🔄 Dependencies - **Linopy**: Added as the core dependency replacing Pyomo -- **xarray**: Now a critical dependency for data handling -- **netcdf4**: Optional dependency for compressing netCDF files +- **xarray**: Now a critical dependency for data handling and file I/O +- **netcdf4**: Dependency for fast and efficient file I/O ### Dropped Dependencies -- **pyomo**: Removed as the core dependency replacing Linopy +- **pyomo**: Replaced by linopy as the modeling language ## 📋 Migration Notes From 87a3ff066b1c756e196ec7edf23410d01fea788f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 29 Mar 2025 18:04:33 +0100 Subject: [PATCH 481/507] Feature/rename to flixopt (#208) * rename package to flixopt * update .yaml and pyproject * Using FlixOpt as the name in text and flixopt everywhere else * Using FlixOpt as the name in text and flixopt everywhere else --- .github/workflows/python-app.yaml | 4 +- README.md | 12 ++-- docs/contribute.md | 4 +- docs/examples/03-Calculation Modes.md | 2 +- docs/examples/index.md | 2 +- docs/faq/contribute.md | 4 +- docs/getting-started.md | 24 +++---- docs/index.md | 4 +- docs/release-notes/v2.0.0.md | 2 +- .../Effects, Penalty & Objective.md | 8 +-- .../Mathematical Notation/LinearConverter.md | 2 +- .../Mathematical Notation/Piecewise.md | 2 +- .../user-guide/Mathematical Notation/index.md | 2 +- docs/user-guide/index.md | 68 +++++++++---------- examples/00_Minmal/minimal_example.py | 4 +- examples/01_Simple/simple_example.py | 4 +- examples/02_Complex/complex_example.py | 4 +- .../02_Complex/complex_example_results.py | 2 +- .../example_calculation_types.py | 2 +- {flixOpt => flixopt}/__init__.py | 2 +- {flixOpt => flixopt}/aggregation.py | 4 +- {flixOpt => flixopt}/calculation.py | 6 +- {flixOpt => flixopt}/commons.py | 2 +- {flixOpt => flixopt}/components.py | 4 +- {flixOpt => flixopt}/config.py | 8 +-- {flixOpt => flixopt}/config.yaml | 6 +- {flixOpt => flixopt}/core.py | 4 +- {flixOpt => flixopt}/effects.py | 4 +- {flixOpt => flixopt}/elements.py | 10 +-- {flixOpt => flixopt}/features.py | 4 +- {flixOpt => flixopt}/flow_system.py | 2 +- {flixOpt => flixopt}/interface.py | 2 +- {flixOpt => flixopt}/io.py | 2 +- {flixOpt => flixopt}/linear_converters.py | 2 +- {flixOpt => flixopt}/plotting.py | 4 +- {flixOpt => flixopt}/results.py | 4 +- {flixOpt => flixopt}/solvers.py | 4 +- {flixOpt => flixopt}/structure.py | 10 +-- {flixOpt => flixopt}/utils.py | 4 +- mkdocs.yml | 2 +- pyproject.toml | 6 +- scripts/gen_ref_pages.py | 10 +-- tests/conftest.py | 2 +- tests/test_dataconverter.py | 2 +- tests/test_functional.py | 6 +- tests/test_integration.py | 2 +- tests/test_io.py | 4 +- tests/test_plots.py | 2 +- tests/test_results_plots.py | 2 +- tests/test_timeseries.py | 2 +- 50 files changed, 141 insertions(+), 143 deletions(-) rename {flixOpt => flixopt}/__init__.py (89%) rename {flixOpt => flixopt}/aggregation.py (99%) rename {flixOpt => flixopt}/calculation.py (99%) rename {flixOpt => flixopt}/commons.py (97%) rename {flixOpt => flixopt}/components.py (99%) rename {flixOpt => flixopt}/config.py (97%) rename {flixOpt => flixopt}/config.yaml (69%) rename {flixOpt => flixopt}/core.py (99%) rename {flixOpt => flixopt}/effects.py (99%) rename {flixOpt => flixopt}/elements.py (98%) rename {flixOpt => flixopt}/features.py (99%) rename {flixOpt => flixopt}/flow_system.py (99%) rename {flixOpt => flixopt}/interface.py (99%) rename {flixOpt => flixopt}/io.py (99%) rename {flixOpt => flixopt}/linear_converters.py (99%) rename {flixOpt => flixopt}/plotting.py (99%) rename {flixOpt => flixopt}/results.py (99%) rename {flixOpt => flixopt}/solvers.py (95%) rename {flixOpt => flixopt}/structure.py (98%) rename {flixOpt => flixopt}/utils.py (96%) diff --git a/.github/workflows/python-app.yaml b/.github/workflows/python-app.yaml index 531989a63..010b9b4ca 100644 --- a/.github/workflows/python-app.yaml +++ b/.github/workflows/python-app.yaml @@ -99,7 +99,7 @@ jobs: (sleep 30 && pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ $PACKAGE_NAME) || \ (sleep 60 && pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ $PACKAGE_NAME) # Basic import test - python -c "import flixOpt; print('Installation successful!')" + python -c "import flixopt; print('Installation successful!')" publish-pypi: name: Publish to PyPI @@ -144,4 +144,4 @@ jobs: # Install from PyPI pip install $PACKAGE_NAME # Basic import test - python -c "import flixOpt; print('PyPI installation successful!')" \ No newline at end of file + python -c "import flixopt; print('PyPI installation successful!')" \ No newline at end of file diff --git a/README.md b/README.md index f0ebc370a..618c75451 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# flixOpt: Energy and Material Flow Optimization Framework +# FlixOpt: Energy and Material Flow Optimization Framework [![📚 Documentation](https://img.shields.io/badge/📚_docs-online-brightgreen.svg)](https://flixopt.github.io/flixopt/) [![CI](https://github.com/flixOpt/flixopt/actions/workflows/python-app.yaml/badge.svg)](https://github.com/flixOpt/flixopt/actions/workflows/python-app.yaml) @@ -18,7 +18,7 @@ **flixopt** provides a user-friendly interface with options for advanced users. -It was originally developed by [TU Dresden](https://github.com/gewv-tu-dresden) as part of the SMARTBIOGRID project, funded by the German Federal Ministry for Economic Affairs and Energy (FKZ: 03KB159B). Building on the Matlab-based flixOptMat framework (developed in the FAKS project), flixOpt also incorporates concepts from [oemof/solph](https://github.com/oemof/oemof-solph). +It was originally developed by [TU Dresden](https://github.com/gewv-tu-dresden) as part of the SMARTBIOGRID project, funded by the German Federal Ministry for Economic Affairs and Energy (FKZ: 03KB159B). Building on the Matlab-based flixOptMat framework (developed in the FAKS project), FlixOpt also incorporates concepts from [oemof/solph](https://github.com/oemof/oemof-solph). --- @@ -50,11 +50,11 @@ It was originally developed by [TU Dresden](https://github.com/gewv-tu-dresden) ## 📦 Installation -Install flixOpt via pip. +Install FlixOpt via pip. `pip install flixopt` With [HiGHS](https://github.com/ERGO-Code/HiGHS?tab=readme-ov-file) included out of the box, flixopt is ready to use.. -We recommend installing flixOpt with all dependencies, which enables additional features like interactive network visualizations ([pyvis](https://github.com/WestHealth/pyvis)) and time series aggregation ([tsam](https://github.com/FZJ-IEK3-VSA/tsam)). +We recommend installing FlixOpt with all dependencies, which enables additional features like interactive network visualizations ([pyvis](https://github.com/WestHealth/pyvis)) and time series aggregation ([tsam](https://github.com/FZJ-IEK3-VSA/tsam)). `pip install "flixopt[full]"` --- @@ -67,7 +67,7 @@ The documentation is available at [https://flixopt.github.io/flixopt/](https://f ## 🛠️ Solver Integration -By default, flixOpt uses the open-source solver [HiGHS](https://highs.dev/) which is installed by default. However, it is compatible with additional solvers such as: +By default, FlixOpt uses the open-source solver [HiGHS](https://highs.dev/) which is installed by default. However, it is compatible with additional solvers such as: - [Gurobi](https://www.gurobi.com/) - [CBC](https://github.com/coin-or/Cbc) @@ -80,7 +80,7 @@ For detailed licensing and installation instructions, refer to the respective so ## 📖 Citation -If you use flixOpt in your research or project, please cite the following: +If you use FlixOpt in your research or project, please cite the following: - **Main Citation:** [DOI:10.18086/eurosun.2022.04.07](https://doi.org/10.18086/eurosun.2022.04.07) - **Short Overview:** [DOI:10.13140/RG.2.2.14948.24969](https://doi.org/10.13140/RG.2.2.14948.24969) diff --git a/docs/contribute.md b/docs/contribute.md index d4535c0c0..439fefe1d 100644 --- a/docs/contribute.md +++ b/docs/contribute.md @@ -8,7 +8,7 @@ We warmly welcome contributions from the community! This guide will help you get 3. Run `pytest` and `ruff check .` to ensure your code passes all tests ## Documentation -flixOpt uses [mkdocs](https://www.mkdocs.org/) to generate documentation. To preview the documentation locally, run `mkdocs serve` in the root directory. +FlixOpt uses [mkdocs](https://www.mkdocs.org/) to generate documentation. To preview the documentation locally, run `mkdocs serve` in the root directory. ## Helpful Commands - `mkdocs serve` to preview the documentation locally. Navigate to `http://127.0.0.1:8000/` to view the documentation. @@ -28,7 +28,7 @@ flixOpt uses [mkdocs](https://www.mkdocs.org/) to generate documentation. To pre - Ensure 100% test coverage for new code ## Branches -As we start to think flixOpt in **Releases**, we decided to introduce multiple **dev**-branches instead of only one: +As we start to think FlixOpt in **Releases**, we decided to introduce multiple **dev**-branches instead of only one: Following the **Semantic Versioning** guidelines, we introduced: - `next/patch`: This is where all pull requests for the next patch release (1.0.x) go. - `next/minor`: This is where all pull requests for the next minor release (1.x.0) go. diff --git a/docs/examples/03-Calculation Modes.md b/docs/examples/03-Calculation Modes.md index e94f815a8..dd0321d43 100644 --- a/docs/examples/03-Calculation Modes.md +++ b/docs/examples/03-Calculation Modes.md @@ -1,5 +1,5 @@ # Calculation Mode comparison -**Note:** This example relies on time series data. You can find it in the `examples` folder of the flixOpt repository. +**Note:** This example relies on time series data. You can find it in the `examples` folder of the FlixOpt repository. ```python {! ../examples/03_Calculation_types/example_calculation_types.py !} ``` diff --git a/docs/examples/index.md b/docs/examples/index.md index d2b0664e1..8d535771f 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -1,5 +1,5 @@ # Examples -Here you can find a collection of examples that demonstrate how to use flixOpt. +Here you can find a collection of examples that demonstrate how to use FlixOpt. We work on improving this gallery. If you have something to share, please contact us! \ No newline at end of file diff --git a/docs/faq/contribute.md b/docs/faq/contribute.md index d4535c0c0..439fefe1d 100644 --- a/docs/faq/contribute.md +++ b/docs/faq/contribute.md @@ -8,7 +8,7 @@ We warmly welcome contributions from the community! This guide will help you get 3. Run `pytest` and `ruff check .` to ensure your code passes all tests ## Documentation -flixOpt uses [mkdocs](https://www.mkdocs.org/) to generate documentation. To preview the documentation locally, run `mkdocs serve` in the root directory. +FlixOpt uses [mkdocs](https://www.mkdocs.org/) to generate documentation. To preview the documentation locally, run `mkdocs serve` in the root directory. ## Helpful Commands - `mkdocs serve` to preview the documentation locally. Navigate to `http://127.0.0.1:8000/` to view the documentation. @@ -28,7 +28,7 @@ flixOpt uses [mkdocs](https://www.mkdocs.org/) to generate documentation. To pre - Ensure 100% test coverage for new code ## Branches -As we start to think flixOpt in **Releases**, we decided to introduce multiple **dev**-branches instead of only one: +As we start to think FlixOpt in **Releases**, we decided to introduce multiple **dev**-branches instead of only one: Following the **Semantic Versioning** guidelines, we introduced: - `next/patch`: This is where all pull requests for the next patch release (1.0.x) go. - `next/minor`: This is where all pull requests for the next minor release (1.x.0) go. diff --git a/docs/getting-started.md b/docs/getting-started.md index f1c3080b6..d163c156f 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,12 +1,12 @@ -# Getting Started with flixOpt +# Getting Started with FlixOpt -This guide will help you install flixOpt, understand its basic concepts, and run your first optimization model. +This guide will help you install FlixOpt, understand its basic concepts, and run your first optimization model. ## Installation ### Basic Installation -Install flixOpt directly into your environment using pip: +Install FlixOpt directly into your environment using pip: ```bash pip install flixopt @@ -24,19 +24,19 @@ pip install "flixopt[full]"" ## Basic Workflow -Working with flixOpt follows a general pattern: +Working with FlixOpt follows a general pattern: -1. **Create a [`FlowSystem`][flixOpt.flow_system.FlowSystem]** with a time series -2. **Define [`Effects`][flixOpt.effects.Effect]** (costs, emissions, etc.) -3. **Define [`Buses`][flixOpt.elements.Bus]** as connection points in your system -4. **Add [`Components`][flixOpt.components]** like converters, storage, sources/sinks with their Flows -5. **Run [`Calculations`][flixOpt.calculation]** to optimize your system -6. **Analyze [`Results`][flixOpt.results]** using built-in or external visualization tools +1. **Create a [`FlowSystem`][flixopt.flow_system.FlowSystem]** with a time series +2. **Define [`Effects`][flixopt.effects.Effect]** (costs, emissions, etc.) +3. **Define [`Buses`][flixopt.elements.Bus]** as connection points in your system +4. **Add [`Components`][flixopt.components]** like converters, storage, sources/sinks with their Flows +5. **Run [`Calculations`][flixopt.calculation]** to optimize your system +6. **Analyze [`Results`][flixopt.results]** using built-in or external visualization tools ## Next Steps -Now that you've installed flixOpt and understand the basic workflow, you can: +Now that you've installed FlixOpt and understand the basic workflow, you can: -- Learn about the [core concepts of flixOpt](user-guide/index.md) +- Learn about the [core concepts of FlixOpt](user-guide/index.md) - Explore some [examples](examples/) - Check the [API reference](api-reference/index.md) for detailed documentation diff --git a/docs/index.md b/docs/index.md index 1c54da8e7..04020639e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -21,8 +21,8 @@ FlixOpt is designed as a general-purpose optimization framework to get your mode - **Results File I/O**: Built to analyze results independent of running the optimization.
    - ![flixOpt Conceptual Usage](./images/architecture_flixOpt.png) -
    Conceptual Usage and IO operations of flixOpt
    + ![FlixOpt Conceptual Usage](./images/architecture_flixOpt.png) +
    Conceptual Usage and IO operations of FlixOpt
    ## Installation diff --git a/docs/release-notes/v2.0.0.md b/docs/release-notes/v2.0.0.md index 2d67ceb54..610c3e3d9 100644 --- a/docs/release-notes/v2.0.0.md +++ b/docs/release-notes/v2.0.0.md @@ -56,7 +56,7 @@ This access doesn't change if you save and load the results to a file or use the ## 📚 Documentation -- Imporved documentation of the flixOpt API and mathematical formulations +- Improved documentation of the FlixOpt API and mathematical formulations - **Google Style Docstrings**: Updated all docstrings to Google style format ## 🔄 Dependencies diff --git a/docs/user-guide/Mathematical Notation/Effects, Penalty & Objective.md b/docs/user-guide/Mathematical Notation/Effects, Penalty & Objective.md index 07f9fb8de..1f2f0abdb 100644 --- a/docs/user-guide/Mathematical Notation/Effects, Penalty & Objective.md +++ b/docs/user-guide/Mathematical Notation/Effects, Penalty & Objective.md @@ -1,10 +1,10 @@ ## Effects -[`Effects`][flixOpt.effects.Effect] are used to allocate things like costs, emissions, or other "effects" occuring in the system. +[`Effects`][flixopt.effects.Effect] are used to allocate things like costs, emissions, or other "effects" occuring in the system. These arise from so called **Shares**, which originate from **Elements** like [Flows](Flow.md). **Example:** -[`Flows`][flixOpt.elements.Flow] have an attribute called `effects_per_flow_hour`, defining the effect amount of per flow hour. +[`Flows`][flixopt.elements.Flow] have an attribute called `effects_per_flow_hour`, defining the effect amount of per flow hour. Assiziated effects could be: - costs - given in [€/kWh]... - ...or emissions - given in [kg/kWh]. @@ -98,7 +98,7 @@ $$ ## Penalty -Additionally to the user defined [Effects](#effects), a Penalty $\Phi$ is part of every flixOpt Model. +Additionally to the user defined [Effects](#effects), a Penalty $\Phi$ is part of every FlixOpt Model. Its used to prevent unsolvable problems and simplify troubleshooting. Shares to the penalty can originate from every Element and are constructed similarly to $\eqref{Share_invest}$ and $\eqref{Share_operation}$. @@ -117,7 +117,7 @@ At the moment, penalties only occur in [Buses](#buses) ## Objective -The optimization objective of a flixOpt Model is defined as $\eqref{eq:Objective}$ +The optimization objective of a FlixOpt Model is defined as $\eqref{eq:Objective}$ $$ \label{eq:Objective} \min(E_{\Omega} + \Phi) $$ diff --git a/docs/user-guide/Mathematical Notation/LinearConverter.md b/docs/user-guide/Mathematical Notation/LinearConverter.md index 3b9250944..a8cea843e 100644 --- a/docs/user-guide/Mathematical Notation/LinearConverter.md +++ b/docs/user-guide/Mathematical Notation/LinearConverter.md @@ -1,4 +1,4 @@ -[`LinearConverters`][flixOpt.components.LinearConverter] define a ratio between incoming and outgoing [Flows](#flows). +[`LinearConverters`][flixopt.components.LinearConverter] define a ratio between incoming and outgoing [Flows](#flows). $$ \label{eq:Linear-Transformer-Ratio} \sum_{f_{\text{in}} \in \mathcal F_{in}} \text a_{f_{\text{in}}}(\text{t}_i) \cdot p_{f_\text{in}}(\text{t}_i) = \sum_{f_{\text{out}} \in \mathcal F_{out}} \text b_{f_\text{out}}(\text{t}_i) \cdot p_{f_\text{out}}(\text{t}_i) diff --git a/docs/user-guide/Mathematical Notation/Piecewise.md b/docs/user-guide/Mathematical Notation/Piecewise.md index e327a0e04..4e73cfece 100644 --- a/docs/user-guide/Mathematical Notation/Piecewise.md +++ b/docs/user-guide/Mathematical Notation/Piecewise.md @@ -1,6 +1,6 @@ # Piecewise -A Piecewise is a collection of [`Pieces`][flixOpt.interface.Piece], which each define a valid range for a variable $v$ +A Piecewise is a collection of [`Pieces`][flixopt.interface.Piece], which each define a valid range for a variable $v$ $$ \label{eq:active_piece} \beta_\text{k} = \lambda_\text{0, k} + \lambda_\text{1, k} diff --git a/docs/user-guide/Mathematical Notation/index.md b/docs/user-guide/Mathematical Notation/index.md index 7d7f67b13..4dabe2af2 100644 --- a/docs/user-guide/Mathematical Notation/index.md +++ b/docs/user-guide/Mathematical Notation/index.md @@ -3,7 +3,7 @@ ## Naming Conventions -flixOpt uses the following naming conventions: +FlixOpt uses the following naming conventions: - All optimization variables are denoted by italic letters (e.g., $x$, $y$, $z$) - All parameters and constants are denoted by non italic small letters (e.g., $\text{a}$, $\text{b}$, $\text{c}$) diff --git a/docs/user-guide/index.md b/docs/user-guide/index.md index 3dadd2ffc..8789779b2 100644 --- a/docs/user-guide/index.md +++ b/docs/user-guide/index.md @@ -1,13 +1,13 @@ -# flixOpt Concepts +# FlixOpt Concepts -flixOpt is built around a set of core concepts that work together to represent and optimize energy and material flow systems. This page provides a high-level overview of these concepts and how they interact. +FlixOpt is built around a set of core concepts that work together to represent and optimize energy and material flow systems. This page provides a high-level overview of these concepts and how they interact. ## Core Concepts ### FlowSystem -The [`FlowSystem`][flixOpt.flow_system.FlowSystem] is the central organizing unit in flixOpt. -Every flixOpt model starts with creating a FlowSystem. It: +The [`FlowSystem`][flixopt.flow_system.FlowSystem] is the central organizing unit in FlixOpt. +Every FlixOpt model starts with creating a FlowSystem. It: - Defines the timesteps for the optimization - Contains and connects [components](#components), [buses](#buses), and [flows](#flows) @@ -15,7 +15,7 @@ Every flixOpt model starts with creating a FlowSystem. It: ### Flows -[`Flow`][flixOpt.elements.Flow] objects represent the movement of energy or material between a [Bus](#buses) and a [Component](#components) in a predefined direction. +[`Flow`][flixopt.elements.Flow] objects represent the movement of energy or material between a [Bus](#buses) and a [Component](#components) in a predefined direction. - Have a `size` which, generally speaking, defines how fast energy or material can be moved. Usually measured in MW, kW, m³/h, etc. - Have a `flow_rate`, which is defines how fast energy or material is transported. Usually measured in MW, kW, m³/h, etc. @@ -37,7 +37,7 @@ Examples: ### Buses -[`Bus`][flixOpt.elements.Bus] objects represent nodes or connection points in a FlowSystem. They: +[`Bus`][flixopt.elements.Bus] objects represent nodes or connection points in a FlowSystem. They: - Balance incoming and outgoing flows - Can represent physical networks like heat, electricity, or gas @@ -45,17 +45,17 @@ Examples: ### Components -[`Component`][flixOpt.elements.Component] objects usually represent physical entities in your system that interact with [`Flows`][flixOpt.elements.Flow]. They include: +[`Component`][flixopt.elements.Component] objects usually represent physical entities in your system that interact with [`Flows`][flixopt.elements.Flow]. They include: -- [`LinearConverters`][flixOpt.components.LinearConverter] - Converts input flows to output flows with (piecewise) linear relationships -- [`Storages`][flixOpt.components.Storage] - Stores energy or material over time -- [`Sources`][flixOpt.components.Source] / [`Sinks`][flixOpt.components.Sink] / [`SourceAndSinks`][flixOpt.components.SourceAndSink] - Produce or consume flows. They are usually used to model external demands or supplies. -- [`Transmissions`][flixOpt.components.Transmission] - Moves flows between locations with possible losses -- Specialized [`LinearConverters`][flixOpt.components.LinearConverter] like [`Boilers`][flixOpt.linear_converters.Boiler], [`HeatPumps`][flixOpt.linear_converters.HeatPump], [`CHPs`][flixOpt.linear_converters.CHP], etc. These simplify the usage of the `LinearConverter` class and can also be used as blueprint on how to define custom classes or parameterize existing ones. +- [`LinearConverters`][flixopt.components.LinearConverter] - Converts input flows to output flows with (piecewise) linear relationships +- [`Storages`][flixopt.components.Storage] - Stores energy or material over time +- [`Sources`][flixopt.components.Source] / [`Sinks`][flixopt.components.Sink] / [`SourceAndSinks`][flixopt.components.SourceAndSink] - Produce or consume flows. They are usually used to model external demands or supplies. +- [`Transmissions`][flixopt.components.Transmission] - Moves flows between locations with possible losses +- Specialized [`LinearConverters`][flixopt.components.LinearConverter] like [`Boilers`][flixopt.linear_converters.Boiler], [`HeatPumps`][flixopt.linear_converters.HeatPump], [`CHPs`][flixopt.linear_converters.CHP], etc. These simplify the usage of the `LinearConverter` class and can also be used as blueprint on how to define custom classes or parameterize existing ones. ### Effects -[`Effect`][flixOpt.effects.Effect] objects represent impacts or metrics related to your system, such as: +[`Effect`][flixopt.effects.Effect] objects represent impacts or metrics related to your system, such as: - Costs (investment, operation) - Emissions (CO₂, NOx, etc.) @@ -70,49 +70,49 @@ This approach allows for a multi-criteria optimization using both... ### Calculation -A [`FlowSystem`][flixOpt.flow_system.FlowSystem] can be converted to a Model and optimized by creating a [`Calculation`][flixOpt.calculation.Calculation] from it. +A [`FlowSystem`][flixopt.flow_system.FlowSystem] can be converted to a Model and optimized by creating a [`Calculation`][flixopt.calculation.Calculation] from it. -flixOpt offers different calculation modes: +FlixOpt offers different calculation modes: -- [`FullCalculation`][flixOpt.calculation.FullCalculation] - Solves the entire problem at once -- [`SegmentedCalculation`][flixOpt.calculation.SegmentedCalculation] - Solves the problem in segments (with optioinal overlap), improving performance for large problems -- [`AggregatedCalculation`][flixOpt.calculation.AggregatedCalculation] - Uses typical periods to reduce computational requirements +- [`FullCalculation`][flixopt.calculation.FullCalculation] - Solves the entire problem at once +- [`SegmentedCalculation`][flixopt.calculation.SegmentedCalculation] - Solves the problem in segments (with optioinal overlap), improving performance for large problems +- [`AggregatedCalculation`][flixopt.calculation.AggregatedCalculation] - Uses typical periods to reduce computational requirements ### Results -The results of a calculation are stored in a [`CalculationResults`][flixOpt.results.CalculationResults] object. -This object contains the solutions of the optimization as well as all information about the [`Calculation`][flixOpt.calculation.Calculation] and the [`FlowSystem`][flixOpt.flow_system.FlowSystem] it was created from. +The results of a calculation are stored in a [`CalculationResults`][flixopt.results.CalculationResults] object. +This object contains the solutions of the optimization as well as all information about the [`Calculation`][flixopt.calculation.Calculation] and the [`FlowSystem`][flixopt.flow_system.FlowSystem] it was created from. The solutions is stored as an `xarray.Dataset`, but can be accessed through their assotiated Component, Bus or Effect. -This [`CalculationResults`][flixOpt.results.CalculationResults] object can be saved to file and reloaded from file, allowing you to analyze the results anytime after the solve. +This [`CalculationResults`][flixopt.results.CalculationResults] object can be saved to file and reloaded from file, allowing you to analyze the results anytime after the solve. ## How These Concepts Work Together -The process of working with flixOpt can be divided into 3 steps: +The process of working with FlixOpt can be divided into 3 steps: -1. Create a [`FlowSystem`][flixOpt.flow_system.FlowSystem], containing all the elements and data of your system +1. Create a [`FlowSystem`][flixopt.flow_system.FlowSystem], containing all the elements and data of your system - Define the time series of your system - - Add [`Components`][flixOpt.components] like [`Boilers`][flixOpt.linear_converters.Boiler], [`HeatPumps`][flixOpt.linear_converters.HeatPump], [`CHPs`][flixOpt.linear_converters.CHP], etc. - - Add [`Buses`][flixOpt.elements.Bus] as connection points in your system - - Add [`Effects`][flixOpt.effects.Effect] to represent costs, emissions, etc. - - *This [`FlowSystem`][flixOpt.flow_system.FlowSystem] can also be loaded from a netCDF file* + - Add [`Components`][flixopt.components] like [`Boilers`][flixopt.linear_converters.Boiler], [`HeatPumps`][flixopt.linear_converters.HeatPump], [`CHPs`][flixopt.linear_converters.CHP], etc. + - Add [`Buses`][flixopt.elements.Bus] as connection points in your system + - Add [`Effects`][flixopt.effects.Effect] to represent costs, emissions, etc. + - *This [`FlowSystem`][flixopt.flow_system.FlowSystem] can also be loaded from a netCDF file* 2. Translate the model to a mathematical optimization problem - - Create a [`Calculation`][flixOpt.calculation.Calculation] from your FlowSystem and choose a Solver + - Create a [`Calculation`][flixopt.calculation.Calculation] from your FlowSystem and choose a Solver - ...The Calculation is translated internaly to a mathematical optimization problem... - ...and solved by the chosen solver. 3. Analyze the results - - The results are stored in a [`CalculationResults`][flixOpt.results.CalculationResults] object + - The results are stored in a [`CalculationResults`][flixopt.results.CalculationResults] object - This object can be saved to file and reloaded from file, retaining all information about the calculation - - As it contains the used [`FlowSystem`][flixOpt.flow_system.FlowSystem], it can be used to start a new calculation + - As it contains the used [`FlowSystem`][flixopt.flow_system.FlowSystem], it can be used to start a new calculation
    - ![flixOpt Conceptual Usage](../images/architecture_flixOpt.png) -
    Conceptual Usage and IO operations of flixOpt
    + ![FlixOpt Conceptual Usage](../images/architecture_flixOpt.png) +
    Conceptual Usage and IO operations of FlixOpt
    ## Advanced Usage -As flixopt is build on [linopy](https://github.com/PyPSA/linopy), any model created with flixOpt can be extended or modified using the great [linopy API](https://linopy.readthedocs.io/en/latest/api.html). -This allows to adjust your model to very specific requirements without loosing the convenience of flixOpt. +As flixopt is build on [linopy](https://github.com/PyPSA/linopy), any model created with FlixOpt can be extended or modified using the great [linopy API](https://linopy.readthedocs.io/en/latest/api.html). +This allows to adjust your model to very specific requirements without loosing the convenience of FlixOpt. diff --git a/examples/00_Minmal/minimal_example.py b/examples/00_Minmal/minimal_example.py index 3de5d4358..a109a0621 100644 --- a/examples/00_Minmal/minimal_example.py +++ b/examples/00_Minmal/minimal_example.py @@ -1,12 +1,12 @@ """ -This script shows how to use the flixOpt framework to model a super minimalistic energy system. +This script shows how to use the flixopt framework to model a super minimalistic energy system. """ import numpy as np import pandas as pd from rich.pretty import pprint -import flixOpt as fx +import flixopt as fx if __name__ == '__main__': # --- Define the Flow System, that will hold all elements, and the time steps you want to model --- diff --git a/examples/01_Simple/simple_example.py b/examples/01_Simple/simple_example.py index ab4a2e747..a225984b5 100644 --- a/examples/01_Simple/simple_example.py +++ b/examples/01_Simple/simple_example.py @@ -1,12 +1,12 @@ """ -This script shows how to use the flixOpt framework to model a simple energy system. +This script shows how to use the flixopt framework to model a simple energy system. """ import numpy as np import pandas as pd from rich.pretty import pprint # Used for pretty printing -import flixOpt as fx +import flixopt as fx if __name__ == '__main__': # --- Create Time Series Data --- diff --git a/examples/02_Complex/complex_example.py b/examples/02_Complex/complex_example.py index 38da18db3..a5bb87bdc 100644 --- a/examples/02_Complex/complex_example.py +++ b/examples/02_Complex/complex_example.py @@ -1,12 +1,12 @@ """ -This script shows how to use the flixOpt framework to model a more complex energy system. +This script shows how to use the flixopt framework to model a more complex energy system. """ import numpy as np import pandas as pd from rich.pretty import pprint # Used for pretty printing -import flixOpt as fx +import flixopt as fx if __name__ == '__main__': # --- Experiment Options --- diff --git a/examples/02_Complex/complex_example_results.py b/examples/02_Complex/complex_example_results.py index edb6c0618..8ff9c7a95 100644 --- a/examples/02_Complex/complex_example_results.py +++ b/examples/02_Complex/complex_example_results.py @@ -5,7 +5,7 @@ import pandas as pd import plotly.offline -import flixOpt as fx +import flixopt as fx if __name__ == '__main__': # --- Load Results --- diff --git a/examples/03_Calculation_types/example_calculation_types.py b/examples/03_Calculation_types/example_calculation_types.py index 51ea03c68..9255e4008 100644 --- a/examples/03_Calculation_types/example_calculation_types.py +++ b/examples/03_Calculation_types/example_calculation_types.py @@ -11,7 +11,7 @@ import xarray as xr from rich.pretty import pprint # Used for pretty printing -import flixOpt as fx +import flixopt as fx if __name__ == '__main__': # Calculation Types diff --git a/flixOpt/__init__.py b/flixopt/__init__.py similarity index 89% rename from flixOpt/__init__.py rename to flixopt/__init__.py index b100389f0..b92766449 100644 --- a/flixOpt/__init__.py +++ b/flixopt/__init__.py @@ -1,5 +1,5 @@ """ -This module bundles all common functionality of flixOpt and sets up the logging +This module bundles all common functionality of flixopt and sets up the logging """ from .commons import ( diff --git a/flixOpt/aggregation.py b/flixopt/aggregation.py similarity index 99% rename from flixOpt/aggregation.py rename to flixopt/aggregation.py index 1b33d0d06..37a59b836 100644 --- a/flixOpt/aggregation.py +++ b/flixopt/aggregation.py @@ -1,5 +1,5 @@ """ -This module contains the Aggregation functionality for the flixOpt framework. +This module contains the Aggregation functionality for the flixopt framework. Through this, aggregating TimeSeriesData is possible. """ @@ -34,7 +34,7 @@ import plotly.graph_objects as go warnings.filterwarnings('ignore', category=DeprecationWarning) -logger = logging.getLogger('flixOpt') +logger = logging.getLogger('flixopt') class Aggregation: diff --git a/flixOpt/calculation.py b/flixopt/calculation.py similarity index 99% rename from flixOpt/calculation.py rename to flixopt/calculation.py index 1717a33f5..13b25e278 100644 --- a/flixOpt/calculation.py +++ b/flixopt/calculation.py @@ -1,5 +1,5 @@ """ -This module contains the Calculation functionality for the flixOpt framework. +This module contains the Calculation functionality for the flixopt framework. It is used to calculate a SystemModel for a given FlowSystem through a solver. There are three different Calculation types: 1. FullCalculation: Calculates the SystemModel for the full FlowSystem @@ -31,7 +31,7 @@ from .solvers import _Solver from .structure import SystemModel, copy_and_convert_datatypes, get_compact_representation -logger = logging.getLogger('flixOpt') +logger = logging.getLogger('flixopt') class Calculation: @@ -70,7 +70,7 @@ def __init__( @property def main_results(self) -> Dict[str, Union[Scalar, Dict]]: - from flixOpt.features import InvestmentModel + from flixopt.features import InvestmentModel return { "Objective": self.model.objective.value, diff --git a/flixOpt/commons.py b/flixopt/commons.py similarity index 97% rename from flixOpt/commons.py rename to flixopt/commons.py index e6e2aa4ce..68412d6fe 100644 --- a/flixOpt/commons.py +++ b/flixopt/commons.py @@ -1,5 +1,5 @@ """ -This module makes the commonly used classes and functions available in the flixOpt framework. +This module makes the commonly used classes and functions available in the flixopt framework. """ from . import linear_converters, plotting, results, solvers diff --git a/flixOpt/components.py b/flixopt/components.py similarity index 99% rename from flixOpt/components.py rename to flixopt/components.py index f3c9a6f7d..2003f003b 100644 --- a/flixOpt/components.py +++ b/flixopt/components.py @@ -1,5 +1,5 @@ """ -This module contains the basic components of the flixOpt framework. +This module contains the basic components of the flixopt framework. """ import logging @@ -18,7 +18,7 @@ if TYPE_CHECKING: from .flow_system import FlowSystem -logger = logging.getLogger('flixOpt') +logger = logging.getLogger('flixopt') @register_class_for_io diff --git a/flixOpt/config.py b/flixopt/config.py similarity index 97% rename from flixOpt/config.py rename to flixopt/config.py index e07458bd8..480199072 100644 --- a/flixOpt/config.py +++ b/flixopt/config.py @@ -8,7 +8,7 @@ from rich.console import Console from rich.logging import RichHandler -logger = logging.getLogger('flixOpt') +logger = logging.getLogger('flixopt') def merge_configs(defaults: dict, overrides: dict) -> dict: @@ -225,11 +225,11 @@ def _get_logging_handler(log_file: Optional[str] = None, use_rich_handler: bool def setup_logging( default_level: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] = 'INFO', - log_file: Optional[str] = 'flixOpt.log', + log_file: Optional[str] = 'flixopt.log', use_rich_handler: bool = False, ): """Setup logging configuration""" - logger = logging.getLogger('flixOpt') # Use a specific logger name for your package + logger = logging.getLogger('flixopt') # Use a specific logger name for your package logger.setLevel(get_logging_level_by_name(default_level)) # Clear existing handlers if logger.hasHandlers(): @@ -252,7 +252,7 @@ def get_logging_level_by_name(level_name: Literal['DEBUG', 'INFO', 'WARNING', 'E def change_logging_level(level_name: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']): - logger = logging.getLogger('flixOpt') + logger = logging.getLogger('flixopt') logging_level = get_logging_level_by_name(level_name) logger.setLevel(logging_level) for handler in logger.handlers: diff --git a/flixOpt/config.yaml b/flixopt/config.yaml similarity index 69% rename from flixOpt/config.yaml rename to flixopt/config.yaml index cd2d649ae..e5336eeef 100644 --- a/flixOpt/config.yaml +++ b/flixopt/config.yaml @@ -1,8 +1,8 @@ -# Default configuration of flixOpt -config_name: flixOpt # Name of the config file. This has no effect on the configuration itself. +# Default configuration of flixopt +config_name: flixopt # Name of the config file. This has no effect on the configuration itself. logging: level: INFO - file: flixOpt.log + file: flixopt.log rich: false # logging output is formatted using rich. This is only advisable when using a proper terminal modeling: BIG: 10000000 # 1e notation not possible in yaml diff --git a/flixOpt/core.py b/flixopt/core.py similarity index 99% rename from flixOpt/core.py rename to flixopt/core.py index a4aa279fa..6efcea27c 100644 --- a/flixOpt/core.py +++ b/flixopt/core.py @@ -1,5 +1,5 @@ """ -This module contains the core functionality of the flixOpt framework. +This module contains the core functionality of the flixopt framework. It provides Datatypes, logging functionality, and some functions to transform data structures. """ @@ -14,7 +14,7 @@ import pandas as pd import xarray as xr -logger = logging.getLogger('flixOpt') +logger = logging.getLogger('flixopt') Scalar = Union[int, float] """A type representing a single number, either integer or float.""" diff --git a/flixOpt/effects.py b/flixopt/effects.py similarity index 99% rename from flixOpt/effects.py rename to flixopt/effects.py index 4513e2d66..787e21116 100644 --- a/flixOpt/effects.py +++ b/flixopt/effects.py @@ -1,5 +1,5 @@ """ -This module contains the effects of the flixOpt framework. +This module contains the effects of the flixopt framework. Furthermore, it contains the EffectCollection, which is used to collect all effects of a system. Different Datatypes are used to represent the effects with assigned values by the user, which are then transformed into the internal data structure. @@ -20,7 +20,7 @@ if TYPE_CHECKING: from .flow_system import FlowSystem -logger = logging.getLogger('flixOpt') +logger = logging.getLogger('flixopt') @register_class_for_io diff --git a/flixOpt/elements.py b/flixopt/elements.py similarity index 98% rename from flixOpt/elements.py rename to flixopt/elements.py index c0fa77728..e2b2caa3e 100644 --- a/flixOpt/elements.py +++ b/flixopt/elements.py @@ -1,5 +1,5 @@ """ -This module contains the basic elements of the flixOpt framework. +This module contains the basic elements of the flixopt framework. """ import logging @@ -19,17 +19,17 @@ if TYPE_CHECKING: from .flow_system import FlowSystem -logger = logging.getLogger('flixOpt') +logger = logging.getLogger('flixopt') @register_class_for_io class Component(Element): """ - A Component contains incoming and outgoing [`Flows`][flixOpt.elements.Flow]. It defines how these Flows interact with each other. + A Component contains incoming and outgoing [`Flows`][flixopt.elements.Flow]. It defines how these Flows interact with each other. The On or Off state of the Component is defined by all its Flows. Its on, if any of its FLows is On. It's mathematically advisable to define the On/Off state in a FLow rather than a Component if possible, as this introduces less binary variables to the Model - Constraints to the On/Off state are defined by the [`on_off_parameters`][flixOpt.interface.OnOffParameters]. + Constraints to the On/Off state are defined by the [`on_off_parameters`][flixopt.interface.OnOffParameters]. """ def __init__( @@ -139,7 +139,7 @@ def __init__(self): @register_class_for_io class Flow(Element): r""" - A **Flow** moves energy (or material) between a [Bus][flixOpt.elements.Bus] and a [Component][flixOpt.elements.Component] in a predefined direction. + A **Flow** moves energy (or material) between a [Bus][flixopt.elements.Bus] and a [Component][flixopt.elements.Component] in a predefined direction. The flow-rate is the main optimization variable of the **Flow**. """ diff --git a/flixOpt/features.py b/flixopt/features.py similarity index 99% rename from flixOpt/features.py rename to flixopt/features.py index 2569be61a..4090a2301 100644 --- a/flixOpt/features.py +++ b/flixopt/features.py @@ -1,5 +1,5 @@ """ -This module contains the features of the flixOpt framework. +This module contains the features of the flixopt framework. Features extend the functionality of Elements. """ @@ -15,7 +15,7 @@ from .interface import InvestParameters, OnOffParameters, Piece, Piecewise, PiecewiseConversion, PiecewiseEffects from .structure import Model, SystemModel -logger = logging.getLogger('flixOpt') +logger = logging.getLogger('flixopt') class InvestmentModel(Model): diff --git a/flixOpt/flow_system.py b/flixopt/flow_system.py similarity index 99% rename from flixOpt/flow_system.py rename to flixopt/flow_system.py index 290e1a1a3..9921e3e79 100644 --- a/flixOpt/flow_system.py +++ b/flixopt/flow_system.py @@ -24,7 +24,7 @@ if TYPE_CHECKING: import pyvis -logger = logging.getLogger('flixOpt') +logger = logging.getLogger('flixopt') class FlowSystem: diff --git a/flixOpt/interface.py b/flixopt/interface.py similarity index 99% rename from flixOpt/interface.py rename to flixopt/interface.py index bd2147573..013b02023 100644 --- a/flixOpt/interface.py +++ b/flixopt/interface.py @@ -15,7 +15,7 @@ from .flow_system import FlowSystem -logger = logging.getLogger('flixOpt') +logger = logging.getLogger('flixopt') @register_class_for_io diff --git a/flixOpt/io.py b/flixopt/io.py similarity index 99% rename from flixOpt/io.py rename to flixopt/io.py index ac077c086..3021a3519 100644 --- a/flixOpt/io.py +++ b/flixopt/io.py @@ -12,7 +12,7 @@ from .core import TimeSeries -logger = logging.getLogger('flixOpt') +logger = logging.getLogger('flixopt') def replace_timeseries(obj, mode: Literal['name', 'stats', 'data'] = 'name'): diff --git a/flixOpt/linear_converters.py b/flixopt/linear_converters.py similarity index 99% rename from flixOpt/linear_converters.py rename to flixopt/linear_converters.py index adf3a4161..2c2e254bf 100644 --- a/flixOpt/linear_converters.py +++ b/flixopt/linear_converters.py @@ -13,7 +13,7 @@ from .interface import OnOffParameters from .structure import register_class_for_io -logger = logging.getLogger('flixOpt') +logger = logging.getLogger('flixopt') @register_class_for_io diff --git a/flixOpt/plotting.py b/flixopt/plotting.py similarity index 99% rename from flixOpt/plotting.py rename to flixopt/plotting.py index 87bd90532..e54a7b558 100644 --- a/flixOpt/plotting.py +++ b/flixopt/plotting.py @@ -1,5 +1,5 @@ """ -This module contains the plotting functionality of the flixOpt framework. +This module contains the plotting functionality of the flixopt framework. It provides high level functions to plot data with plotly and matplotlib. It's meant to be used in results.py, but is designed to be used by the end user as well. """ @@ -21,7 +21,7 @@ if TYPE_CHECKING: import pyvis -logger = logging.getLogger('flixOpt') +logger = logging.getLogger('flixopt') # Define the colors for the 'portland' colormap in matplotlib _portland_colors = [ diff --git a/flixOpt/results.py b/flixopt/results.py similarity index 99% rename from flixOpt/results.py rename to flixopt/results.py index 5dc4a93ed..056d57d18 100644 --- a/flixOpt/results.py +++ b/flixopt/results.py @@ -22,7 +22,7 @@ from .calculation import Calculation, SegmentedCalculation -logger = logging.getLogger('flixOpt') +logger = logging.getLogger('flixopt') class CalculationResults: @@ -239,7 +239,7 @@ def plot_network( path: Optional[pathlib.Path] = None, show: bool = False ) -> 'pyvis.network.Network': - """ See flixOpt.flow_system.FlowSystem.plot_network """ + """ See flixopt.flow_system.FlowSystem.plot_network """ try: from .flow_system import FlowSystem flow_system = FlowSystem.from_dataset(self.flow_system) diff --git a/flixOpt/solvers.py b/flixopt/solvers.py similarity index 95% rename from flixOpt/solvers.py rename to flixopt/solvers.py index 3f688d930..6c6b3183f 100644 --- a/flixOpt/solvers.py +++ b/flixopt/solvers.py @@ -1,11 +1,11 @@ """ -This module contains the solvers of the flixOpt framework, making them available to the end user in a compact way. +This module contains the solvers of the flixopt framework, making them available to the end user in a compact way. """ import logging from dataclasses import dataclass, field from typing import Any, ClassVar, Dict, Optional -logger = logging.getLogger('flixOpt') +logger = logging.getLogger('flixopt') @dataclass diff --git a/flixOpt/structure.py b/flixopt/structure.py similarity index 98% rename from flixOpt/structure.py rename to flixopt/structure.py index 6e8f13818..27e641dcc 100644 --- a/flixOpt/structure.py +++ b/flixopt/structure.py @@ -1,5 +1,5 @@ """ -This module contains the core structure of the flixOpt framework. +This module contains the core structure of the flixopt framework. These classes are not directly used by the end user, but are used by other modules. """ @@ -25,7 +25,7 @@ from .effects import EffectCollectionModel from .flow_system import FlowSystem -logger = logging.getLogger('flixOpt') +logger = logging.getLogger('flixopt') CLASS_REGISTRY = {} @@ -106,7 +106,7 @@ def coords_extra(self) -> Tuple[pd.DatetimeIndex]: class Interface: """ - This class is used to collect arguments about a Model. Its the base class for all Elements and Models in flixOpt. + This class is used to collect arguments about a Model. Its the base class for all Elements and Models in flixopt. """ def transform_data(self, flow_system: 'FlowSystem'): @@ -252,7 +252,7 @@ def __str__(self): class Element(Interface): - """This class is the basic Element of flixOpt. Every Element has a label""" + """This class is the basic Element of flixopt. Every Element has a label""" def __init__(self, label: str, meta_data: Dict = None): """ @@ -349,7 +349,7 @@ def add( self._sub_models_short[item.label_full] = short_name or item.label_full else: raise ValueError( - f'Item must be a linopy.Variable, linopy.Constraint or flixOpt.structure.Model, got {type(item)}') + f'Item must be a linopy.Variable, linopy.Constraint or flixopt.structure.Model, got {type(item)}') return item def filter_variables(self, diff --git a/flixOpt/utils.py b/flixopt/utils.py similarity index 96% rename from flixOpt/utils.py rename to flixopt/utils.py index af0f103e5..52b4166ce 100644 --- a/flixOpt/utils.py +++ b/flixopt/utils.py @@ -1,5 +1,5 @@ """ -This module contains several utility functions used throughout the flixOpt framework. +This module contains several utility functions used throughout the flixopt framework. """ import logging @@ -8,7 +8,7 @@ import numpy as np import xarray as xr -logger = logging.getLogger('flixOpt') +logger = logging.getLogger('flixopt') def is_number(number_alias: Union[int, float, str]): diff --git a/mkdocs.yml b/mkdocs.yml index cb1cc1442..ebf4ac0d9 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -133,4 +133,4 @@ extra_javascript: - https://polyfill.io/v3/polyfill.min.js?features=es6 #Support for older browsers watch: - - flixOpt \ No newline at end of file + - flixopt \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 45dda3924..afd1c397e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,7 +85,7 @@ where = ["."] exclude = ["tests", "docs", "examples", "examples.*", "Tutorials", ".git", ".vscode", "build", ".venv", "venv/"] [tool.setuptools.package-data] -"flixOpt" = ["config.yaml"] +"flixopt" = ["config.yaml"] [tool.setuptools_scm] version_scheme = "post-release" @@ -108,8 +108,6 @@ select = ["E", "F", "W", "I", "B", "N"] # Enable linting rules by category (e.g ignore = [ # Ignore specific rules "E501", # Ignore line-length checks (use Black for formatting) "F401", # Allow unused imports in some cases (use __all__) - "N813", # Allow importing of flixOpt as fx (lowercase) - "N999", # Allow module Name flixOpt ] extend-fixable = ["B"] # Enable fix for flake8-bugbear (`B`), on top of any rules specified by `fixable`. @@ -117,7 +115,7 @@ extend-fixable = ["B"] # Enable fix for flake8-bugbear (`B`), on top of any ru [tool.ruff.lint.per-file-ignores] "tests/*.py" = ["S101"] # Ignore assertions in test files "tests/test_integration.py" = ["N806"] # Ignore NOT lowercase names in test files -"flixOpt/linear_converters.py" = ["N803"] # Parameters with NOT lowercase names +"flixopt/linear_converters.py" = ["N803"] # Parameters with NOT lowercase names [tool.ruff.format] quote-style = "single" diff --git a/scripts/gen_ref_pages.py b/scripts/gen_ref_pages.py index 8a1b2ff1d..680495d7f 100644 --- a/scripts/gen_ref_pages.py +++ b/scripts/gen_ref_pages.py @@ -11,7 +11,7 @@ nav = mkdocs_gen_files.Nav() -src = root / "flixOpt" +src = root / "flixopt" api_dir = "api-reference" for path in sorted(src.rglob("*.py")): @@ -34,10 +34,10 @@ if parts: nav[parts] = doc_path.as_posix() - # Generate documentation file - always using the flixOpt prefix + # Generate documentation file - always using the flixopt prefix with mkdocs_gen_files.open(full_doc_path, "w") as fd: - # Use 'flixOpt.' prefix for all module references - module_id = "flixOpt." + ".".join(parts) + # Use 'flixopt.' prefix for all module references + module_id = "flixopt." + ".".join(parts) fd.write(f"::: {module_id}\n" f" options:\n" f" inherited_members: true\n") @@ -48,7 +48,7 @@ with mkdocs_gen_files.open(f"{api_dir}/index.md", "w") as index_file: index_file.write("# API Reference\n\n") index_file.write( - "This section contains the documentation for all modules and classes in flixOpt.\n" + "This section contains the documentation for all modules and classes in flixopt.\n" "For more information on how to use the classes and functions, see the [Concepts & Math](../concepts-and-math/index.md) section.\n") with mkdocs_gen_files.open(f"{api_dir}/SUMMARY.md", "w") as nav_file: diff --git a/tests/conftest.py b/tests/conftest.py index 7b639c91a..433ea2eb0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,7 +10,7 @@ import pandas as pd import pytest -import flixOpt as fx +import flixopt as fx @pytest.fixture() diff --git a/tests/test_dataconverter.py b/tests/test_dataconverter.py index bfa3bb9e8..04e78e78e 100644 --- a/tests/test_dataconverter.py +++ b/tests/test_dataconverter.py @@ -3,7 +3,7 @@ import pytest import xarray as xr -from flixOpt.core import ConversionError, DataConverter # Adjust this import to match your project structure +from flixopt.core import ConversionError, DataConverter # Adjust this import to match your project structure @pytest.fixture diff --git a/tests/test_functional.py b/tests/test_functional.py index 4e2e09423..3f22e9404 100644 --- a/tests/test_functional.py +++ b/tests/test_functional.py @@ -1,7 +1,7 @@ """ -Unit tests for the flixOpt framework. +Unit tests for the flixopt framework. -This module defines a set of unit tests for testing the functionality of the `flixOpt` framework. +This module defines a set of unit tests for testing the functionality of the `flixopt` framework. The tests focus on verifying the correct behavior of flow systems, including component modeling, investment optimization, and operational constraints like on-off behavior. @@ -22,7 +22,7 @@ import pytest from numpy.testing import assert_allclose -import flixOpt as fx +import flixopt as fx np.random.seed(45) diff --git a/tests/test_integration.py b/tests/test_integration.py index 90bbae3b2..846ab7992 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -4,7 +4,7 @@ import pandas as pd import pytest -import flixOpt as fx +import flixopt as fx from .conftest import ( assert_almost_equal_numeric, diff --git a/tests/test_io.py b/tests/test_io.py index 4f2cedd0b..0db679a39 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -2,8 +2,8 @@ import pytest -import flixOpt as fx -from flixOpt.io import CalculationResultsPaths +import flixopt as fx +from flixopt.io import CalculationResultsPaths from .conftest import ( assert_almost_equal_numeric, diff --git a/tests/test_plots.py b/tests/test_plots.py index e6b481218..d08e9ba4e 100644 --- a/tests/test_plots.py +++ b/tests/test_plots.py @@ -12,7 +12,7 @@ import plotly import pytest -from flixOpt import plotting +from flixopt import plotting class TestPlots(unittest.TestCase): diff --git a/tests/test_results_plots.py b/tests/test_results_plots.py index 3aa2e10e5..38fb8f08d 100644 --- a/tests/test_results_plots.py +++ b/tests/test_results_plots.py @@ -1,7 +1,7 @@ import matplotlib.pyplot as plt import pytest -import flixOpt as fx +import flixopt as fx from .conftest import create_calculation_and_solve, simple_flow_system diff --git a/tests/test_timeseries.py b/tests/test_timeseries.py index 8c1e5ce20..7ba5df2c9 100644 --- a/tests/test_timeseries.py +++ b/tests/test_timeseries.py @@ -8,7 +8,7 @@ import pytest import xarray as xr -from flixOpt.core import ConversionError, DataConverter, TimeSeries, TimeSeriesCollection, TimeSeriesData +from flixopt.core import ConversionError, DataConverter, TimeSeries, TimeSeriesCollection, TimeSeriesData @pytest.fixture From 796f7ad60ccc6bc1291c85510064c1d5460fea5a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 29 Mar 2025 18:07:19 +0100 Subject: [PATCH 482/507] Feature/format (#209) ruff format --- examples/00_Minmal/minimal_example.py | 1 - examples/01_Simple/simple_example.py | 3 +- examples/02_Complex/complex_example.py | 4 +- .../example_calculation_types.py | 32 +- flixopt/aggregation.py | 79 +-- flixopt/calculation.py | 149 +++--- flixopt/components.py | 133 +++-- flixopt/core.py | 191 ++++--- flixopt/effects.py | 37 +- flixopt/elements.py | 68 +-- flixopt/features.py | 467 ++++++++++-------- flixopt/flow_system.py | 102 ++-- flixopt/interface.py | 4 +- flixopt/io.py | 44 +- flixopt/linear_converters.py | 6 +- flixopt/plotting.py | 40 +- flixopt/results.py | 297 ++++++----- flixopt/solvers.py | 5 +- flixopt/structure.py | 34 +- flixopt/utils.py | 4 +- scripts/gen_ref_pages.py | 37 +- tests/conftest.py | 162 +++--- tests/run_all_tests.py | 2 +- tests/test_dataconverter.py | 26 +- tests/test_functional.py | 32 +- tests/test_integration.py | 61 +-- tests/test_io.py | 8 +- tests/test_results_plots.py | 33 +- tests/test_timeseries.py | 263 ++++------ 29 files changed, 1231 insertions(+), 1093 deletions(-) diff --git a/examples/00_Minmal/minimal_example.py b/examples/00_Minmal/minimal_example.py index a109a0621..e9ef241ff 100644 --- a/examples/00_Minmal/minimal_example.py +++ b/examples/00_Minmal/minimal_example.py @@ -55,7 +55,6 @@ calculation.do_modeling() calculation.solve(fx.solvers.HighsSolver(0.01, 60)) - # --- Analyze Results --- # Access the results of an element df1 = calculation.results['costs'].filter_solution('time').to_dataframe() diff --git a/examples/01_Simple/simple_example.py b/examples/01_Simple/simple_example.py index a225984b5..45550c9cc 100644 --- a/examples/01_Simple/simple_example.py +++ b/examples/01_Simple/simple_example.py @@ -116,6 +116,5 @@ df = calculation.results['Storage'].node_balance_with_charge_state() print(df) - #Save results to file for later usage + # Save results to file for later usage calculation.results.to_file() - diff --git a/examples/02_Complex/complex_example.py b/examples/02_Complex/complex_example.py index a5bb87bdc..44ef496d7 100644 --- a/examples/02_Complex/complex_example.py +++ b/examples/02_Complex/complex_example.py @@ -50,7 +50,9 @@ Gaskessel = fx.linear_converters.Boiler( 'Kessel', eta=0.5, # Efficiency ratio - on_off_parameters=fx.OnOffParameters(effects_per_running_hour={Costs.label: 0, CO2.label: 1000}), # CO2 emissions per hour + on_off_parameters=fx.OnOffParameters( + effects_per_running_hour={Costs.label: 0, CO2.label: 1000} + ), # CO2 emissions per hour Q_th=fx.Flow( label='Q_th', # Thermal output bus='Fernwärme', # Linked bus diff --git a/examples/03_Calculation_types/example_calculation_types.py b/examples/03_Calculation_types/example_calculation_types.py index 9255e4008..97b18e3c0 100644 --- a/examples/03_Calculation_types/example_calculation_types.py +++ b/examples/03_Calculation_types/example_calculation_types.py @@ -121,12 +121,14 @@ # Gas Tariff a_gas_tarif = fx.Source( - 'Gastarif', source=fx.Flow('Q_Gas', bus='Gas', size=1000, effects_per_flow_hour={costs.label: gas_price, CO2.label: 0.3}) + 'Gastarif', + source=fx.Flow('Q_Gas', bus='Gas', size=1000, effects_per_flow_hour={costs.label: gas_price, CO2.label: 0.3}), ) # Coal Tariff a_kohle_tarif = fx.Source( - 'Kohletarif', source=fx.Flow('Q_Kohle', bus='Kohle', size=1000, effects_per_flow_hour={costs.label: 4.6, CO2.label: 0.3}) + 'Kohletarif', + source=fx.Flow('Q_Kohle', bus='Kohle', size=1000, effects_per_flow_hour={costs.label: 4.6, CO2.label: 0.3}), ) # Electricity Tariff and Feed-in @@ -136,7 +138,9 @@ 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}), + source=fx.Flow( + 'P_el', bus='Strom', size=1000, effects_per_flow_hour={costs.label: TS_electricity_price_buy, CO2: 0.3} + ), ) # Flow System Setup @@ -190,26 +194,34 @@ def get_solutions(calcs: List, variable: str) -> xr.Dataset: # --- Plotting for comparison --- fx.plotting.with_plotly( get_solutions(calculations, 'Speicher|charge_state').to_dataframe(), - mode='line', title='Charge State Comparison', ylabel='Charge state', + mode='line', + title='Charge State Comparison', + ylabel='Charge state', ).write_html('results/Charge State.html') fx.plotting.with_plotly( get_solutions(calculations, 'BHKW2(Q_th)|flow_rate').to_dataframe(), - mode='line', title='BHKW2(Q_th) Flow Rate Comparison', ylabel='Flow rate', + mode='line', + title='BHKW2(Q_th) Flow Rate Comparison', + ylabel='Flow rate', ).write_html('results/BHKW2 Thermal Power.html') fx.plotting.with_plotly( get_solutions(calculations, 'costs(operation)|total_per_timestep').to_dataframe(), - mode='line', title='Operation Cost Comparison', ylabel='Costs [€]' + mode='line', + title='Operation Cost Comparison', + ylabel='Costs [€]', ).write_html('results/Operation Costs.html') fx.plotting.with_plotly( pd.DataFrame(get_solutions(calculations, 'costs(operation)|total_per_timestep').to_dataframe().sum()).T, - mode='bar', title='Total Cost Comparison', ylabel='Costs [€]' + mode='bar', + title='Total Cost Comparison', + ylabel='Costs [€]', ).update_layout(barmode='group').write_html('results/Total Costs.html') fx.plotting.with_plotly( pd.DataFrame([calc.durations for calc in calculations], index=[calc.name for calc in calculations]), 'bar' - ).update_layout( - title='Duration Comparison', xaxis_title='Calculation type', yaxis_title='Time (s)' - ).write_html('results/Speed Comparison.html') + ).update_layout(title='Duration Comparison', xaxis_title='Calculation type', yaxis_title='Time (s)').write_html( + 'results/Speed Comparison.html' + ) diff --git a/flixopt/aggregation.py b/flixopt/aggregation.py index 37a59b836..f149d5f20 100644 --- a/flixopt/aggregation.py +++ b/flixopt/aggregation.py @@ -16,6 +16,7 @@ try: import tsam.timeseriesaggregation as tsam + TSAM_AVAILABLE = True except ImportError: TSAM_AVAILABLE = False @@ -52,7 +53,6 @@ def __init__( time_series_for_high_peaks: List[str] = None, time_series_for_low_peaks: List[str] = None, ): - """ Args: original_data: The original data to aggregate @@ -64,8 +64,9 @@ def __init__( time_series_for_low_peaks: List of time series to use for explicitly selecting periods with low values. """ if not TSAM_AVAILABLE: - raise ImportError("The 'tsam' package is required for clustering functionality. " - "Install it with 'pip install tsam'.") + raise ImportError( + "The 'tsam' package is required for clustering functionality. Install it with 'pip install tsam'." + ) self.original_data = copy.deepcopy(original_data) self.hours_per_time_step = hours_per_time_step self.hours_per_period = hours_per_period @@ -140,12 +141,7 @@ def describe_clusters(self) -> str: def use_extreme_periods(self): return self.time_series_for_high_peaks or self.time_series_for_low_peaks - def plot( - self, - colormap: str = 'viridis', - show: bool = True, - save: Optional[pathlib.Path] = None - ) -> 'go.Figure': + def plot(self, colormap: str = 'viridis', show: bool = True, save: Optional[pathlib.Path] = None) -> 'go.Figure': from . import plotting df_org = self.original_data.copy().rename( @@ -339,10 +335,7 @@ def do_modeling(self): penalty = self.aggregation_parameters.penalty_of_period_freedom if (self.aggregation_parameters.percentage_of_period_freedom > 0) and penalty != 0: for variable in self.variables_direct.values(): - self._model.effects.add_share_to_penalty( - 'Aggregation', - variable * penalty - ) + self._model.effects.add_share_to_penalty('Aggregation', variable * penalty) def _equate_indices(self, variable: linopy.Variable, indices: Tuple[np.ndarray, np.ndarray]) -> None: assert len(indices[0]) == len(indices[1]), 'The length of the indices must match!!' @@ -350,22 +343,36 @@ def _equate_indices(self, variable: linopy.Variable, indices: Tuple[np.ndarray, # Gleichung: # eq1: x(p1,t) - x(p3,t) = 0 # wobei p1 und p3 im gleichen Cluster sind und t = 0..N_p - con = self.add(self._model.add_constraints( - variable.isel(time=indices[0]) - variable.isel(time=indices[1]) == 0, - name=f'{self.label_full}|equate_indices|{variable.name}'), - f'equate_indices|{variable.name}') + con = self.add( + self._model.add_constraints( + variable.isel(time=indices[0]) - variable.isel(time=indices[1]) == 0, + name=f'{self.label_full}|equate_indices|{variable.name}', + ), + f'equate_indices|{variable.name}', + ) # Korrektur: (bisher nur für Binärvariablen:) - if variable.name in self._model.variables.binaries and self.aggregation_parameters.percentage_of_period_freedom > 0: - var_k1 = self.add(self._model.add_variables( - binary=True, - coords={'time': variable.isel(time=indices[0]).indexes['time']}, - name=f'{self.label_full}|correction1|{variable.name}'), f'correction1|{variable.name}') + if ( + variable.name in self._model.variables.binaries + and self.aggregation_parameters.percentage_of_period_freedom > 0 + ): + var_k1 = self.add( + self._model.add_variables( + binary=True, + coords={'time': variable.isel(time=indices[0]).indexes['time']}, + name=f'{self.label_full}|correction1|{variable.name}', + ), + f'correction1|{variable.name}', + ) - var_k0 = self.add(self._model.add_variables( - binary=True, - coords={'time': variable.isel(time=indices[0]).indexes['time']}, - name=f'{self.label_full}|correction0|{variable.name}'), f'correction0|{variable.name}') + var_k0 = self.add( + self._model.add_variables( + binary=True, + coords={'time': variable.isel(time=indices[0]).indexes['time']}, + name=f'{self.label_full}|correction0|{variable.name}', + ), + f'correction0|{variable.name}', + ) # equation extends ... # --> On(p3) can be 0/1 independent of On(p1,t)! @@ -377,16 +384,20 @@ def _equate_indices(self, variable: linopy.Variable, indices: Tuple[np.ndarray, # interlock var_k1 and var_K2: # eq: var_k0(t)+var_k1(t) <= 1.1 - self.add(self._model.add_constraints( - var_k0 + var_k1 <= 1.1, - name=f'{self.label_full}|lock_k0_and_k1|{variable.name}'), - f'lock_k0_and_k1|{variable.name}' + self.add( + self._model.add_constraints( + var_k0 + var_k1 <= 1.1, name=f'{self.label_full}|lock_k0_and_k1|{variable.name}' + ), + f'lock_k0_and_k1|{variable.name}', ) # Begrenzung der Korrektur-Anzahl: # eq: sum(K) <= n_Corr_max - self.add(self._model.add_constraints( - sum(var_k0) + sum(var_k1) <= round(self.aggregation_parameters.percentage_of_period_freedom / 100 * length), - name=f'{self.label_full}|limit_corrections|{variable.name}'), - f'limit_corrections|{variable.name}' + self.add( + self._model.add_constraints( + sum(var_k0) + sum(var_k1) + <= round(self.aggregation_parameters.percentage_of_period_freedom / 100 * length), + name=f'{self.label_full}|limit_corrections|{variable.name}', + ), + f'limit_corrections|{variable.name}', ) diff --git a/flixopt/calculation.py b/flixopt/calculation.py index 13b25e278..c7367cad2 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -66,45 +66,52 @@ def __init__( try: self.folder.mkdir(parents=False) except FileNotFoundError as e: - raise FileNotFoundError(f'Folder {self.folder} and its parent do not exist. Please create them first.') from e + raise FileNotFoundError( + f'Folder {self.folder} and its parent do not exist. Please create them first.' + ) from e @property def main_results(self) -> Dict[str, Union[Scalar, Dict]]: from flixopt.features import InvestmentModel return { - "Objective": self.model.objective.value, - "Penalty": float(self.model.effects.penalty.total.solution.values), - "Effects": { - f"{effect.label} [{effect.unit}]": { - "operation": float(effect.model.operation.total.solution.values), - "invest": float(effect.model.invest.total.solution.values), - "total": float(effect.model.total.solution.values), + 'Objective': self.model.objective.value, + 'Penalty': float(self.model.effects.penalty.total.solution.values), + 'Effects': { + f'{effect.label} [{effect.unit}]': { + 'operation': float(effect.model.operation.total.solution.values), + 'invest': float(effect.model.invest.total.solution.values), + 'total': float(effect.model.total.solution.values), } for effect in self.flow_system.effects }, - "Invest-Decisions": { - "Invested": { + 'Invest-Decisions': { + 'Invested': { model.label_of_element: float(model.size.solution) for component in self.flow_system.components.values() for model in component.model.all_sub_models if isinstance(model, InvestmentModel) and float(model.size.solution) >= CONFIG.modeling.EPSILON }, - "Not invested": { + 'Not invested': { model.label_of_element: float(model.size.solution) for component in self.flow_system.components.values() for model in component.model.all_sub_models if isinstance(model, InvestmentModel) and float(model.size.solution) < CONFIG.modeling.EPSILON }, }, - "Buses with excess": [ - {bus.label_full: { - "input": float(np.sum(bus.model.excess_input.solution.values)), - "output": float(np.sum(bus.model.excess_output.solution.values)) - }} + 'Buses with excess': [ + { + bus.label_full: { + 'input': float(np.sum(bus.model.excess_input.solution.values)), + 'output': float(np.sum(bus.model.excess_output.solution.values)), + } + } for bus in self.flow_system.buses.values() - if bus.with_excess and (float(np.sum(bus.model.excess_input.solution.values)) > 1e-3 or - float(np.sum(bus.model.excess_output.solution.values)) > 1e-3) + if bus.with_excess + and ( + float(np.sum(bus.model.excess_input.solution.values)) > 1e-3 + or float(np.sum(bus.model.excess_output.solution.values)) > 1e-3 + ) ], } @@ -137,32 +144,39 @@ def do_modeling(self) -> SystemModel: self.durations['modeling'] = round(timeit.default_timer() - t_start, 2) return self.model - def solve(self, - solver: _Solver, - log_file: Optional[pathlib.Path] = None, - log_main_results: bool = True): + def solve(self, solver: _Solver, log_file: Optional[pathlib.Path] = None, log_main_results: bool = True): t_start = timeit.default_timer() - self.model.solve(log_fn=pathlib.Path(log_file) if log_file is not None else self.folder / f'{self.name}.log', - solver_name=solver.name, - **solver.options) + self.model.solve( + log_fn=pathlib.Path(log_file) if log_file is not None else self.folder / f'{self.name}.log', + solver_name=solver.name, + **solver.options, + ) self.durations['solving'] = round(timeit.default_timer() - t_start, 2) if self.model.status == 'warning': # Save the model and the flow_system to file in case of infeasibility paths = fx_io.CalculationResultsPaths(self.folder, self.name) from .io import document_linopy_model + document_linopy_model(self.model, paths.model_documentation) self.flow_system.to_netcdf(paths.flow_system) - raise RuntimeError(f'Model was infeasible. Please check {paths.model_documentation=} and {paths.flow_system=} for more information.') + raise RuntimeError( + f'Model was infeasible. Please check {paths.model_documentation=} and {paths.flow_system=} for more information.' + ) # Log the formatted output if log_main_results: logger.info(f'{" Main Results ":#^80}') logger.info( - "\n" + yaml.dump(utils.round_floats(self.main_results), - default_flow_style=False, sort_keys=False, allow_unicode=True, indent=4 - ) + '\n' + + yaml.dump( + utils.round_floats(self.main_results), + default_flow_style=False, + sort_keys=False, + allow_unicode=True, + indent=4, + ) ) self.results = CalculationResults.from_calculation(self) @@ -186,7 +200,7 @@ def __init__( aggregation_parameters: AggregationParameters, components_to_clusterize: Optional[List[Component]] = None, active_timesteps: Optional[pd.DatetimeIndex] = None, - folder: Optional[pathlib.Path] = None + folder: Optional[pathlib.Path] = None, ): """ Class for Optimizing the `FlowSystem` including: @@ -230,14 +244,23 @@ def _perform_aggregation(self): t_start_agg = timeit.default_timer() # Validation - dt_min, dt_max = np.min(self.flow_system.time_series_collection.hours_per_timestep), np.max(self.flow_system.time_series_collection.hours_per_timestep) + dt_min, dt_max = ( + np.min(self.flow_system.time_series_collection.hours_per_timestep), + np.max(self.flow_system.time_series_collection.hours_per_timestep), + ) 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.time_series_collection.hours_per_timestep.max() - is_integer = (self.aggregation_parameters.hours_per_period % self.flow_system.time_series_collection.hours_per_timestep.max()).item() == 0 + steps_per_period = ( + self.aggregation_parameters.hours_per_period + / self.flow_system.time_series_collection.hours_per_timestep.max() + ) + is_integer = ( + self.aggregation_parameters.hours_per_period + % self.flow_system.time_series_collection.hours_per_timestep.max() + ).item() == 0 if not (steps_per_period.size == 1 and is_integer): raise ValueError( f'The selected {self.aggregation_parameters.hours_per_period=} does not match the time ' @@ -249,7 +272,9 @@ def _perform_aggregation(self): # Aggregation - creation of aggregated timeseries: self.aggregation = Aggregation( - original_data=self.flow_system.time_series_collection.to_dataframe(include_extra_timestep=False), # Exclude last row (NaN) + original_data=self.flow_system.time_series_collection.to_dataframe( + include_extra_timestep=False + ), # Exclude last row (NaN) hours_per_time_step=float(dt_min), hours_per_period=self.aggregation_parameters.hours_per_period, nr_of_periods=self.aggregation_parameters.nr_of_periods, @@ -259,9 +284,11 @@ def _perform_aggregation(self): ) self.aggregation.cluster() - self.aggregation.plot(show=True, save= self.folder / 'aggregation.html') + self.aggregation.plot(show=True, save=self.folder / 'aggregation.html') if self.aggregation_parameters.aggregate_data_and_fix_non_binary_vars: - self.flow_system.time_series_collection.insert_new_data(self.aggregation.aggregated_data, include_extra_timestep=False) + self.flow_system.time_series_collection.insert_new_data( + self.aggregation.aggregated_data, include_extra_timestep=False + ) self.durations['aggregation'] = round(timeit.default_timer() - t_start_agg, 2) @@ -273,7 +300,7 @@ def __init__( timesteps_per_segment: int, overlap_timesteps: int, nr_of_previous_values: int = 1, - folder: Optional[pathlib.Path] = None + folder: Optional[pathlib.Path] = None, ): """ Dividing and Modeling the problem in (overlapping) segments. @@ -303,7 +330,9 @@ def __init__( self.all_timesteps = self.flow_system.time_series_collection.all_timesteps self.all_timesteps_extra = self.flow_system.time_series_collection.all_timesteps_extra - self.segment_names = [f'Segment_{i + 1}' for i in range(math.ceil(len(self.all_timesteps) / self.timesteps_per_segment))] + self.segment_names = [ + f'Segment_{i + 1}' for i in range(math.ceil(len(self.all_timesteps) / self.timesteps_per_segment)) + ] self.active_timesteps_per_segment = self._calculate_timesteps_of_segment() assert timesteps_per_segment > 2, 'The Segment length must be greater 2, due to unwanted internal side effects' @@ -324,21 +353,25 @@ def __init__( self._transfered_start_values: List[Dict[str, Any]] = [] def do_modeling_and_solve( - self, - solver: _Solver, - log_file: Optional[pathlib.Path] = None, - log_main_results: bool = False): + self, solver: _Solver, log_file: Optional[pathlib.Path] = None, log_main_results: bool = False + ): logger.info(f'{"":#^80}') logger.info(f'{" Segmented Solving ":#^80}') - for i, (segment_name, timesteps_of_segment) in enumerate(zip(self.segment_names, self.active_timesteps_per_segment, strict=False)): + for i, (segment_name, timesteps_of_segment) in enumerate( + zip(self.segment_names, self.active_timesteps_per_segment, strict=False) + ): if self.sub_calculations: self._transfer_start_values(i) - logger.info(f'{segment_name} [{i+1:>2}/{len(self.segment_names):<2}] ' - f'({timesteps_of_segment[0]} -> {timesteps_of_segment[-1]}):') + logger.info( + f'{segment_name} [{i + 1:>2}/{len(self.segment_names):<2}] ' + f'({timesteps_of_segment[0]} -> {timesteps_of_segment[-1]}):' + ) - calculation = FullCalculation(f'{self.name}-{segment_name}', self.flow_system, active_timesteps=timesteps_of_segment) + calculation = FullCalculation( + f'{self.name}-{segment_name}', self.flow_system, active_timesteps=timesteps_of_segment + ) self.sub_calculations.append(calculation) calculation.do_modeling() invest_elements = [ @@ -352,9 +385,11 @@ def do_modeling_and_solve( f'Investments are not supported in Segmented Calculation! ' f'Following InvestmentModels were found: {invest_elements}' ) - calculation.solve(solver, - log_file=pathlib.Path(log_file) if log_file is not None else self.folder / f'{self.name}.log', - log_main_results=log_main_results) + calculation.solve( + solver, + log_file=pathlib.Path(log_file) if log_file is not None else self.folder / f'{self.name}.log', + log_main_results=log_main_results, + ) self._reset_start_values() @@ -369,16 +404,20 @@ def _transfer_start_values(self, segment_index: int): This function gets the last values of the previous solved segment and inserts them as start values for the next segment """ - timesteps_of_prior_segment = self.active_timesteps_per_segment[segment_index-1] + timesteps_of_prior_segment = self.active_timesteps_per_segment[segment_index - 1] start = self.active_timesteps_per_segment[segment_index][0] start_previous_values = timesteps_of_prior_segment[self.timesteps_per_segment - self.nr_of_previous_values] - end_previous_values = timesteps_of_prior_segment[self.timesteps_per_segment-1] + end_previous_values = timesteps_of_prior_segment[self.timesteps_per_segment - 1] - logger.debug(f'start of next segment: {start}. indices of previous values: {start_previous_values}:{end_previous_values}') + logger.debug( + f'start of next segment: {start}. indices of previous values: {start_previous_values}:{end_previous_values}' + ) start_values_of_this_segment = {} for flow in self.flow_system.flows.values(): - flow.previous_flow_rate = flow.model.flow_rate.solution.sel(time=slice(start_previous_values, end_previous_values)).values + flow.previous_flow_rate = flow.model.flow_rate.solution.sel( + time=slice(start_previous_values, end_previous_values) + ).values start_values_of_this_segment[flow.label_full] = flow.previous_flow_rate for comp in self.flow_system.components.values(): if isinstance(comp, Storage): @@ -411,8 +450,6 @@ def timesteps_per_segment_with_overlap(self): def start_values_of_segments(self) -> Dict[int, Dict[str, Any]]: """Gives an overview of the start values of all Segments""" return { - 0: { - element.label_full: value for element, value in self._original_start_values.items() - }, + 0: {element.label_full: value for element, value in self._original_start_values.items()}, **{i: start_values for i, start_values in enumerate(self._transfered_start_values, 1)}, } diff --git a/flixopt/components.py b/flixopt/components.py index 2003f003b..d5d1df12d 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -192,10 +192,14 @@ def create_model(self, model: SystemModel) -> 'StorageModel': def transform_data(self, flow_system: 'FlowSystem') -> None: super().transform_data(flow_system) self.relative_minimum_charge_state = flow_system.create_time_series( - f'{self.label_full}|relative_minimum_charge_state', self.relative_minimum_charge_state, needs_extra_timestep=True + f'{self.label_full}|relative_minimum_charge_state', + self.relative_minimum_charge_state, + needs_extra_timestep=True, ) self.relative_maximum_charge_state = flow_system.create_time_series( - f'{self.label_full}|relative_maximum_charge_state', self.relative_maximum_charge_state, needs_extra_timestep=True + f'{self.label_full}|relative_maximum_charge_state', + self.relative_maximum_charge_state, + needs_extra_timestep=True, ) self.eta_charge = flow_system.create_time_series(f'{self.label_full}|eta_charge', self.eta_charge) self.eta_discharge = flow_system.create_time_series(f'{self.label_full}|eta_discharge', self.eta_discharge) @@ -227,11 +231,15 @@ def _plausibility_checks(self) -> None: maximum_inital_capacity = minimum_capacity * self.relative_maximum_charge_state.isel(time=1) if self.initial_charge_state > maximum_inital_capacity: - raise ValueError(f'{self.label_full}: {self.initial_charge_state=} ' - f'is above allowed maximum charge_state {maximum_inital_capacity}') + raise ValueError( + f'{self.label_full}: {self.initial_charge_state=} ' + f'is above allowed maximum charge_state {maximum_inital_capacity}' + ) if self.initial_charge_state < minimum_inital_capacity: - raise ValueError(f'{self.label_full}: {self.initial_charge_state=} ' - f'is below allowed minimum charge_state {minimum_inital_capacity}') + raise ValueError( + f'{self.label_full}: {self.initial_charge_state=} ' + f'is below allowed minimum charge_state {minimum_inital_capacity}' + ) elif self.initial_charge_state != 'lastValueOfSim': raise ValueError(f'{self.label_full}: {self.initial_charge_state=} has an invalid value') @@ -358,19 +366,23 @@ def do_modeling(self): # equate size of both directions if isinstance(self.element.in1.size, InvestParameters) and self.element.in2 is not None: # eq: in1.size = in2.size - self.add(self._model.add_constraints( - self.element.in1.model._investment.size == self.element.in2.model._investment.size, - name=f'{self.label_full}|same_size'), - 'same_size' + self.add( + self._model.add_constraints( + self.element.in1.model._investment.size == self.element.in2.model._investment.size, + name=f'{self.label_full}|same_size', + ), + 'same_size', ) def create_transmission_equation(self, name: str, in_flow: Flow, out_flow: Flow) -> linopy.Constraint: """Creates an Equation for the Transmission efficiency and adds it to the model""" # eq: out(t) + on(t)*loss_abs(t) = in(t)*(1 - loss_rel(t)) - con_transmission = self.add(self._model.add_constraints( - out_flow.model.flow_rate == -in_flow.model.flow_rate * (self.element.relative_losses.active_data - 1), - name=f'{self.label_full}|{name}'), - name + con_transmission = self.add( + self._model.add_constraints( + out_flow.model.flow_rate == -in_flow.model.flow_rate * (self.element.relative_losses.active_data - 1), + name=f'{self.label_full}|{name}', + ), + name, ) if self.element.absolute_losses is not None: @@ -402,9 +414,8 @@ def do_modeling(self): self.add( self._model.add_constraints( sum([flow.model.flow_rate * conv_factors[flow.label].active_data for flow in used_inputs]) - == - sum([flow.model.flow_rate * conv_factors[flow.label].active_data for flow in used_outputs]), - name=f'{self.label_full}|conversion_{i}' + == sum([flow.model.flow_rate * conv_factors[flow.label].active_data for flow in used_outputs]), + name=f'{self.label_full}|conversion_{i}', ) ) @@ -421,7 +432,7 @@ def do_modeling(self): label=self.label_full, piecewise_variables=piecewise_conversion, zero_point=self.on_off.on if self.on_off is not None else False, - as_time_series=True + as_time_series=True, ) piecewise_conversion.do_modeling() self.sub_models.append(piecewise_conversion) @@ -441,21 +452,25 @@ def do_modeling(self): super().do_modeling() lb, ub = self.absolute_charge_state_bounds - self.charge_state = self.add(self._model.add_variables( - lower=lb, upper=ub, coords=self._model.coords_extra, - name=f'{self.label_full}|charge_state'), - 'charge_state' + self.charge_state = self.add( + self._model.add_variables( + lower=lb, upper=ub, coords=self._model.coords_extra, name=f'{self.label_full}|charge_state' + ), + 'charge_state', ) - self.netto_discharge = self.add(self._model.add_variables( - coords=self._model.coords, name=f'{self.label_full}|netto_discharge'), - 'netto_discharge' + self.netto_discharge = self.add( + self._model.add_variables(coords=self._model.coords, name=f'{self.label_full}|netto_discharge'), + 'netto_discharge', ) # netto_discharge: # eq: nettoFlow(t) - discharging(t) + charging(t) = 0 - self.add(self._model.add_constraints( - self.netto_discharge == self.element.discharging.model.flow_rate - self.element.charging.model.flow_rate, - name=f'{self.label_full}|netto_discharge'), - 'netto_discharge' + self.add( + self._model.add_constraints( + self.netto_discharge + == self.element.discharging.model.flow_rate - self.element.charging.model.flow_rate, + name=f'{self.label_full}|netto_discharge', + ), + 'netto_discharge', ) charge_state = self.charge_state @@ -466,14 +481,15 @@ def do_modeling(self): eff_charge = self.element.eta_charge.active_data eff_discharge = self.element.eta_discharge.active_data - self.add(self._model.add_constraints( - charge_state.isel(time=slice(1, None)) - == - charge_state.isel(time=slice(None, -1)) * (1 - rel_loss * hours_per_step) - + charge_rate * eff_charge * hours_per_step - - discharge_rate * eff_discharge * hours_per_step, - name=f'{self.label_full}|charge_state'), - 'charge_state' + self.add( + self._model.add_constraints( + charge_state.isel(time=slice(1, None)) + == charge_state.isel(time=slice(None, -1)) * (1 - rel_loss * hours_per_step) + + charge_rate * eff_charge * hours_per_step + - discharge_rate * eff_discharge * hours_per_step, + name=f'{self.label_full}|charge_state', + ), + 'charge_state', ) if isinstance(self.element.capacity_in_flow_hours, InvestParameters): @@ -496,32 +512,40 @@ def _initial_and_final_charge_state(self): name = f'{self.label_full}|{name_short}' if utils.is_number(self.element.initial_charge_state): - self.add(self._model.add_constraints( - self.charge_state.isel(time=0) == self.element.initial_charge_state, - name=name), - name_short + self.add( + self._model.add_constraints( + self.charge_state.isel(time=0) == self.element.initial_charge_state, name=name + ), + name_short, ) elif self.element.initial_charge_state == 'lastValueOfSim': - self.add(self._model.add_constraints( - self.charge_state.isel(time=0) == self.charge_state.isel(time=-1), - name=name), - name_short + self.add( + self._model.add_constraints( + self.charge_state.isel(time=0) == self.charge_state.isel(time=-1), name=name + ), + name_short, ) else: # TODO: Validation in Storage Class, not in Model - raise PlausibilityError(f'initial_charge_state has undefined value: {self.element.initial_charge_state}') + raise PlausibilityError( + f'initial_charge_state has undefined value: {self.element.initial_charge_state}' + ) if self.element.maximal_final_charge_state is not None: - self.add(self._model.add_constraints( - self.charge_state.isel(time=-1) <= self.element.maximal_final_charge_state, - name=f'{self.label_full}|final_charge_max'), - 'final_charge_max' + self.add( + self._model.add_constraints( + self.charge_state.isel(time=-1) <= self.element.maximal_final_charge_state, + name=f'{self.label_full}|final_charge_max', + ), + 'final_charge_max', ) if self.element.minimal_final_charge_state is not None: - self.add(self._model.add_constraints( - self.charge_state.isel(time=-1) >= self.element.minimal_final_charge_state, - name=f'{self.label_full}|final_charge_min'), - 'final_charge_min' + self.add( + self._model.add_constraints( + self.charge_state.isel(time=-1) >= self.element.minimal_final_charge_state, + name=f'{self.label_full}|final_charge_min', + ), + 'final_charge_min', ) @property @@ -551,6 +575,7 @@ class SourceAndSink(Component): """ class for source (output-flow) and sink (input-flow) in one commponent """ + def __init__( self, label: str, diff --git a/flixopt/core.py b/flixopt/core.py index 6efcea27c..379828554 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -28,10 +28,13 @@ class PlausibilityError(Exception): """Error for a failing Plausibility check.""" + pass + class ConversionError(Exception): """Base exception for data conversion errors.""" + pass @@ -46,7 +49,7 @@ class DataConverter: def as_dataarray(data: NumericData, timesteps: pd.DatetimeIndex) -> xr.DataArray: """Convert data to xarray.DataArray with specified timesteps index.""" if not isinstance(timesteps, pd.DatetimeIndex) or len(timesteps) == 0: - raise ValueError(f"Timesteps must be a non-empty DatetimeIndex, got {type(timesteps).__name__}") + raise ValueError(f'Timesteps must be a non-empty DatetimeIndex, got {type(timesteps).__name__}') if not timesteps.name == 'time': raise ConversionError(f'DatetimeIndex is not named correctly. Must be named "time", got {timesteps.name=}') @@ -69,7 +72,7 @@ def as_dataarray(data: NumericData, timesteps: pd.DatetimeIndex) -> xr.DataArray return xr.DataArray(data.values, coords=coords, dims=dims) elif isinstance(data, np.ndarray): if data.ndim != 1: - raise ConversionError(f"Array must be 1-dimensional, got {data.ndim}") + raise ConversionError(f'Array must be 1-dimensional, got {data.ndim}') elif data.shape[0] != expected_shape[0]: raise ConversionError(f"Array shape {data.shape} doesn't match expected {expected_shape}") return xr.DataArray(data, coords=coords, dims=dims) @@ -77,14 +80,16 @@ def as_dataarray(data: NumericData, timesteps: pd.DatetimeIndex) -> xr.DataArray if data.dims != tuple(dims): raise ConversionError(f"DataArray dimensions {data.dims} don't match expected {dims}") if data.sizes[dims[0]] != len(coords[0]): - raise ConversionError(f"DataArray length {data.sizes[dims[0]]} doesn't match expected {len(coords[0])}") + raise ConversionError( + f"DataArray length {data.sizes[dims[0]]} doesn't match expected {len(coords[0])}" + ) return data.copy(deep=True) else: - raise ConversionError(f"Unsupported type: {type(data).__name__}") + raise ConversionError(f'Unsupported type: {type(data).__name__}') except Exception as e: if isinstance(e, ConversionError): raise - raise ConversionError(f"Converting data {type(data)} to xarray.Dataset raised an error: {str(e)}") from e + raise ConversionError(f'Converting data {type(data)} to xarray.Dataset raised an error: {str(e)}') from e class TimeSeriesData: @@ -145,14 +150,15 @@ class TimeSeries: """ @classmethod - def from_datasource(cls, - data: NumericData, - name: str, - timesteps: pd.DatetimeIndex, - aggregation_weight: Optional[float] = None, - aggregation_group: Optional[str] = None, - needs_extra_timestep: bool = False - ) -> 'TimeSeries': + def from_datasource( + cls, + data: NumericData, + name: str, + timesteps: pd.DatetimeIndex, + aggregation_weight: Optional[float] = None, + aggregation_group: Optional[str] = None, + needs_extra_timestep: bool = False, + ) -> 'TimeSeries': """ Initialize the TimeSeries from multiple data sources. @@ -172,7 +178,7 @@ def from_datasource(cls, name, aggregation_weight, aggregation_group, - needs_extra_timestep + needs_extra_timestep, ) @classmethod @@ -198,23 +204,25 @@ def from_json(cls, data: Optional[Dict[str, Any]] = None, path: Optional[str] = data = json.load(f) # Convert ISO date strings to datetime objects - data["data"]["coords"]["time"]["data"] = pd.to_datetime(data["data"]["coords"]["time"]["data"]) + data['data']['coords']['time']['data'] = pd.to_datetime(data['data']['coords']['time']['data']) # Create the TimeSeries instance return cls( - data=xr.DataArray.from_dict(data["data"]), - name=data["name"], - aggregation_weight=data["aggregation_weight"], - aggregation_group=data["aggregation_group"], - needs_extra_timestep=data["needs_extra_timestep"] + data=xr.DataArray.from_dict(data['data']), + name=data['name'], + aggregation_weight=data['aggregation_weight'], + aggregation_group=data['aggregation_group'], + needs_extra_timestep=data['needs_extra_timestep'], ) - def __init__(self, - data: xr.DataArray, - name: str, - aggregation_weight: Optional[float] = None, - aggregation_group: Optional[str] = None, - needs_extra_timestep: bool = False): + def __init__( + self, + data: xr.DataArray, + name: str, + aggregation_weight: Optional[float] = None, + aggregation_group: Optional[str] = None, + needs_extra_timestep: bool = False, + ): """ Initialize a TimeSeries with a DataArray. @@ -269,17 +277,15 @@ def to_json(self, path: Optional[pathlib.Path] = None) -> Dict[str, Any]: Dictionary representation of the TimeSeries """ data = { - "name": self.name, - "aggregation_weight": self.aggregation_weight, - "aggregation_group": self.aggregation_group, - "needs_extra_timestep": self.needs_extra_timestep, - "data": self.active_data.to_dict(), + 'name': self.name, + 'aggregation_weight': self.aggregation_weight, + 'aggregation_group': self.aggregation_group, + 'needs_extra_timestep': self.needs_extra_timestep, + 'data': self.active_data.to_dict(), } # Convert datetime objects to ISO strings - data["data"]["coords"]["time"]["data"] = [ - date.isoformat() for date in data["data"]["coords"]["time"]["data"] - ] + data['data']['coords']['time']['data'] = [date.isoformat() for date in data['data']['coords']['time']['data']] # Save to file if path is provided if path is not None: @@ -331,7 +337,7 @@ def active_timesteps(self, timesteps: Optional[pd.DatetimeIndex]): elif isinstance(timesteps, pd.DatetimeIndex): self._active_timesteps = timesteps else: - raise TypeError("active_timesteps must be a pandas DatetimeIndex or None") + raise TypeError('active_timesteps must be a pandas DatetimeIndex or None') self._update_active_data() @@ -446,10 +452,10 @@ def __repr__(self): 'aggregation_group': self.aggregation_group, 'needs_extra_timestep': self.needs_extra_timestep, 'shape': self.active_data.shape, - 'time_range': f"{self.active_timesteps[0]} to {self.active_timesteps[-1]}" + 'time_range': f'{self.active_timesteps[0]} to {self.active_timesteps[-1]}', } - attr_str = ', '.join(f"{k}={repr(v)}" for k, v in attrs.items()) - return f"TimeSeries({attr_str})" + attr_str = ', '.join(f'{k}={repr(v)}' for k, v in attrs.items()) + return f'TimeSeries({attr_str})' def __str__(self): """ @@ -470,12 +476,11 @@ class TimeSeriesCollection: """ def __init__( - self, - timesteps: pd.DatetimeIndex, - hours_of_last_timestep: Optional[float] = None, - hours_of_previous_timesteps: Optional[Union[float, np.ndarray]] = None + self, + timesteps: pd.DatetimeIndex, + hours_of_last_timestep: Optional[float] = None, + hours_of_previous_timesteps: Optional[Union[float, np.ndarray]] = None, ): - """ Args: timesteps: The timesteps of the Collection. @@ -510,21 +515,14 @@ def __init__( @classmethod def with_uniform_timesteps( - cls, - start_time: pd.Timestamp, - periods: int, - freq: str, - hours_per_step: Optional[float] = None + cls, start_time: pd.Timestamp, periods: int, freq: str, hours_per_step: Optional[float] = None ) -> 'TimeSeriesCollection': """Create a collection with uniform timesteps.""" timesteps = pd.date_range(start_time, periods=periods, freq=freq, name='time') return cls(timesteps, hours_of_previous_timesteps=hours_per_step) def create_time_series( - self, - data: Union[NumericData, TimeSeriesData], - name: str, - needs_extra_timestep: bool = False + self, data: Union[NumericData, TimeSeriesData], name: str, needs_extra_timestep: bool = False ) -> TimeSeries: """ Creates a TimeSeries from the given data and adds it to the collection. @@ -554,16 +552,13 @@ def create_time_series( timesteps=timesteps_to_use, aggregation_weight=data.agg_weight, aggregation_group=data.agg_group, - needs_extra_timestep=needs_extra_timestep + needs_extra_timestep=needs_extra_timestep, ) # Connect the user time series to the created TimeSeries data.label = name else: time_series = TimeSeries.from_datasource( - name=name, - data=data, - timesteps=timesteps_to_use, - needs_extra_timestep=needs_extra_timestep + name=name, data=data, timesteps=timesteps_to_use, needs_extra_timestep=needs_extra_timestep ) # Add to the collection @@ -600,7 +595,7 @@ def activate_timesteps(self, active_timesteps: Optional[pd.DatetimeIndex] = None self._active_timesteps = active_timesteps first_ts_index = np.where(self.all_timesteps == active_timesteps[0])[0][0] last_ts_idx = np.where(self.all_timesteps == active_timesteps[-1])[0][0] - self._active_timesteps_extra = self.all_timesteps_extra[first_ts_index:last_ts_idx + 2] + self._active_timesteps_extra = self.all_timesteps_extra[first_ts_index : last_ts_idx + 2] self._active_hours_per_timestep = self.all_hours_per_timestep.isel(time=slice(first_ts_index, last_ts_idx + 1)) # Update all time series @@ -636,13 +631,14 @@ def insert_new_data(self, data: pd.DataFrame, include_extra_timestep: bool = Fal include_extra_timestep: Whether the provided data already includes the extra timestep, by default False """ if not isinstance(data, pd.DataFrame): - raise TypeError(f"data must be a pandas DataFrame, got {type(data).__name__}") + raise TypeError(f'data must be a pandas DataFrame, got {type(data).__name__}') # Check if the DataFrame index matches the expected timesteps expected_timesteps = self.timesteps_extra if include_extra_timestep else self.timesteps if not data.index.equals(expected_timesteps): raise ValueError( - f"DataFrame index must match {'collection timesteps with extra timestep' if include_extra_timestep else 'collection timesteps'}") + f'DataFrame index must match {"collection timesteps with extra timestep" if include_extra_timestep else "collection timesteps"}' + ) for name, ts in self.time_series_data.items(): if name in data.columns: @@ -670,9 +666,9 @@ def insert_new_data(self, data: pd.DataFrame, include_extra_timestep: bool = Fal logger.debug(f'Updated data for {name}') - def to_dataframe(self, - filtered: Literal['all', 'constant', 'non_constant'] = 'non_constant', - include_extra_timestep: bool = True) -> pd.DataFrame: + def to_dataframe( + self, filtered: Literal['all', 'constant', 'non_constant'] = 'non_constant', include_extra_timestep: bool = True + ) -> pd.DataFrame: """ Convert collection to DataFrame with optional filtering and timestep control. @@ -723,10 +719,12 @@ def to_dataset(self, include_constants: bool = True) -> xr.Dataset: # Ensure the correct time coordinates ds = ds.reindex(time=self.timesteps_extra) - ds.attrs.update({ + ds.attrs.update( + { 'timesteps_extra': f'{self.timesteps_extra[0]} ... {self.timesteps_extra[-1]} | len={len(self.timesteps_extra)}', 'hours_per_timestep': self._format_stats(self.hours_per_timestep), - }) + } + ) return ds @@ -754,13 +752,12 @@ def _validate_timesteps(timesteps: pd.DatetimeIndex): @staticmethod def _create_timesteps_with_extra( - timesteps: pd.DatetimeIndex, - hours_of_last_timestep: Optional[float] + timesteps: pd.DatetimeIndex, hours_of_last_timestep: Optional[float] ) -> pd.DatetimeIndex: """Create timesteps with an extra step at the end.""" if hours_of_last_timestep is not None: # Create the extra timestep using the specified duration - last_date = pd.DatetimeIndex([timesteps[-1] + pd.Timedelta(hours=hours_of_last_timestep)],name='time') + last_date = pd.DatetimeIndex([timesteps[-1] + pd.Timedelta(hours=hours_of_last_timestep)], name='time') else: # Use the last interval as the extra timestep duration last_date = pd.DatetimeIndex([timesteps[-1] + (timesteps[-1] - timesteps[-2])], name='time') @@ -770,8 +767,7 @@ def _create_timesteps_with_extra( @staticmethod def _calculate_hours_of_previous_timesteps( - timesteps: pd.DatetimeIndex, - hours_of_previous_timesteps: Optional[Union[float, np.ndarray]] + timesteps: pd.DatetimeIndex, hours_of_previous_timesteps: Optional[Union[float, np.ndarray]] ) -> Union[float, np.ndarray]: """Calculate duration of regular timesteps.""" if hours_of_previous_timesteps is not None: @@ -788,20 +784,13 @@ def calculate_hours_per_timestep(timesteps_extra: pd.DatetimeIndex) -> xr.DataAr hours_per_step = np.diff(timesteps_extra) / pd.Timedelta(hours=1) return xr.DataArray( - data=hours_per_step, - coords={'time': timesteps_extra[:-1]}, - dims=('time',), - name='hours_per_step' + data=hours_per_step, coords={'time': timesteps_extra[:-1]}, dims=('time',), name='hours_per_step' ) def _calculate_group_weights(self) -> Dict[str, float]: """Calculate weights for aggregation groups.""" # Count series in each group - groups = [ - ts.aggregation_group - for ts in self.time_series_data.values() - if ts.aggregation_group is not None - ] + groups = [ts.aggregation_group for ts in self.time_series_data.values() if ts.aggregation_group is not None] group_counts = Counter(groups) # Calculate weight for each group (1/count) @@ -832,7 +821,7 @@ def _format_stats(self, data) -> str: min_val = np.min(values) max_val = np.max(values) - return f"mean: {mean_val:.2f}, min: {min_val:.2f}, max: {max_val:.2f}" + return f'mean: {mean_val:.2f}, min: {min_val:.2f}, max: {max_val:.2f}' def __getitem__(self, name: str) -> TimeSeries: """Get a TimeSeries by name.""" @@ -860,18 +849,12 @@ def __contains__(self, item: Union[str, TimeSeries]) -> bool: @property def non_constants(self) -> List[TimeSeries]: """Get time series with varying values.""" - return [ - ts for ts in self.time_series_data.values() - if not ts.all_equal - ] + return [ts for ts in self.time_series_data.values() if not ts.all_equal] @property def constants(self) -> List[TimeSeries]: """Get time series with constant values.""" - return [ - ts for ts in self.time_series_data.values() - if ts.all_equal - ] + return [ts for ts in self.time_series_data.values() if ts.all_equal] @property def timesteps(self) -> pd.DatetimeIndex: @@ -886,7 +869,9 @@ def timesteps_extra(self) -> pd.DatetimeIndex: @property def hours_per_timestep(self) -> xr.DataArray: """Get the duration of each active timestep.""" - return self.all_hours_per_timestep if self._active_hours_per_timestep is None else self._active_hours_per_timestep + return ( + self.all_hours_per_timestep if self._active_hours_per_timestep is None else self._active_hours_per_timestep + ) @property def hours_of_last_timestep(self) -> float: @@ -894,34 +879,36 @@ def hours_of_last_timestep(self) -> float: return float(self.hours_per_timestep[-1].item()) def __repr__(self): - return f"TimeSeriesCollection:\n{self.to_dataset()}" + return f'TimeSeriesCollection:\n{self.to_dataset()}' def __str__(self): longest_name = max([time_series.name for time_series in self.time_series_data], key=len) - stats_summary = "\n".join( - [f" - {time_series.name:<{len(longest_name)}}: {get_numeric_stats(time_series.active_data)}" - for time_series in self.time_series_data] + stats_summary = '\n'.join( + [ + f' - {time_series.name:<{len(longest_name)}}: {get_numeric_stats(time_series.active_data)}' + for time_series in self.time_series_data + ] ) return ( - f"TimeSeriesCollection with {len(self.time_series_data)} series\n" - f" Time Range: {self.timesteps[0]} → {self.timesteps[-1]}\n" - f" No. of timesteps: {len(self.timesteps)} + 1 extra\n" - f" Hours per timestep: {get_numeric_stats(self.hours_per_timestep)}\n" - f" Time Series Data:\n" - f"{stats_summary}" + f'TimeSeriesCollection with {len(self.time_series_data)} series\n' + f' Time Range: {self.timesteps[0]} → {self.timesteps[-1]}\n' + f' No. of timesteps: {len(self.timesteps)} + 1 extra\n' + f' Hours per timestep: {get_numeric_stats(self.hours_per_timestep)}\n' + f' Time Series Data:\n' + f'{stats_summary}' ) def get_numeric_stats(data: xr.DataArray, decimals: int = 2, padd: int = 10) -> str: """Calculates the mean, median, min, max, and standard deviation of a numeric DataArray.""" - format_spec = f">{padd}.{decimals}f" if padd else f".{decimals}f" + format_spec = f'>{padd}.{decimals}f' if padd else f'.{decimals}f' if np.unique(data).size == 1: - return f"{data.max().item():{format_spec}} (constant)" + return f'{data.max().item():{format_spec}} (constant)' mean = data.mean().item() median = data.median().item() min_val = data.min().item() max_val = data.max().item() std = data.std().item() - return f"{mean:{format_spec}} (mean), {median:{format_spec}} (median), {min_val:{format_spec}} (min), {max_val:{format_spec}} (max), {std:{format_spec}} (std)" + return f'{mean:{format_spec}} (mean), {median:{format_spec}} (median), {min_val:{format_spec}} (min), {max_val:{format_spec}} (max), {std:{format_spec}} (std)' diff --git a/flixopt/effects.py b/flixopt/effects.py index 787e21116..82aa63a43 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -79,9 +79,7 @@ def __init__( self.specific_share_to_other_effects_operation: EffectValuesUser = ( specific_share_to_other_effects_operation or {} ) - self.specific_share_to_other_effects_invest: EffectValuesUser = ( - specific_share_to_other_effects_invest or {} - ) + self.specific_share_to_other_effects_invest: EffectValuesUser = specific_share_to_other_effects_invest or {} self.minimum_operation = minimum_operation self.maximum_operation = maximum_operation self.minimum_operation_per_hour: NumericDataTS = minimum_operation_per_hour @@ -100,9 +98,7 @@ def transform_data(self, flow_system: 'FlowSystem'): ) self.specific_share_to_other_effects_operation = flow_system.create_effect_time_series( - f'{self.label_full}|operation->', - self.specific_share_to_other_effects_operation, - 'operation' + f'{self.label_full}|operation->', self.specific_share_to_other_effects_operation, 'operation' ) def create_model(self, model: SystemModel) -> 'EffectModel': @@ -128,7 +124,7 @@ def __init__(self, model: SystemModel, element: Effect): 'invest', label_full=f'{self.label_full}(invest)', total_max=self.element.maximum_invest, - total_min=self.element.minimum_invest + total_min=self.element.minimum_invest, ) ) @@ -159,19 +155,19 @@ def do_modeling(self): 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=None, - name=f'{self.label_full}|total' + name=f'{self.label_full}|total', ), - 'total' + 'total', ) self.add( self._model.add_constraints( - self.total == self.operation.total.sum() + self.invest.total.sum(), - name=f'{self.label_full}|total' + self.total == self.operation.total.sum() + self.invest.total.sum(), name=f'{self.label_full}|total' ), - 'total' + 'total', ) + EffectValuesExpr = Dict[str, linopy.LinearExpression] # Used to create Shares EffectTimeSeries = Dict[str, TimeSeries] # Used internally to index values EffectValuesDict = Dict[str, NumericDataTS] # How effect values are stored @@ -228,11 +224,11 @@ def create_effect_values_dict(self, effect_values_user: EffectValuesUser) -> Opt """ def get_effect_label(eff: Union[Effect, str]) -> str: - """ Temporary function to get the label of an effect and warn for deprecation """ + """Temporary function to get the label of an effect and warn for deprecation""" if isinstance(eff, Effect): warnings.warn( - f"The use of effect objects when specifying EffectValues is deprecated. " - f"Use the label of the effect instead. Used effect: {eff.label_full}", + f'The use of effect objects when specifying EffectValues is deprecated. ' + f'Use the label of the effect instead. Used effect: {eff.label_full}', UserWarning, stacklevel=2, ) @@ -255,6 +251,7 @@ def error_str(effect_label: str, share_ffect_label: str): f' {effect_label} -> has share in: {share_ffect_label}\n' f' {share_ffect_label} -> has share in: {effect_label}' ) + for effect in self.effects.values(): # Effekt darf nicht selber als Share in seinen ShareEffekten auftauchen: # operation: @@ -350,7 +347,7 @@ def add_share_to_effects( for effect, expression in expressions.items(): if target == 'operation': self.effects[effect].model.operation.add_share(name, expression) - elif target =='invest': + elif target == 'invest': self.effects[effect].model.invest.add_share(name, expression) else: raise ValueError(f'Target {target} not supported!') @@ -363,15 +360,15 @@ def add_share_to_penalty(self, name: str, expression: linopy.LinearExpression) - def do_modeling(self): for effect in self.effects: effect.create_model(self._model) - self.penalty = self.add(ShareAllocationModel(self._model, shares_are_time_series=False, label_of_element='Penalty')) + self.penalty = self.add( + ShareAllocationModel(self._model, shares_are_time_series=False, label_of_element='Penalty') + ) for model in [effect.model for effect in self.effects] + [self.penalty]: model.do_modeling() self._add_share_between_effects() - self._model.add_objective( - self.effects.objective_effect.model.total + self.penalty.total - ) + self._model.add_objective(self.effects.objective_effect.model.total + self.penalty.total) def _add_share_between_effects(self): for origin_effect in self.effects: diff --git a/flixopt/elements.py b/flixopt/elements.py index e2b2caa3e..05898d4e5 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -203,7 +203,9 @@ def __init__( self.flow_hours_total_min = flow_hours_total_min self.on_off_parameters = on_off_parameters - self.previous_flow_rate = previous_flow_rate if not isinstance(previous_flow_rate, list) else np.array(previous_flow_rate) + self.previous_flow_rate = ( + previous_flow_rate if not isinstance(previous_flow_rate, list) else np.array(previous_flow_rate) + ) self.component: str = 'UnknownComponent' self.is_input_in_component: Optional[bool] = None @@ -214,7 +216,7 @@ def __init__( f'in the future. Add the Bus to the FlowSystem instead and pass its label to the Flow.', UserWarning, stacklevel=1, - ) + ) self._bus_object = bus else: self.bus = bus @@ -307,9 +309,9 @@ def do_modeling(self): lower=self.absolute_flow_rate_bounds[0] if self.element.on_off_parameters is None else 0, upper=self.absolute_flow_rate_bounds[1], coords=self._model.coords, - name=f'{self.label_full}|flow_rate' + name=f'{self.label_full}|flow_rate', ), - 'flow_rate' + 'flow_rate', ) # OnOff @@ -323,7 +325,7 @@ def do_modeling(self): defining_bounds=[self.absolute_flow_rate_bounds], previous_values=[self.element.previous_flow_rate], ), - 'on_off' + 'on_off', ) self.on_off.do_modeling() @@ -338,7 +340,7 @@ def do_modeling(self): relative_bounds_of_defining_variable=self.relative_flow_rate_bounds, on_variable=self.on_off.on if self.on_off is not None else None, ), - 'investment' + 'investment', ) self._investment.do_modeling() @@ -347,17 +349,17 @@ def do_modeling(self): lower=self.element.flow_hours_total_min if self.element.flow_hours_total_min is not None else -np.inf, upper=self.element.flow_hours_total_max if self.element.flow_hours_total_max is not None else np.inf, coords=None, - name=f'{self.label_full}|total_flow_hours' + name=f'{self.label_full}|total_flow_hours', ), - 'total_flow_hours' + 'total_flow_hours', ) self.add( self._model.add_constraints( self.total_flow_hours == (self.flow_rate * self._model.hours_per_step).sum(), - name=f'{self.label_full}|total_flow_hours' + name=f'{self.label_full}|total_flow_hours', ), - 'total_flow_hours' + 'total_flow_hours', ) # Load factor @@ -393,7 +395,7 @@ def _create_bounds_for_load_factor(self): self.total_flow_hours <= size * flow_hours_per_size_max, name=f'{self.label_full}|{name_short}', ), - name_short + name_short, ) # eq: size * sum(dt)* load_factor_min <= var_sumFlowHours @@ -408,7 +410,7 @@ def _create_bounds_for_load_factor(self): self.total_flow_hours >= size * flow_hours_per_size_min, name=f'{self.label_full}|{name_short}', ), - name_short + name_short, ) @property @@ -444,23 +446,20 @@ def do_modeling(self) -> None: self.add(flow.model.flow_rate, flow.label_full) inputs = sum([flow.model.flow_rate for flow in self.element.inputs]) outputs = sum([flow.model.flow_rate for flow in self.element.outputs]) - eq_bus_balance = self.add(self._model.add_constraints( - inputs == outputs, - name=f'{self.label_full}|balance' - )) + eq_bus_balance = self.add(self._model.add_constraints(inputs == outputs, name=f'{self.label_full}|balance')) # Fehlerplus/-minus: if self.element.with_excess: excess_penalty = np.multiply( self._model.hours_per_step, self.element.excess_penalty_per_flow_hour.active_data ) - self.excess_input = self.add(self._model.add_variables( - lower=0, coords=self._model.coords, name=f'{self.label_full}|excess_input'), - 'excess_input' + self.excess_input = self.add( + self._model.add_variables(lower=0, coords=self._model.coords, name=f'{self.label_full}|excess_input'), + 'excess_input', ) - self.excess_output = self.add(self._model.add_variables( - lower=0, coords=self._model.coords, name=f'{self.label_full}|excess_output'), - 'excess_output' + self.excess_output = self.add( + self._model.add_variables(lower=0, coords=self._model.coords, name=f'{self.label_full}|excess_output'), + 'excess_output', ) eq_bus_balance.lhs -= -self.excess_input + self.excess_output @@ -503,13 +502,16 @@ def do_modeling(self): sub_model.do_modeling() if self.element.on_off_parameters: - self.on_off = self.add(OnOffModel( - self._model, - self.element.on_off_parameters, - self.label_of_element, - defining_variables=[flow.model.flow_rate for flow in all_flows], - defining_bounds=[flow.model.absolute_flow_rate_bounds for flow in all_flows], - previous_values=[flow.previous_flow_rate for flow in all_flows])) + self.on_off = self.add( + OnOffModel( + self._model, + self.element.on_off_parameters, + self.label_of_element, + defining_variables=[flow.model.flow_rate for flow in all_flows], + defining_bounds=[flow.model.absolute_flow_rate_bounds for flow in all_flows], + previous_values=[flow.previous_flow_rate for flow in all_flows], + ) + ) self.on_off.do_modeling() @@ -520,6 +522,8 @@ def do_modeling(self): simultaneous_use.do_modeling() def results_structure(self): - return {**super().results_structure(), - 'inputs': [flow.model.flow_rate.name for flow in self.element.inputs], - 'outputs': [flow.model.flow_rate.name for flow in self.element.outputs]} + return { + **super().results_structure(), + 'inputs': [flow.model.flow_rate.name for flow in self.element.inputs], + 'outputs': [flow.model.flow_rate.name for flow in self.element.outputs], + } diff --git a/flixopt/features.py b/flixopt/features.py index 4090a2301..92caf9dc2 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -44,24 +44,27 @@ def __init__( def do_modeling(self): if self.parameters.fixed_size and not self.parameters.optional: - self.size = self.add(self._model.add_variables( - lower=self.parameters.fixed_size, - upper=self.parameters.fixed_size, - name=f'{self.label_full}|size'), - 'size') + self.size = self.add( + self._model.add_variables( + lower=self.parameters.fixed_size, upper=self.parameters.fixed_size, name=f'{self.label_full}|size' + ), + 'size', + ) else: - self.size = self.add(self._model.add_variables( - lower=0 if self.parameters.optional else self.parameters.minimum_size, - upper=self.parameters.maximum_size, - name=f'{self.label_full}|size'), - 'size') + self.size = self.add( + self._model.add_variables( + lower=0 if self.parameters.optional else self.parameters.minimum_size, + upper=self.parameters.maximum_size, + name=f'{self.label_full}|size', + ), + 'size', + ) # Optional if self.parameters.optional: - self.is_invested = self.add(self._model.add_variables( - binary=True, - name=f'{self.label_full}|is_invested'), - 'is_invested') + self.is_invested = self.add( + self._model.add_variables(binary=True, name=f'{self.label_full}|is_invested'), 'is_invested' + ) self._create_bounds_for_optional_investment() @@ -71,14 +74,15 @@ def do_modeling(self): self._create_shares() def _create_shares(self): - # fix_effects: fix_effects = self.parameters.fix_effects if fix_effects != {}: self._model.effects.add_share_to_effects( name=self.label_of_element, - expressions={effect: self.is_invested * factor if self.is_invested is not None else factor - for effect, factor in fix_effects.items()}, + expressions={ + effect: self.is_invested * factor if self.is_invested is not None else factor + for effect, factor in fix_effects.items() + }, target='invest', ) @@ -104,57 +108,74 @@ def _create_shares(self): label_of_element=self.label_of_element, piecewise_origin=(self.size.name, self.parameters.piecewise_effects.piecewise_origin), piecewise_shares=self.parameters.piecewise_effects.piecewise_shares, - zero_point=self.is_invested), - 'segments' + zero_point=self.is_invested, + ), + 'segments', ) self.piecewise_effects.do_modeling() def _create_bounds_for_optional_investment(self): if self.parameters.fixed_size: # eq: investment_size = isInvested * fixed_size - self.add(self._model.add_constraints( - self.size == self.is_invested * self.parameters.fixed_size, - name=f'{self.label_full}|is_invested'), - 'is_invested') + self.add( + self._model.add_constraints( + self.size == self.is_invested * self.parameters.fixed_size, name=f'{self.label_full}|is_invested' + ), + 'is_invested', + ) else: # eq1: P_invest <= isInvested * investSize_max - self.add(self._model.add_constraints( - self.size <= self.is_invested * self.parameters.maximum_size, - name=f'{self.label_full}|is_invested_ub'), - 'is_invested_ub') + self.add( + self._model.add_constraints( + self.size <= self.is_invested * self.parameters.maximum_size, + name=f'{self.label_full}|is_invested_ub', + ), + 'is_invested_ub', + ) # eq2: P_invest >= isInvested * max(epsilon, investSize_min) - self.add(self._model.add_constraints( - self.size >= self.is_invested * np.maximum(CONFIG.modeling.EPSILON,self.parameters.minimum_size), - name=f'{self.label_full}|is_invested_lb'), - 'is_invested_lb') + self.add( + self._model.add_constraints( + self.size >= self.is_invested * np.maximum(CONFIG.modeling.EPSILON, self.parameters.minimum_size), + name=f'{self.label_full}|is_invested_lb', + ), + 'is_invested_lb', + ) def _create_bounds_for_defining_variable(self): variable = self._defining_variable lb_relative, ub_relative = self._relative_bounds_of_defining_variable if np.all(lb_relative == ub_relative): - self.add(self._model.add_constraints( - variable == self.size * ub_relative, - name=f'{self.label_full}|fix_{variable.name}'), - f'fix_{variable.name}') + self.add( + self._model.add_constraints( + variable == self.size * ub_relative, name=f'{self.label_full}|fix_{variable.name}' + ), + f'fix_{variable.name}', + ) if self._on_variable is not None: - raise ValueError(f'Flow {self.label} has a fixed relative flow rate and an on_variable.' - f'This combination is currently not supported.') + raise ValueError( + f'Flow {self.label} has a fixed relative flow rate and an on_variable.' + f'This combination is currently not supported.' + ) return # eq: defining_variable(t) <= size * upper_bound(t) - self.add(self._model.add_constraints( - variable <= self.size * ub_relative, - name=f'{self.label_full}|ub_{variable.name}'), - f'ub_{variable.name}') + self.add( + self._model.add_constraints( + variable <= self.size * ub_relative, name=f'{self.label_full}|ub_{variable.name}' + ), + f'ub_{variable.name}', + ) if self._on_variable is None: # eq: defining_variable(t) >= investment_size * relative_minimum(t) - self.add(self._model.add_constraints( - variable >= self.size * lb_relative, - name=f'{self.label_full}|lb_{variable.name}'), - f'lb_{variable.name}') + self.add( + self._model.add_constraints( + variable >= self.size * lb_relative, name=f'{self.label_full}|lb_{variable.name}' + ), + f'lb_{variable.name}', + ) else: ## 2. Gleichung: Minimum durch Investmentgröße und On # eq: defining_variable(t) >= mega * (On(t)-1) + size * relative_minimum(t) @@ -163,10 +184,12 @@ def _create_bounds_for_defining_variable(self): # eq: - defining_variable(t) + mega * On(t) + size * relative_minimum(t) <= + mega mega = lb_relative * self.parameters.maximum_size on = self._on_variable - self.add(self._model.add_constraints( - variable >= mega * (on - 1) + self.size * lb_relative, - name=f'{self.label_full}|lb_{variable.name}'), - f'lb_{variable.name}') + self.add( + self._model.add_constraints( + variable >= mega * (on - 1) + self.size * lb_relative, name=f'{self.label_full}|lb_{variable.name}' + ), + f'lb_{variable.name}', + ) # anmerkung: Glg bei Spezialfall relative_minimum = 0 redundant zu OnOff ?? @@ -232,17 +255,17 @@ def do_modeling(self): self._model.add_variables( lower=self.parameters.on_hours_total_min if self.parameters.on_hours_total_min is not None else 0, upper=self.parameters.on_hours_total_max if self.parameters.on_hours_total_max is not None else np.inf, - name=f'{self.label_full}|on_hours_total' + name=f'{self.label_full}|on_hours_total', ), - 'on_hours_total' + 'on_hours_total', ) self.add( self._model.add_constraints( self.total_on_hours == (self.on * self._model.hours_per_step).sum(), - name=f'{self.label_full}|on_hours_total' + name=f'{self.label_full}|on_hours_total', ), - 'on_hours_total' + 'on_hours_total', ) self._add_on_constraints() @@ -254,7 +277,7 @@ def do_modeling(self): binary=True, coords=self._model.coords, ), - 'off' + 'off', ) # eq: var_on(t) + var_off(t) = 1 @@ -279,16 +302,25 @@ def do_modeling(self): ) if self.parameters.use_switch_on: - self.switch_on = self.add(self._model.add_variables( - binary=True, name=f'{self.label_full}|switch_on', coords=self._model.coords),'switch_on') + self.switch_on = self.add( + self._model.add_variables(binary=True, name=f'{self.label_full}|switch_on', coords=self._model.coords), + 'switch_on', + ) - self.switch_off = self.add(self._model.add_variables( - binary=True, name=f'{self.label_full}|switch_off', coords=self._model.coords), 'switch_off') + self.switch_off = self.add( + self._model.add_variables(binary=True, name=f'{self.label_full}|switch_off', coords=self._model.coords), + 'switch_off', + ) - self.switch_on_nr = self.add(self._model.add_variables( - upper=self.parameters.switch_on_total_max if self.parameters.switch_on_total_max is not None else np.inf, - name=f'{self.label_full}|switch_on_nr'), - 'switch_on_nr') + self.switch_on_nr = self.add( + self._model.add_variables( + upper=self.parameters.switch_on_total_max + if self.parameters.switch_on_total_max is not None + else np.inf, + name=f'{self.label_full}|switch_on_nr', + ), + 'switch_on_nr', + ) self._add_switch_constraints() @@ -312,19 +344,17 @@ def _add_on_constraints(self): # eq: On(t) * max(epsilon, lower_bound) <= Q_th(t) self.add( self._model.add_constraints( - self.on * np.maximum(CONFIG.modeling.EPSILON, lb) <= def_var, - name=f'{self.label_full}|on_con1' + self.on * np.maximum(CONFIG.modeling.EPSILON, lb) <= def_var, name=f'{self.label_full}|on_con1' ), - 'on_con1' + 'on_con1', ) # eq: Q_th(t) <= Q_th_max * On(t) self.add( self._model.add_constraints( - self.on * np.maximum(CONFIG.modeling.EPSILON, ub) >= def_var, - name=f'{self.label_full}|on_con2' + self.on * np.maximum(CONFIG.modeling.EPSILON, ub) >= def_var, name=f'{self.label_full}|on_con2' ), - 'on_con2' + 'on_con2', ) else: # Bei mehreren Leistungsvariablen: @@ -335,10 +365,9 @@ def _add_on_constraints(self): # eq: On(t) * Epsilon <= sum(alle Leistungen(t)) self.add( self._model.add_constraints( - self.on * lb <= sum(self._defining_variables), - name=f'{self.label_full}|on_con1' + self.on * lb <= sum(self._defining_variables), name=f'{self.label_full}|on_con1' ), - 'on_con1' + 'on_con1', ) ## sum(alle Leistung) >0 -> On = 1|On=0 -> sum(Leistung)=0 @@ -348,9 +377,9 @@ def _add_on_constraints(self): self.add( self._model.add_constraints( self.on * ub >= sum([def_var / nr_of_def_vars for def_var in self._defining_variables]), - name=f'{self.label_full}|on_con2' + name=f'{self.label_full}|on_con2', ), - 'on_con2' + 'on_con2', ) if np.max(ub) > CONFIG.modeling.BIG_BINARY_BOUND: @@ -418,30 +447,34 @@ def _get_duration_in_hours( f'(dt={self._model.hours_per_step[0]}h)!' ) - duration_in_hours = self.add(self._model.add_variables( - lower=0, - upper=maximum_duration.active_data if maximum_duration is not None else mega, - coords=self._model.coords, - name=f'{self.label_full}|{variable_name}'), - variable_name + duration_in_hours = self.add( + self._model.add_variables( + lower=0, + upper=maximum_duration.active_data if maximum_duration is not None else mega, + coords=self._model.coords, + name=f'{self.label_full}|{variable_name}', + ), + variable_name, ) # 1) eq: duration(t) - On(t) * BIG <= 0 - self.add(self._model.add_constraints( - duration_in_hours <= binary_variable * mega, - name=f'{self.label_full}|{variable_name}_con1'), - f'{variable_name}_con1' + self.add( + self._model.add_constraints( + duration_in_hours <= binary_variable * mega, name=f'{self.label_full}|{variable_name}_con1' + ), + f'{variable_name}_con1', ) # 2a) eq: duration(t) - duration(t-1) <= dt(t) # on(t)=1 -> duration(t) - duration(t-1) <= dt(t) # on(t)=0 -> duration(t-1) >= negat. value - self.add(self._model.add_constraints( - duration_in_hours.isel(time=slice(1, None)) - <= - duration_in_hours.isel(time=slice(None, -1)) + self._model.hours_per_step.isel(time=slice(None, -1)), - name=f'{self.label_full}|{variable_name}_con2a'), - f'{variable_name}_con2a' + self.add( + self._model.add_constraints( + duration_in_hours.isel(time=slice(1, None)) + <= duration_in_hours.isel(time=slice(None, -1)) + self._model.hours_per_step.isel(time=slice(None, -1)), + name=f'{self.label_full}|{variable_name}_con2a', + ), + f'{variable_name}_con2a', ) # 2b) eq: dt(t) - BIG * ( 1-On(t) ) <= duration(t) - duration(t-1) @@ -450,13 +483,15 @@ def _get_duration_in_hours( # on(t)=1 -> duration(t)- duration(t-1) >= dt(t) # on(t)=0 -> duration(t)- duration(t-1) >= negat. value - self.add(self._model.add_constraints( - duration_in_hours.isel(time=slice(1, None)) - >= - duration_in_hours.isel(time=slice(None, -1)) + self._model.hours_per_step.isel(time=slice(None, -1)) - + (binary_variable.isel(time=slice(1, None)) - 1) * mega, - name=f'{self.label_full}|{variable_name}_con2b'), - f'{variable_name}_con2b' + self.add( + self._model.add_constraints( + duration_in_hours.isel(time=slice(1, None)) + >= duration_in_hours.isel(time=slice(None, -1)) + + self._model.hours_per_step.isel(time=slice(None, -1)) + + (binary_variable.isel(time=slice(1, None)) - 1) * mega, + name=f'{self.label_full}|{variable_name}_con2b', + ), + f'{variable_name}_con2b', ) # 3) check minimum_duration before switchOff-step @@ -467,13 +502,14 @@ def _get_duration_in_hours( # Note: (previous values before t=1 are not recognised!) # eq: duration(t) >= minimum_duration(t) * [On(t) - On(t+1)] for t=1..(n-1) # eq: -duration(t) + minimum_duration(t) * On(t) - minimum_duration(t) * On(t+1) <= 0 - self.add(self._model.add_constraints( - duration_in_hours - >= - (binary_variable.isel(time=slice(None, -1)) - binary_variable.isel(time=slice(1, None))) - * minimum_duration.isel(time=slice(None, -1)), - name=f'{self.label_full}|{variable_name}_minimum_duration'), - f'{variable_name}_minimum_duration' + self.add( + self._model.add_constraints( + duration_in_hours + >= (binary_variable.isel(time=slice(None, -1)) - binary_variable.isel(time=slice(1, None))) + * minimum_duration.isel(time=slice(None, -1)), + name=f'{self.label_full}|{variable_name}_minimum_duration', + ), + f'{variable_name}_minimum_duration', ) if 0 < previous_duration < minimum_duration.isel(time=0): @@ -481,25 +517,31 @@ def _get_duration_in_hours( # Note: Only if the previous consecutive_duration is smaller than the minimum duration # and the previous_duration is greater 0! # eq: On(t=0) = 1 - self.add(self._model.add_constraints( - binary_variable.isel(time=0) == 1, - name=f'{self.label_full}|{variable_name}_minimum_inital'), - f'{variable_name}_minimum_inital' + self.add( + self._model.add_constraints( + binary_variable.isel(time=0) == 1, name=f'{self.label_full}|{variable_name}_minimum_inital' + ), + f'{variable_name}_minimum_inital', ) # 4) first index: # eq: duration(t=0)= dt(0) * On(0) - self.add(self._model.add_constraints( - duration_in_hours.isel(time=0) == self._model.hours_per_step.isel(time=0) * binary_variable.isel(time=0), - name=f'{self.label_full}|{variable_name}_initial'), - f'{variable_name}_initial' + self.add( + self._model.add_constraints( + duration_in_hours.isel(time=0) + == self._model.hours_per_step.isel(time=0) * binary_variable.isel(time=0), + name=f'{self.label_full}|{variable_name}_initial', + ), + f'{variable_name}_initial', ) return duration_in_hours def _add_switch_constraints(self): assert self.switch_on is not None, f'Switch On Variable of {self.label_full} must be defined to add constraints' - assert self.switch_off is not None, f'Switch Off Variable of {self.label_full} must be defined to add constraints' + assert self.switch_off is not None, ( + f'Switch Off Variable of {self.label_full} must be defined to add constraints' + ) assert self.switch_on_nr is not None, ( f'Nr of Switch On Variable of {self.label_full} must be defined to add constraints' ) @@ -509,41 +551,37 @@ def _add_switch_constraints(self): self.add( self._model.add_constraints( self.switch_on.isel(time=slice(1, None)) - self.switch_off.isel(time=slice(1, None)) - == - self.on.isel(time=slice(1,None)) - self.on.isel(time=slice(None,-1)), - name=f'{self.label_full}|switch_con' + == self.on.isel(time=slice(1, None)) - self.on.isel(time=slice(None, -1)), + name=f'{self.label_full}|switch_con', ), - 'switch_con' + 'switch_con', ) # Initital switch on # eq: SwitchOn(t=0)-SwitchOff(t=0) = On(t=0) - On(t=-1) self.add( self._model.add_constraints( self.switch_on.isel(time=0) - self.switch_off.isel(time=0) - == - self.on.isel(time=0) - self.previous_on_values[-1], - name=f'{self.label_full}|initial_switch_con' + == self.on.isel(time=0) - self.previous_on_values[-1], + name=f'{self.label_full}|initial_switch_con', ), - 'initial_switch_con' + 'initial_switch_con', ) ## Entweder SwitchOff oder SwitchOn # eq: SwitchOn(t) + SwitchOff(t) <= 1.1 self.add( self._model.add_constraints( - self.switch_on + self.switch_off <= 1.1, - name=f'{self.label_full}|switch_on_or_off' + self.switch_on + self.switch_off <= 1.1, name=f'{self.label_full}|switch_on_or_off' ), - 'switch_on_or_off' + 'switch_on_or_off', ) ## Anzahl Starts: # eq: nrSwitchOn = sum(SwitchOn(t)) self.add( self._model.add_constraints( - self.switch_on_nr == self.switch_on.sum(), - name=f'{self.label_full}|switch_on_nr' + self.switch_on_nr == self.switch_on.sum(), name=f'{self.label_full}|switch_on_nr' ), - 'switch_on_nr' + 'switch_on_nr', ) def _create_shares(self): @@ -561,8 +599,10 @@ def _create_shares(self): if effects_per_running_hour != {}: self._model.effects.add_share_to_effects( name=self.label_of_element, - expressions={effect: self.on * factor * self._model.hours_per_step - for effect, factor in effects_per_running_hour.items()}, + expressions={ + effect: self.on * factor * self._model.hours_per_step + for effect, factor in effects_per_running_hour.items() + }, target='operation', ) @@ -607,8 +647,7 @@ def compute_previous_on_states(previous_values: List[Optional[NumericData]], eps @staticmethod def compute_consecutive_duration( - binary_values: NumericData, - hours_per_timestep: Union[int, float, np.ndarray] + binary_values: NumericData, hours_per_timestep: Union[int, float, np.ndarray] ) -> Scalar: """ Computes the final consecutive duration in State 'on' (=1) in hours, from a binary. @@ -673,37 +712,45 @@ def __init__( self._as_time_series = as_time_series def do_modeling(self): - self.inside_piece = self.add(self._model.add_variables( - binary=True, - name=f'{self.label_full}|inside_piece', - coords=self._model.coords if self._as_time_series else None), - 'inside_piece' + self.inside_piece = self.add( + self._model.add_variables( + binary=True, + name=f'{self.label_full}|inside_piece', + coords=self._model.coords if self._as_time_series else None, + ), + 'inside_piece', ) - self.lambda0 = self.add(self._model.add_variables( - lower=0, upper=1, - name=f'{self.label_full}|lambda0', - coords=self._model.coords if self._as_time_series else None), - 'lambda0' + self.lambda0 = self.add( + self._model.add_variables( + lower=0, + upper=1, + name=f'{self.label_full}|lambda0', + coords=self._model.coords if self._as_time_series else None, + ), + 'lambda0', ) - self.lambda1 = self.add(self._model.add_variables( - lower=0, upper=1, - name=f'{self.label_full}|lambda1', - coords=self._model.coords if self._as_time_series else None), - 'lambda1' + self.lambda1 = self.add( + self._model.add_variables( + lower=0, + upper=1, + name=f'{self.label_full}|lambda1', + coords=self._model.coords if self._as_time_series else None, + ), + 'lambda1', ) # eq: lambda0(t) + lambda1(t) = inside_piece(t) - self.add(self._model.add_constraints( - self.inside_piece == self.lambda0 + self.lambda1, - name=f'{self.label_full}|inside_piece'), - 'inside_piece' + self.add( + self._model.add_constraints( + self.inside_piece == self.lambda0 + self.lambda1, name=f'{self.label_full}|inside_piece' + ), + 'inside_piece', ) class PiecewiseModel(Model): - def __init__( self, model: SystemModel, @@ -738,10 +785,10 @@ def do_modeling(self): for i in range(len(list(self._piecewise_variables.values())[0])): new_piece = self.add( PieceModel( - model=self._model, - label_of_element=self.label_of_element, - label=f'Piece_{i}', - as_time_series=self._as_time_series, + model=self._model, + label_of_element=self.label_of_element, + label=f'Piece_{i}', + as_time_series=self._as_time_series, ) ) self.pieces.append(new_piece) @@ -749,12 +796,20 @@ def do_modeling(self): for var_name in self._piecewise_variables: variable = self._model.variables[var_name] - self.add(self._model.add_constraints( - variable == sum([piece_model.lambda0 * piece_bounds.start - + piece_model.lambda1 * piece_bounds.end - for piece_model, piece_bounds in zip(self.pieces, self._piecewise_variables[var_name], strict=False)]), - name=f'{self.label_full}|{var_name}_lambda'), - f'{var_name}_lambda' + self.add( + self._model.add_constraints( + variable + == sum( + [ + piece_model.lambda0 * piece_bounds.start + piece_model.lambda1 * piece_bounds.end + for piece_model, piece_bounds in zip( + self.pieces, self._piecewise_variables[var_name], strict=False + ) + ] + ), + name=f'{self.label_full}|{var_name}_lambda', + ), + f'{var_name}_lambda', ) # a) eq: Segment1.onSeg(t) + Segment2.onSeg(t) + ... = 1 Aufenthalt nur in Segmenten erlaubt @@ -763,20 +818,22 @@ def do_modeling(self): self.zero_point = self._zero_point rhs = self.zero_point elif self._zero_point is True: - self.zero_point = self.add(self._model.add_variables( - coords=self._model.coords, - binary=True, - name=f'{self.label_full}|zero_point'), - 'zero_point' + self.zero_point = self.add( + self._model.add_variables( + coords=self._model.coords, binary=True, name=f'{self.label_full}|zero_point' + ), + 'zero_point', ) rhs = self.zero_point else: rhs = 1 - self.add(self._model.add_constraints( - sum([piece.inside_piece for piece in self.pieces]) <= rhs, - name=f'{self.label_full}|{variable.name}_single_segment'), - 'single_segment' + self.add( + self._model.add_constraints( + sum([piece.inside_piece for piece in self.pieces]) <= rhs, + name=f'{self.label_full}|{variable.name}_single_segment', + ), + 'single_segment', ) @@ -818,25 +875,31 @@ def do_modeling(self): self._model.add_variables( lower=self._total_min, upper=self._total_max, coords=None, name=f'{self.label_full}|total' ), - 'total' + 'total', ) # eq: sum = sum(share_i) # skalar - self._eq_total = self.add(self._model.add_constraints(self.total == 0, name=f'{self.label_full}|total'), 'total') + self._eq_total = self.add( + self._model.add_constraints(self.total == 0, name=f'{self.label_full}|total'), 'total' + ) if self._shares_are_time_series: self.total_per_timestep = self.add( - self._model.add_variables( - lower=-np.inf if (self._min_per_hour is None) else np.multiply(self._min_per_hour, self._model.hours_per_step), - upper=np.inf if (self._max_per_hour is None) else np.multiply(self._max_per_hour, self._model.hours_per_step), + self._model.add_variables( + lower=-np.inf + if (self._min_per_hour is None) + else np.multiply(self._min_per_hour, self._model.hours_per_step), + upper=np.inf + if (self._max_per_hour is None) + else np.multiply(self._max_per_hour, self._model.hours_per_step), coords=self._model.coords, - name=f'{self.label_full}|total_per_timestep' + name=f'{self.label_full}|total_per_timestep', ), - 'total_per_timestep' + 'total_per_timestep', ) self._eq_total_per_timestep = self.add( self._model.add_constraints(self.total_per_timestep == 0, name=f'{self.label_full}|total_per_timestep'), - 'total_per_timestep' + 'total_per_timestep', ) # Add it to the total @@ -862,16 +925,17 @@ def add_share( else: self.shares[name] = self.add( self._model.add_variables( - coords=None if isinstance(expression, linopy.LinearExpression) and expression.ndim == 0 or not isinstance(expression, linopy.LinearExpression) else self._model.coords, - name=f'{name}->{self.label_full}' + coords=None + if isinstance(expression, linopy.LinearExpression) + and expression.ndim == 0 + or not isinstance(expression, linopy.LinearExpression) + else self._model.coords, + name=f'{name}->{self.label_full}', ), - name + name, ) self.share_constraints[name] = self.add( - self._model.add_constraints( - self.shares[name] == expression, name=f'{name}->{self.label_full}' - ), - name + self._model.add_constraints(self.shares[name] == expression, name=f'{name}->{self.label_full}'), name ) if self.shares[name].ndim == 0: self._eq_total.lhs -= self.shares[name] @@ -902,16 +966,16 @@ def __init__( def do_modeling(self): self.shares = { - effect: self.add(self._model.add_variables( - coords=None, - name=f'{self.label_full}|{effect}'), - f'{effect}' - ) for effect in self._piecewise_shares + effect: self.add(self._model.add_variables(coords=None, name=f'{self.label_full}|{effect}'), f'{effect}') + for effect in self._piecewise_shares } piecewise_variables = { self._piecewise_origin[0]: self._piecewise_origin[1], - **{self.shares[effect_label].name: self._piecewise_shares[effect_label] for effect_label in self._piecewise_shares} + **{ + self.shares[effect_label].name: self._piecewise_shares[effect_label] + for effect_label in self._piecewise_shares + }, } self.piecewise_model = self.add( @@ -930,7 +994,7 @@ def do_modeling(self): # Shares self._model.effects.add_share_to_effects( name=self.label_of_element, - expressions={effect: variable*1 for effect, variable in self.shares.items()}, + expressions={effect: variable * 1 for effect, variable in self.shares.items()}, target='invest', ) @@ -953,15 +1017,26 @@ class PreventSimultaneousUsageModel(Model): # --> könnte man auch umsetzen (statt force_on_variable() für die Flows, aber sollte aufs selbe wie "new" kommen) """ - def __init__(self, model: SystemModel, variables: List[linopy.Variable], label_of_element: str, label: str = 'PreventSimultaneousUsage'): + def __init__( + self, + model: SystemModel, + variables: List[linopy.Variable], + label_of_element: str, + label: str = 'PreventSimultaneousUsage', + ): super().__init__(model, label_of_element, label) self._simultanious_use_variables = variables - assert len(self._simultanious_use_variables) >= 2, f'Model {self.__class__.__name__} must get at least two variables' + assert len(self._simultanious_use_variables) >= 2, ( + f'Model {self.__class__.__name__} must get at least two variables' + ) for variable in self._simultanious_use_variables: # classic assert variable.attrs['binary'], f'Variable {variable} must be binary for use in {self.__class__.__name__}' def do_modeling(self): # eq: sum(flow_i.on(t)) <= 1.1 (1 wird etwas größer gewählt wg. Binärvariablengenauigkeit) - self.add(self._model.add_constraints(sum(self._simultanious_use_variables) <= 1.1, - name=f'{self.label_full}|prevent_simultaneous_use'), - 'prevent_simultaneous_use') + self.add( + self._model.add_constraints( + sum(self._simultanious_use_variables) <= 1.1, name=f'{self.label_full}|prevent_simultaneous_use' + ), + 'prevent_simultaneous_use', + ) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 9921e3e79..93720de60 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -33,10 +33,10 @@ class FlowSystem: """ def __init__( - self, - timesteps: pd.DatetimeIndex, - hours_of_last_timestep: Optional[float] = None, - hours_of_previous_timesteps: Optional[Union[int, float, np.ndarray]] = None, + self, + timesteps: pd.DatetimeIndex, + hours_of_last_timestep: Optional[float] = None, + hours_of_previous_timesteps: Optional[Union[int, float, np.ndarray]] = None, ): """ Args: @@ -66,14 +66,15 @@ def from_dataset(cls, ds: xr.Dataset): timesteps_extra = pd.DatetimeIndex(ds.attrs['timesteps_extra'], name='time') hours_of_last_timestep = TimeSeriesCollection.calculate_hours_per_timestep(timesteps_extra).isel(time=-1).item() - flow_system = FlowSystem(timesteps=timesteps_extra[:-1], - hours_of_last_timestep=hours_of_last_timestep, - hours_of_previous_timesteps=ds.attrs['hours_of_previous_timesteps'], - ) + flow_system = FlowSystem( + timesteps=timesteps_extra[:-1], + hours_of_last_timestep=hours_of_last_timestep, + hours_of_previous_timesteps=ds.attrs['hours_of_previous_timesteps'], + ) structure = fx_io.insert_dataarray({key: ds.attrs[key] for key in ['components', 'buses', 'effects']}, ds) flow_system.add_elements( - * [Bus.from_dict(bus) for bus in structure['buses'].values()] + *[Bus.from_dict(bus) for bus in structure['buses'].values()] + [Effect.from_dict(effect) for effect in structure['effects'].values()] + [CLASS_REGISTRY[comp['__class__']].from_dict(comp) for comp in structure['components'].values()] ) @@ -90,18 +91,15 @@ def from_dict(cls, data: Dict) -> 'FlowSystem': timesteps_extra = pd.DatetimeIndex(data['timesteps_extra'], name='time') hours_of_last_timestep = TimeSeriesCollection.calculate_hours_per_timestep(timesteps_extra).isel(time=-1).item() - flow_system = FlowSystem(timesteps=timesteps_extra[:-1], - hours_of_last_timestep=hours_of_last_timestep, - hours_of_previous_timesteps=data['hours_of_previous_timesteps'], - ) - - flow_system.add_elements( - *[Bus.from_dict(bus) for bus in data['buses'].values()] + flow_system = FlowSystem( + timesteps=timesteps_extra[:-1], + hours_of_last_timestep=hours_of_last_timestep, + hours_of_previous_timesteps=data['hours_of_previous_timesteps'], ) - flow_system.add_elements( - *[Effect.from_dict(effect) for effect in data['effects'].values()] - ) + flow_system.add_elements(*[Bus.from_dict(bus) for bus in data['buses'].values()]) + + flow_system.add_elements(*[Effect.from_dict(effect) for effect in data['effects'].values()]) flow_system.add_elements( *[CLASS_REGISTRY[comp['__class__']].from_dict(comp) for comp in data['components'].values()] @@ -129,7 +127,7 @@ def add_elements(self, *elements: Element) -> None: if self._connected: warnings.warn( 'You are adding elements to an already connected FlowSystem. This is not recommended (But it works).', - stacklevel=2 + stacklevel=2, ) self._connected = False for new_element in list(elements): @@ -140,7 +138,9 @@ def add_elements(self, *elements: Element) -> None: elif isinstance(new_element, Bus): self._add_buses(new_element) else: - raise TypeError(f'Tried to add incompatible object to FlowSystem: {type(new_element)=}: {new_element=} ') + raise TypeError( + f'Tried to add incompatible object to FlowSystem: {type(new_element)=}: {new_element=} ' + ) def to_json(self, path: Union[str, pathlib.Path]): """ @@ -157,20 +157,19 @@ def to_json(self, path: Union[str, pathlib.Path]): def as_dict(self, data_mode: Literal['data', 'name', 'stats'] = 'data') -> Dict: """Convert the object to a dictionary representation.""" data = { - "components": { + 'components': { comp.label: comp.to_dict() for comp in sorted(self.components.values(), key=lambda component: component.label.upper()) }, - "buses": { - bus.label: bus.to_dict() - for bus in sorted(self.buses.values(), key=lambda bus: bus.label.upper()) + 'buses': { + bus.label: bus.to_dict() for bus in sorted(self.buses.values(), key=lambda bus: bus.label.upper()) }, - "effects": { + 'effects': { effect.label: effect.to_dict() for effect in sorted(self.effects, key=lambda effect: effect.label.upper()) }, - "timesteps_extra": [date.isoformat() for date in self.time_series_collection.timesteps_extra], - "hours_of_previous_timesteps": self.time_series_collection.hours_of_previous_timesteps, + 'timesteps_extra': [date.isoformat() for date in self.time_series_collection.timesteps_extra], + 'hours_of_previous_timesteps': self.time_series_collection.hours_of_previous_timesteps, } if data_mode == 'data': return fx_io.replace_timeseries(data, 'data') @@ -273,10 +272,10 @@ def transform_data(self): element.transform_data(self) def create_time_series( - self, - name: str, - data: Optional[Union[NumericData, TimeSeriesData, TimeSeries]], - needs_extra_timestep: bool = False, + self, + name: str, + data: Optional[Union[NumericData, TimeSeriesData, TimeSeries]], + needs_extra_timestep: bool = False, ) -> Optional[TimeSeries]: """ Tries to create a TimeSeries from NumericData Data and adds it to the time_series_collection @@ -292,21 +291,18 @@ def create_time_series( if data in self.time_series_collection: return data return self.time_series_collection.create_time_series( - data=data.active_data, - name=name, - needs_extra_timestep=needs_extra_timestep + data=data.active_data, name=name, needs_extra_timestep=needs_extra_timestep ) return self.time_series_collection.create_time_series( - data=data, - name=name, - needs_extra_timestep=needs_extra_timestep + data=data, name=name, needs_extra_timestep=needs_extra_timestep ) - def create_effect_time_series(self, - label_prefix: Optional[str], - effect_values: EffectValuesUser, - label_suffix: Optional[str] = None, - ) -> Optional[EffectTimeSeries]: + def create_effect_time_series( + self, + label_prefix: Optional[str], + effect_values: EffectValuesUser, + label_suffix: Optional[str] = None, + ) -> Optional[EffectTimeSeries]: """ Transform EffectValues to EffectTimeSeries. Creates a TimeSeries for each key in the nested_values dictionary, using the value as the data. @@ -320,10 +316,7 @@ def create_effect_time_series(self, return None return { - effect: self.create_time_series( - '|'.join(filter(None, [label_prefix, effect, label_suffix])), - value - ) + effect: self.create_time_series('|'.join(filter(None, [label_prefix, effect, label_suffix])), value) for effect, value in effect_values.items() } @@ -376,19 +369,24 @@ def _connect_network(self): f'This is deprecated and will be removed in the future. ' f'Please pass the Bus.label to the Flow and the Bus to the FlowSystem instead.', UserWarning, - stacklevel=1) + stacklevel=1, + ) # Connect Buses bus = self.buses.get(flow.bus) if bus is None: - raise KeyError(f'Bus {flow.bus} not found in the FlowSystem, but used by "{flow.label_full}". ' - f'Please add it first.') + raise KeyError( + f'Bus {flow.bus} not found in the FlowSystem, but used by "{flow.label_full}". ' + f'Please add it first.' + ) if flow.is_input_in_component and flow not in bus.outputs: bus.outputs.append(flow) elif not flow.is_input_in_component and flow not in bus.inputs: bus.inputs.append(flow) - logger.debug(f'Connected {len(self.buses)} Buses and {len(self.components)} ' - f'via {len(self.flows)} Flows inside the FlowSystem.') + logger.debug( + f'Connected {len(self.buses)} Buses and {len(self.components)} ' + f'via {len(self.flows)} Flows inside the FlowSystem.' + ) self._connected = True def __repr__(self): diff --git a/flixopt/interface.py b/flixopt/interface.py index 013b02023..f9dbeb518 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -97,8 +97,8 @@ def __init__(self, piecewise_origin: Piecewise, piecewise_shares: Dict[str, Piec def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): raise NotImplementedError('PiecewiseEffects is not yet implemented for non scalar shares') - #self.piecewise_origin.transform_data(flow_system, f'{name_prefix}|PiecewiseEffects|origin') - #for name, piecewise in self.piecewise_shares.items(): + # self.piecewise_origin.transform_data(flow_system, f'{name_prefix}|PiecewiseEffects|origin') + # for name, piecewise in self.piecewise_shares.items(): # piecewise.transform_data(flow_system, f'{name_prefix}|PiecewiseEffects|{name}') diff --git a/flixopt/io.py b/flixopt/io.py index 3021a3519..35d927136 100644 --- a/flixopt/io.py +++ b/flixopt/io.py @@ -25,13 +25,13 @@ def replace_timeseries(obj, mode: Literal['name', 'stats', 'data'] = 'name'): if obj.all_equal: return obj.active_data.values[0].item() elif mode == 'name': - return f"::::{obj.name}" + return f'::::{obj.name}' elif mode == 'stats': return obj.stats elif mode == 'data': return obj else: - raise ValueError(f"Invalid mode {mode}") + raise ValueError(f'Invalid mode {mode}') else: return obj @@ -42,7 +42,7 @@ def insert_dataarray(obj, ds: xr.Dataset): return {k: insert_dataarray(v, ds) for k, v in obj.items()} elif isinstance(obj, list): return [insert_dataarray(v, ds) for v in obj] - elif isinstance(obj, str) and obj.startswith("::::"): + elif isinstance(obj, str) and obj.startswith('::::'): da = ds[obj[4:]] if da.isel(time=-1).isnull(): return da.isel(time=slice(0, -1)) @@ -55,12 +55,14 @@ def remove_none_and_empty(obj): """Recursively removes None and empty dicts and lists values from a dictionary or list.""" if isinstance(obj, dict): - return {k: remove_none_and_empty(v) for k, v in obj.items() if - not (v is None or (isinstance(v, (list, dict)) and not v))} + return { + k: remove_none_and_empty(v) + for k, v in obj.items() + if not (v is None or (isinstance(v, (list, dict)) and not v)) + } elif isinstance(obj, list): - return [remove_none_and_empty(v) for v in obj if - not (v is None or (isinstance(v, (list, dict)) and not v))] + return [remove_none_and_empty(v) for v in obj if not (v is None or (isinstance(v, (list, dict)) and not v))] else: return obj @@ -158,8 +160,8 @@ def document_linopy_model(model: linopy.Model, path: pathlib.Path = None) -> Dic 'termination_condition': model.termination_condition, 'status': model.status, 'nvars': model.nvars, - 'nvarsbin': model.binaries.nvars if len(model.binaries) > 0 else 0, #Temporary, waiting for linopy to fix - 'nvarscont': model.continuous.nvars if len(model.continuous) > 0 else 0, #Temporary, waiting for linopy to fix + 'nvarsbin': model.binaries.nvars if len(model.binaries) > 0 else 0, # Temporary, waiting for linopy to fix + 'nvarscont': model.continuous.nvars if len(model.continuous) > 0 else 0, # Temporary, waiting for linopy to fix 'ncons': model.ncons, 'variables': {variable_name: variable.__repr__() for variable_name, variable in model.variables.items()}, 'constraints': { @@ -171,11 +173,12 @@ def document_linopy_model(model: linopy.Model, path: pathlib.Path = None) -> Dic 'infeasible_constraints': '', } - if model.status == 'warning': + if model.status == 'warning': logger.critical(f'The model has a warning status {model.status=}. Trying to extract infeasibilities') try: import io from contextlib import redirect_stdout + f = io.StringIO() # Redirect stdout to our buffer @@ -184,7 +187,9 @@ def document_linopy_model(model: linopy.Model, path: pathlib.Path = None) -> Dic documentation['infeasible_constraints'] = f.getvalue() except NotImplementedError: - logger.critical('Infeasible constraints could not get retrieved. This functionality is only availlable with gurobi') + logger.critical( + 'Infeasible constraints could not get retrieved. This functionality is only availlable with gurobi' + ) documentation['infeasible_constraints'] = 'Not possible to retrieve infeasible constraints' if path is not None: @@ -196,9 +201,9 @@ def document_linopy_model(model: linopy.Model, path: pathlib.Path = None) -> Dic def save_dataset_to_netcdf( - ds: xr.Dataset, - path: Union[str, pathlib.Path], - compression: int = 0, + ds: xr.Dataset, + path: Union[str, pathlib.Path], + compression: int = 0, ) -> None: """ Save a dataset to a netcdf file. Store the attrs as a json string in the 'attrs' attribute. @@ -219,14 +224,17 @@ def save_dataset_to_netcdf( if importlib.util.find_spec('netCDF4') is not None: apply_encoding = True else: - logger.warning('Dataset was exported without compression due to missing dependency "netcdf4".' - 'Install netcdf4 via `pip install netcdf4`.') + logger.warning( + 'Dataset was exported without compression due to missing dependency "netcdf4".' + 'Install netcdf4 via `pip install netcdf4`.' + ) ds = ds.copy(deep=True) ds.attrs = {'attrs': json.dumps(ds.attrs)} ds.to_netcdf( path, - encoding=None if not apply_encoding else {data_var: {"zlib": True, "complevel": 5} - for data_var in ds.data_vars} + encoding=None + if not apply_encoding + else {data_var: {'zlib': True, 'complevel': 5} for data_var in ds.data_vars}, ) diff --git a/flixopt/linear_converters.py b/flixopt/linear_converters.py index 2c2e254bf..3fd032632 100644 --- a/flixopt/linear_converters.py +++ b/flixopt/linear_converters.py @@ -297,7 +297,11 @@ def COP(self, value): # noqa: N802 def check_bounds( - value: NumericDataTS, parameter_label: str, element_label: str, lower_bound: NumericDataTS, upper_bound: NumericDataTS + value: NumericDataTS, + parameter_label: str, + element_label: str, + lower_bound: NumericDataTS, + upper_bound: NumericDataTS, ) -> None: """ Check if the value is within the bounds. The bounds are exclusive. diff --git a/flixopt/plotting.py b/flixopt/plotting.py index e54a7b558..1d376a3b9 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -25,11 +25,11 @@ # Define the colors for the 'portland' colormap in matplotlib _portland_colors = [ - [12/255, 51/255, 131/255], # Dark blue - [10/255, 136/255, 186/255], # Light blue - [242/255, 211/255, 56/255], # Yellow - [242/255, 143/255, 56/255], # Orange - [217/255, 30/255, 30/255] # Red + [12 / 255, 51 / 255, 131 / 255], # Dark blue + [10 / 255, 136 / 255, 186 / 255], # Light blue + [242 / 255, 211 / 255, 56 / 255], # Yellow + [242 / 255, 143 / 255, 56 / 255], # Orange + [217 / 255, 30 / 255, 30 / 255], # Red ] plt.colormaps.register(mcolors.LinearSegmentedColormap.from_list('portland', _portland_colors)) @@ -60,7 +60,7 @@ def __init__(self, engine: PlottingEngine = 'plotly', default_colormap: str = 'v engine: The plotting engine to use ('plotly' or 'matplotlib') default_colormap: Default colormap to use if none is specified """ - if engine not in ["plotly", "matplotlib"]: + if engine not in ['plotly', 'matplotlib']: raise TypeError(f'engine must be "plotly" or "matplotlib", but is {engine}') self.engine = engine self.default_colormap = default_colormap @@ -80,9 +80,7 @@ def _generate_colors_from_colormap(self, colormap_name: str, num_colors: int) -> try: colorscale = px.colors.get_colorscale(colormap_name) except PlotlyError as e: - logger.warning( - f"Colorscale '{colormap_name}' not found in Plotly. Using {self.default_colormap}: {e}" - ) + logger.warning(f"Colorscale '{colormap_name}' not found in Plotly. Using {self.default_colormap}: {e}") colorscale = px.colors.get_colorscale(self.default_colormap) # Generate evenly spaced points @@ -806,7 +804,7 @@ def pie_with_plotly( """ if data.empty: - logger.warning("Empty DataFrame provided for pie chart. Returning empty figure.") + logger.warning('Empty DataFrame provided for pie chart. Returning empty figure.') return go.Figure() # Create a copy to avoid modifying the original DataFrame @@ -814,7 +812,7 @@ def pie_with_plotly( # Check if any negative values and warn if (data_copy < 0).any().any(): - logger.warning("Negative values detected in data. Using absolute values for pie chart.") + logger.warning('Negative values detected in data. Using absolute values for pie chart.') data_copy = data_copy.abs() # If data has multiple rows, sum them to get total for each column @@ -852,7 +850,7 @@ def pie_with_plotly( legend_title=legend_title, plot_bgcolor='rgba(0,0,0,0)', # Transparent background paper_bgcolor='rgba(0,0,0,0)', # Transparent paper background - font=dict(size=14), # Increase font size for better readability + font=dict(size=14), # Increase font size for better readability ) return fig @@ -896,7 +894,7 @@ def pie_with_matplotlib( """ if data.empty: - logger.warning("Empty DataFrame provided for pie chart. Returning empty figure.") + logger.warning('Empty DataFrame provided for pie chart. Returning empty figure.') if fig is None or ax is None: fig, ax = plt.subplots(figsize=figsize) return fig, ax @@ -906,7 +904,7 @@ def pie_with_matplotlib( # Check if any negative values and warn if (data_copy < 0).any().any(): - logger.warning("Negative values detected in data. Using absolute values for pie chart.") + logger.warning('Negative values detected in data. Using absolute values for pie chart.') data_copy = data_copy.abs() # If data has multiple rows, sum them to get total for each column @@ -963,13 +961,7 @@ def pie_with_matplotlib( # Create a legend if there are many segments if len(labels) > 6: - ax.legend( - wedges, - labels, - title=legend_title, - loc="center left", - bbox_to_anchor=(1, 0, 0.5, 1) - ) + ax.legend(wedges, labels, title=legend_title, loc='center left', bbox_to_anchor=(1, 0, 0.5, 1)) # Apply tight layout fig.tight_layout() @@ -1022,9 +1014,7 @@ def dual_pie_with_plotly( # Create a subplot figure fig = make_subplots( - rows=1, cols=2, specs=[[{'type': 'pie'}, {'type': 'pie'}]], - subplot_titles=subtitles, - horizontal_spacing=0.05 + rows=1, cols=2, specs=[[{'type': 'pie'}, {'type': 'pie'}]], subplot_titles=subtitles, horizontal_spacing=0.05 ) # Process series to handle negative values and apply minimum percentage threshold @@ -1301,7 +1291,7 @@ def export_figure( default_filetype: Optional[str] = None, user_path: Optional[pathlib.Path] = None, show: bool = True, - save: bool = False + save: bool = False, ) -> Union[plotly.graph_objs.Figure, Tuple[plt.Figure, plt.Axes]]: """ Export a figure to a file and or show it. diff --git a/flixopt/results.py b/flixopt/results.py index 056d57d18..d9eb5a654 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -51,12 +51,13 @@ class CalculationResults: Example: Load results from saved files: - >>> results = CalculationResults.from_file("results_dir", "optimization_run_1") - >>> element_result = results["Boiler"] - >>> results.plot_heatmap("Boiler(Q_th)|flow_rate") + >>> results = CalculationResults.from_file('results_dir', 'optimization_run_1') + >>> element_result = results['Boiler'] + >>> results.plot_heatmap('Boiler(Q_th)|flow_rate') >>> results.to_file(compression=5) - >>> results.to_file(folder="new_results_dir", compression=5) # Save the results to a new folder + >>> results.to_file(folder='new_results_dir', compression=5) # Save the results to a new folder """ + @classmethod def from_file(cls, folder: Union[str, pathlib.Path], name: str): """Create CalculationResults instance by loading from saved files. @@ -89,12 +90,14 @@ def from_file(cls, folder: Union[str, pathlib.Path], name: str): with open(paths.summary, 'r', encoding='utf-8') as f: summary = yaml.load(f, Loader=yaml.FullLoader) - return cls(solution=fx_io.load_dataset_from_netcdf(paths.solution), - flow_system=fx_io.load_dataset_from_netcdf(paths.flow_system), - name=name, - folder=folder, - model=model, - summary=summary) + return cls( + solution=fx_io.load_dataset_from_netcdf(paths.solution), + flow_system=fx_io.load_dataset_from_netcdf(paths.flow_system), + name=name, + folder=folder, + model=model, + summary=summary, + ) @classmethod def from_calculation(cls, calculation: 'Calculation'): @@ -146,14 +149,15 @@ def __init__( self.name = name self.model = model self.folder = pathlib.Path(folder) if folder is not None else pathlib.Path.cwd() / 'results' - self.components = {label: ComponentResults.from_json(self, infos) - for label, infos in self.solution.attrs['Components'].items()} + self.components = { + label: ComponentResults.from_json(self, infos) for label, infos in self.solution.attrs['Components'].items() + } - self.buses = {label: BusResults.from_json(self, infos) - for label, infos in self.solution.attrs['Buses'].items()} + self.buses = {label: BusResults.from_json(self, infos) for label, infos in self.solution.attrs['Buses'].items()} - self.effects = {label: EffectResults.from_json(self, infos) - for label, infos in self.solution.attrs['Effects'].items()} + self.effects = { + label: EffectResults.from_json(self, infos) for label, infos in self.solution.attrs['Effects'].items() + } self.timesteps_extra = self.solution.indexes['time'] self.hours_per_timestep = TimeSeriesCollection.calculate_hours_per_timestep(self.timesteps_extra) @@ -174,12 +178,12 @@ def storages(self) -> List['ComponentResults']: @property def objective(self) -> float: - """ The objective result of the optimization. """ + """The objective result of the optimization.""" return self.summary['Main Results']['Objective'] @property def variables(self) -> linopy.Variables: - """ The variables of the optimization. Only available if the linopy.Model is available. """ + """The variables of the optimization. Only available if the linopy.Model is available.""" if self.model is None: raise ValueError('The linopy model is not available.') return self.model.variables @@ -191,9 +195,9 @@ def constraints(self) -> linopy.Constraints: raise ValueError('The linopy model is not available.') return self.model.constraints - def filter_solution(self, - variable_dims: Optional[Literal['scalar', 'time']] = None, - element: Optional[str] = None) -> xr.Dataset: + def filter_solution( + self, variable_dims: Optional[Literal['scalar', 'time']] = None, element: Optional[str] = None + ) -> xr.Dataset: """ Filter the solution to a specific variable dimension and element. If no element is specified, all elements are included. @@ -214,7 +218,7 @@ def plot_heatmap( color_map: str = 'portland', save: Union[bool, pathlib.Path] = False, show: bool = True, - engine: plotting.PlottingEngine = 'plotly' + engine: plotting.PlottingEngine = 'plotly', ) -> Union[plotly.graph_objs.Figure, Tuple[plt.Figure, plt.Axes]]: return plot_heatmap( dataarray=self.solution[variable_name], @@ -229,19 +233,20 @@ def plot_heatmap( ) def plot_network( - self, - controls: Union[ - bool, - List[ - Literal['nodes', 'edges', 'layout', 'interaction', 'manipulation', 'physics', 'selection', 'renderer'] - ], - ] = True, - path: Optional[pathlib.Path] = None, - show: bool = False + self, + controls: Union[ + bool, + List[ + Literal['nodes', 'edges', 'layout', 'interaction', 'manipulation', 'physics', 'selection', 'renderer'] + ], + ] = True, + path: Optional[pathlib.Path] = None, + show: bool = False, ) -> 'pyvis.network.Network': - """ See flixopt.flow_system.FlowSystem.plot_network """ + """See flixopt.flow_system.FlowSystem.plot_network""" try: from .flow_system import FlowSystem + flow_system = FlowSystem.from_dataset(self.flow_system) except Exception as e: logger.critical(f'Could not reconstruct the flow_system from dataset: {e}') @@ -274,7 +279,9 @@ def to_file( try: folder.mkdir(parents=False) except FileNotFoundError as e: - raise FileNotFoundError(f'Folder {folder} and its parent do not exist. Please create them first.') from e + raise FileNotFoundError( + f'Folder {folder} and its parent do not exist. Please create them first.' + ) from e paths = fx_io.CalculationResultsPaths(folder, name) @@ -302,16 +309,11 @@ def to_file( class _ElementResults: @classmethod def from_json(cls, calculation_results, json_data: Dict) -> '_ElementResults': - return cls(calculation_results, - json_data['label'], - json_data['variables'], - json_data['constraints']) - - def __init__(self, - calculation_results: CalculationResults, - label: str, - variables: List[str], - constraints: List[str]): + return cls(calculation_results, json_data['label'], json_data['variables'], json_data['constraints']) + + def __init__( + self, calculation_results: CalculationResults, label: str, variables: List[str], constraints: List[str] + ): self._calculation_results = calculation_results self.label = label self._variable_names = variables @@ -355,21 +357,25 @@ def filter_solution(self, variable_dims: Optional[Literal['scalar', 'time']] = N class _NodeResults(_ElementResults): @classmethod - def from_json(cls, calculation_results, json_data: Dict) -> '_NodeResults': - return cls(calculation_results, - json_data['label'], - json_data['variables'], - json_data['constraints'], - json_data['inputs'], - json_data['outputs']) - - def __init__(self, - calculation_results: CalculationResults, - label: str, - variables: List[str], - constraints: List[str], - inputs: List[str], - outputs: List[str]): + def from_json(cls, calculation_results, json_data: Dict) -> '_NodeResults': + return cls( + calculation_results, + json_data['label'], + json_data['variables'], + json_data['constraints'], + json_data['inputs'], + json_data['outputs'], + ) + + def __init__( + self, + calculation_results: CalculationResults, + label: str, + variables: List[str], + constraints: List[str], + inputs: List[str], + outputs: List[str], + ): super().__init__(calculation_results, label, variables, constraints) self.inputs = inputs self.outputs = outputs @@ -393,7 +399,7 @@ def plot_node_balance( self.node_balance(with_last_timestep=True).to_dataframe(), colors=colors, mode='area', - title=f'Flow rates of {self.label}' + title=f'Flow rates of {self.label}', ) default_filetype = '.html' elif engine == 'matplotlib': @@ -423,7 +429,7 @@ def plot_node_balance_pie( text_info: str = 'percent+label+value', save: Union[bool, pathlib.Path] = False, show: bool = True, - engine: plotting.PlottingEngine = 'plotly' + engine: plotting.PlottingEngine = 'plotly', ) -> plotly.graph_objects.Figure: """ Plots a pie chart of the flow hours of the inputs and outputs of buses or components. @@ -436,18 +442,24 @@ def plot_node_balance_pie( show: Whether to show the figure. engine: Plotting engine to use. Only 'plotly' is implemented atm. """ - inputs = sanitize_dataset( - ds=self.solution[self.inputs], - threshold=1e-5, - drop_small_vars=True, - zero_small_values=True, - ) * self._calculation_results.hours_per_timestep - outputs = sanitize_dataset( - ds=self.solution[self.outputs], - threshold=1e-5, - drop_small_vars=True, - zero_small_values=True, - ) * self._calculation_results.hours_per_timestep + inputs = ( + sanitize_dataset( + ds=self.solution[self.inputs], + threshold=1e-5, + drop_small_vars=True, + zero_small_values=True, + ) + * self._calculation_results.hours_per_timestep + ) + outputs = ( + sanitize_dataset( + ds=self.solution[self.outputs], + threshold=1e-5, + drop_small_vars=True, + zero_small_values=True, + ) + * self._calculation_results.hours_per_timestep + ) if engine == 'plotly': figure_like = plotting.dual_pie_with_plotly( @@ -490,17 +502,21 @@ def node_balance( negate_inputs: bool = True, negate_outputs: bool = False, threshold: Optional[float] = 1e-5, - with_last_timestep: bool = False + with_last_timestep: bool = False, ) -> xr.Dataset: return sanitize_dataset( ds=self.solution[self.inputs + self.outputs], threshold=threshold, timesteps=self._calculation_results.timesteps_extra if with_last_timestep else None, negate=( - self.outputs + self.inputs if negate_outputs and negate_inputs - else self.outputs if negate_outputs - else self.inputs if negate_inputs - else None), + self.outputs + self.inputs + if negate_outputs and negate_inputs + else self.outputs + if negate_outputs + else self.inputs + if negate_inputs + else None + ), ) @@ -521,7 +537,7 @@ def _charge_state(self) -> str: @property def charge_state(self) -> xr.DataArray: - """ Get the solution of the charge state of the Storage. """ + """Get the solution of the charge state of the Storage.""" if not self.is_storage: raise ValueError(f'Cant get charge_state. "{self.label}" is not a storage') return self.solution[self._charge_state] @@ -531,7 +547,7 @@ def plot_charge_state( save: Union[bool, pathlib.Path] = False, show: bool = True, colors: plotting.ColorType = 'viridis', - engine: plotting.PlottingEngine = 'plotly' + engine: plotting.PlottingEngine = 'plotly', ) -> plotly.graph_objs.Figure: """ Plots the charge state of a Storage. @@ -545,7 +561,9 @@ def plot_charge_state( ValueError: If the Component is not a Storage. """ if engine != 'plotly': - raise NotImplementedError(f'Plotting engine "{engine}" not implemented for ComponentResults.plot_charge_state.') + raise NotImplementedError( + f'Plotting engine "{engine}" not implemented for ComponentResults.plot_charge_state.' + ) if not self.is_storage: raise ValueError(f'Cant plot charge_state. "{self.label}" is not a storage') @@ -560,8 +578,11 @@ def plot_charge_state( # TODO: Use colors for charge state? charge_state = self.charge_state.to_dataframe() - fig.add_trace(plotly.graph_objs.Scatter( - x=charge_state.index, y=charge_state.values.flatten(), mode='lines', name=self._charge_state)) + fig.add_trace( + plotly.graph_objs.Scatter( + x=charge_state.index, y=charge_state.values.flatten(), mode='lines', name=self._charge_state + ) + ) return plotting.export_figure( fig, @@ -569,13 +590,11 @@ def plot_charge_state( default_filetype='.html', user_path=None if isinstance(save, bool) else pathlib.Path(save), show=show, - save=True if save else False) + save=True if save else False, + ) def node_balance_with_charge_state( - self, - negate_inputs: bool = True, - negate_outputs: bool = False, - threshold: Optional[float] = 1e-5 + self, negate_inputs: bool = True, negate_outputs: bool = False, threshold: Optional[float] = 1e-5 ) -> xr.Dataset: """ Returns a dataset with the node balance of the Storage including its charge state. @@ -595,10 +614,14 @@ def node_balance_with_charge_state( threshold=threshold, timesteps=self._calculation_results.timesteps_extra, negate=( - self.outputs + self.inputs if negate_outputs and negate_inputs - else self.outputs if negate_outputs - else self.inputs if negate_inputs - else None), + self.outputs + self.inputs + if negate_outputs and negate_inputs + else self.outputs + if negate_outputs + else self.inputs + if negate_inputs + else None + ), ) @@ -606,7 +629,7 @@ class EffectResults(_ElementResults): """Results for an Effect""" def get_shares_from(self, element: str): - """ Get the shares from an Element (without subelements) to the Effect""" + """Get the shares from an Element (without subelements) to the Effect""" return self.solution[[name for name in self._variable_names if name.startswith(f'{element}->')]] @@ -614,18 +637,21 @@ class SegmentedCalculationResults: """ Class to store the results of a SegmentedCalculation. """ + @classmethod def from_calculation(cls, calculation: 'SegmentedCalculation'): - return cls([calc.results for calc in calculation.sub_calculations], - all_timesteps=calculation.all_timesteps, - timesteps_per_segment=calculation.timesteps_per_segment, - overlap_timesteps=calculation.overlap_timesteps, - name=calculation.name, - folder=calculation.folder) + return cls( + [calc.results for calc in calculation.sub_calculations], + all_timesteps=calculation.all_timesteps, + timesteps_per_segment=calculation.timesteps_per_segment, + overlap_timesteps=calculation.overlap_timesteps, + name=calculation.name, + folder=calculation.folder, + ) @classmethod def from_file(cls, folder: Union[str, pathlib.Path], name: str): - """ Create SegmentedCalculationResults directly from file""" + """Create SegmentedCalculationResults directly from file""" folder = pathlib.Path(folder) path = folder / name nc_file = path.with_suffix('.nc4') @@ -634,21 +660,24 @@ def from_file(cls, folder: Union[str, pathlib.Path], name: str): meta_data = json.load(f) return cls( [CalculationResults.from_file(folder, name) for name in meta_data['sub_calculations']], - all_timesteps=pd.DatetimeIndex([datetime.datetime.fromisoformat(date) - for date in meta_data['all_timesteps']], name='time'), + all_timesteps=pd.DatetimeIndex( + [datetime.datetime.fromisoformat(date) for date in meta_data['all_timesteps']], name='time' + ), timesteps_per_segment=meta_data['timesteps_per_segment'], overlap_timesteps=meta_data['overlap_timesteps'], name=name, - folder=folder + folder=folder, ) - def __init__(self, - segment_results: List[CalculationResults], - all_timesteps: pd.DatetimeIndex, - timesteps_per_segment: int, - overlap_timesteps: int, - name: str, - folder: Optional[pathlib.Path] = None): + def __init__( + self, + segment_results: List[CalculationResults], + all_timesteps: pd.DatetimeIndex, + timesteps_per_segment: int, + overlap_timesteps: int, + name: str, + folder: Optional[pathlib.Path] = None, + ): self.segment_results = segment_results self.all_timesteps = all_timesteps self.timesteps_per_segment = timesteps_per_segment @@ -663,7 +692,7 @@ def meta_data(self) -> Dict[str, Union[int, List[str]]]: 'all_timesteps': [datetime.datetime.isoformat(date) for date in self.all_timesteps], 'timesteps_per_segment': self.timesteps_per_segment, 'overlap_timesteps': self.overlap_timesteps, - 'sub_calculations': [calc.name for calc in self.segment_results] + 'sub_calculations': [calc.name for calc in self.segment_results], } @property @@ -672,12 +701,12 @@ def segment_names(self) -> List[str]: def solution_without_overlap(self, variable_name: str) -> xr.DataArray: """Returns the solution of a variable without overlapping timesteps""" - dataarrays = [result.solution[variable_name].isel(time=slice(None, self.timesteps_per_segment)) - for result in self.segment_results[:-1] - ] + [self.segment_results[-1].solution[variable_name]] + dataarrays = [ + result.solution[variable_name].isel(time=slice(None, self.timesteps_per_segment)) + for result in self.segment_results[:-1] + ] + [self.segment_results[-1].solution[variable_name]] return xr.concat(dataarrays, dim='time') - def plot_heatmap( self, variable_name: str, @@ -712,10 +741,9 @@ def plot_heatmap( engine=engine, ) - def to_file(self, - folder: Optional[Union[str, pathlib.Path]] = None, - name: Optional[str] = None, - compression: int = 5): + def to_file( + self, folder: Optional[Union[str, pathlib.Path]] = None, name: Optional[str] = None, compression: int = 5 + ): """Save the results to a file""" folder = self.folder if folder is None else pathlib.Path(folder) name = self.name if name is None else name @@ -724,7 +752,9 @@ def to_file(self, try: folder.mkdir(parents=False) except FileNotFoundError as e: - raise FileNotFoundError(f'Folder {folder} and its parent do not exist. Please create them first.') from e + raise FileNotFoundError( + f'Folder {folder} and its parent do not exist. Please create them first.' + ) from e for segment in self.segment_results: segment.to_file(folder=folder, name=f'{name}-{segment.name}', compression=compression) @@ -742,7 +772,7 @@ def plot_heatmap( color_map: str = 'portland', save: Union[bool, pathlib.Path] = False, show: bool = True, - engine: plotting.PlottingEngine = 'plotly' + engine: plotting.PlottingEngine = 'plotly', ): """ Plots a heatmap of the solution of a variable. @@ -759,33 +789,32 @@ def plot_heatmap( engine: The engine to use for plotting. Can be either 'plotly' or 'matplotlib'. """ heatmap_data = plotting.heat_map_data_from_df( - dataarray.to_dataframe(name), heatmap_timeframes, heatmap_timesteps_per_frame, 'ffill') + dataarray.to_dataframe(name), heatmap_timeframes, heatmap_timesteps_per_frame, 'ffill' + ) xlabel, ylabel = f'timeframe [{heatmap_timeframes}]', f'timesteps [{heatmap_timesteps_per_frame}]' if engine == 'plotly': figure_like = plotting.heat_map_plotly( - heatmap_data, title=name, color_map=color_map, - xlabel=xlabel, ylabel=ylabel + heatmap_data, title=name, color_map=color_map, xlabel=xlabel, ylabel=ylabel ) default_filetype = '.html' elif engine == 'matplotlib': figure_like = plotting.heat_map_matplotlib( - heatmap_data, title=name, color_map=color_map, - xlabel=xlabel, ylabel=ylabel + heatmap_data, title=name, color_map=color_map, xlabel=xlabel, ylabel=ylabel ) default_filetype = '.png' else: raise ValueError(f'Engine "{engine}" not supported. Use "plotly" or "matplotlib"') return plotting.export_figure( - figure_like=figure_like, - default_path=folder / f'{name} ({heatmap_timeframes}-{heatmap_timesteps_per_frame})', - default_filetype=default_filetype, - user_path=None if isinstance(save, bool) else pathlib.Path(save), - show=show, - save=True if save else False, - ) + figure_like=figure_like, + default_path=folder / f'{name} ({heatmap_timeframes}-{heatmap_timesteps_per_frame})', + default_filetype=default_filetype, + user_path=None if isinstance(save, bool) else pathlib.Path(save), + show=show, + save=True if save else False, + ) def sanitize_dataset( @@ -848,8 +877,8 @@ def sanitize_dataset( def filter_dataset( - ds: xr.Dataset, - variable_dims: Optional[Literal['scalar', 'time']] = None, + ds: xr.Dataset, + variable_dims: Optional[Literal['scalar', 'time']] = None, ) -> xr.Dataset: """ Filters a dataset by its dimensions. diff --git a/flixopt/solvers.py b/flixopt/solvers.py index 6c6b3183f..aaf1641ce 100644 --- a/flixopt/solvers.py +++ b/flixopt/solvers.py @@ -1,6 +1,7 @@ """ This module contains the solvers of the flixopt framework, making them available to the end user in a compact way. """ + import logging from dataclasses import dataclass, field from typing import Any, ClassVar, Dict, Optional @@ -18,6 +19,7 @@ class _Solver: and the lower bound, which is the theoretically optimal solution (LP) logfile_name (str): Filename for saving the solver log. """ + name: ClassVar[str] mip_gap: float time_limit_seconds: int @@ -42,6 +44,7 @@ class GurobiSolver(_Solver): time_limit_seconds (int): Solver's time limit in seconds. extra_options (str): Filename for saving the solver log. """ + name: ClassVar[str] = 'gurobi' @property @@ -61,6 +64,7 @@ class HighsSolver(_Solver): threads (int): Number of threads to use. extra_options (str): Filename for saving the solver log. """ + threads: Optional[int] = None name: ClassVar[str] = 'highs' @@ -71,4 +75,3 @@ def _options(self) -> Dict[str, Any]: 'time_limit': self.time_limit_seconds, 'threads': self.threads, } - diff --git a/flixopt/structure.py b/flixopt/structure.py index 27e641dcc..e7f1c62a4 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -30,12 +30,15 @@ CLASS_REGISTRY = {} + def register_class_for_io(cls): """Register a class for serialization/deserialization.""" name = cls.__name__ if name in CLASS_REGISTRY: - raise ValueError(f'Class {name} already registered! Use a different Name for the class! ' - f'This error should only happen in developement') + raise ValueError( + f'Class {name} already registered! Use a different Name for the class! ' + f'This error should only happen in developement' + ) CLASS_REGISTRY[name] = cls return cls @@ -110,10 +113,10 @@ class Interface: """ def transform_data(self, flow_system: 'FlowSystem'): - """ Transforms the data of the interface to match the FlowSystem's dimensions""" + """Transforms the data of the interface to match the FlowSystem's dimensions""" raise NotImplementedError('Every Interface needs a transform_data() method') - def infos(self, use_numpy: bool =True, use_element_label: bool = False) -> Dict: + def infos(self, use_numpy: bool = True, use_element_label: bool = False) -> Dict: """ Generate a dictionary representation of the object's constructor arguments. Excludes default values and empty dictionaries and lists. @@ -300,7 +303,9 @@ def _valid_label(label: str) -> str: class Model: """Stores Variables and Constraints.""" - def __init__(self, model: SystemModel, label_of_element: str, label: Optional[str] = None, label_full: Optional[str] = None): + def __init__( + self, model: SystemModel, label_of_element: str, label: Optional[str] = None, label_full: Optional[str] = None + ): """ Args: model: The SystemModel that is used to create the model. @@ -326,9 +331,7 @@ def do_modeling(self): raise NotImplementedError('Every Model needs a do_modeling() method') def add( - self, - item: Union[linopy.Variable, linopy.Constraint, 'Model'], - short_name: Optional[str] = None + self, item: Union[linopy.Variable, linopy.Constraint, 'Model'], short_name: Optional[str] = None ) -> Union[linopy.Variable, linopy.Constraint, 'Model']: """ Add a variable, constraint or sub-model to the model @@ -349,12 +352,15 @@ def add( self._sub_models_short[item.label_full] = short_name or item.label_full else: raise ValueError( - f'Item must be a linopy.Variable, linopy.Constraint or flixopt.structure.Model, got {type(item)}') + f'Item must be a linopy.Variable, linopy.Constraint or flixopt.structure.Model, got {type(item)}' + ) return item - def filter_variables(self, - filter_by: Optional[Literal['binary', 'continuous', 'integer']] = None, - length: Literal['scalar', 'time'] = None): + def filter_variables( + self, + filter_by: Optional[Literal['binary', 'continuous', 'integer']] = None, + length: Literal['scalar', 'time'] = None, + ): if filter_by is None: all_variables = self.variables elif filter_by == 'binary': @@ -379,7 +385,7 @@ def label(self) -> str: @property def label_full(self) -> str: - """ Used to construct the names of variables and constraints """ + """Used to construct the names of variables and constraints""" if self._label_full is not None: return self._label_full elif self._label is not None: @@ -537,7 +543,7 @@ def copy_and_convert_datatypes(data: Any, use_numpy: bool = True, use_element_la return data.label return data.infos(use_numpy, use_element_label) elif isinstance(data, xr.DataArray): - #TODO: This is a temporary basic work around + # TODO: This is a temporary basic work around return copy_and_convert_datatypes(data.values, use_numpy, use_element_label) else: raise TypeError(f'copy_and_convert_datatypes() did get unexpected data of type "{type(data)}": {data=}') diff --git a/flixopt/utils.py b/flixopt/utils.py index 52b4166ce..bb6e8ec40 100644 --- a/flixopt/utils.py +++ b/flixopt/utils.py @@ -30,7 +30,9 @@ def round_floats(obj, decimals=2): return obj -def convert_dataarray(data: xr.DataArray, mode: Literal['py', 'numpy', 'xarray', 'structure']) -> Union[List, np.ndarray, xr.DataArray, str]: +def convert_dataarray( + data: xr.DataArray, mode: Literal['py', 'numpy', 'xarray', 'structure'] +) -> Union[List, np.ndarray, xr.DataArray, str]: """ Convert a DataArray to a different format. diff --git a/scripts/gen_ref_pages.py b/scripts/gen_ref_pages.py index 680495d7f..13894d10a 100644 --- a/scripts/gen_ref_pages.py +++ b/scripts/gen_ref_pages.py @@ -11,23 +11,23 @@ nav = mkdocs_gen_files.Nav() -src = root / "flixopt" -api_dir = "api-reference" +src = root / 'flixopt' +api_dir = 'api-reference' -for path in sorted(src.rglob("*.py")): - module_path = path.relative_to(src).with_suffix("") - doc_path = path.relative_to(src).with_suffix(".md") +for path in sorted(src.rglob('*.py')): + module_path = path.relative_to(src).with_suffix('') + doc_path = path.relative_to(src).with_suffix('.md') full_doc_path = Path(api_dir, doc_path) parts = tuple(module_path.parts) - if parts[-1] == "__init__": + if parts[-1] == '__init__': parts = parts[:-1] if not parts: continue # Skip the root __init__.py - doc_path = doc_path.with_name("index.md") - full_doc_path = full_doc_path.with_name("index.md") - elif parts[-1] == "__main__" or parts[-1].startswith("_"): + doc_path = doc_path.with_name('index.md') + full_doc_path = full_doc_path.with_name('index.md') + elif parts[-1] == '__main__' or parts[-1].startswith('_'): continue # Only add to navigation if there are actual parts @@ -35,21 +35,20 @@ nav[parts] = doc_path.as_posix() # Generate documentation file - always using the flixopt prefix - with mkdocs_gen_files.open(full_doc_path, "w") as fd: + with mkdocs_gen_files.open(full_doc_path, 'w') as fd: # Use 'flixopt.' prefix for all module references - module_id = "flixopt." + ".".join(parts) - fd.write(f"::: {module_id}\n" - f" options:\n" - f" inherited_members: true\n") + module_id = 'flixopt.' + '.'.join(parts) + fd.write(f'::: {module_id}\n options:\n inherited_members: true\n') mkdocs_gen_files.set_edit_path(full_doc_path, path.relative_to(root)) # Create an index file for the API reference -with mkdocs_gen_files.open(f"{api_dir}/index.md", "w") as index_file: - index_file.write("# API Reference\n\n") +with mkdocs_gen_files.open(f'{api_dir}/index.md', 'w') as index_file: + index_file.write('# API Reference\n\n') index_file.write( - "This section contains the documentation for all modules and classes in flixopt.\n" - "For more information on how to use the classes and functions, see the [Concepts & Math](../concepts-and-math/index.md) section.\n") + 'This section contains the documentation for all modules and classes in flixopt.\n' + 'For more information on how to use the classes and functions, see the [Concepts & Math](../concepts-and-math/index.md) section.\n' + ) -with mkdocs_gen_files.open(f"{api_dir}/SUMMARY.md", "w") as nav_file: +with mkdocs_gen_files.open(f'{api_dir}/SUMMARY.md', 'w') as nav_file: nav_file.writelines(nav.build_literate_nav()) diff --git a/tests/conftest.py b/tests/conftest.py index 433ea2eb0..72aa1dee1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -30,11 +30,7 @@ def solver_fixture(request): # Custom assertion function def assert_almost_equal_numeric( - actual, - desired, - err_msg, - relative_error_range_in_percent=0.011, - absolute_tolerance=1e-9 + actual, desired, err_msg, relative_error_range_in_percent=0.011, absolute_tolerance=1e-9 ): """ Custom assertion function for comparing numeric values with relative and absolute tolerances @@ -45,13 +41,7 @@ def assert_almost_equal_numeric( delta = abs(relative_tol * desired) if desired != 0 else absolute_tolerance assert np.isclose(actual, desired, atol=delta), err_msg else: - np.testing.assert_allclose( - actual, - desired, - rtol=relative_tol, - atol=absolute_tolerance, - err_msg=err_msg - ) + np.testing.assert_allclose(actual, desired, rtol=relative_tol, atol=absolute_tolerance, err_msg=err_msg) @pytest.fixture @@ -110,27 +100,20 @@ def simple_flow_system() -> fx.FlowSystem: ) heat_load = fx.Sink( - 'Wärmelast', - sink=fx.Flow('Q_th_Last', bus='Fernwärme', size=1, fixed_relative_profile=base_thermal_load) + 'Wärmelast', sink=fx.Flow('Q_th_Last', bus='Fernwärme', size=1, fixed_relative_profile=base_thermal_load) ) gas_tariff = fx.Source( - 'Gastarif', - source=fx.Flow('Q_Gas', bus='Gas', size=1000, effects_per_flow_hour={'costs': 0.04, 'CO2': 0.3}) + 'Gastarif', source=fx.Flow('Q_Gas', bus='Gas', size=1000, effects_per_flow_hour={'costs': 0.04, 'CO2': 0.3}) ) electricity_feed_in = fx.Sink( - 'Einspeisung', - sink=fx.Flow('P_el', bus='Strom', effects_per_flow_hour=-1 * base_electrical_price) + 'Einspeisung', sink=fx.Flow('P_el', bus='Strom', effects_per_flow_hour=-1 * base_electrical_price) ) # Create flow system flow_system = fx.FlowSystem(base_timesteps) - flow_system.add_elements( - fx.Bus('Strom'), - fx.Bus('Fernwärme'), - fx.Bus('Gas') - ) + flow_system.add_elements(fx.Bus('Strom'), fx.Bus('Fernwärme'), fx.Bus('Gas')) flow_system.add_elements(storage, costs, co2, boiler, heat_load, gas_tariff, electricity_feed_in, chp) return flow_system @@ -173,7 +156,9 @@ def flow_system_complex() -> fx.FlowSystem: fx.Bus('Fernwärme'), fx.Bus('Gas'), fx.Sink('Wärmelast', sink=fx.Flow('Q_th_Last', 'Fernwärme', size=1, fixed_relative_profile=thermal_load)), - fx.Source('Gastarif', source=fx.Flow('Q_Gas', 'Gas', size=1000, effects_per_flow_hour={'costs': 0.04, 'CO2': 0.3})), + fx.Source( + 'Gastarif', source=fx.Flow('Q_Gas', 'Gas', size=1000, effects_per_flow_hour={'costs': 0.04, 'CO2': 0.3}) + ), fx.Sink('Einspeisung', sink=fx.Flow('P_el', 'Strom', effects_per_flow_hour=-1 * electrical_load)), ) @@ -212,9 +197,9 @@ def flow_system_complex() -> fx.FlowSystem: piecewise_origin=fx.Piecewise([fx.Piece(5, 25), fx.Piece(25, 100)]), piecewise_shares={ 'costs': fx.Piecewise([fx.Piece(50, 250), fx.Piece(250, 800)]), - 'PE': fx.Piecewise([fx.Piece(5, 25), fx.Piece(25, 100)]) - } - ), + 'PE': fx.Piecewise([fx.Piece(5, 25), fx.Piece(25, 100)]), + }, + ), optional=False, specific_effects={'costs': 0.01, 'CO2': 0.01}, minimum_size=0, @@ -245,15 +230,17 @@ def flow_system_base(flow_system_complex) -> fx.FlowSystem: """ flow_system = flow_system_complex - flow_system.add_elements(fx.linear_converters.CHP( - 'KWK', - eta_th=0.5, - eta_el=0.4, - on_off_parameters=fx.OnOffParameters(effects_per_switch_on=0.01), - P_el=fx.Flow('P_el', bus='Strom', size=60, relative_minimum=5 / 60, previous_flow_rate=10), - Q_th=fx.Flow('Q_th', bus='Fernwärme', size=1e3), - Q_fu=fx.Flow('Q_fu', bus='Gas', size=1e3), - )) + flow_system.add_elements( + fx.linear_converters.CHP( + 'KWK', + eta_th=0.5, + eta_el=0.4, + on_off_parameters=fx.OnOffParameters(effects_per_switch_on=0.01), + P_el=fx.Flow('P_el', bus='Strom', size=60, relative_minimum=5 / 60, previous_flow_rate=10), + Q_th=fx.Flow('Q_th', bus='Fernwärme', size=1e3), + Q_fu=fx.Flow('Q_fu', bus='Gas', size=1e3), + ) + ) return flow_system @@ -262,18 +249,24 @@ def flow_system_base(flow_system_complex) -> fx.FlowSystem: def flow_system_piecewise_conversion(flow_system_complex) -> fx.FlowSystem: flow_system = flow_system_complex - flow_system.add_elements(fx.LinearConverter( - 'KWK', - inputs=[fx.Flow('Q_fu', bus='Gas')], - outputs=[fx.Flow('P_el', bus='Strom', size=60, relative_maximum=55, previous_flow_rate=10), - fx.Flow('Q_th', bus='Fernwärme')], - piecewise_conversion= fx.PiecewiseConversion({ - 'P_el': fx.Piecewise([fx.Piece(5, 30), fx.Piece(40, 60)]), - 'Q_th': fx.Piecewise([fx.Piece(6, 35), fx.Piece(45, 100)]), - 'Q_fu': fx.Piecewise([fx.Piece(12, 70), fx.Piece(90, 200)]), - }), - on_off_parameters=fx.OnOffParameters(effects_per_switch_on=0.01), - )) + flow_system.add_elements( + fx.LinearConverter( + 'KWK', + inputs=[fx.Flow('Q_fu', bus='Gas')], + outputs=[ + fx.Flow('P_el', bus='Strom', size=60, relative_maximum=55, previous_flow_rate=10), + fx.Flow('Q_th', bus='Fernwärme'), + ], + piecewise_conversion=fx.PiecewiseConversion( + { + 'P_el': fx.Piecewise([fx.Piece(5, 30), fx.Piece(40, 60)]), + 'Q_th': fx.Piecewise([fx.Piece(6, 35), fx.Piece(45, 100)]), + 'Q_fu': fx.Piecewise([fx.Piece(12, 70), fx.Piece(90, 200)]), + } + ), + on_off_parameters=fx.OnOffParameters(effects_per_switch_on=0.01), + ) + ) return flow_system @@ -285,18 +278,29 @@ def flow_system_segments_of_flows_2(flow_system_complex) -> fx.FlowSystem: """ flow_system = flow_system_complex - flow_system.add_elements(fx.LinearConverter( - 'KWK', - inputs=[fx.Flow('Q_fu', bus='Gas')], - outputs=[fx.Flow('P_el', bus='Strom', size=60, relative_maximum=55, previous_flow_rate=10), - fx.Flow('Q_th', bus='Fernwärme')], - piecewise_conversion= fx.PiecewiseConversion({ - 'P_el': fx.Piecewise([fx.Piece(np.linspace(5, 6, len(flow_system.time_series_collection.timesteps)), 30), fx.Piece(40, np.linspace(60, 70, len(flow_system.time_series_collection.timesteps)))]), - 'Q_th': fx.Piecewise([fx.Piece(6, 35), fx.Piece(45, 100)]), - 'Q_fu': fx.Piecewise([fx.Piece(12, 70), fx.Piece(90, 200)]), - }), - on_off_parameters=fx.OnOffParameters(effects_per_switch_on=0.01), - )) + flow_system.add_elements( + fx.LinearConverter( + 'KWK', + inputs=[fx.Flow('Q_fu', bus='Gas')], + outputs=[ + fx.Flow('P_el', bus='Strom', size=60, relative_maximum=55, previous_flow_rate=10), + fx.Flow('Q_th', bus='Fernwärme'), + ], + piecewise_conversion=fx.PiecewiseConversion( + { + 'P_el': fx.Piecewise( + [ + fx.Piece(np.linspace(5, 6, len(flow_system.time_series_collection.timesteps)), 30), + fx.Piece(40, np.linspace(60, 70, len(flow_system.time_series_collection.timesteps))), + ] + ), + 'Q_th': fx.Piecewise([fx.Piece(6, 35), fx.Piece(45, 100)]), + 'Q_fu': fx.Piecewise([fx.Piece(12, 70), fx.Piece(90, 200)]), + } + ), + on_off_parameters=fx.OnOffParameters(effects_per_switch_on=0.01), + ) + ) return flow_system @@ -317,8 +321,10 @@ def flow_system_long(): p_el = data['Strompr.€/MWh'].values gas_price = data['Gaspr.€/MWh'].values - thermal_load_ts, electrical_load_ts = fx.TimeSeriesData(thermal_load), fx.TimeSeriesData(electrical_load, - agg_weight=0.7) + thermal_load_ts, electrical_load_ts = ( + fx.TimeSeriesData(thermal_load), + fx.TimeSeriesData(electrical_load, agg_weight=0.7), + ) p_feed_in, p_sell = ( fx.TimeSeriesData(-(p_el - 0.5), agg_group='p_el'), fx.TimeSeriesData(p_el + 0.5, agg_group='p_el'), @@ -326,18 +332,30 @@ def flow_system_long(): flow_system = fx.FlowSystem(pd.DatetimeIndex(data.index)) flow_system.add_elements( - fx.Bus('Strom'), fx.Bus('Fernwärme'), fx.Bus('Gas'), fx.Bus('Kohle'), - + fx.Bus('Strom'), + fx.Bus('Fernwärme'), + fx.Bus('Gas'), + fx.Bus('Kohle'), fx.Effect('costs', '€', 'Kosten', is_standard=True, is_objective=True), fx.Effect('CO2', 'kg', 'CO2_e-Emissionen'), fx.Effect('PE', 'kWh_PE', 'Primärenergie'), - - fx.Sink('Wärmelast', sink=fx.Flow('Q_th_Last', bus='Fernwärme', size=1, fixed_relative_profile=thermal_load_ts)), + fx.Sink( + 'Wärmelast', sink=fx.Flow('Q_th_Last', bus='Fernwärme', size=1, fixed_relative_profile=thermal_load_ts) + ), fx.Sink('Stromlast', sink=fx.Flow('P_el_Last', bus='Strom', size=1, fixed_relative_profile=electrical_load_ts)), - fx.Source('Kohletarif',source=fx.Flow('Q_Kohle', bus='Kohle', size=1000, effects_per_flow_hour={'costs': 4.6, 'CO2': 0.3})), - fx.Source('Gastarif', source=fx.Flow('Q_Gas', bus='Gas', size=1000, effects_per_flow_hour={'costs': gas_price, 'CO2': 0.3})), + fx.Source( + 'Kohletarif', + source=fx.Flow('Q_Kohle', bus='Kohle', size=1000, effects_per_flow_hour={'costs': 4.6, 'CO2': 0.3}), + ), + fx.Source( + 'Gastarif', + source=fx.Flow('Q_Gas', bus='Gas', size=1000, effects_per_flow_hour={'costs': gas_price, 'CO2': 0.3}), + ), fx.Sink('Einspeisung', sink=fx.Flow('P_el', bus='Strom', size=1000, effects_per_flow_hour=p_feed_in)), - fx.Source('Stromtarif', source=fx.Flow('P_el', bus='Strom', size=1000, effects_per_flow_hour={'costs': p_sell, 'CO2': 0.3})) + fx.Source( + 'Stromtarif', + source=fx.Flow('P_el', bus='Strom', size=1000, effects_per_flow_hour={'costs': p_sell, 'CO2': 0.3}), + ), ) flow_system.add_elements( @@ -380,9 +398,9 @@ def flow_system_long(): # Return all the necessary data return flow_system, { - 'thermal_load_ts': thermal_load_ts, - 'electrical_load_ts': electrical_load_ts, - } + 'thermal_load_ts': thermal_load_ts, + 'electrical_load_ts': electrical_load_ts, + } def create_calculation_and_solve(flow_system: fx.FlowSystem, solver, name: str) -> fx.FullCalculation: diff --git a/tests/run_all_tests.py b/tests/run_all_tests.py index 2f0434f4f..5597a47f3 100644 --- a/tests/run_all_tests.py +++ b/tests/run_all_tests.py @@ -7,4 +7,4 @@ import pytest if __name__ == '__main__': - pytest.main(['test_io.py', '--disable-warnings']) + pytest.main(['test_functional.py', '--disable-warnings']) diff --git a/tests/test_dataconverter.py b/tests/test_dataconverter.py index 04e78e78e..49f1438e7 100644 --- a/tests/test_dataconverter.py +++ b/tests/test_dataconverter.py @@ -8,7 +8,7 @@ @pytest.fixture def sample_time_index(request): - index = pd.date_range("2024-01-01", periods=5, freq="D", name='time') + index = pd.date_range('2024-01-01', periods=5, freq='D', name='time') return index @@ -34,10 +34,7 @@ def test_series_conversion(sample_time_index): def test_dataframe_conversion(sample_time_index): # Create a single-column DataFrame - df = pd.DataFrame( - {"A": [1, 2, 3, 4, 5]}, - index=sample_time_index - ) + df = pd.DataFrame({'A': [1, 2, 3, 4, 5]}, index=sample_time_index) # Test DataFrame conversion result = DataConverter.as_dataarray(df, sample_time_index) @@ -58,11 +55,7 @@ def test_ndarray_conversion(sample_time_index): def test_dataarray_conversion(sample_time_index): # Create a DataArray - original = xr.DataArray( - data=np.array([1, 2, 3, 4, 5]), - coords={'time': sample_time_index}, - dims=['time'] - ) + original = xr.DataArray(data=np.array([1, 2, 3, 4, 5]), coords={'time': sample_time_index}, dims=['time']) # Test DataArray conversion result = DataConverter.as_dataarray(original, sample_time_index) @@ -78,18 +71,15 @@ def test_dataarray_conversion(sample_time_index): def test_invalid_inputs(sample_time_index): # Test invalid input type with pytest.raises(ConversionError): - DataConverter.as_dataarray("invalid_string", sample_time_index) + DataConverter.as_dataarray('invalid_string', sample_time_index) # Test mismatched Series index - mismatched_series = pd.Series([1, 2, 3, 4, 5, 6], index=pd.date_range("2025-01-01", periods=6, freq="D")) + mismatched_series = pd.Series([1, 2, 3, 4, 5, 6], index=pd.date_range('2025-01-01', periods=6, freq='D')) with pytest.raises(ConversionError): DataConverter.as_dataarray(mismatched_series, sample_time_index) # Test DataFrame with multiple columns - df_multi_col = pd.DataFrame({ - "A": [1, 2, 3, 4, 5], - "B": [6, 7, 8, 9, 10] - }, index=sample_time_index) + df_multi_col = pd.DataFrame({'A': [1, 2, 3, 4, 5], 'B': [6, 7, 8, 9, 10]}, index=sample_time_index) with pytest.raises(ConversionError): DataConverter.as_dataarray(df_multi_col, sample_time_index) @@ -104,7 +94,7 @@ def test_invalid_inputs(sample_time_index): def test_time_index_validation(): # Test with unnamed index - unnamed_index = pd.date_range("2024-01-01", periods=5, freq="D") + unnamed_index = pd.date_range('2024-01-01', periods=5, freq='D') with pytest.raises(ConversionError): DataConverter.as_dataarray(42, unnamed_index) @@ -119,5 +109,5 @@ def test_time_index_validation(): DataConverter.as_dataarray(42, wrong_type_index) -if __name__ == "__main__": +if __name__ == '__main__': pytest.main() diff --git a/tests/test_functional.py b/tests/test_functional.py index 3f22e9404..5db83f656 100644 --- a/tests/test_functional.py +++ b/tests/test_functional.py @@ -93,9 +93,7 @@ def flow_system_minimal(timesteps) -> fx.FlowSystem: return flow_system -def solve_and_load( - flow_system: fx.FlowSystem, solver -) -> fx.results.CalculationResults: +def solve_and_load(flow_system: fx.FlowSystem, solver) -> fx.results.CalculationResults: calculation = fx.FullCalculation('Calculation', flow_system) calculation.do_modeling() calculation.solve(solver) @@ -354,11 +352,12 @@ def test_fixed_relative_profile(self): self.flow_system.add_elements( fx.Source( 'Wärmequelle', - source=fx.Flow('Q_th', - bus=self.get_element('Fernwärme'), - fixed_relative_profile=np.linspace(0, 5, len(self.datetime_array)), - size=fx.InvestParameters(optional=False, minimum_size=2, maximum_size=5), - ) + source=fx.Flow( + 'Q_th', + bus=self.get_element('Fernwärme'), + fixed_relative_profile=np.linspace(0, 5, len(self.datetime_array)), + size=fx.InvestParameters(optional=False, minimum_size=2, maximum_size=5), + ), ) ) self.get_element('Fernwärme').excess_penalty_per_flow_hour = 1e5 @@ -381,8 +380,6 @@ def test_fixed_relative_profile(self): ) - - def test_on(solver_fixture, time_steps_fixture): """Tests if the On Variable is correctly created and calculated in a Flow""" flow_system = flow_system_base(time_steps_fixture) @@ -391,9 +388,9 @@ def test_on(solver_fixture, time_steps_fixture): 'Boiler', 0.5, Q_fu=fx.Flow('Q_fu', bus='Gas'), - Q_th=fx.Flow('Q_th', bus='Fernwärme', size=100, on_off_parameters=fx.OnOffParameters() - ), - )) + Q_th=fx.Flow('Q_th', bus='Fernwärme', size=100, on_off_parameters=fx.OnOffParameters()), + ) + ) solve_and_load(flow_system, solver_fixture) boiler = flow_system.all_elements['Boiler'] @@ -608,7 +605,9 @@ def test_on_total_bounds(solver_fixture, time_steps_fixture): ), ), ) - flow_system.all_elements['Wärmelast'].sink.fixed_relative_profile = np.array([0, 10, 20, 0, 12]) # Else its non deterministic + flow_system.all_elements['Wärmelast'].sink.fixed_relative_profile = np.array( + [0, 10, 20, 0, 12] + ) # Else its non deterministic solve_and_load(flow_system, solver_fixture) boiler = flow_system.all_elements['Boiler'] @@ -713,6 +712,7 @@ def test_consecutive_on_off(solver_fixture, time_steps_fixture): err_msg='"Boiler__Q_th__flow_rate" does not have the right value', ) + def test_consecutive_off(solver_fixture, time_steps_fixture): """Tests if the consecutive on hours are correctly created and calculated in a Flow""" flow_system = flow_system_base(time_steps_fixture) @@ -736,7 +736,9 @@ def test_consecutive_off(solver_fixture, time_steps_fixture): ), ), ) - flow_system.all_elements['Wärmelast'].sink.fixed_relative_profile = np.array([5, 0, 20, 18, 12]) # Else its non deterministic + flow_system.all_elements['Wärmelast'].sink.fixed_relative_profile = np.array( + [5, 0, 20, 18, 12] + ) # Else its non deterministic solve_and_load(flow_system, solver_fixture) boiler = flow_system.all_elements['Boiler'] diff --git a/tests/test_integration.py b/tests/test_integration.py index 846ab7992..e3d4faf0d 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -23,16 +23,12 @@ def test_simple_flow_system(self, simple_flow_system, highs_solver): # Cost assertions assert_almost_equal_numeric( - effects['costs'].model.total.solution.item(), - 81.88394666666667, - 'costs doesnt match expected value' + effects['costs'].model.total.solution.item(), 81.88394666666667, 'costs doesnt match expected value' ) # CO2 assertions assert_almost_equal_numeric( - effects['CO2'].model.total.solution.item(), - 255.09184, - 'CO2 doesnt match expected value' + effects['CO2'].model.total.solution.item(), 255.09184, 'CO2 doesnt match expected value' ) def test_model_components(self, simple_flow_system, highs_solver): @@ -66,10 +62,7 @@ def test_results_persistence(self, simple_flow_system, highs_solver): calculation.results.to_file() # Load results from file - results = fx.results.CalculationResults.from_file( - calculation.folder, - calculation.name - ) + results = fx.results.CalculationResults.from_file(calculation.folder, calculation.name) # Verify key variables from loaded results assert_almost_equal_numeric( @@ -77,11 +70,7 @@ def test_results_persistence(self, simple_flow_system, highs_solver): 81.88394666666667, 'costs doesnt match expected value', ) - assert_almost_equal_numeric( - results.solution['CO2|total'].values, - 255.09184, - 'CO2 doesnt match expected value' - ) + assert_almost_equal_numeric(results.solution['CO2|total'].values, 255.09184, 'CO2 doesnt match expected value') class TestComponents: @@ -91,10 +80,7 @@ def test_transmission_basic(self, basic_flow_system, highs_solver): flow_system.add_elements(fx.Bus('Wärme lokal')) boiler = fx.linear_converters.Boiler( - 'Boiler', - eta=0.5, - Q_th=fx.Flow('Q_th', bus='Wärme lokal'), - Q_fu=fx.Flow('Q_fu', bus='Gas') + 'Boiler', eta=0.5, Q_th=fx.Flow('Q_th', bus='Wärme lokal'), Q_fu=fx.Flow('Q_fu', bus='Gas') ) transmission = fx.Transmission( @@ -113,7 +99,7 @@ def test_transmission_basic(self, basic_flow_system, highs_solver): assert_almost_equal_numeric( transmission.in1.model.on_off.on.solution.values, np.array([1, 1, 1, 1, 1, 1, 1, 1, 1, 1]), - 'On does not work properly' + 'On does not work properly', ) assert_almost_equal_numeric( @@ -130,9 +116,7 @@ def test_transmission_advanced(self, basic_flow_system, highs_solver): boiler = fx.linear_converters.Boiler( 'Boiler_Standard', eta=0.9, - Q_th=fx.Flow( - 'Q_th', bus='Fernwärme', relative_maximum=np.array([0, 0, 0, 1, 1, 1, 1, 1, 1, 1]) - ), + Q_th=fx.Flow('Q_th', bus='Fernwärme', relative_maximum=np.array([0, 0, 0, 1, 1, 1, 1, 1, 1, 1])), Q_fu=fx.Flow('Q_fu', bus='Gas'), ) @@ -146,7 +130,8 @@ def test_transmission_advanced(self, basic_flow_system, highs_solver): 'Q_th_Last', bus='Wärme lokal', size=1, - fixed_relative_profile=flow_system.components['Wärmelast'].sink.fixed_relative_profile * np.array([0, 0, 0, 0, 0, 1, 1, 1, 1, 1]), + fixed_relative_profile=flow_system.components['Wärmelast'].sink.fixed_relative_profile + * np.array([0, 0, 0, 0, 0, 1, 1, 1, 1, 1]), ), ) @@ -168,7 +153,7 @@ def test_transmission_advanced(self, basic_flow_system, highs_solver): assert_almost_equal_numeric( transmission.in1.model.on_off.on.solution.values, np.array([1, 1, 1, 0, 0, 0, 0, 0, 0, 0]), - 'On does not work properly' + 'On does not work properly', ) assert_almost_equal_numeric( @@ -192,14 +177,14 @@ def test_transmission_advanced(self, basic_flow_system, highs_solver): class TestComplex: - def test_basic_flow_system(self, flow_system_base, highs_solver): calculation = create_calculation_and_solve(flow_system_base, highs_solver, 'test_basic_flow_system') # Assertions assert_almost_equal_numeric( calculation.results.model['costs|total'].solution.item(), - -11597.873624489237, 'costs doesnt match expected value' + -11597.873624489237, + 'costs doesnt match expected value', ) assert_almost_equal_numeric( @@ -262,12 +247,14 @@ def test_basic_flow_system(self, flow_system_base, highs_solver): ) assert_almost_equal_numeric( - calculation.results.model['CO2(operation)|total'].solution.values, 1293.1864834809337, - 'CO2 doesnt match expected value' + calculation.results.model['CO2(operation)|total'].solution.values, + 1293.1864834809337, + 'CO2 doesnt match expected value', ) assert_almost_equal_numeric( - calculation.results.model['CO2(invest)|total'].solution.values, 0.9999999999999994, - 'CO2 doesnt match expected value' + calculation.results.model['CO2(invest)|total'].solution.values, + 0.9999999999999994, + 'CO2 doesnt match expected value', ) assert_almost_equal_numeric( calculation.results.model['Kessel(Q_th)|flow_rate'].solution.values, @@ -324,7 +311,9 @@ def test_basic_flow_system(self, flow_system_base, highs_solver): ) def test_piecewise_conversion(self, flow_system_piecewise_conversion, highs_solver): - calculation = create_calculation_and_solve(flow_system_piecewise_conversion, highs_solver, 'test_piecewise_conversion') + calculation = create_calculation_and_solve( + flow_system_piecewise_conversion, highs_solver, 'test_piecewise_conversion' + ) effects = calculation.flow_system.effects comps = calculation.flow_system.components @@ -367,7 +356,6 @@ def test_piecewise_conversion(self, flow_system_piecewise_conversion, highs_solv class TestModelingTypes: - @pytest.fixture(params=['full', 'segmented', 'aggregated']) def modeling_calculation(self, request, flow_system_long, highs_solver): """ @@ -416,21 +404,22 @@ def test_modeling_types_costs(self, modeling_calculation): expected_costs = { 'full': 343613, 'segmented': 343613, # Approximate value - 'aggregated': 342967.0 + 'aggregated': 342967.0, } if modeling_type in ['full', 'aggregated']: assert_almost_equal_numeric( calc.results.model['costs|total'].solution.item(), expected_costs[modeling_type], - f'Costs do not match for {modeling_type} modeling type' + f'Costs do not match for {modeling_type} modeling type', ) else: assert_almost_equal_numeric( calc.results.solution_without_overlap('costs(operation)|total_per_timestep').sum(), expected_costs[modeling_type], - f'Costs do not match for {modeling_type} modeling type' + f'Costs do not match for {modeling_type} modeling type', ) + if __name__ == '__main__': pytest.main(['-v']) diff --git a/tests/test_io.py b/tests/test_io.py index 0db679a39..84536f61d 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -36,9 +36,11 @@ def test_flow_system_file_io(flow_system, highs_solver): calculation_1.do_modeling() calculation_1.solve(highs_solver) - assert_almost_equal_numeric(calculation_0.results.model.objective.value, - calculation_1.results.model.objective.value, - 'objective of loaded flow_system doesnt match the original') + assert_almost_equal_numeric( + calculation_0.results.model.objective.value, + calculation_1.results.model.objective.value, + 'objective of loaded flow_system doesnt match the original', + ) assert_almost_equal_numeric( calculation_0.results.solution['costs|total'].values, diff --git a/tests/test_results_plots.py b/tests/test_results_plots.py index 38fb8f08d..26197c98a 100644 --- a/tests/test_results_plots.py +++ b/tests/test_results_plots.py @@ -10,6 +10,7 @@ def show(request): return request.param + @pytest.fixture(params=[simple_flow_system]) def flow_system(request): return request.getfixturevalue(request.param.__name__) @@ -25,11 +26,17 @@ def plotting_engine(request): return request.param -@pytest.fixture(params=[ - 'viridis', # Test string colormap - ['#ff0000', '#00ff00', '#0000ff', '#ffff00', '#ff00ff', '#00ffff'], # Test color list - {'Boiler(Q_th)|flow_rate': '#ff0000', 'Heat Demand(Q_th)|flow_rate': '#00ff00', 'Speicher(Q_th_load)|flow_rate': '#0000ff'} # Test color dict -]) +@pytest.fixture( + params=[ + 'viridis', # Test string colormap + ['#ff0000', '#00ff00', '#0000ff', '#ffff00', '#ff00ff', '#00ffff'], # Test color list + { + 'Boiler(Q_th)|flow_rate': '#ff0000', + 'Heat Demand(Q_th)|flow_rate': '#00ff00', + 'Speicher(Q_th_load)|flow_rate': '#0000ff', + }, # Test color dict + ] +) def color_spec(request): return request.param @@ -40,13 +47,15 @@ def test_results_plots(flow_system, plotting_engine, show, save, color_spec): results['Boiler'].plot_node_balance(engine=plotting_engine, save=save, show=show, colors=color_spec) - results.plot_heatmap('Speicher(Q_th_load)|flow_rate', - heatmap_timeframes='D', - heatmap_timesteps_per_frame='h', - color_map='viridis', # Note: heatmap only accepts string colormap - save=show, - show=save, - engine=plotting_engine) + results.plot_heatmap( + 'Speicher(Q_th_load)|flow_rate', + heatmap_timeframes='D', + heatmap_timesteps_per_frame='h', + color_map='viridis', # Note: heatmap only accepts string colormap + save=show, + show=save, + engine=plotting_engine, + ) results['Speicher'].plot_node_balance_pie(engine=plotting_engine, save=save, show=show, colors=color_spec) diff --git a/tests/test_timeseries.py b/tests/test_timeseries.py index 7ba5df2c9..48c7ab7b2 100644 --- a/tests/test_timeseries.py +++ b/tests/test_timeseries.py @@ -55,10 +55,7 @@ def test_initialization(self, simple_dataarray): def test_initialization_with_aggregation_params(self, simple_dataarray): """Test initialization with aggregation parameters.""" ts = TimeSeries( - simple_dataarray, - name='Weighted Series', - aggregation_weight=0.5, - aggregation_group='test_group' + simple_dataarray, name='Weighted Series', aggregation_weight=0.5, aggregation_group='test_group' ) assert ts.name == 'Weighted Series' @@ -74,9 +71,7 @@ def test_initialization_validation(self, sample_timesteps): # Test multi-dimensional data multi_dim_data = xr.DataArray( - [[1, 2, 3], [4, 5, 6]], - coords={'dim1': [0, 1], 'time': sample_timesteps[:3]}, - dims=['dim1', 'time'] + [[1, 2, 3], [4, 5, 6]], coords={'dim1': [0, 1], 'time': sample_timesteps[:3]}, dims=['dim1', 'time'] ) with pytest.raises(ValueError, match='dimensions of DataArray must be 1'): TimeSeries(multi_dim_data, name='Multi-dim Series') @@ -92,17 +87,15 @@ def test_active_timesteps_getter_setter(self, sample_timeseries, sample_timestep assert sample_timeseries.active_timesteps.equals(subset_index) # Active data should reflect the subset - assert sample_timeseries.active_data.equals( - sample_timeseries.stored_data.sel(time=subset_index) - ) + assert sample_timeseries.active_data.equals(sample_timeseries.stored_data.sel(time=subset_index)) # Reset to full index sample_timeseries.active_timesteps = None assert sample_timeseries.active_timesteps.equals(sample_timesteps) # Test invalid type - with pytest.raises(TypeError, match="must be a pandas DatetimeIndex"): - sample_timeseries.active_timesteps = "invalid" + with pytest.raises(TypeError, match='must be a pandas DatetimeIndex'): + sample_timeseries.active_timesteps = 'invalid' def test_reset(self, sample_timeseries, sample_timesteps): """Test reset method.""" @@ -120,11 +113,7 @@ def test_reset(self, sample_timeseries, sample_timesteps): def test_restore_data(self, sample_timeseries, simple_dataarray): """Test restore_data method.""" # Modify the stored data - new_data = xr.DataArray( - [1, 2, 3, 4, 5], - coords={'time': sample_timeseries.active_timesteps}, - dims=['time'] - ) + new_data = xr.DataArray([1, 2, 3, 4, 5], coords={'time': sample_timeseries.active_timesteps}, dims=['time']) # Store original data for comparison original_data = sample_timeseries.stored_data @@ -145,37 +134,24 @@ def test_stored_data_setter(self, sample_timeseries, sample_timesteps): # Test with a Series series_data = pd.Series([5, 6, 7, 8, 9], index=sample_timesteps) sample_timeseries.stored_data = series_data - assert np.array_equal( - sample_timeseries.stored_data.values, - series_data.values - ) + assert np.array_equal(sample_timeseries.stored_data.values, series_data.values) # Test with a single-column DataFrame df_data = pd.DataFrame({'col1': [15, 16, 17, 18, 19]}, index=sample_timesteps) sample_timeseries.stored_data = df_data - assert np.array_equal( - sample_timeseries.stored_data.values, - df_data['col1'].values - ) + assert np.array_equal(sample_timeseries.stored_data.values, df_data['col1'].values) # Test with a NumPy array array_data = np.array([25, 26, 27, 28, 29]) sample_timeseries.stored_data = array_data - assert np.array_equal( - sample_timeseries.stored_data.values, - array_data - ) + assert np.array_equal(sample_timeseries.stored_data.values, array_data) # Test with a scalar sample_timeseries.stored_data = 42 assert np.all(sample_timeseries.stored_data.values == 42) # Test with another DataArray - another_dataarray = xr.DataArray( - [30, 31, 32, 33, 34], - coords={'time': sample_timesteps}, - dims=['time'] - ) + another_dataarray = xr.DataArray([30, 31, 32, 33, 34], coords={'time': sample_timesteps}, dims=['time']) sample_timeseries.stored_data = another_dataarray assert sample_timeseries.stored_data.equals(another_dataarray) @@ -204,11 +180,7 @@ def test_from_datasource(self, sample_timesteps): # Test with aggregation parameters ts_with_agg = TimeSeries.from_datasource( - series_data, - 'Aggregated Series', - sample_timesteps, - aggregation_weight=0.7, - aggregation_group='group1' + series_data, 'Aggregated Series', sample_timesteps, aggregation_weight=0.7, aggregation_group='group1' ) assert ts_with_agg.aggregation_weight == 0.7 assert ts_with_agg.aggregation_group == 'group1' @@ -231,18 +203,12 @@ def test_to_json_from_json(self, sample_timeseries): # Test from_json with file loading loaded_ts = TimeSeries.from_json(path=filepath) assert loaded_ts.name == sample_timeseries.name - assert np.array_equal( - loaded_ts.stored_data.values, - sample_timeseries.stored_data.values - ) + assert np.array_equal(loaded_ts.stored_data.values, sample_timeseries.stored_data.values) # Test from_json with dictionary loaded_ts_dict = TimeSeries.from_json(data=json_dict) assert loaded_ts_dict.name == sample_timeseries.name - assert np.array_equal( - loaded_ts_dict.stored_data.values, - sample_timeseries.stored_data.values - ) + assert np.array_equal(loaded_ts_dict.stored_data.values, sample_timeseries.stored_data.values) # Test validation in from_json with pytest.raises(ValueError, match="one of 'path' or 'data'"): @@ -251,38 +217,34 @@ def test_to_json_from_json(self, sample_timeseries): def test_all_equal(self, sample_timesteps): """Test all_equal property.""" # All equal values - equal_data = xr.DataArray( - [5, 5, 5, 5, 5], - coords={'time': sample_timesteps}, - dims=['time'] - ) + equal_data = xr.DataArray([5, 5, 5, 5, 5], coords={'time': sample_timesteps}, dims=['time']) ts_equal = TimeSeries(equal_data, 'Equal Series') assert ts_equal.all_equal is True # Not all equal - unequal_data = xr.DataArray( - [5, 5, 6, 5, 5], - coords={'time': sample_timesteps}, - dims=['time'] - ) + unequal_data = xr.DataArray([5, 5, 6, 5, 5], coords={'time': sample_timesteps}, dims=['time']) ts_unequal = TimeSeries(unequal_data, 'Unequal Series') assert ts_unequal.all_equal is False def test_arithmetic_operations(self, sample_timeseries): """Test arithmetic operations.""" # Create a second TimeSeries for testing - data2 = xr.DataArray( - [1, 2, 3, 4, 5], - coords={'time': sample_timeseries.active_timesteps}, - dims=['time'] - ) + data2 = xr.DataArray([1, 2, 3, 4, 5], coords={'time': sample_timeseries.active_timesteps}, dims=['time']) ts2 = TimeSeries(data2, 'Second Series') # Test operations between two TimeSeries objects - assert np.array_equal((sample_timeseries + ts2).values, sample_timeseries.active_data.values + ts2.active_data.values) - assert np.array_equal((sample_timeseries - ts2).values, sample_timeseries.active_data.values - ts2.active_data.values) - assert np.array_equal((sample_timeseries * ts2).values, sample_timeseries.active_data.values * ts2.active_data.values) - assert np.array_equal((sample_timeseries / ts2).values, sample_timeseries.active_data.values / ts2.active_data.values) + assert np.array_equal( + (sample_timeseries + ts2).values, sample_timeseries.active_data.values + ts2.active_data.values + ) + assert np.array_equal( + (sample_timeseries - ts2).values, sample_timeseries.active_data.values - ts2.active_data.values + ) + assert np.array_equal( + (sample_timeseries * ts2).values, sample_timeseries.active_data.values * ts2.active_data.values + ) + assert np.array_equal( + (sample_timeseries / ts2).values, sample_timeseries.active_data.values / ts2.active_data.values + ) # Test operations with DataArrays assert np.array_equal((sample_timeseries + data2).values, sample_timeseries.active_data.values + data2.values) @@ -317,27 +279,18 @@ def test_comparison_operations(self, sample_timesteps): def test_numpy_ufunc(self, sample_timeseries): """Test numpy ufunc compatibility.""" # Test basic numpy functions - assert np.array_equal( - np.add(sample_timeseries, 5).values, - np.add(sample_timeseries.active_data, 5).values - ) + assert np.array_equal(np.add(sample_timeseries, 5).values, np.add(sample_timeseries.active_data, 5).values) assert np.array_equal( - np.multiply(sample_timeseries, 2).values, - np.multiply(sample_timeseries.active_data, 2).values + np.multiply(sample_timeseries, 2).values, np.multiply(sample_timeseries.active_data, 2).values ) # Test with two TimeSeries objects - data2 = xr.DataArray( - [1, 2, 3, 4, 5], - coords={'time': sample_timeseries.active_timesteps}, - dims=['time'] - ) + data2 = xr.DataArray([1, 2, 3, 4, 5], coords={'time': sample_timeseries.active_timesteps}, dims=['time']) ts2 = TimeSeries(data2, 'Second Series') assert np.array_equal( - np.add(sample_timeseries, ts2).values, - np.add(sample_timeseries.active_data, ts2.active_data).values + np.add(sample_timeseries, ts2).values, np.add(sample_timeseries.active_data, ts2.active_data).values ) def test_sel_and_isel_properties(self, sample_timeseries): @@ -361,31 +314,26 @@ def sample_collection(sample_timesteps): def populated_collection(sample_collection): """Create a TimeSeriesCollection with test data.""" # Add a constant time series - sample_collection.create_time_series(42, "constant_series") + sample_collection.create_time_series(42, 'constant_series') # Add a varying time series varying_data = np.array([10, 20, 30, 40, 50]) - sample_collection.create_time_series(varying_data, "varying_series") + sample_collection.create_time_series(varying_data, 'varying_series') # Add a time series with extra timestep sample_collection.create_time_series( - np.array([1, 2, 3, 4, 5, 6]), - "extra_timestep_series", - needs_extra_timestep=True + np.array([1, 2, 3, 4, 5, 6]), 'extra_timestep_series', needs_extra_timestep=True ) # Add series with aggregation settings sample_collection.create_time_series( - TimeSeriesData(np.array([5, 5, 5, 5, 5]), agg_group="group1"), - "group1_series1" + TimeSeriesData(np.array([5, 5, 5, 5, 5]), agg_group='group1'), 'group1_series1' ) sample_collection.create_time_series( - TimeSeriesData(np.array([6, 6, 6, 6, 6]), agg_group="group1"), - "group1_series2" + TimeSeriesData(np.array([6, 6, 6, 6, 6]), agg_group='group1'), 'group1_series2' ) sample_collection.create_time_series( - TimeSeriesData(np.array([10, 10, 10, 10, 10]), agg_weight=0.5), - "weighted_series" + TimeSeriesData(np.array([10, 10, 10, 10, 10]), agg_weight=0.5), 'weighted_series' ) return sample_collection @@ -407,10 +355,7 @@ def test_initialization_with_custom_hours(self, sample_timesteps): """Test initialization with custom hour settings.""" # Test with last timestep duration last_timestep_hours = 12 - collection = TimeSeriesCollection( - sample_timesteps, - hours_of_last_timestep=last_timestep_hours - ) + collection = TimeSeriesCollection(sample_timesteps, hours_of_last_timestep=last_timestep_hours) # Verify the last timestep duration extra_step_delta = collection.all_timesteps_extra[-1] - collection.all_timesteps_extra[-2] @@ -418,62 +363,56 @@ def test_initialization_with_custom_hours(self, sample_timesteps): # Test with previous timestep duration hours_per_step = 8 - collection2 = TimeSeriesCollection( - sample_timesteps, - hours_of_previous_timesteps=hours_per_step - ) + collection2 = TimeSeriesCollection(sample_timesteps, hours_of_previous_timesteps=hours_per_step) assert collection2.hours_of_previous_timesteps == hours_per_step def test_create_time_series(self, sample_collection): """Test creating time series.""" # Test scalar - ts1 = sample_collection.create_time_series(42, "scalar_series") - assert ts1.name == "scalar_series" + ts1 = sample_collection.create_time_series(42, 'scalar_series') + assert ts1.name == 'scalar_series' assert np.all(ts1.active_data.values == 42) # Test numpy array data = np.array([1, 2, 3, 4, 5]) - ts2 = sample_collection.create_time_series(data, "array_series") + ts2 = sample_collection.create_time_series(data, 'array_series') assert np.array_equal(ts2.active_data.values, data) # Test with TimeSeriesData - ts3 = sample_collection.create_time_series( - TimeSeriesData(10, agg_weight=0.7), - "weighted_series" - ) + ts3 = sample_collection.create_time_series(TimeSeriesData(10, agg_weight=0.7), 'weighted_series') assert ts3.aggregation_weight == 0.7 # Test with extra timestep - ts4 = sample_collection.create_time_series(5, "extra_series", needs_extra_timestep=True) + ts4 = sample_collection.create_time_series(5, 'extra_series', needs_extra_timestep=True) assert ts4.needs_extra_timestep assert len(ts4.active_data) == len(sample_collection.timesteps_extra) # Test duplicate name - with pytest.raises(ValueError, match="already exists"): - sample_collection.create_time_series(1, "scalar_series") + with pytest.raises(ValueError, match='already exists'): + sample_collection.create_time_series(1, 'scalar_series') def test_access_time_series(self, populated_collection): """Test accessing time series.""" # Test __getitem__ - ts = populated_collection["varying_series"] - assert ts.name == "varying_series" + ts = populated_collection['varying_series'] + assert ts.name == 'varying_series' # Test __contains__ with string - assert "constant_series" in populated_collection - assert "nonexistent_series" not in populated_collection + assert 'constant_series' in populated_collection + assert 'nonexistent_series' not in populated_collection # Test __contains__ with TimeSeries object - assert populated_collection["varying_series"] in populated_collection + assert populated_collection['varying_series'] in populated_collection # Test __iter__ names = [ts.name for ts in populated_collection] assert len(names) == 6 - assert "varying_series" in names + assert 'varying_series' in names # Test access to non-existent series with pytest.raises(KeyError): - populated_collection["nonexistent_series"] + populated_collection['nonexistent_series'] def test_constants_and_non_constants(self, populated_collection): """Test constants and non_constants properties.""" @@ -488,10 +427,10 @@ def test_constants_and_non_constants(self, populated_collection): assert all(not ts.all_equal for ts in non_constants) # Test modifying a series changes the results - populated_collection["constant_series"].stored_data = np.array([1, 2, 3, 4, 5]) + populated_collection['constant_series'].stored_data = np.array([1, 2, 3, 4, 5]) updated_constants = populated_collection.constants assert len(updated_constants) == 3 # One less constant - assert "constant_series" not in [ts.name for ts in updated_constants] + assert 'constant_series' not in [ts.name for ts in updated_constants] def test_timesteps_properties(self, populated_collection, sample_timesteps): """Test timestep-related properties.""" @@ -507,8 +446,8 @@ def test_timesteps_properties(self, populated_collection, sample_timesteps): assert len(populated_collection.timesteps_extra) == len(subset) + 1 # Check that time series were updated - assert populated_collection["varying_series"].active_timesteps.equals(subset) - assert populated_collection["extra_timestep_series"].active_timesteps.equals( + assert populated_collection['varying_series'].active_timesteps.equals(subset) + assert populated_collection['extra_timestep_series'].active_timesteps.equals( populated_collection.timesteps_extra ) @@ -542,48 +481,47 @@ def test_calculate_aggregation_weights(self, populated_collection): weights = populated_collection.calculate_aggregation_weights() # Group weights should be 0.5 each (1/2) - assert populated_collection.group_weights["group1"] == 0.5 + assert populated_collection.group_weights['group1'] == 0.5 # Series in group1 should have weight 0.5 - assert weights["group1_series1"] == 0.5 - assert weights["group1_series2"] == 0.5 + assert weights['group1_series1'] == 0.5 + assert weights['group1_series2'] == 0.5 # Series with explicit weight should have that weight - assert weights["weighted_series"] == 0.5 + assert weights['weighted_series'] == 0.5 # Series without group or weight should have weight 1 - assert weights["constant_series"] == 1 + assert weights['constant_series'] == 1 def test_insert_new_data(self, populated_collection, sample_timesteps): """Test inserting new data.""" # Create new data - new_data = pd.DataFrame({ - "constant_series": [100, 100, 100, 100, 100], - "varying_series": [5, 10, 15, 20, 25], - # extra_timestep_series is omitted to test partial updates - }, index=sample_timesteps) + new_data = pd.DataFrame( + { + 'constant_series': [100, 100, 100, 100, 100], + 'varying_series': [5, 10, 15, 20, 25], + # extra_timestep_series is omitted to test partial updates + }, + index=sample_timesteps, + ) # Insert data populated_collection.insert_new_data(new_data) # Verify updates - assert np.all(populated_collection["constant_series"].active_data.values == 100) - assert np.array_equal( - populated_collection["varying_series"].active_data.values, - np.array([5, 10, 15, 20, 25]) - ) + assert np.all(populated_collection['constant_series'].active_data.values == 100) + assert np.array_equal(populated_collection['varying_series'].active_data.values, np.array([5, 10, 15, 20, 25])) # Series not in the DataFrame should be unchanged assert np.array_equal( - populated_collection["extra_timestep_series"].active_data.values[:-1], - np.array([1, 2, 3, 4, 5]) + populated_collection['extra_timestep_series'].active_data.values[:-1], np.array([1, 2, 3, 4, 5]) ) # Test with mismatched index - bad_index = pd.date_range("2023-02-01", periods=5, freq="D", name="time") - bad_data = pd.DataFrame({"constant_series": [1, 1, 1, 1, 1]}, index=bad_index) + bad_index = pd.date_range('2023-02-01', periods=5, freq='D', name='time') + bad_data = pd.DataFrame({'constant_series': [1, 1, 1, 1, 1]}, index=bad_index) - with pytest.raises(ValueError, match="must match collection timesteps"): + with pytest.raises(ValueError, match='must match collection timesteps'): populated_collection.insert_new_data(bad_data) def test_restore_data(self, populated_collection): @@ -592,16 +530,19 @@ def test_restore_data(self, populated_collection): original_values = {name: ts.stored_data.copy() for name, ts in populated_collection.time_series_data.items()} # Modify data - new_data = pd.DataFrame({ - name: np.ones(len(populated_collection.timesteps)) * 999 - for name in populated_collection.time_series_data - if not populated_collection[name].needs_extra_timestep - }, index=populated_collection.timesteps) + new_data = pd.DataFrame( + { + name: np.ones(len(populated_collection.timesteps)) * 999 + for name in populated_collection.time_series_data + if not populated_collection[name].needs_extra_timestep + }, + index=populated_collection.timesteps, + ) populated_collection.insert_new_data(new_data) # Verify data was changed - assert np.all(populated_collection["constant_series"].active_data.values == 999) + assert np.all(populated_collection['constant_series'].active_data.values == 999) # Restore data populated_collection.restore_data() @@ -614,10 +555,7 @@ def test_restore_data(self, populated_collection): def test_class_method_with_uniform_timesteps(self): """Test the with_uniform_timesteps class method.""" collection = TimeSeriesCollection.with_uniform_timesteps( - start_time=pd.Timestamp("2023-01-01"), - periods=24, - freq="H", - hours_per_step=1 + start_time=pd.Timestamp('2023-01-01'), periods=24, freq='H', hours_per_step=1 ) assert len(collection.timesteps) == 24 @@ -631,13 +569,16 @@ def test_hours_per_timestep(self, populated_collection): assert np.allclose(hours, 24) # Default is daily timesteps # Create non-uniform timesteps - non_uniform_times = pd.DatetimeIndex([ - pd.Timestamp("2023-01-01"), - pd.Timestamp("2023-01-02"), - pd.Timestamp("2023-01-03 12:00:00"), # 1.5 days from previous - pd.Timestamp("2023-01-04"), # 0.5 days from previous - pd.Timestamp("2023-01-06") # 2 days from previous - ], name="time") + non_uniform_times = pd.DatetimeIndex( + [ + pd.Timestamp('2023-01-01'), + pd.Timestamp('2023-01-02'), + pd.Timestamp('2023-01-03 12:00:00'), # 1.5 days from previous + pd.Timestamp('2023-01-04'), # 0.5 days from previous + pd.Timestamp('2023-01-06'), # 2 days from previous + ], + name='time', + ) collection = TimeSeriesCollection(non_uniform_times) hours = collection.hours_per_timestep.values @@ -649,16 +590,16 @@ def test_hours_per_timestep(self, populated_collection): def test_validation_and_errors(self, sample_timesteps): """Test validation and error handling.""" # Test non-DatetimeIndex - with pytest.raises(TypeError, match="must be a pandas DatetimeIndex"): + with pytest.raises(TypeError, match='must be a pandas DatetimeIndex'): TimeSeriesCollection(pd.Index([1, 2, 3, 4, 5])) # Test too few timesteps - with pytest.raises(ValueError, match="must contain at least 2 timestamps"): - TimeSeriesCollection(pd.DatetimeIndex([pd.Timestamp("2023-01-01")], name="time")) + with pytest.raises(ValueError, match='must contain at least 2 timestamps'): + TimeSeriesCollection(pd.DatetimeIndex([pd.Timestamp('2023-01-01')], name='time')) # Test invalid active_timesteps collection = TimeSeriesCollection(sample_timesteps) - invalid_timesteps = pd.date_range("2024-01-01", periods=3, freq="D", name="time") + invalid_timesteps = pd.date_range('2024-01-01', periods=3, freq='D', name='time') - with pytest.raises(ValueError, match="must be a subset"): + with pytest.raises(ValueError, match='must be a subset'): collection.activate_timesteps(invalid_timesteps) From 3253d7008a34a37bb93539e05f33ee37e607511f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 29 Mar 2025 18:15:00 +0100 Subject: [PATCH 483/507] Bugfix release date --- docs/release-notes/v2.0.0.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/release-notes/v2.0.0.md b/docs/release-notes/v2.0.0.md index 610c3e3d9..5956e64bf 100644 --- a/docs/release-notes/v2.0.0.md +++ b/docs/release-notes/v2.0.0.md @@ -1,6 +1,6 @@ # Release v2.0.0 -**Release Date:** March 23, 2025 +**Release Date:** March 29, 2025 ## 🚀 Major Framework Changes From 3ac8f93901e10d4fb5da927d2c5b0d0d3374f748 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 29 Mar 2025 18:17:19 +0100 Subject: [PATCH 484/507] Update release notes --- docs/release-notes/v2.0.0.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/v2.0.0.md b/docs/release-notes/v2.0.0.md index 5956e64bf..008d1360d 100644 --- a/docs/release-notes/v2.0.0.md +++ b/docs/release-notes/v2.0.0.md @@ -76,6 +76,7 @@ This version represents a significant architecture change. If you're upgrading: - Data handling now uses xarray.Dataset throughout, which may require changes in how you interact with results - The way labels are constructed has changed throughout the system - The results of calculations are now handled differently, and may require changes in how you access results +- The framework was renamed from flixOpt to flixopt. Use `import flixopt as fx`. For complete details, please refer to the full commit history. From 6cac04cbfb5825aba2a1f976a6e210b5ad1f762c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 29 Mar 2025 18:23:17 +0100 Subject: [PATCH 485/507] Update workflow for docs --- .github/workflows/deploy-docs.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy-docs.yaml b/.github/workflows/deploy-docs.yaml index 5aa05733d..ba4ff4b29 100644 --- a/.github/workflows/deploy-docs.yaml +++ b/.github/workflows/deploy-docs.yaml @@ -26,7 +26,7 @@ jobs: run: | python -m pip install --upgrade pip # Install all documentation dependencies directly instead of using -e .[docs] - pip install mkdocs-material mkdocstrings-python mkdocs-table-reader-plugin mkdocs-include-markdown-plugin mkdocs-gen-files mkdocs-literate-nav markdown-include pymdown-extensions pygments + pip install mkdocs-material mkdocstrings-python mkdocs-table-reader-plugin mkdocs-include-markdown-plugin mkdocs-gen-files mkdocs-literate-nav markdown-include pymdown-extensions pygments mike - name: Deploy docs run: | From c35f553b54a4dc5ddddec9efe819bb89be23abb0 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 29 Mar 2025 18:29:48 +0100 Subject: [PATCH 486/507] Bugfixing docs buildt --- .github/workflows/deploy-docs.yaml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/deploy-docs.yaml b/.github/workflows/deploy-docs.yaml index ba4ff4b29..73a3f0b1f 100644 --- a/.github/workflows/deploy-docs.yaml +++ b/.github/workflows/deploy-docs.yaml @@ -25,8 +25,7 @@ jobs: - name: Install documentation dependencies run: | python -m pip install --upgrade pip - # Install all documentation dependencies directly instead of using -e .[docs] - pip install mkdocs-material mkdocstrings-python mkdocs-table-reader-plugin mkdocs-include-markdown-plugin mkdocs-gen-files mkdocs-literate-nav markdown-include pymdown-extensions pygments mike + pip install -e ".[docs]" - name: Deploy docs run: | From 228116f1dbd228a7c73b09832a474b0003821ba9 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 29 Mar 2025 18:44:04 +0100 Subject: [PATCH 487/507] Make workflow not depend on Testpy to work --- .github/workflows/python-app.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-app.yaml b/.github/workflows/python-app.yaml index 010b9b4ca..7bc995a18 100644 --- a/.github/workflows/python-app.yaml +++ b/.github/workflows/python-app.yaml @@ -104,7 +104,7 @@ jobs: publish-pypi: name: Publish to PyPI runs-on: ubuntu-22.04 - needs: [publish-testpypi] # Only run after TestPyPI publish succeeds + needs: [test] # Only run after TestPyPI publish succeeds if: github.event_name == 'release' && github.event.action == 'created' # Only on release creation steps: From fdd9e44dbbc6a55cd1bc20673ea5d7ce2773bd53 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 29 Mar 2025 18:44:27 +0100 Subject: [PATCH 488/507] Update docs to use latest docs --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 618c75451..02f9d1832 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # FlixOpt: Energy and Material Flow Optimization Framework -[![📚 Documentation](https://img.shields.io/badge/📚_docs-online-brightgreen.svg)](https://flixopt.github.io/flixopt/) +[![📚 Documentation](https://img.shields.io/badge/📚_docs-online-brightgreen.svg)](https://flixopt.github.io/flixopt/latest/) [![CI](https://github.com/flixOpt/flixopt/actions/workflows/python-app.yaml/badge.svg)](https://github.com/flixOpt/flixopt/actions/workflows/python-app.yaml) [![PyPI version](https://badge.fury.io/py/flixopt.svg)](https://badge.fury.io/py/flixopt) [![Python Versions](https://img.shields.io/pypi/pyversions/flixopt.svg)](https://pypi.org/project/flixopt/) From 963fc667b417d1730b1495158b320e690384467c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 29 Mar 2025 19:08:07 +0100 Subject: [PATCH 489/507] Update docs to use latest docs --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 02f9d1832..b65072551 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ We recommend installing FlixOpt with all dependencies, which enables additional ## 📚 Documentation -The documentation is available at [https://flixopt.github.io/flixopt/](https://flixopt.github.io/flixopt/) +The documentation is available at [https://flixopt.github.io/flixopt/latest/](https://flixopt.github.io/flixopt/latest/) --- From 2f128516468b91de31b4f1b3623600b088ef1630 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 29 Mar 2025 19:38:34 +0100 Subject: [PATCH 490/507] Add a check to prevent errors when reimporting the package --- flixopt/plotting.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 1d376a3b9..ff1fc351b 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -32,7 +32,9 @@ [217 / 255, 30 / 255, 30 / 255], # Red ] -plt.colormaps.register(mcolors.LinearSegmentedColormap.from_list('portland', _portland_colors)) +# Check if the colormap already exists before registering it +if 'portland' not in plt.colormaps: + plt.colormaps.register(mcolors.LinearSegmentedColormap.from_list('portland', _portland_colors)) ColorType = Union[str, List[str], Dict[str, str]] From 516096d0264a819881ca6d3fc57e5bdc030a3222 Mon Sep 17 00:00:00 2001 From: PStange <60431226+PStange@users.noreply.github.com> Date: Tue, 8 Apr 2025 13:32:50 +0200 Subject: [PATCH 491/507] Replace "|" with "__" when Saving plots to file (#225) --- flixopt/plotting.py | 1 + 1 file changed, 1 insertion(+) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index ff1fc351b..e4c440aaf 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -1311,6 +1311,7 @@ def export_figure( TypeError: If the figure type is not supported. """ filename = user_path or default_path + filename = filename.with_name(filename.name.replace('|', '__')) if filename.suffix == '': if default_filetype is None: raise ValueError('No default filetype provided') From db1f7d89108e42e1e1875bbd7e791d4d1bd6ae1a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 8 Apr 2025 13:49:38 +0200 Subject: [PATCH 492/507] Add warning if relative_minimum is used without on_off_parameters --- flixopt/elements.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/flixopt/elements.py b/flixopt/elements.py index 05898d4e5..a3cac3492 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -277,6 +277,13 @@ def _plausibility_checks(self) -> None: f'if you want to allow flows to be switched on and off.' ) + if (self.relative_minimum > 0).any() and self.on_off_parameters is None: + logger.warning( + f'Flow {self.label} has a relative_minimum of {self.relative_minimum.active_data} and no on_off_parameters. ' + f'This prevents the flow_rate from switching off (flow_rate = 0). ' + f'Consider using on_off_parameters to allow the flow to be switched on and off.' + ) + @property def label_full(self) -> str: return f'{self.component}({self.label})' From b094b9e8d55c81f6233787a082f3e70bf84da80b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 8 Apr 2025 13:53:04 +0200 Subject: [PATCH 493/507] Add more __ methods to TimeSeries to support comparisions --- flixopt/core.py | 60 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/flixopt/core.py b/flixopt/core.py index 379828554..79df43d61 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -426,8 +426,64 @@ def __gt__(self, other): True if all values in this TimeSeries are greater than other """ if isinstance(other, TimeSeries): - return (self.active_data > other.active_data).all().item() - return NotImplemented + return self.active_data > other.active_data + return self.active_data > other + + def __ge__(self, other): + """ + Compare if this TimeSeries is greater than or equal to another. + + Args: + other: Another TimeSeries to compare with + + Returns: + True if all values in this TimeSeries are greater than or equal to other + """ + if isinstance(other, TimeSeries): + return self.active_data >= other.active_data + return self.active_data >= other + + def __lt__(self, other): + """ + Compare if this TimeSeries is less than another. + + Args: + other: Another TimeSeries to compare with + + Returns: + True if all values in this TimeSeries are less than other + """ + if isinstance(other, TimeSeries): + return self.active_data < other.active_data + return self.active_data < other + + def __le__(self, other): + """ + Compare if this TimeSeries is less than or equal to another. + + Args: + other: Another TimeSeries to compare with + + Returns: + True if all values in this TimeSeries are less than or equal to other + """ + if isinstance(other, TimeSeries): + return self.active_data <= other.active_data + return self.active_data <= other + + def __eq__(self, other): + """ + Compare if this TimeSeries is equal to another. + + Args: + other: Another TimeSeries to compare with + + Returns: + True if all values in this TimeSeries are equal to other + """ + if isinstance(other, TimeSeries): + return self.active_data == other.active_data + return self.active_data == other def __array_ufunc__(self, ufunc, method, *inputs, **kwargs): """ From 4a36f14b486a7d2ee26a960b7c45b4a220f40c64 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 8 Apr 2025 14:00:22 +0200 Subject: [PATCH 494/507] Fix comparison --- flixopt/elements.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixopt/elements.py b/flixopt/elements.py index a3cac3492..298872482 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -115,7 +115,7 @@ def transform_data(self, flow_system: 'FlowSystem'): ) def _plausibility_checks(self) -> None: - if self.excess_penalty_per_flow_hour == 0: + if (self.excess_penalty_per_flow_hour == 0).all(): logger.warning(f'In Bus {self.label}, the excess_penalty_per_flow_hour is 0. Use "None" or a value > 0.') @property From 15fdd339658b9cf4e6ae673061b46b13adaf85a1 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 8 Apr 2025 17:21:28 +0200 Subject: [PATCH 495/507] Bugfix plausibility in Bus --- flixopt/elements.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixopt/elements.py b/flixopt/elements.py index 298872482..6020ee87a 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -115,7 +115,7 @@ def transform_data(self, flow_system: 'FlowSystem'): ) def _plausibility_checks(self) -> None: - if (self.excess_penalty_per_flow_hour == 0).all(): + if self.excess_penalty_per_flow_hour is not None and (self.excess_penalty_per_flow_hour == 0).all(): logger.warning(f'In Bus {self.label}, the excess_penalty_per_flow_hour is 0. Use "None" or a value > 0.') @property From 3822a967dab3d6fe96a6fdc878d2bdaae8f2a8c4 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 10 Apr 2025 11:40:21 +0200 Subject: [PATCH 496/507] Bugfix of load_factor? --- flixopt/elements.py | 30 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/flixopt/elements.py b/flixopt/elements.py index 05898d4e5..9f4a84dac 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -389,14 +389,13 @@ def _create_bounds_for_load_factor(self): flow_hours_per_size_max = self._model.hours_per_step.sum() * self.element.load_factor_max size = self.element.size if self._investment is None else self._investment.size - if self._investment is not None: - self.add( - self._model.add_constraints( - self.total_flow_hours <= size * flow_hours_per_size_max, - name=f'{self.label_full}|{name_short}', - ), - name_short, - ) + self.add( + self._model.add_constraints( + self.total_flow_hours <= size * flow_hours_per_size_max, + name=f'{self.label_full}|{name_short}', + ), + name_short, + ) # eq: size * sum(dt)* load_factor_min <= var_sumFlowHours if self.element.load_factor_min is not None: @@ -404,14 +403,13 @@ def _create_bounds_for_load_factor(self): flow_hours_per_size_min = self._model.hours_per_step.sum() * self.element.load_factor_min size = self.element.size if self._investment is None else self._investment.size - if self._investment is not None: - self.add( - self._model.add_constraints( - self.total_flow_hours >= size * flow_hours_per_size_min, - name=f'{self.label_full}|{name_short}', - ), - name_short, - ) + self.add( + self._model.add_constraints( + self.total_flow_hours >= size * flow_hours_per_size_min, + name=f'{self.label_full}|{name_short}', + ), + name_short, + ) @property def absolute_flow_rate_bounds(self) -> Tuple[NumericData, NumericData]: From 98bfea3ecb5e3655fa0504a3799472ac9d48fdcf Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 10 Apr 2025 14:57:18 +0200 Subject: [PATCH 497/507] Change to object comparison instead of equality --- flixopt/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixopt/core.py b/flixopt/core.py index 79df43d61..08be18f1d 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -899,7 +899,7 @@ def __contains__(self, item: Union[str, TimeSeries]) -> bool: if isinstance(item, str): return item in self.time_series_data elif isinstance(item, TimeSeries): - return item in self.time_series_data.values() + return any([item is ts for ts in self.time_series_data.values()]) return False @property From 45eff825bad800e01732e782038e551470d66da1 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 10 Apr 2025 14:59:40 +0200 Subject: [PATCH 498/507] Update tests --- tests/test_timeseries.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_timeseries.py b/tests/test_timeseries.py index 48c7ab7b2..91b0b26f3 100644 --- a/tests/test_timeseries.py +++ b/tests/test_timeseries.py @@ -268,13 +268,13 @@ def test_comparison_operations(self, sample_timesteps): ts2 = TimeSeries(data2, 'Series 2') # Test __gt__ method - assert (ts1 > ts2) is True # All values in ts1 are greater than ts2 + assert (ts1 > ts2).all().item() # Test with mixed values data3 = xr.DataArray([5, 25, 15, 45, 25], coords={'time': sample_timesteps}, dims=['time']) ts3 = TimeSeries(data3, 'Series 3') - assert (ts1 > ts3) is False # Not all values in ts1 are greater than ts3 + assert not (ts1 > ts3).all().item() # Not all values in ts1 are greater than ts3 def test_numpy_ufunc(self, sample_timeseries): """Test numpy ufunc compatibility.""" From 1c568136be75700e53e9b8f7855bf57872d86e3d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 10 Apr 2025 17:01:20 +0200 Subject: [PATCH 499/507] Add release notes --- docs/release-notes/v2.0.1.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 docs/release-notes/v2.0.1.md diff --git a/docs/release-notes/v2.0.1.md b/docs/release-notes/v2.0.1.md new file mode 100644 index 000000000..657bcca87 --- /dev/null +++ b/docs/release-notes/v2.0.1.md @@ -0,0 +1,12 @@ +# Release v2.0.1 + +**Release Date:** 2025-04-20 + +## Improvements + +* Add logger warning if relative_minimum is used without on_off_parameters in Flow, as this prevents the flow_rate from switching "OFF" + +## Bug Fixes + +* Replace "|" with "__" in filenames when saving figures, as "|" can lead to issues on windows +* Fixed a Bug that prevented the load factor from working without InvestmentParameters From c77ea759058441c0e3b2dbaafb34c4db09a799f0 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 11 Apr 2025 10:24:22 +0200 Subject: [PATCH 500/507] Allow Python 3.13 (#217) * Bump up python version in tests * Remove numpy restriction in dependencies --- .github/workflows/python-app.yaml | 2 +- pyproject.toml | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/python-app.yaml b/.github/workflows/python-app.yaml index 7bc995a18..abaef42e1 100644 --- a/.github/workflows/python-app.yaml +++ b/.github/workflows/python-app.yaml @@ -37,7 +37,7 @@ jobs: strategy: fail-fast: false # Continue testing other Python versions if one fails matrix: - python-version: ['3.10', '3.11', '3.12'] + python-version: ['3.10', '3.11', '3.12', '3.13'] steps: - name: Check out code diff --git a/pyproject.toml b/pyproject.toml index afd1c397e..f3b48273f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "flixopt" dynamic = ["version"] description = "Vector based energy and material flow optimization framework in Python." readme = "README.md" -requires-python = ">=3.10, <3.13" +requires-python = ">=3.10" license = { text = "MIT License" } authors = [ { name = "Chair of Building Energy Systems and Heat Supply, TU Dresden", email = "peter.stange@tu-dresden.de" }, @@ -26,13 +26,14 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Intended Audience :: Developers", "Intended Audience :: Science/Research", "Topic :: Scientific/Engineering", "License :: OSI Approved :: MIT License", ] dependencies = [ - "numpy >= 1.21.5, < 2", + "numpy >= 1.21.5", "PyYAML >= 6.0", "linopy >= 0.5.1", "netcdf4 >= 1.6.1", From 61d37eeb6d762a1773aa5566945f3b2fc38dd79f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 11 Apr 2025 14:52:20 +0200 Subject: [PATCH 501/507] Fixes/tests (#234) * Bugfix in FlowModel * Bugfix in OnOffModel * bugfix in rename from cherry pick * bugfix in rename from cherry pick * Add tests of mathematical model for classes * Bugfix divest effects * Add lower bound to variable (just for good measure) * ruff check * Add pytest mark "slow" * ruff check * remove code duplicate * Bugfix consecutive duration and add tests * Add test for compute_previous_on_states() * Add check in compute_previous_on_states() for array size * Make sure the minimum size is not 0 --- flixopt/elements.py | 57 +- flixopt/features.py | 40 +- flixopt/interface.py | 10 +- tests/conftest.py | 93 ++- tests/test_bus.py | 58 ++ tests/test_effect.py | 142 ++++ tests/test_examples.py | 1 + tests/test_flow.py | 998 +++++++++++++++++++++++++++++ tests/test_integration.py | 2 +- tests/test_io.py | 2 +- tests/test_on_hours_computation.py | 105 +++ tests/test_plots.py | 1 + tests/test_results_plots.py | 4 +- tests/test_timeseries.py | 2 +- 14 files changed, 1473 insertions(+), 42 deletions(-) create mode 100644 tests/test_bus.py create mode 100644 tests/test_effect.py create mode 100644 tests/test_flow.py create mode 100644 tests/test_on_hours_computation.py diff --git a/flixopt/elements.py b/flixopt/elements.py index 4ed9771e7..e6b5d802b 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -313,8 +313,8 @@ def do_modeling(self): # eq relative_minimum(t) * size <= flow_rate(t) <= relative_maximum(t) * size self.flow_rate: linopy.Variable = self.add( self._model.add_variables( - lower=self.absolute_flow_rate_bounds[0] if self.element.on_off_parameters is None else 0, - upper=self.absolute_flow_rate_bounds[1], + lower=self.flow_rate_lower_bound, + upper=self.flow_rate_upper_bound, coords=self._model.coords, name=f'{self.label_full}|flow_rate', ), @@ -329,7 +329,7 @@ def do_modeling(self): label_of_element=self.label_of_element, on_off_parameters=self.element.on_off_parameters, defining_variables=[self.flow_rate], - defining_bounds=[self.absolute_flow_rate_bounds], + defining_bounds=[self.flow_rate_bounds_on], previous_values=[self.element.previous_flow_rate], ), 'on_off', @@ -344,7 +344,8 @@ def do_modeling(self): label_of_element=self.label_of_element, parameters=self.element.size, defining_variable=self.flow_rate, - relative_bounds_of_defining_variable=self.relative_flow_rate_bounds, + relative_bounds_of_defining_variable=(self.flow_rate_lower_bound_relative, + self.flow_rate_upper_bound_relative), on_variable=self.on_off.on if self.on_off is not None else None, ), 'investment', @@ -353,7 +354,7 @@ def do_modeling(self): self.total_flow_hours = self.add( self._model.add_variables( - lower=self.element.flow_hours_total_min if self.element.flow_hours_total_min is not None else -np.inf, + lower=self.element.flow_hours_total_min if self.element.flow_hours_total_min is not None else 0, upper=self.element.flow_hours_total_max if self.element.flow_hours_total_max is not None else np.inf, coords=None, name=f'{self.label_full}|total_flow_hours', @@ -419,9 +420,9 @@ def _create_bounds_for_load_factor(self): ) @property - def absolute_flow_rate_bounds(self) -> Tuple[NumericData, NumericData]: + def flow_rate_bounds_on(self) -> Tuple[NumericData, NumericData]: """Returns absolute flow rate bounds. Important for OnOffModel""" - relative_minimum, relative_maximum = self.relative_flow_rate_bounds + relative_minimum, relative_maximum = self.flow_rate_lower_bound_relative, self.flow_rate_upper_bound_relative size = self.element.size if not isinstance(size, InvestParameters): return relative_minimum * size, relative_maximum * size @@ -430,12 +431,44 @@ def absolute_flow_rate_bounds(self) -> Tuple[NumericData, NumericData]: return relative_minimum * size.minimum_size, relative_maximum * size.maximum_size @property - def relative_flow_rate_bounds(self) -> Tuple[NumericData, NumericData]: - """Returns relative flow rate bounds.""" + def flow_rate_lower_bound_relative(self) -> NumericData: + """Returns the lower bound of the flow_rate relative to its size""" fixed_profile = self.element.fixed_relative_profile if fixed_profile is None: - return self.element.relative_minimum.active_data, self.element.relative_maximum.active_data - return fixed_profile.active_data, fixed_profile.active_data + return self.element.relative_minimum.active_data + return fixed_profile.active_data + + @property + def flow_rate_upper_bound_relative(self) -> NumericData: + """ Returns the upper bound of the flow_rate relative to its size""" + fixed_profile = self.element.fixed_relative_profile + if fixed_profile is None: + return self.element.relative_maximum.active_data + return fixed_profile.active_data + + @property + def flow_rate_lower_bound(self) -> NumericData: + """ + Returns the minimum bound the flow_rate can reach. + Further constraining might be done in OnOffModel and InvestmentModel + """ + if self.element.on_off_parameters is not None: + return 0 + if isinstance(self.element.size, InvestParameters): + if self.element.size.optional: + return 0 + return self.flow_rate_lower_bound_relative * self.element.size.minimum_size + return self.flow_rate_lower_bound_relative * self.element.size + + @property + def flow_rate_upper_bound(self) -> NumericData: + """ + Returns the maximum bound the flow_rate can reach. + Further constraining might be done in OnOffModel and InvestmentModel + """ + if isinstance(self.element.size, InvestParameters): + return self.flow_rate_upper_bound_relative * self.element.size.maximum_size + return self.flow_rate_upper_bound_relative * self.element.size class BusModel(ElementModel): @@ -513,7 +546,7 @@ def do_modeling(self): self.element.on_off_parameters, self.label_of_element, defining_variables=[flow.model.flow_rate for flow in all_flows], - defining_bounds=[flow.model.absolute_flow_rate_bounds for flow in all_flows], + defining_bounds=[flow.model.flow_rate_bounds_on for flow in all_flows], previous_values=[flow.previous_flow_rate for flow in all_flows], ) ) diff --git a/flixopt/features.py b/flixopt/features.py index 92caf9dc2..67f036147 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -90,7 +90,7 @@ def _create_shares(self): # share: divest_effects - isInvested * divest_effects self._model.effects.add_share_to_effects( name=self.label_of_element, - expressions={effect: -self.is_invested * factor + factor for effect, factor in fix_effects.items()}, + expressions={effect: -self.is_invested * factor + factor for effect, factor in self.parameters.divest_effects.items()}, target='invest', ) @@ -314,6 +314,7 @@ def do_modeling(self): self.switch_on_nr = self.add( self._model.add_variables( + lower=0, upper=self.parameters.switch_on_total_max if self.parameters.switch_on_total_max is not None else np.inf, @@ -635,6 +636,9 @@ def compute_previous_on_states(previous_values: List[Optional[NumericData]], eps A binary array (0 and 1) indicating the previous on/off states of the variables. Returns `array([0])` if no previous values are available. """ + for arr in previous_values: + if isinstance(arr, np.ndarray) and arr.ndim > 1: + raise ValueError('Only 1D arrays or None values are supported for previous_values') if not previous_values or all([val is None for val in previous_values]): return np.array([0]) @@ -672,28 +676,28 @@ def compute_consecutive_duration( elif np.isscalar(binary_values) and not np.isscalar(hours_per_timestep): return binary_values * hours_per_timestep[-1] - # Find the indexes where value=`0` in a 1D-array - zero_indices = np.where(np.isclose(binary_values, 0, atol=CONFIG.modeling.EPSILON))[0] - length_of_last_duration = zero_indices[-1] + 1 if zero_indices.size > 0 else len(binary_values) + if np.isclose(binary_values[-1], 0, atol=CONFIG.modeling.EPSILON): + return 0 - if not np.isscalar(binary_values) and np.isscalar(hours_per_timestep): - return np.sum(binary_values[-length_of_last_duration:] * hours_per_timestep) - - elif not np.isscalar(binary_values) and not np.isscalar(hours_per_timestep): - if length_of_last_duration > len(hours_per_timestep): # check that lengths are compatible - raise TypeError( - f'When trying to calculate the consecutive duration, the length of the last duration ' - f'({len(length_of_last_duration)}) is longer than the hours_per_timestep ({len(hours_per_timestep)}), ' - f'as {binary_values=}' - ) - return np.sum(binary_values[-length_of_last_duration:] * hours_per_timestep[-length_of_last_duration:]) + if np.isscalar(hours_per_timestep): + hours_per_timestep = np.ones(len(binary_values)) * hours_per_timestep + hours_per_timestep: np.ndarray + indexes_with_zero_values = np.where(np.isclose(binary_values, 0, atol=CONFIG.modeling.EPSILON))[0] + if len(indexes_with_zero_values) == 0: + nr_of_indexes_with_consecutive_ones = len(binary_values) else: - raise Exception( - f'Unexpected state reached in function get_consecutive_duration(). binary_values={binary_values}; ' - f'hours_per_timestep={hours_per_timestep}' + nr_of_indexes_with_consecutive_ones = len(binary_values) - indexes_with_zero_values[-1] - 1 + + if len(hours_per_timestep) < nr_of_indexes_with_consecutive_ones: + raise ValueError( + f'When trying to calculate the consecutive duration, the length of the last duration ' + f'({len(nr_of_indexes_with_consecutive_ones)}) is longer than the provided hours_per_timestep ({len(hours_per_timestep)}), ' + f'as {binary_values=}' ) + return np.sum(binary_values[-nr_of_indexes_with_consecutive_ones:] * hours_per_timestep[-nr_of_indexes_with_consecutive_ones:]) + class PieceModel(Model): """Class for modeling a linear piece of one or more variables in parallel""" diff --git a/flixopt/interface.py b/flixopt/interface.py index f9dbeb518..c38d6c619 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -111,7 +111,7 @@ class InvestParameters(Interface): def __init__( self, fixed_size: Optional[Union[int, float]] = None, - minimum_size: Union[int, float] = 0, # TODO: Use EPSILON? + minimum_size: Optional[Union[int, float]] = None, maximum_size: Optional[Union[int, float]] = None, optional: bool = True, # Investition ist weglassbar fix_effects: Optional['EffectValuesUserScalar'] = None, @@ -141,8 +141,8 @@ def __init__( ] # € (Attention: Annualize costs to chosen period!) (Args 'specific_effects' and 'fix_effects' can be used in parallel to Investsizepieces) - minimum_size: Min nominal value (only if: size_is_fixed = False). - maximum_size: Max nominal value (only if: size_is_fixed = False). + minimum_size: Min nominal value (only if: size_is_fixed = False). Defaults to CONFIG.modeling.EPSILON. + maximum_size: Max nominal value (only if: size_is_fixed = False). Defaults to CONFIG.modeling.BIG. """ self.fix_effects: EffectValuesUser = fix_effects or {} self.divest_effects: EffectValuesUser = divest_effects or {} @@ -150,8 +150,8 @@ def __init__( self.optional = optional self.specific_effects: EffectValuesUser = specific_effects or {} self.piecewise_effects = piecewise_effects - self._minimum_size = minimum_size - self._maximum_size = maximum_size or CONFIG.modeling.BIG # default maximum + self._minimum_size = minimum_size if minimum_size is not None else CONFIG.modeling.EPSILON + self._maximum_size = maximum_size if maximum_size is not None else CONFIG.modeling.BIG # default maximum def transform_data(self, flow_system: 'FlowSystem'): self.fix_effects = flow_system.effects.create_effect_values_dict(self.fix_effects) diff --git a/tests/conftest.py b/tests/conftest.py index 72aa1dee1..50dad5a82 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,11 +6,14 @@ import os +import linopy.testing import numpy as np import pandas as pd import pytest +import xarray as xr import flixopt as fx +from flixopt.structure import SystemModel @pytest.fixture() @@ -403,8 +406,94 @@ def flow_system_long(): } -def create_calculation_and_solve(flow_system: fx.FlowSystem, solver, name: str) -> fx.FullCalculation: +def create_calculation_and_solve(flow_system: fx.FlowSystem, solver, name: str, allow_infeasible: bool=False) -> fx.FullCalculation: calculation = fx.FullCalculation(name, flow_system) calculation.do_modeling() - calculation.solve(solver) + try: + calculation.solve(solver) + except RuntimeError as e: + if allow_infeasible: + pass + else: + raise RuntimeError from e return calculation + + +def create_linopy_model(flow_system: fx.FlowSystem) -> SystemModel: + calculation = fx.FullCalculation('GenericName', flow_system) + calculation.do_modeling() + return calculation.model + +@pytest.fixture(params=['h', '3h']) +def timesteps_linopy(request): + return pd.date_range('2020-01-01', periods=10, freq=request.param, name='time') + + +@pytest.fixture +def basic_flow_system_linopy(timesteps_linopy) -> fx.FlowSystem: + """Create basic elements for component testing""" + flow_system = fx.FlowSystem(pd.date_range('2020-01-01', periods=10, freq='h', name='time')) + thermal_load = np.array([np.random.random() for _ in range(10)]) * 180 + p_el = (np.array([np.random.random() for _ in range(10)]) + 0.5) / 1.5 * 50 + + flow_system.add_elements( + fx.Bus('Strom'), + fx.Bus('Fernwärme'), + fx.Bus('Gas'), + fx.Effect('Costs', '€', 'Kosten', is_standard=True, is_objective=True), + fx.Sink('Wärmelast', sink=fx.Flow('Q_th_Last', 'Fernwärme', size=1, fixed_relative_profile=thermal_load)), + fx.Source('Gastarif', source=fx.Flow('Q_Gas', 'Gas', size=1000, effects_per_flow_hour=0.04)), + fx.Sink('Einspeisung', sink=fx.Flow('P_el', 'Strom', effects_per_flow_hour=-1 * p_el)), + ) + + return flow_system + +def assert_conequal(actual: linopy.Constraint, desired: linopy.Constraint): + """Assert that two constraints are equal with detailed error messages.""" + name = actual.name + + try: + linopy.testing.assert_linequal(actual.lhs, desired.lhs) + except AssertionError as e: + raise AssertionError(f"{name} left-hand sides don't match:\n{e}") from e + + try: + linopy.testing.assert_linequal(actual.rhs, desired.rhs) + except AssertionError as e: + raise AssertionError(f"{name} right-hand sides don't match:\n{e}") from e + + try: + xr.testing.assert_equal(actual.sign, desired.sign) + except AssertionError as e: + raise AssertionError(f"{name} signs don't match:\nActual: {actual.sign}\nExpected: {desired.sign}") from e + + +def assert_var_equal(actual: linopy.Variable, desired: linopy.Variable): + """Assert that two variables are equal with detailed error messages.""" + name = actual.name + try: + xr.testing.assert_equal(actual.lower, desired.lower) + except AssertionError as e: + raise AssertionError(f"{name} lower bounds don't match:\nActual: {actual.lower}\nExpected: {desired.lower}") from e + + try: + xr.testing.assert_equal(actual.upper, desired.upper) + except AssertionError as e: + raise AssertionError(f"{name} upper bounds don't match:\nActual: {actual.upper}\nExpected: {desired.upper}") from e + + if actual.type != desired.type: + raise AssertionError(f"{name} types don't match: {actual.type} != {desired.type}") + + if actual.size != desired.size: + raise AssertionError(f"{name} sizes don't match: {actual.size} != {desired.size}") + + if actual.shape != desired.shape: + raise AssertionError(f"{name} shapes don't match: {actual.shape} != {desired.shape}") + + try: + xr.testing.assert_equal(actual.coords, desired.coords) + except AssertionError as e: + raise AssertionError(f"{name} coordinates don't match:\nActual: {actual.coords}\nExpected: {desired.coords}") from e + + if actual.coord_dims != desired.coord_dims: + raise AssertionError(f"{name} coordinate dimensions don't match: {actual.coord_dims} != {desired.coord_dims}") diff --git a/tests/test_bus.py b/tests/test_bus.py new file mode 100644 index 000000000..4a41a9f9e --- /dev/null +++ b/tests/test_bus.py @@ -0,0 +1,58 @@ +import numpy as np +import pandas as pd +import pytest +import xarray as xr + +import flixopt as fx + +from .conftest import assert_conequal, assert_var_equal, create_linopy_model + + +class TestBusModel: + """Test the FlowModel class.""" + + def test_bus(self, basic_flow_system_linopy): + """Test that flow model constraints are correctly generated.""" + flow_system = basic_flow_system_linopy + bus = fx.Bus('TestBus', excess_penalty_per_flow_hour=None) + flow_system.add_elements(bus, + fx.Sink('WärmelastTest', sink=fx.Flow('Q_th_Last', 'TestBus')), + fx.Source('GastarifTest', source=fx.Flow('Q_Gas', 'TestBus'))) + model = create_linopy_model(flow_system) + + assert set(bus.model.variables) == {'WärmelastTest(Q_th_Last)|flow_rate', 'GastarifTest(Q_Gas)|flow_rate'} + assert set(bus.model.constraints) == {'TestBus|balance'} + + assert_conequal( + model.constraints['TestBus|balance'], + model.variables['GastarifTest(Q_Gas)|flow_rate'] == model.variables['WärmelastTest(Q_th_Last)|flow_rate'] + ) + + def test_bus_penalty(self, basic_flow_system_linopy): + """Test that flow model constraints are correctly generated.""" + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + bus = fx.Bus('TestBus') + flow_system.add_elements(bus, + fx.Sink('WärmelastTest', sink=fx.Flow('Q_th_Last', 'TestBus')), + fx.Source('GastarifTest', source=fx.Flow('Q_Gas', 'TestBus'))) + model = create_linopy_model(flow_system) + + assert set(bus.model.variables) == {'TestBus|excess_input', + 'TestBus|excess_output', + 'WärmelastTest(Q_th_Last)|flow_rate', + 'GastarifTest(Q_Gas)|flow_rate'} + assert set(bus.model.constraints) == {'TestBus|balance'} + + assert_var_equal(model.variables['TestBus|excess_input'], model.add_variables(lower=0, coords = (timesteps,))) + assert_var_equal(model.variables['TestBus|excess_output'], model.add_variables(lower=0, coords=(timesteps,))) + + assert_conequal( + model.constraints['TestBus|balance'], + model.variables['GastarifTest(Q_Gas)|flow_rate'] - model.variables['WärmelastTest(Q_th_Last)|flow_rate'] + model.variables['TestBus|excess_input'] - model.variables['TestBus|excess_output'] == 0 + ) + + assert_conequal( + model.constraints['TestBus->Penalty'], + model.variables['TestBus->Penalty'] == (model.variables['TestBus|excess_input'] * 1e5 * model.hours_per_step).sum() + (model.variables['TestBus|excess_output'] * 1e5 * model.hours_per_step).sum(), + ) diff --git a/tests/test_effect.py b/tests/test_effect.py new file mode 100644 index 000000000..5cbc04ac6 --- /dev/null +++ b/tests/test_effect.py @@ -0,0 +1,142 @@ +import numpy as np +import pandas as pd +import pytest +import xarray as xr + +import flixopt as fx + +from .conftest import assert_conequal, assert_var_equal, create_linopy_model + + +class TestBusModel: + """Test the FlowModel class.""" + + def test_minimal(self, basic_flow_system_linopy): + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + effect = fx.Effect('Effect1', '€', 'Testing Effect') + + flow_system.add_elements(effect) + model = create_linopy_model(flow_system) + + assert set(effect.model.variables) == {'Effect1(invest)|total', + 'Effect1(operation)|total', + 'Effect1(operation)|total_per_timestep', + 'Effect1|total',} + assert set(effect.model.constraints) == {'Effect1(invest)|total', + 'Effect1(operation)|total', + 'Effect1(operation)|total_per_timestep', + 'Effect1|total',} + + assert_var_equal(model.variables['Effect1|total'], model.add_variables()) + assert_var_equal(model.variables['Effect1(invest)|total'], model.add_variables()) + assert_var_equal(model.variables['Effect1(operation)|total'], model.add_variables()) + assert_var_equal(model.variables['Effect1(operation)|total_per_timestep'], model.add_variables(coords=(timesteps,))) + + assert_conequal(model.constraints['Effect1|total'], + model.variables['Effect1|total'] == model.variables['Effect1(operation)|total'] + model.variables['Effect1(invest)|total']) + assert_conequal(model.constraints['Effect1(invest)|total'], model.variables['Effect1(invest)|total'] == 0) + assert_conequal(model.constraints['Effect1(operation)|total'], + model.variables['Effect1(operation)|total'] == model.variables['Effect1(operation)|total_per_timestep'].sum()) + assert_conequal(model.constraints['Effect1(operation)|total_per_timestep'], + model.variables['Effect1(operation)|total_per_timestep'] ==0) + + def test_bounds(self, basic_flow_system_linopy): + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + effect = fx.Effect('Effect1', '€', 'Testing Effect', + minimum_operation=1.0, + maximum_operation=1.1, + minimum_invest=2.0, + maximum_invest=2.1, + minimum_total=3.0, + maximum_total=3.1, + minimum_operation_per_hour=4.0, + maximum_operation_per_hour=4.1 + ) + + flow_system.add_elements(effect) + model = create_linopy_model(flow_system) + + assert set(effect.model.variables) == {'Effect1(invest)|total', + 'Effect1(operation)|total', + 'Effect1(operation)|total_per_timestep', + 'Effect1|total',} + assert set(effect.model.constraints) == {'Effect1(invest)|total', + 'Effect1(operation)|total', + 'Effect1(operation)|total_per_timestep', + 'Effect1|total',} + + assert_var_equal(model.variables['Effect1|total'], model.add_variables(lower=3.0, upper=3.1)) + assert_var_equal(model.variables['Effect1(invest)|total'], model.add_variables(lower=2.0, upper=2.1)) + assert_var_equal(model.variables['Effect1(operation)|total'], model.add_variables(lower=1.0, upper=1.1)) + assert_var_equal( + model.variables['Effect1(operation)|total_per_timestep'], model.add_variables( + lower=4.0 * model.hours_per_step, upper=4.1* model.hours_per_step, coords=(timesteps,)) + ) + + assert_conequal(model.constraints['Effect1|total'], + model.variables['Effect1|total'] == model.variables['Effect1(operation)|total'] + model.variables['Effect1(invest)|total']) + assert_conequal(model.constraints['Effect1(invest)|total'], model.variables['Effect1(invest)|total'] == 0) + assert_conequal(model.constraints['Effect1(operation)|total'], + model.variables['Effect1(operation)|total'] == model.variables['Effect1(operation)|total_per_timestep'].sum()) + assert_conequal(model.constraints['Effect1(operation)|total_per_timestep'], + model.variables['Effect1(operation)|total_per_timestep'] ==0) + + def test_shares(self, basic_flow_system_linopy): + flow_system = basic_flow_system_linopy + effect1 = fx.Effect('Effect1', '€', 'Testing Effect', + specific_share_to_other_effects_operation={ + 'Effect2': 1.1, + 'Effect3': 1.2 + }, + specific_share_to_other_effects_invest={ + 'Effect2': 2.1, + 'Effect3': 2.2 + } + ) + effect2 = fx.Effect('Effect2', '€', 'Testing Effect') + effect3 = fx.Effect('Effect3', '€', 'Testing Effect') + flow_system.add_elements(effect1, effect2, effect3) + model = create_linopy_model(flow_system) + + assert set(effect2.model.variables) == { + 'Effect2(invest)|total', + 'Effect2(operation)|total', + 'Effect2(operation)|total_per_timestep', + 'Effect2|total', + 'Effect1(invest)->Effect2(invest)', + 'Effect1(operation)->Effect2(operation)', + } + assert set(effect2.model.constraints) == { + 'Effect2(invest)|total', + 'Effect2(operation)|total', + 'Effect2(operation)|total_per_timestep', + 'Effect2|total', + 'Effect1(invest)->Effect2(invest)', + 'Effect1(operation)->Effect2(operation)', + } + + assert_conequal( + model.constraints['Effect2(invest)|total'], + model.variables['Effect2(invest)|total'] == model.variables['Effect1(invest)->Effect2(invest)'], + ) + + assert_conequal( + model.constraints['Effect2(operation)|total_per_timestep'], + model.variables['Effect2(operation)|total_per_timestep'] == model.variables['Effect1(operation)->Effect2(operation)'], + ) + + assert_conequal( + model.constraints['Effect1(operation)->Effect2(operation)'], + model.variables['Effect1(operation)->Effect2(operation)'] + == model.variables['Effect1(operation)|total_per_timestep'] * 1.1 + ) + + assert_conequal( + model.constraints['Effect1(invest)->Effect2(invest)'], + model.variables['Effect1(invest)->Effect2(invest)'] + == model.variables['Effect1(invest)|total'] * 2.1, + ) + + diff --git a/tests/test_examples.py b/tests/test_examples.py index 85f87d4cc..ad2846679 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -16,6 +16,7 @@ ), # Sort by parent and script name ids=lambda path: str(path.relative_to(EXAMPLES_DIR)), # Show relative file paths ) +@pytest.mark.slow def test_example_scripts(example_script): """ Test all example scripts in the examples directory. diff --git a/tests/test_flow.py b/tests/test_flow.py new file mode 100644 index 000000000..5026c6120 --- /dev/null +++ b/tests/test_flow.py @@ -0,0 +1,998 @@ +import numpy as np +import pandas as pd +import pytest +import xarray as xr + +import flixopt as fx + +from .conftest import assert_conequal, assert_var_equal, create_linopy_model + + +class TestFlowModel: + """Test the FlowModel class.""" + + def test_flow_minimal(self, basic_flow_system_linopy): + """Test that flow model constraints are correctly generated.""" + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + flow = fx.Flow('Wärme', bus='Fernwärme', size=100) + + flow_system.add_elements(fx.Sink('Sink', sink=flow)) + + model = create_linopy_model(flow_system) + + assert_conequal( + model.constraints['Sink(Wärme)|total_flow_hours'], + flow.model.variables['Sink(Wärme)|total_flow_hours'] == (flow.model.variables['Sink(Wärme)|flow_rate'] * model.hours_per_step).sum() + ) + assert_var_equal(flow.model.flow_rate, + model.add_variables(lower=0, upper=100, coords=(timesteps,))) + assert_var_equal(flow.model.total_flow_hours, model.add_variables(lower=0)) + + assert set(flow.model.variables) == set(['Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate']) + assert set(flow.model.constraints) == set(['Sink(Wärme)|total_flow_hours']) + + def test_flow(self, basic_flow_system_linopy): + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + flow = fx.Flow( + 'Wärme', + bus='Fernwärme', + size=100, + relative_minimum=np.linspace(0, 0.5, timesteps.size), + relative_maximum=np.linspace(0.5, 1, timesteps.size), + flow_hours_total_max=1000, + flow_hours_total_min=10, + load_factor_min=0.1, + load_factor_max=0.9, + ) + + flow_system.add_elements(fx.Sink('Sink', sink=flow)) + model = create_linopy_model(flow_system) + + # total_flow_hours + assert_conequal( + model.constraints['Sink(Wärme)|total_flow_hours'], + flow.model.variables['Sink(Wärme)|total_flow_hours'] + == (flow.model.variables['Sink(Wärme)|flow_rate'] * model.hours_per_step).sum(), + ) + + assert_var_equal( + flow.model.total_flow_hours, + model.add_variables(lower=10, upper=1000) + ) + + assert_var_equal( + flow.model.flow_rate, + model.add_variables(lower=np.linspace(0, 0.5, timesteps.size) * 100, + upper=np.linspace(0.5, 1, timesteps.size) * 100, + coords=(timesteps,)) + ) + + assert_conequal( + model.constraints['Sink(Wärme)|load_factor_min'], + flow.model.variables['Sink(Wärme)|total_flow_hours'] + >= model.hours_per_step.sum('time') * 0.1 * 100, + ) + + assert_conequal( + model.constraints['Sink(Wärme)|load_factor_max'], + flow.model.variables['Sink(Wärme)|total_flow_hours'] + <= model.hours_per_step.sum('time') * 0.9 * 100, + ) + + assert set(flow.model.variables) == set(['Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate']) + assert set(flow.model.constraints) == set(['Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|load_factor_max', 'Sink(Wärme)|load_factor_min']) + + def test_effects_per_flow_hour(self, basic_flow_system_linopy): + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + + costs_per_flow_hour = xr.DataArray(np.linspace(1,2,timesteps.size), coords=(timesteps,)) + co2_per_flow_hour = xr.DataArray(np.linspace(4, 5, timesteps.size), coords=(timesteps,)) + + flow = fx.Flow( + 'Wärme', + bus='Fernwärme', + effects_per_flow_hour={'Costs': costs_per_flow_hour, 'CO2': co2_per_flow_hour} + ) + flow_system.add_elements(fx.Sink('Sink', sink=flow), fx.Effect('CO2', 't', '')) + model = create_linopy_model(flow_system) + costs, co2 = flow_system.effects['Costs'], flow_system.effects['CO2'] + + assert set(flow.model.variables) == {'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate'} + assert set(flow.model.constraints) == {'Sink(Wärme)|total_flow_hours'} + + assert 'Sink(Wärme)->Costs(operation)' in set(costs.model.constraints) + assert 'Sink(Wärme)->CO2(operation)' in set(co2.model.constraints) + + assert_conequal( + model.constraints['Sink(Wärme)->Costs(operation)'], + model.variables['Sink(Wärme)->Costs(operation)'] == flow.model.variables['Sink(Wärme)|flow_rate'] * model.hours_per_step * costs_per_flow_hour) + + assert_conequal( + model.constraints['Sink(Wärme)->CO2(operation)'], + model.variables['Sink(Wärme)->CO2(operation)'] == flow.model.variables['Sink(Wärme)|flow_rate'] * model.hours_per_step * co2_per_flow_hour) + + +class TestFlowInvestModel: + """Test the FlowModel class.""" + + def test_flow_invest(self, basic_flow_system_linopy): + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + + flow = fx.Flow( + 'Wärme', + bus='Fernwärme', + size=fx.InvestParameters(minimum_size=20, maximum_size=100, optional=False), + relative_minimum=np.linspace(0.1, 0.5, timesteps.size), + relative_maximum=np.linspace(0.5, 1, timesteps.size), + ) + + flow_system.add_elements(fx.Sink('Sink', sink=flow)) + model = create_linopy_model(flow_system) + + assert set(flow.model.variables) == set( + [ + 'Sink(Wärme)|total_flow_hours', + 'Sink(Wärme)|flow_rate', + 'Sink(Wärme)|size', + ] + ) + assert set(flow.model.constraints) == set( + [ + 'Sink(Wärme)|total_flow_hours', + 'Sink(Wärme)|lb_Sink(Wärme)|flow_rate', + 'Sink(Wärme)|ub_Sink(Wärme)|flow_rate', + ] + ) + + # size + assert_var_equal(model['Sink(Wärme)|size'], model.add_variables(lower=20, upper=100)) + + # flow_rate + assert_var_equal( + flow.model.flow_rate, + model.add_variables( + lower=np.linspace(0.1, 0.5, timesteps.size) * 20, + upper=np.linspace(0.5, 1, timesteps.size) * 100, + coords=(timesteps,), + ), + ) + assert_conequal( + model.constraints['Sink(Wärme)|lb_Sink(Wärme)|flow_rate'], + flow.model.variables['Sink(Wärme)|flow_rate'] + >= flow.model.variables['Sink(Wärme)|size'] + * xr.DataArray(np.linspace(0.1, 0.5, timesteps.size), coords=(timesteps,)), + ) + assert_conequal( + model.constraints['Sink(Wärme)|ub_Sink(Wärme)|flow_rate'], + flow.model.variables['Sink(Wärme)|flow_rate'] + <= flow.model.variables['Sink(Wärme)|size'] + * xr.DataArray(np.linspace(0.5, 1, timesteps.size), coords=(timesteps,)), + ) + + def test_flow_invest_optional(self, basic_flow_system_linopy): + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + + flow = fx.Flow( + 'Wärme', + bus='Fernwärme', + size=fx.InvestParameters(minimum_size=20, maximum_size=100, optional=True), + relative_minimum=np.linspace(0.1, 0.5, timesteps.size), + relative_maximum=np.linspace(0.5, 1, timesteps.size), + ) + + flow_system.add_elements(fx.Sink('Sink', sink=flow)) + model = create_linopy_model(flow_system) + + assert set(flow.model.variables) == set( + ['Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate', 'Sink(Wärme)|size', 'Sink(Wärme)|is_invested'] + ) + assert set(flow.model.constraints) == set( + [ + 'Sink(Wärme)|total_flow_hours', + 'Sink(Wärme)|is_invested_ub', + 'Sink(Wärme)|is_invested_lb', + 'Sink(Wärme)|lb_Sink(Wärme)|flow_rate', + 'Sink(Wärme)|ub_Sink(Wärme)|flow_rate', + ] + ) + + assert_var_equal(model['Sink(Wärme)|size'], model.add_variables(lower=0, upper=100)) + + assert_var_equal(model['Sink(Wärme)|is_invested'], model.add_variables(binary=True)) + + # flow_rate + assert_var_equal( + flow.model.flow_rate, + model.add_variables( + lower=0, # Optional investment + upper=np.linspace(0.5, 1, timesteps.size) * 100, + coords=(timesteps,), + ), + ) + assert_conequal( + model.constraints['Sink(Wärme)|lb_Sink(Wärme)|flow_rate'], + flow.model.variables['Sink(Wärme)|flow_rate'] + >= flow.model.variables['Sink(Wärme)|size'] + * xr.DataArray(np.linspace(0.1, 0.5, timesteps.size), coords=(timesteps,)), + ) + assert_conequal( + model.constraints['Sink(Wärme)|ub_Sink(Wärme)|flow_rate'], + flow.model.variables['Sink(Wärme)|flow_rate'] + <= flow.model.variables['Sink(Wärme)|size'] + * xr.DataArray(np.linspace(0.5, 1, timesteps.size), coords=(timesteps,)), + ) + + # Is invested + assert_conequal( + model.constraints['Sink(Wärme)|is_invested_ub'], + flow.model.variables['Sink(Wärme)|size'] <= flow.model.variables['Sink(Wärme)|is_invested'] * 100, + ) + assert_conequal( + model.constraints['Sink(Wärme)|is_invested_lb'], + flow.model.variables['Sink(Wärme)|size'] >= flow.model.variables['Sink(Wärme)|is_invested'] * 20, + ) + + def test_flow_invest_optional_wo_min_size(self, basic_flow_system_linopy): + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + + flow = fx.Flow( + 'Wärme', + bus='Fernwärme', + size=fx.InvestParameters(maximum_size=100, optional=True), + relative_minimum=np.linspace(0.1, 0.5, timesteps.size), + relative_maximum=np.linspace(0.5, 1, timesteps.size), + ) + + flow_system.add_elements(fx.Sink('Sink', sink=flow)) + model = create_linopy_model(flow_system) + + assert set(flow.model.variables) == set( + ['Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate', 'Sink(Wärme)|size', 'Sink(Wärme)|is_invested'] + ) + assert set(flow.model.constraints) == set( + [ + 'Sink(Wärme)|total_flow_hours', + 'Sink(Wärme)|is_invested_ub', + 'Sink(Wärme)|is_invested_lb', + 'Sink(Wärme)|lb_Sink(Wärme)|flow_rate', + 'Sink(Wärme)|ub_Sink(Wärme)|flow_rate', + ] + ) + + assert_var_equal(model['Sink(Wärme)|size'], model.add_variables(lower=0, upper=100)) + + assert_var_equal(model['Sink(Wärme)|is_invested'], model.add_variables(binary=True)) + + # flow_rate + assert_var_equal( + flow.model.flow_rate, + model.add_variables( + lower=0, # Optional investment + upper=np.linspace(0.5, 1, timesteps.size) * 100, + coords=(timesteps,), + ), + ) + assert_conequal( + model.constraints['Sink(Wärme)|lb_Sink(Wärme)|flow_rate'], + flow.model.variables['Sink(Wärme)|flow_rate'] + >= flow.model.variables['Sink(Wärme)|size'] + * xr.DataArray(np.linspace(0.1, 0.5, timesteps.size), coords=(timesteps,)), + ) + assert_conequal( + model.constraints['Sink(Wärme)|ub_Sink(Wärme)|flow_rate'], + flow.model.variables['Sink(Wärme)|flow_rate'] + <= flow.model.variables['Sink(Wärme)|size'] + * xr.DataArray(np.linspace(0.5, 1, timesteps.size), coords=(timesteps,)), + ) + + # Is invested + assert_conequal( + model.constraints['Sink(Wärme)|is_invested_ub'], + flow.model.variables['Sink(Wärme)|size'] <= flow.model.variables['Sink(Wärme)|is_invested'] * 100, + ) + assert_conequal( + model.constraints['Sink(Wärme)|is_invested_lb'], + flow.model.variables['Sink(Wärme)|size'] >= flow.model.variables['Sink(Wärme)|is_invested'] * 1e-5, + ) + + def test_flow_invest_wo_min_size_non_optional(self, basic_flow_system_linopy): + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + + flow = fx.Flow( + 'Wärme', + bus='Fernwärme', + size=fx.InvestParameters(maximum_size=100, optional=False), + relative_minimum=np.linspace(0.1, 0.5, timesteps.size), + relative_maximum=np.linspace(0.5, 1, timesteps.size), + ) + + flow_system.add_elements(fx.Sink('Sink', sink=flow)) + model = create_linopy_model(flow_system) + + assert set(flow.model.variables) == set( + ['Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate', 'Sink(Wärme)|size'] + ) + assert set(flow.model.constraints) == set( + [ + 'Sink(Wärme)|total_flow_hours', + 'Sink(Wärme)|lb_Sink(Wärme)|flow_rate', + 'Sink(Wärme)|ub_Sink(Wärme)|flow_rate', + ] + ) + + assert_var_equal(model['Sink(Wärme)|size'], model.add_variables(lower=1e-5, upper=100)) + + # flow_rate + assert_var_equal( + flow.model.flow_rate, + model.add_variables( + lower=np.linspace(0.1, 0.5, timesteps.size) * 1e-5, + upper=np.linspace(0.5, 1, timesteps.size) * 100, + coords=(timesteps,), + ), + ) + assert_conequal( + model.constraints['Sink(Wärme)|lb_Sink(Wärme)|flow_rate'], + flow.model.variables['Sink(Wärme)|flow_rate'] + >= flow.model.variables['Sink(Wärme)|size'] + * xr.DataArray(np.linspace(0.1, 0.5, timesteps.size), coords=(timesteps,)), + ) + assert_conequal( + model.constraints['Sink(Wärme)|ub_Sink(Wärme)|flow_rate'], + flow.model.variables['Sink(Wärme)|flow_rate'] + <= flow.model.variables['Sink(Wärme)|size'] + * xr.DataArray(np.linspace(0.5, 1, timesteps.size), coords=(timesteps,)), + ) + + def test_flow_invest_fixed_size(self, basic_flow_system_linopy): + """Test flow with fixed size investment.""" + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + + flow = fx.Flow( + 'Wärme', + bus='Fernwärme', + size=fx.InvestParameters(fixed_size=75, optional=False), + relative_minimum=0.2, + relative_maximum=0.9, + ) + + flow_system.add_elements(fx.Sink('Sink', sink=flow)) + model = create_linopy_model(flow_system) + + assert set(flow.model.variables) == {'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate', 'Sink(Wärme)|size'} + + # Check that size is fixed to 75 + assert_var_equal(flow.model.variables['Sink(Wärme)|size'], model.add_variables(lower=75, upper=75)) + + # Check flow rate bounds + assert_var_equal(flow.model.flow_rate, model.add_variables(lower=0.2 * 75, upper=0.9 * 75, coords=(timesteps,))) + + def test_flow_invest_with_effects(self, basic_flow_system_linopy): + """Test flow with investment effects.""" + flow_system = basic_flow_system_linopy + + # Create effects + co2 = fx.Effect(label='CO2', unit='ton', description='CO2 emissions') + + flow = fx.Flow( + 'Wärme', + bus='Fernwärme', + size=fx.InvestParameters( + minimum_size=20, + maximum_size=100, + optional=True, + fix_effects={'Costs': 1000, 'CO2': 5}, # Fixed investment effects + specific_effects={'Costs': 500, 'CO2': 0.1}, # Specific investment effects + ), + ) + + flow_system.add_elements( fx.Sink('Sink', sink=flow), co2) + model = create_linopy_model(flow_system) + + # Check investment effects + assert 'Sink(Wärme)->Costs(invest)' in model.variables + assert 'Sink(Wärme)->CO2(invest)' in model.variables + + # Check fix effects (applied only when is_invested=1) + assert_conequal( + model.constraints['Sink(Wärme)->Costs(invest)'], + model.variables['Sink(Wärme)->Costs(invest)'] + == flow.model.variables['Sink(Wärme)|is_invested'] * 1000 + flow.model.variables['Sink(Wärme)|size'] * 500, + ) + + assert_conequal( + model.constraints['Sink(Wärme)->CO2(invest)'], + model.variables['Sink(Wärme)->CO2(invest)'] + == flow.model.variables['Sink(Wärme)|is_invested'] * 5 + flow.model.variables['Sink(Wärme)|size'] * 0.1, + ) + + def test_flow_invest_divest_effects(self, basic_flow_system_linopy): + """Test flow with divestment effects.""" + flow_system = basic_flow_system_linopy + + flow = fx.Flow( + 'Wärme', + bus='Fernwärme', + size=fx.InvestParameters( + minimum_size=20, + maximum_size=100, + optional=True, + divest_effects={'Costs': 500}, # Cost incurred when NOT investing + ), + ) + + flow_system.add_elements(fx.Sink('Sink', sink=flow)) + model = create_linopy_model(flow_system) + + # Check divestment effects + assert 'Sink(Wärme)->Costs(invest)' in model.constraints + + assert_conequal( + model.constraints['Sink(Wärme)->Costs(invest)'], + model.variables['Sink(Wärme)->Costs(invest)'] + (model.variables['Sink(Wärme)|is_invested'] -1) * 500 == 0 + ) + + +class TestFlowOnModel: + """Test the FlowModel class.""" + + def test_flow_on(self, basic_flow_system_linopy): + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + flow = fx.Flow( + 'Wärme', + bus='Fernwärme', + size=100, + relative_minimum=xr.DataArray(0.2, coords=(timesteps,)), + relative_maximum=xr.DataArray(0.8, coords=(timesteps,)), + on_off_parameters=fx.OnOffParameters(), + ) + flow_system.add_elements(fx.Sink('Sink', sink=flow)) + model = create_linopy_model(flow_system) + + assert set(flow.model.variables) == set( + ['Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate', 'Sink(Wärme)|on', 'Sink(Wärme)|on_hours_total'] + ) + + assert set(flow.model.constraints) == set( + [ + 'Sink(Wärme)|total_flow_hours', + 'Sink(Wärme)|on_hours_total', + 'Sink(Wärme)|on_con1', + 'Sink(Wärme)|on_con2', + ] + ) + # flow_rate + assert_var_equal( + flow.model.flow_rate, + model.add_variables( + lower=0, + upper=0.8 * 100, + coords=(timesteps,), + ), + ) + + # OnOff + assert_var_equal( + flow.model.on_off.on, + model.add_variables(binary=True, coords=(timesteps,)), + ) + assert_var_equal( + model.variables['Sink(Wärme)|on_hours_total'], + model.add_variables(lower=0), + ) + assert_conequal( + model.constraints['Sink(Wärme)|on_con1'], + flow.model.variables['Sink(Wärme)|on'] * 0.2 * 100 <= flow.model.variables['Sink(Wärme)|flow_rate'], + ) + assert_conequal( + model.constraints['Sink(Wärme)|on_con2'], + flow.model.variables['Sink(Wärme)|on'] * 0.8 * 100 >= flow.model.variables['Sink(Wärme)|flow_rate'], + ) + + assert_conequal( + model.constraints['Sink(Wärme)|on_hours_total'], + flow.model.variables['Sink(Wärme)|on_hours_total'] + == (flow.model.variables['Sink(Wärme)|on'] * model.hours_per_step).sum(), + ) + + def test_effects_per_running_hour(self, basic_flow_system_linopy): + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + + costs_per_running_hour = xr.DataArray(np.linspace(1, 2, timesteps.size), coords=(timesteps,)) + co2_per_running_hour = xr.DataArray(np.linspace(4, 5, timesteps.size), coords=(timesteps,)) + + flow = fx.Flow( + 'Wärme', + bus='Fernwärme', + on_off_parameters=fx.OnOffParameters( + effects_per_running_hour={'Costs': costs_per_running_hour, 'CO2': co2_per_running_hour} + ), + ) + flow_system.add_elements(fx.Sink('Sink', sink=flow), fx.Effect('CO2', 't', '')) + model = create_linopy_model(flow_system) + costs, co2 = flow_system.effects['Costs'], flow_system.effects['CO2'] + + assert set(flow.model.variables) == { + 'Sink(Wärme)|total_flow_hours', + 'Sink(Wärme)|flow_rate', + 'Sink(Wärme)|on', + 'Sink(Wärme)|on_hours_total', + } + assert set(flow.model.constraints) == { + 'Sink(Wärme)|total_flow_hours', + 'Sink(Wärme)|on_con1', + 'Sink(Wärme)|on_con2', + 'Sink(Wärme)|on_hours_total', + } + + assert 'Sink(Wärme)->Costs(operation)' in set(costs.model.constraints) + assert 'Sink(Wärme)->CO2(operation)' in set(co2.model.constraints) + + assert_conequal( + model.constraints['Sink(Wärme)->Costs(operation)'], + model.variables['Sink(Wärme)->Costs(operation)'] + == flow.model.variables['Sink(Wärme)|on'] * model.hours_per_step * costs_per_running_hour, + ) + + assert_conequal( + model.constraints['Sink(Wärme)->CO2(operation)'], + model.variables['Sink(Wärme)->CO2(operation)'] + == flow.model.variables['Sink(Wärme)|on'] * model.hours_per_step * co2_per_running_hour, + ) + + def test_consecutive_on_hours(self, basic_flow_system_linopy): + """Test flow with minimum and maximum consecutive on hours.""" + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + + flow = fx.Flow( + 'Wärme', + bus='Fernwärme', + size=100, + on_off_parameters=fx.OnOffParameters( + consecutive_on_hours_min=2, # Must run for at least 2 hours when turned on + consecutive_on_hours_max=8, # Can't run more than 8 consecutive hours + ), + ) + + flow_system.add_elements( fx.Sink('Sink', sink=flow)) + model = create_linopy_model(flow_system) + + assert {'Sink(Wärme)|consecutive_on_hours', 'Sink(Wärme)|on'}.issubset(set(flow.model.variables)) + + assert { + 'Sink(Wärme)|consecutive_on_hours_con1', + 'Sink(Wärme)|consecutive_on_hours_con2a', + 'Sink(Wärme)|consecutive_on_hours_con2b', + 'Sink(Wärme)|consecutive_on_hours_initial', + 'Sink(Wärme)|consecutive_on_hours_minimum_duration' + }.issubset(set(flow.model.constraints)) + + assert_var_equal( + model.variables['Sink(Wärme)|consecutive_on_hours'], + model.add_variables(lower=0, upper=8, coords=(timesteps,)) + ) + + mega = model.hours_per_step.sum('time') + + assert_conequal( + model.constraints['Sink(Wärme)|consecutive_on_hours_con1'], + model.variables['Sink(Wärme)|consecutive_on_hours'] <= model.variables['Sink(Wärme)|on'] * mega + ) + + assert_conequal( + model.constraints['Sink(Wärme)|consecutive_on_hours_con2a'], + model.variables['Sink(Wärme)|consecutive_on_hours'].isel(time=slice(1, None)) + <= model.variables['Sink(Wärme)|consecutive_on_hours'].isel(time=slice(None, -1)) + model.hours_per_step.isel(time=slice(None, -1)) + ) + + # eq: duration(t) >= duration(t - 1) + dt(t) + (On(t) - 1) * BIG + assert_conequal( + model.constraints['Sink(Wärme)|consecutive_on_hours_con2b'], + model.variables['Sink(Wärme)|consecutive_on_hours'].isel(time=slice(1, None)) + >= model.variables['Sink(Wärme)|consecutive_on_hours'].isel(time=slice(None, -1)) + + model.hours_per_step.isel(time=slice(None, -1)) + + (model.variables['Sink(Wärme)|on'].isel(time=slice(1, None)) - 1) * mega + ) + + assert_conequal( + model.constraints['Sink(Wärme)|consecutive_on_hours_initial'], + model.variables['Sink(Wärme)|consecutive_on_hours'].isel(time=0) + == model.variables['Sink(Wärme)|on'].isel(time=0) * model.hours_per_step.isel(time=0), + ) + + assert_conequal( + model.constraints['Sink(Wärme)|consecutive_on_hours_minimum_duration'], + model.variables['Sink(Wärme)|consecutive_on_hours'] + >= (model.variables['Sink(Wärme)|on'].isel(time=slice(None, -1)) - model.variables['Sink(Wärme)|on'].isel(time=slice(1, None))) * 2 + ) + + def test_consecutive_off_hours(self, basic_flow_system_linopy): + """Test flow with minimum and maximum consecutive off hours.""" + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + + flow = fx.Flow( + 'Wärme', + bus='Fernwärme', + size=100, + on_off_parameters=fx.OnOffParameters( + consecutive_off_hours_min=4, # Must stay off for at least 4 hours when shut down + consecutive_off_hours_max=12, # Can't be off for more than 12 consecutive hours + ), + ) + + flow_system.add_elements( fx.Sink('Sink', sink=flow)) + model = create_linopy_model(flow_system) + + assert {'Sink(Wärme)|consecutive_off_hours', 'Sink(Wärme)|off'}.issubset(set(flow.model.variables)) + + assert { + 'Sink(Wärme)|consecutive_off_hours_con1', + 'Sink(Wärme)|consecutive_off_hours_con2a', + 'Sink(Wärme)|consecutive_off_hours_con2b', + 'Sink(Wärme)|consecutive_off_hours_initial', + 'Sink(Wärme)|consecutive_off_hours_minimum_duration' + }.issubset(set(flow.model.constraints)) + + assert_var_equal( + model.variables['Sink(Wärme)|consecutive_off_hours'], + model.add_variables(lower=0, upper=12, coords=(timesteps,)) + ) + + mega = model.hours_per_step.sum('time') + 1 # previously off for 1h + + assert_conequal( + model.constraints['Sink(Wärme)|consecutive_off_hours_con1'], + model.variables['Sink(Wärme)|consecutive_off_hours'] <= model.variables['Sink(Wärme)|off'] * mega + ) + + assert_conequal( + model.constraints['Sink(Wärme)|consecutive_off_hours_con2a'], + model.variables['Sink(Wärme)|consecutive_off_hours'].isel(time=slice(1, None)) + <= model.variables['Sink(Wärme)|consecutive_off_hours'].isel(time=slice(None, -1)) + model.hours_per_step.isel(time=slice(None, -1)) + ) + + # eq: duration(t) >= duration(t - 1) + dt(t) + (On(t) - 1) * BIG + assert_conequal( + model.constraints['Sink(Wärme)|consecutive_off_hours_con2b'], + model.variables['Sink(Wärme)|consecutive_off_hours'].isel(time=slice(1, None)) + >= model.variables['Sink(Wärme)|consecutive_off_hours'].isel(time=slice(None, -1)) + + model.hours_per_step.isel(time=slice(None, -1)) + + (model.variables['Sink(Wärme)|off'].isel(time=slice(1, None)) - 1) * mega + ) + + assert_conequal( + model.constraints['Sink(Wärme)|consecutive_off_hours_initial'], + model.variables['Sink(Wärme)|consecutive_off_hours'].isel(time=0) + == model.variables['Sink(Wärme)|off'].isel(time=0) * model.hours_per_step.isel(time=0), + ) + + assert_conequal( + model.constraints['Sink(Wärme)|consecutive_off_hours_minimum_duration'], + model.variables['Sink(Wärme)|consecutive_off_hours'] + >= (model.variables['Sink(Wärme)|off'].isel(time=slice(None, -1)) - model.variables['Sink(Wärme)|off'].isel(time=slice(1, None))) * 4 + ) + + def test_switch_on_constraints(self, basic_flow_system_linopy): + """Test flow with constraints on the number of startups.""" + flow_system = basic_flow_system_linopy + + flow = fx.Flow( + 'Wärme', + bus='Fernwärme', + size=100, + on_off_parameters=fx.OnOffParameters( + switch_on_total_max=5, # Maximum 5 startups + effects_per_switch_on={'Costs': 100}, # 100 EUR startup cost + ), + ) + + flow_system.add_elements(fx.Sink('Sink', sink=flow)) + model = create_linopy_model(flow_system) + + # Check that variables exist + assert {'Sink(Wärme)|switch_on', 'Sink(Wärme)|switch_off', 'Sink(Wärme)|switch_on_nr'}.issubset( + set(flow.model.variables) + ) + + # Check that constraints exist + assert { + 'Sink(Wärme)|switch_con', + 'Sink(Wärme)|initial_switch_con', + 'Sink(Wärme)|switch_on_or_off', + 'Sink(Wärme)|switch_on_nr', + }.issubset(set(flow.model.constraints)) + + # Check switch_on_nr variable bounds + assert_var_equal(flow.model.variables['Sink(Wärme)|switch_on_nr'], model.add_variables(lower=0, upper=5)) + + # Verify switch_on_nr constraint (limits number of startups) + assert_conequal( + model.constraints['Sink(Wärme)|switch_on_nr'], + flow.model.variables['Sink(Wärme)|switch_on_nr'] + == flow.model.variables['Sink(Wärme)|switch_on'].sum('time'), + ) + + # Check that startup cost effect constraint exists + assert 'Sink(Wärme)->Costs(operation)' in model.constraints + + # Verify the startup cost effect constraint + assert_conequal( + model.constraints['Sink(Wärme)->Costs(operation)'], + model.variables['Sink(Wärme)->Costs(operation)'] == flow.model.variables['Sink(Wärme)|switch_on'] * 100, + ) + + def test_on_hours_limits(self, basic_flow_system_linopy): + """Test flow with limits on total on hours.""" + flow_system = basic_flow_system_linopy + + flow = fx.Flow( + 'Wärme', + bus='Fernwärme', + size=100, + on_off_parameters=fx.OnOffParameters( + on_hours_total_min=20, # Minimum 20 hours of operation + on_hours_total_max=100, # Maximum 100 hours of operation + ), + ) + + flow_system.add_elements(fx.Sink('Sink', sink=flow)) + model = create_linopy_model(flow_system) + + # Check that variables exist + assert {'Sink(Wärme)|on', 'Sink(Wärme)|on_hours_total'}.issubset(set(flow.model.variables)) + + # Check that constraints exist + assert 'Sink(Wärme)|on_hours_total' in model.constraints + + # Check on_hours_total variable bounds + assert_var_equal(flow.model.variables['Sink(Wärme)|on_hours_total'], model.add_variables(lower=20, upper=100)) + + # Check on_hours_total constraint + assert_conequal( + model.constraints['Sink(Wärme)|on_hours_total'], + flow.model.variables['Sink(Wärme)|on_hours_total'] + == (flow.model.variables['Sink(Wärme)|on'] * model.hours_per_step).sum(), + ) + + +class TestFlowOnInvestModel: + """Test the FlowModel class.""" + + def test_flow_on_invest_optional(self, basic_flow_system_linopy): + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + flow = fx.Flow( + 'Wärme', + bus='Fernwärme', + size=fx.InvestParameters(minimum_size=20, maximum_size=200, optional=True), + relative_minimum=xr.DataArray(0.2, coords=(timesteps,)), + relative_maximum=xr.DataArray(0.8, coords=(timesteps,)), + on_off_parameters=fx.OnOffParameters(), + ) + flow_system.add_elements(fx.Sink('Sink', sink=flow)) + model = create_linopy_model(flow_system) + + assert set(flow.model.variables) == set( + [ + 'Sink(Wärme)|total_flow_hours', + 'Sink(Wärme)|flow_rate', + 'Sink(Wärme)|is_invested', + 'Sink(Wärme)|size', + 'Sink(Wärme)|on', + 'Sink(Wärme)|on_hours_total', + ] + ) + + assert set(flow.model.constraints) == set( + [ + 'Sink(Wärme)|total_flow_hours', + 'Sink(Wärme)|on_hours_total', + 'Sink(Wärme)|on_con1', + 'Sink(Wärme)|on_con2', + 'Sink(Wärme)|is_invested_lb', + 'Sink(Wärme)|is_invested_ub', + 'Sink(Wärme)|lb_Sink(Wärme)|flow_rate', + 'Sink(Wärme)|ub_Sink(Wärme)|flow_rate', + ] + ) + + # flow_rate + assert_var_equal( + flow.model.flow_rate, + model.add_variables( + lower=0, + upper=0.8 * 200, + coords=(timesteps,), + ), + ) + + # OnOff + assert_var_equal( + flow.model.on_off.on, + model.add_variables(binary=True, coords=(timesteps,)), + ) + assert_var_equal( + model.variables['Sink(Wärme)|on_hours_total'], + model.add_variables(lower=0), + ) + assert_conequal( + model.constraints['Sink(Wärme)|on_con1'], + flow.model.variables['Sink(Wärme)|on'] * 0.2 * 20 <= flow.model.variables['Sink(Wärme)|flow_rate'], + ) + assert_conequal( + model.constraints['Sink(Wärme)|on_con2'], + flow.model.variables['Sink(Wärme)|on'] * 0.8 * 200 >= flow.model.variables['Sink(Wärme)|flow_rate'], + ) + assert_conequal( + model.constraints['Sink(Wärme)|on_hours_total'], + flow.model.variables['Sink(Wärme)|on_hours_total'] + == (flow.model.variables['Sink(Wärme)|on'] * model.hours_per_step).sum(), + ) + + # Investment + assert_var_equal(model['Sink(Wärme)|size'], model.add_variables(lower=0, upper=200)) + + mega = 0.2 * 200 # Relative minimum * maximum size + assert_conequal( + model.constraints['Sink(Wärme)|lb_Sink(Wärme)|flow_rate'], + flow.model.variables['Sink(Wärme)|flow_rate'] + >= flow.model.variables['Sink(Wärme)|on'] * mega + flow.model.variables['Sink(Wärme)|size'] * 0.2 - mega, + ) + assert_conequal( + model.constraints['Sink(Wärme)|ub_Sink(Wärme)|flow_rate'], + flow.model.variables['Sink(Wärme)|flow_rate'] <= flow.model.variables['Sink(Wärme)|size'] * 0.8, + ) + + def test_flow_on_invest_non_optional(self, basic_flow_system_linopy): + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + flow = fx.Flow( + 'Wärme', + bus='Fernwärme', + size=fx.InvestParameters(minimum_size=20, maximum_size=200, optional=False), + relative_minimum=xr.DataArray(0.2, coords=(timesteps,)), + relative_maximum=xr.DataArray(0.8, coords=(timesteps,)), + on_off_parameters=fx.OnOffParameters(), + ) + flow_system.add_elements(fx.Sink('Sink', sink=flow)) + model = create_linopy_model(flow_system) + + assert set(flow.model.variables) == set( + [ + 'Sink(Wärme)|total_flow_hours', + 'Sink(Wärme)|flow_rate', + 'Sink(Wärme)|size', + 'Sink(Wärme)|on', + 'Sink(Wärme)|on_hours_total', + ] + ) + + assert set(flow.model.constraints) == set( + [ + 'Sink(Wärme)|total_flow_hours', + 'Sink(Wärme)|on_hours_total', + 'Sink(Wärme)|on_con1', + 'Sink(Wärme)|on_con2', + 'Sink(Wärme)|lb_Sink(Wärme)|flow_rate', + 'Sink(Wärme)|ub_Sink(Wärme)|flow_rate', + ] + ) + + # flow_rate + assert_var_equal( + flow.model.flow_rate, + model.add_variables( + lower=0, + upper=0.8 * 200, + coords=(timesteps,), + ), + ) + + # OnOff + assert_var_equal( + flow.model.on_off.on, + model.add_variables(binary=True, coords=(timesteps,)), + ) + assert_var_equal( + model.variables['Sink(Wärme)|on_hours_total'], + model.add_variables(lower=0), + ) + assert_conequal( + model.constraints['Sink(Wärme)|on_con1'], + flow.model.variables['Sink(Wärme)|on'] * 0.2 * 20 <= flow.model.variables['Sink(Wärme)|flow_rate'], + ) + assert_conequal( + model.constraints['Sink(Wärme)|on_con2'], + flow.model.variables['Sink(Wärme)|on'] * 0.8 * 200 >= flow.model.variables['Sink(Wärme)|flow_rate'], + ) + assert_conequal( + model.constraints['Sink(Wärme)|on_hours_total'], + flow.model.variables['Sink(Wärme)|on_hours_total'] + == (flow.model.variables['Sink(Wärme)|on'] * model.hours_per_step).sum(), + ) + + # Investment + assert_var_equal(model['Sink(Wärme)|size'], model.add_variables(lower=20, upper=200)) + + mega = 0.2 * 200 # Relative minimum * maximum size + assert_conequal( + model.constraints['Sink(Wärme)|lb_Sink(Wärme)|flow_rate'], + flow.model.variables['Sink(Wärme)|flow_rate'] + >= flow.model.variables['Sink(Wärme)|on'] * mega + flow.model.variables['Sink(Wärme)|size'] * 0.2 - mega, + ) + assert_conequal( + model.constraints['Sink(Wärme)|ub_Sink(Wärme)|flow_rate'], + flow.model.variables['Sink(Wärme)|flow_rate'] <= flow.model.variables['Sink(Wärme)|size'] * 0.8, + ) + + +class TestFlowWithFixedProfile: + """Test Flow with fixed relative profile.""" + + def test_fixed_relative_profile(self, basic_flow_system_linopy): + """Test flow with a fixed relative profile.""" + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + + # Create a time-varying profile (e.g., for a load or renewable generation) + profile = np.sin(np.linspace(0, 2 * np.pi, len(timesteps))) * 0.5 + 0.5 # Values between 0 and 1 + + flow = fx.Flow( + 'Wärme', bus='Fernwärme', size=100, fixed_relative_profile=xr.DataArray(profile, coords=(timesteps,)) + ) + + flow_system.add_elements(fx.Sink('Sink', sink=flow)) + model = create_linopy_model(flow_system) + + assert_var_equal(flow.model.variables['Sink(Wärme)|flow_rate'], + model.add_variables(lower=profile * 100, + upper=profile * 100, + coords=(timesteps,)) + ) + + + def test_fixed_profile_with_investment(self, basic_flow_system_linopy): + """Test flow with fixed profile and investment.""" + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + + # Create a fixed profile + profile = np.sin(np.linspace(0, 2 * np.pi, len(timesteps))) * 0.5 + 0.5 + + flow = fx.Flow( + 'Wärme', + bus='Fernwärme', + size=fx.InvestParameters(minimum_size=50, maximum_size=200, optional=True), + fixed_relative_profile=xr.DataArray(profile, coords=(timesteps,)), + ) + + flow_system.add_elements( fx.Sink('Sink', sink=flow)) + model = create_linopy_model(flow_system) + + assert_var_equal( + flow.model.variables['Sink(Wärme)|flow_rate'], + model.add_variables(lower=0, upper=profile * 200, coords=(timesteps,)), + ) + + # The constraint should link flow_rate to size * profile + assert_conequal( + model.constraints['Sink(Wärme)|fix_Sink(Wärme)|flow_rate'], + flow.model.variables['Sink(Wärme)|flow_rate'] + == flow.model.variables['Sink(Wärme)|size'] * xr.DataArray(profile, coords=(timesteps,)), + ) + + +if __name__ == '__main__': + pytest.main() diff --git a/tests/test_integration.py b/tests/test_integration.py index e3d4faf0d..dc203c33e 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -354,7 +354,7 @@ def test_piecewise_conversion(self, flow_system_piecewise_conversion, highs_solv 'Speicher investCosts_segmented_costs doesnt match expected value', ) - +@pytest.mark.slow class TestModelingTypes: @pytest.fixture(params=['full', 'segmented', 'aggregated']) def modeling_calculation(self, request, flow_system_long, highs_solver): diff --git a/tests/test_io.py b/tests/test_io.py index 84536f61d..2e6c61ccf 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -22,7 +22,7 @@ def flow_system(request): else: return fs[0] - +@pytest.mark.slow def test_flow_system_file_io(flow_system, highs_solver): calculation_0 = fx.FullCalculation('IO', flow_system=flow_system) calculation_0.do_modeling() diff --git a/tests/test_on_hours_computation.py b/tests/test_on_hours_computation.py new file mode 100644 index 000000000..5608155c0 --- /dev/null +++ b/tests/test_on_hours_computation.py @@ -0,0 +1,105 @@ +import numpy as np +import pytest + +from flixopt.features import OnOffModel + + +class TestComputeConsecutiveDuration: + """Tests for the compute_consecutive_duration static method.""" + + @pytest.mark.parametrize("binary_values, hours_per_timestep, expected", [ + # Case 1: Both scalar inputs + (1, 5, 5), + (0, 3, 0), + + # Case 2: Scalar binary, array hours + (1, np.array([1, 2, 3]), 3), + (0, np.array([2, 4, 6]), 0), + + # Case 3: Array binary, scalar hours + (np.array([0, 0, 1, 1, 1, 0]), 2, 0), + (np.array([0, 1, 1, 0, 1, 1]), 1, 2), + (np.array([1, 1, 1]), 2, 6), + + # Case 4: Both array inputs + (np.array([0, 1, 1, 0, 1, 1]), np.array([1, 2, 3, 4, 5, 6]), 11), # 5+6 + (np.array([1, 0, 0, 1, 1, 1]), np.array([2, 2, 2, 3, 4, 5]), 12), # 3+4+5 + + # Case 5: Edge cases + (np.array([1]), np.array([4]), 4), + (np.array([0]), np.array([3]), 0), + ]) + def test_compute_duration(self, binary_values, hours_per_timestep, expected): + """Test compute_consecutive_duration with various inputs.""" + result = OnOffModel.compute_consecutive_duration(binary_values, hours_per_timestep) + assert np.isclose(result, expected) + + @pytest.mark.parametrize("binary_values, hours_per_timestep", [ + # Case: Incompatible array lengths + (np.array([1, 1, 1, 1, 1]), np.array([1, 2])), + ]) + def test_compute_duration_raises_error(self, binary_values, hours_per_timestep): + """Test error conditions.""" + with pytest.raises(TypeError): + OnOffModel.compute_consecutive_duration(binary_values, hours_per_timestep) + + +class TestComputePreviousOnStates: + """Tests for the compute_previous_on_states static method.""" + + @pytest.mark.parametrize( + 'previous_values, expected', + [ + # Case 1: Empty list + ([], np.array([0])), + + # Case 2: All None values + ([None, None], np.array([0])), + + # Case 3: Single value arrays + ([np.array([0])], np.array([0])), + ([np.array([1])], np.array([1])), + ([np.array([0.001])], np.array([1])), # Using default epsilon + ([np.array([1e-4])], np.array([1])), + ([np.array([1e-8])], np.array([0])), + + # Case 4: Multiple 1D arrays + ([np.array([0, 5, 0]), np.array([0, 0, 1])], np.array([0, 1, 1])), + ([np.array([0.1, 0, 0.3]), None, np.array([0, 0, 0])], np.array([1, 0, 1])), + ([np.array([0, 0, 0]), np.array([0, 1, 0])], np.array([0, 1, 0])), + ([np.array([0.1, 0, 0]), np.array([0, 0, 0.2])], np.array([1, 0, 1])), + + # Case 6: Mix of None, 1D and 2D arrays + ([None, np.array([0, 0, 0]), np.array([0, 1, 0]), np.array([0, 0, 0])], np.array([0, 1, 0])), + ([np.array([0, 0, 0]), None, np.array([0, 0, 0]), np.array([0, 0, 0])], np.array([0, 0, 0])), + ], + ) + def test_compute_previous_on_states(self, previous_values, expected): + """Test compute_previous_on_states with various inputs.""" + result = OnOffModel.compute_previous_on_states(previous_values) + np.testing.assert_array_equal(result, expected) + + @pytest.mark.parametrize("previous_values, epsilon, expected", [ + # Testing with different epsilon values + ([np.array([1e-6, 1e-4, 1e-2])], 1e-3, np.array([0, 0, 1])), + ([np.array([1e-6, 1e-4, 1e-2])], 1e-5, np.array([0, 1, 1])), + ([np.array([1e-6, 1e-4, 1e-2])], 1e-1, np.array([0, 0, 0])), + + # Mixed case with custom epsilon + ([np.array([0.05, 0.005, 0.0005])], 0.01, np.array([1, 0, 0])), + ]) + def test_compute_previous_on_states_with_epsilon(self, previous_values, epsilon, expected): + """Test compute_previous_on_states with custom epsilon values.""" + result = OnOffModel.compute_previous_on_states(previous_values, epsilon) + np.testing.assert_array_equal(result, expected) + + @pytest.mark.parametrize("previous_values, expected_shape", [ + # Check that output shapes match expected dimensions + ([np.array([0, 1, 0, 1])], (4,)), + ([np.array([0, 1]), np.array([1, 0]), np.array([0, 0])], (2,)), + ([np.array([0, 1]), np.array([1, 0])], (2,)), + ]) + def test_output_shapes(self, previous_values, expected_shape): + """Test that output array has the correct shape.""" + result = OnOffModel.compute_previous_on_states(previous_values) + assert result.shape == expected_shape diff --git a/tests/test_plots.py b/tests/test_plots.py index d08e9ba4e..840b4e7b3 100644 --- a/tests/test_plots.py +++ b/tests/test_plots.py @@ -15,6 +15,7 @@ from flixopt import plotting +@pytest.mark.slow class TestPlots(unittest.TestCase): def setUp(self): np.random.seed(72) diff --git a/tests/test_results_plots.py b/tests/test_results_plots.py index 26197c98a..855944a48 100644 --- a/tests/test_results_plots.py +++ b/tests/test_results_plots.py @@ -40,7 +40,7 @@ def plotting_engine(request): def color_spec(request): return request.param - +@pytest.mark.slow def test_results_plots(flow_system, plotting_engine, show, save, color_spec): calculation = create_calculation_and_solve(flow_system, fx.solvers.HighsSolver(0.01, 30), 'test_results_plots') results = calculation.results @@ -67,7 +67,7 @@ def test_results_plots(flow_system, plotting_engine, show, save, color_spec): plt.close('all') - +@pytest.mark.slow def test_color_handling_edge_cases(flow_system, plotting_engine, show, save): """Test edge cases for color handling""" calculation = create_calculation_and_solve(flow_system, fx.solvers.HighsSolver(0.01, 30), 'test_color_edge_cases') diff --git a/tests/test_timeseries.py b/tests/test_timeseries.py index 91b0b26f3..a8bc5fa85 100644 --- a/tests/test_timeseries.py +++ b/tests/test_timeseries.py @@ -555,7 +555,7 @@ def test_restore_data(self, populated_collection): def test_class_method_with_uniform_timesteps(self): """Test the with_uniform_timesteps class method.""" collection = TimeSeriesCollection.with_uniform_timesteps( - start_time=pd.Timestamp('2023-01-01'), periods=24, freq='H', hours_per_step=1 + start_time=pd.Timestamp('2023-01-01'), periods=24, freq='h', hours_per_step=1 ) assert len(collection.timesteps) == 24 From f9e2f040d4c7b4074987a69a8bc528ed0d0078f1 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 11 Apr 2025 15:04:35 +0200 Subject: [PATCH 502/507] Fixes/tests more (#239) Add tests for LinearConverter, Storage and Component --- flixopt/components.py | 22 +- flixopt/elements.py | 11 +- flixopt/features.py | 16 +- flixopt/structure.py | 10 +- tests/test_component.py | 184 +++++++++++ tests/test_linear_converter.py | 556 +++++++++++++++++++++++++++++++++ tests/test_storage.py | 399 +++++++++++++++++++++++ 7 files changed, 1174 insertions(+), 24 deletions(-) create mode 100644 tests/test_component.py create mode 100644 tests/test_linear_converter.py create mode 100644 tests/test_storage.py diff --git a/flixopt/components.py b/flixopt/components.py index d5d1df12d..9b4e64625 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -60,6 +60,7 @@ def create_model(self, model: SystemModel) -> 'LinearConverterModel': return self.model def _plausibility_checks(self) -> None: + super()._plausibility_checks() if not self.conversion_factors and not self.piecewise_conversion: raise PlausibilityError('Either conversion_factors or piecewise_conversion must be defined!') if self.conversion_factors and self.piecewise_conversion: @@ -213,6 +214,7 @@ def _plausibility_checks(self) -> None: """ Check for infeasible or uncommon combinations of parameters """ + super()._plausibility_checks() if utils.is_number(self.initial_charge_state): if isinstance(self.capacity_in_flow_hours, InvestParameters): if self.capacity_in_flow_hours.fixed_size is None: @@ -301,6 +303,7 @@ def __init__( self.absolute_losses = absolute_losses def _plausibility_checks(self): + super()._plausibility_checks() # check buses: if self.in2 is not None: assert self.in2.bus == self.out1.bus, ( @@ -396,6 +399,7 @@ def __init__(self, model: SystemModel, element: LinearConverter): super().__init__(model, element) self.element: LinearConverter = element self.on_off: Optional[OnOffModel] = None + self.piecewise_conversion: Optional[PiecewiseConversion] = None def do_modeling(self): super().do_modeling() @@ -426,16 +430,16 @@ def do_modeling(self): for flow, piecewise in self.element.piecewise_conversion.items() } - piecewise_conversion = PiecewiseModel( - model=self._model, - label_of_element=self.label_of_element, - label=self.label_full, - piecewise_variables=piecewise_conversion, - zero_point=self.on_off.on if self.on_off is not None else False, - as_time_series=True, + self.piecewise_conversion = self.add( + PiecewiseModel( + model=self._model, + label_of_element=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, + ) ) - piecewise_conversion.do_modeling() - self.sub_models.append(piecewise_conversion) + self.piecewise_conversion.do_modeling() class StorageModel(ComponentModel): diff --git a/flixopt/elements.py b/flixopt/elements.py index e6b5d802b..79a1ef075 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -57,6 +57,7 @@ def __init__( super().__init__(label, meta_data=meta_data) self.inputs: List['Flow'] = inputs or [] self.outputs: List['Flow'] = outputs or [] + self._check_unique_flow_labels() self.on_off_parameters = on_off_parameters self.prevent_simultaneous_flows: List['Flow'] = prevent_simultaneous_flows or [] @@ -77,9 +78,15 @@ def infos(self, use_numpy=True, use_element_label: bool = False) -> Dict: infos['outputs'] = [flow.infos(use_numpy, use_element_label) for flow in self.outputs] return infos + def _check_unique_flow_labels(self): + all_flow_labels = [flow.label for flow in self.inputs + self.outputs] + + if len(set(all_flow_labels)) != len(all_flow_labels): + duplicates = {label for label in all_flow_labels if all_flow_labels.count(label) > 1} + raise ValueError(f'Flow names must be unique! "{self.label_full}" got 2 or more of: {duplicates}') + def _plausibility_checks(self) -> None: - # TODO: Check for plausibility - pass + self._check_unique_flow_labels() @register_class_for_io diff --git a/flixopt/features.py b/flixopt/features.py index 67f036147..e34cb9a40 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -359,8 +359,8 @@ def _add_on_constraints(self): ) else: # Bei mehreren Leistungsvariablen: - ub = sum(bound[1] for bound in self._defining_bounds) - lb = CONFIG.modeling.EPSILON + ub = sum(bound[1] for bound in self._defining_bounds) / nr_of_def_vars + lb = CONFIG.modeling.EPSILON #TODO: Can this be a bigger value? (maybe the smallest bound?) # When all defining variables are 0, On is 0 # eq: On(t) * Epsilon <= sum(alle Leistungen(t)) @@ -759,10 +759,10 @@ def __init__( self, model: SystemModel, label_of_element: str, - label: str, piecewise_variables: Dict[str, Piecewise], zero_point: Optional[Union[bool, linopy.Variable]], as_time_series: bool, + label: str = '', ): """ Modeling a Piecewise relation between miultiple variables. @@ -811,9 +811,9 @@ def do_modeling(self): ) ] ), - name=f'{self.label_full}|{var_name}_lambda', + name=f'{self.label_full}|{var_name}|lambda', ), - f'{var_name}_lambda', + f'{var_name}|lambda', ) # a) eq: Segment1.onSeg(t) + Segment2.onSeg(t) + ... = 1 Aufenthalt nur in Segmenten erlaubt @@ -835,9 +835,9 @@ def do_modeling(self): self.add( self._model.add_constraints( sum([piece.inside_piece for piece in self.pieces]) <= rhs, - name=f'{self.label_full}|{variable.name}_single_segment', + name=f'{self.label_full}|{variable.name}|single_segment', ), - 'single_segment', + f'{var_name}|single_segment', ) @@ -986,10 +986,10 @@ def do_modeling(self): PiecewiseModel( model=self._model, label_of_element=self.label_of_element, - label=f'{self.label_full}|PiecewiseModel', piecewise_variables=piecewise_variables, zero_point=self._zero_point, as_time_series=False, + label='PiecewiseEffects', ) ) diff --git a/flixopt/structure.py b/flixopt/structure.py index e7f1c62a4..1d0f2324f 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -304,7 +304,7 @@ class Model: """Stores Variables and Constraints.""" def __init__( - self, model: SystemModel, label_of_element: str, label: Optional[str] = None, label_full: Optional[str] = None + self, model: SystemModel, label_of_element: str, label: str = '', label_full: Optional[str] = None ): """ Args: @@ -325,7 +325,7 @@ def __init__( self._variables_short: Dict[str, str] = {} self._constraints_short: Dict[str, str] = {} self._sub_models_short: Dict[str, str] = {} - logger.debug(f'Created {self.__class__.__name__} "{self._label}"') + logger.debug(f'Created {self.__class__.__name__} "{self.label_full}"') def do_modeling(self): raise NotImplementedError('Every Model needs a do_modeling() method') @@ -381,14 +381,14 @@ def filter_variables( @property def label(self) -> str: - return self._label if self._label is not None else self.label_of_element + return self._label if self._label else self.label_of_element @property def label_full(self) -> str: """Used to construct the names of variables and constraints""" - if self._label_full is not None: + if self._label_full: return self._label_full - elif self._label is not None: + elif self._label: return f'{self.label_of_element}|{self.label}' return self.label_of_element diff --git a/tests/test_component.py b/tests/test_component.py new file mode 100644 index 000000000..d87a28c29 --- /dev/null +++ b/tests/test_component.py @@ -0,0 +1,184 @@ +import numpy as np +import pandas as pd +import pytest +import xarray as xr + +import flixopt as fx +import flixopt.elements + +from .conftest import assert_conequal, assert_var_equal, create_linopy_model + + +class TestComponentModel: + + def test_flow_label_check(self, basic_flow_system_linopy): + """Test that flow model constraints are correctly generated.""" + _ = basic_flow_system_linopy + inputs = [ + fx.Flow('Q_th_Last', 'Fernwärme', relative_minimum=np.ones(10) * 0.1), + fx.Flow('Q_Gas', 'Fernwärme', relative_minimum=np.ones(10) * 0.1) + ] + outputs = [ + fx.Flow('Q_th_Last', 'Gas', relative_minimum=np.ones(10) * 0.01), + fx.Flow('Q_Gas', 'Gas', relative_minimum=np.ones(10) * 0.01) + ] + with pytest.raises(ValueError, match='Flow names must be unique!'): + _ = flixopt.elements.Component('TestComponent', inputs=inputs, outputs=outputs) + + def test_component(self, basic_flow_system_linopy): + """Test that flow model constraints are correctly generated.""" + flow_system = basic_flow_system_linopy + inputs = [ + fx.Flow('In1', 'Fernwärme', relative_minimum=np.ones(10) * 0.1), + fx.Flow('In2', 'Fernwärme', relative_minimum=np.ones(10) * 0.1) + ] + outputs = [ + fx.Flow('Out1', 'Gas', relative_minimum=np.ones(10) * 0.01), + fx.Flow('Out2', 'Gas', relative_minimum=np.ones(10) * 0.01) + ] + comp = flixopt.elements.Component('TestComponent', inputs=inputs, outputs=outputs) + flow_system.add_elements(comp) + _ = create_linopy_model(flow_system) + + assert {'TestComponent(In1)|flow_rate', + 'TestComponent(In1)|total_flow_hours', + 'TestComponent(In2)|flow_rate', + 'TestComponent(In2)|total_flow_hours', + 'TestComponent(Out1)|flow_rate', + 'TestComponent(Out1)|total_flow_hours', + 'TestComponent(Out2)|flow_rate', + 'TestComponent(Out2)|total_flow_hours'} == set(comp.model.variables) + + assert {'TestComponent(In1)|total_flow_hours', + 'TestComponent(In2)|total_flow_hours', + 'TestComponent(Out1)|total_flow_hours', + 'TestComponent(Out2)|total_flow_hours'} == set(comp.model.constraints) + + def test_on_with_multiple_flows(self, basic_flow_system_linopy): + """Test that flow model constraints are correctly generated.""" + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + ub_out2 = np.linspace(1, 1.5, 10).round(2) + inputs = [ + fx.Flow('In1', 'Fernwärme', relative_minimum=np.ones(10) * 0.1, size=100), + ] + outputs = [ + fx.Flow('Out1', 'Gas', relative_minimum=np.ones(10) * 0.2, size=200), + fx.Flow('Out2', 'Gas', relative_minimum=np.ones(10) * 0.3, + relative_maximum = ub_out2, size=300), + ] + comp = flixopt.elements.Component('TestComponent', inputs=inputs, outputs=outputs, + on_off_parameters=fx.OnOffParameters()) + flow_system.add_elements(comp) + model = create_linopy_model(flow_system) + + assert { + 'TestComponent(In1)|flow_rate', + 'TestComponent(In1)|total_flow_hours', + 'TestComponent(In1)|on', + 'TestComponent(In1)|on_hours_total', + 'TestComponent(Out1)|flow_rate', + 'TestComponent(Out1)|total_flow_hours', + 'TestComponent(Out1)|on', + 'TestComponent(Out1)|on_hours_total', + 'TestComponent(Out2)|flow_rate', + 'TestComponent(Out2)|total_flow_hours', + 'TestComponent(Out2)|on', + 'TestComponent(Out2)|on_hours_total', + 'TestComponent|on', + 'TestComponent|on_hours_total', + } == set(comp.model.variables) + + assert { + 'TestComponent(In1)|total_flow_hours', + 'TestComponent(In1)|on_con1', + 'TestComponent(In1)|on_con2', + 'TestComponent(In1)|on_hours_total', + 'TestComponent(Out1)|total_flow_hours', + 'TestComponent(Out1)|on_con1', + 'TestComponent(Out1)|on_con2', + 'TestComponent(Out1)|on_hours_total', + 'TestComponent(Out2)|total_flow_hours', + 'TestComponent(Out2)|on_con1', + 'TestComponent(Out2)|on_con2', + 'TestComponent(Out2)|on_hours_total', + 'TestComponent|on_con1', + 'TestComponent|on_con2', + 'TestComponent|on_hours_total', + } == set(comp.model.constraints) + + assert_var_equal(model['TestComponent(Out2)|flow_rate'], + model.add_variables(lower=0, upper=300 * ub_out2, coords=(timesteps,))) + assert_var_equal(model['TestComponent|on'], model.add_variables(binary=True, coords = (timesteps,))) + assert_var_equal(model['TestComponent(Out2)|on'], model.add_variables(binary=True, coords=(timesteps,))) + + assert_conequal(model.constraints['TestComponent(Out2)|on_con1'], model.variables['TestComponent(Out2)|on'] * 0.3 * 300 <= model.variables['TestComponent(Out2)|flow_rate']) + assert_conequal(model.constraints['TestComponent(Out2)|on_con2'], model.variables['TestComponent(Out2)|on'] * 300 * ub_out2 >= model.variables['TestComponent(Out2)|flow_rate']) + + assert_conequal(model.constraints['TestComponent|on_con1'], + model.variables['TestComponent|on'] * 1e-5 <= model.variables['TestComponent(In1)|flow_rate'] + model.variables['TestComponent(Out1)|flow_rate'] + model.variables['TestComponent(Out2)|flow_rate']) + # TODO: Might there be a better way to no use 1e-5? + assert_conequal(model.constraints['TestComponent|on_con2'], + model.variables['TestComponent|on'] * (100 + 200 + 300 * ub_out2)/3 + >= (model.variables['TestComponent(In1)|flow_rate'] + + model.variables['TestComponent(Out1)|flow_rate'] + + model.variables['TestComponent(Out2)|flow_rate']) / 3 + ) + + def test_on_with_single_flow(self, basic_flow_system_linopy): + """Test that flow model constraints are correctly generated.""" + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + inputs = [ + fx.Flow('In1', 'Fernwärme', relative_minimum=np.ones(10) * 0.1, size=100), + ] + outputs = [] + comp = flixopt.elements.Component( + 'TestComponent', inputs=inputs, outputs=outputs, on_off_parameters=fx.OnOffParameters() + ) + flow_system.add_elements(comp) + model = create_linopy_model(flow_system) + + assert { + 'TestComponent(In1)|flow_rate', + 'TestComponent(In1)|total_flow_hours', + 'TestComponent(In1)|on', + 'TestComponent(In1)|on_hours_total', + 'TestComponent|on', + 'TestComponent|on_hours_total', + } == set(comp.model.variables) + + assert { + 'TestComponent(In1)|total_flow_hours', + 'TestComponent(In1)|on_con1', + 'TestComponent(In1)|on_con2', + 'TestComponent(In1)|on_hours_total', + 'TestComponent|on_con1', + 'TestComponent|on_con2', + 'TestComponent|on_hours_total', + } == set(comp.model.constraints) + + assert_var_equal( + model['TestComponent(In1)|flow_rate'], model.add_variables(lower=0, upper=100, coords=(timesteps,)) + ) + assert_var_equal(model['TestComponent|on'], model.add_variables(binary=True, coords=(timesteps,))) + assert_var_equal(model['TestComponent(In1)|on'], model.add_variables(binary=True, coords=(timesteps,))) + + assert_conequal( + model.constraints['TestComponent(In1)|on_con1'], + model.variables['TestComponent(In1)|on'] * 0.1 * 100 <= model.variables['TestComponent(In1)|flow_rate'], + ) + assert_conequal( + model.constraints['TestComponent(In1)|on_con2'], + model.variables['TestComponent(In1)|on'] * 100 >= model.variables['TestComponent(In1)|flow_rate'], + ) + + assert_conequal( + model.constraints['TestComponent|on_con1'], + model.variables['TestComponent|on'] * 0.1 * 100 <= model.variables['TestComponent(In1)|flow_rate'], + ) + assert_conequal( + model.constraints['TestComponent|on_con2'], + model.variables['TestComponent|on'] * 100 >= model.variables['TestComponent(In1)|flow_rate'], + ) + diff --git a/tests/test_linear_converter.py b/tests/test_linear_converter.py new file mode 100644 index 000000000..aaab60dcc --- /dev/null +++ b/tests/test_linear_converter.py @@ -0,0 +1,556 @@ +import numpy as np +import pandas as pd +import pytest +import xarray as xr + +import flixopt as fx +from flixopt.features import PiecewiseModel + +from .conftest import assert_conequal, assert_var_equal, create_linopy_model + + +class TestLinearConverterModel: + """Test the LinearConverterModel class.""" + + def test_basic_linear_converter(self, basic_flow_system_linopy): + """Test basic initialization and modeling of a LinearConverter.""" + flow_system = basic_flow_system_linopy + + # Create input and output flows + input_flow = fx.Flow('input', bus='input_bus', size=100) + output_flow = fx.Flow('output', bus='output_bus', size=100) + + # Create a simple linear converter with constant conversion factor + converter = fx.LinearConverter( + label='Converter', + inputs=[input_flow], + outputs=[output_flow], + conversion_factors=[{input_flow.label: 0.8, output_flow.label: 1.0}] + ) + + # Add to flow system + flow_system.add_elements( + fx.Bus('input_bus'), + fx.Bus('output_bus'), + converter + ) + + # Create model + model = create_linopy_model(flow_system) + + # Check variables and constraints + assert 'Converter(input)|flow_rate' in model.variables + assert 'Converter(output)|flow_rate' in model.variables + assert 'Converter|conversion_0' in model.constraints + + # Check conversion constraint (input * 0.8 == output * 1.0) + assert_conequal( + model.constraints['Converter|conversion_0'], + input_flow.model.flow_rate * 0.8 == output_flow.model.flow_rate * 1.0 + ) + + def test_linear_converter_time_varying(self, basic_flow_system_linopy): + """Test a LinearConverter with time-varying conversion factors.""" + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + + # Create time-varying efficiency (e.g., temperature-dependent) + varying_efficiency = np.linspace(0.7, 0.9, len(timesteps)) + efficiency_series = xr.DataArray(varying_efficiency, coords=(timesteps,)) + + # Create input and output flows + input_flow = fx.Flow('input', bus='input_bus', size=100) + output_flow = fx.Flow('output', bus='output_bus', size=100) + + # Create a linear converter with time-varying conversion factor + converter = fx.LinearConverter( + label='Converter', + inputs=[input_flow], + outputs=[output_flow], + conversion_factors=[{input_flow.label: efficiency_series, output_flow.label: 1.0}] + ) + + # Add to flow system + flow_system.add_elements( + fx.Bus('input_bus'), + fx.Bus('output_bus'), + converter + ) + + # Create model + model = create_linopy_model(flow_system) + + # Check variables and constraints + assert 'Converter(input)|flow_rate' in model.variables + assert 'Converter(output)|flow_rate' in model.variables + assert 'Converter|conversion_0' in model.constraints + + # Check conversion constraint (input * efficiency_series == output * 1.0) + assert_conequal( + model.constraints['Converter|conversion_0'], + input_flow.model.flow_rate * efficiency_series == output_flow.model.flow_rate * 1.0 + ) + + def test_linear_converter_multiple_factors(self, basic_flow_system_linopy): + """Test a LinearConverter with multiple conversion factors.""" + flow_system = basic_flow_system_linopy + + # Create flows + input_flow1 = fx.Flow('input1', bus='input_bus1', size=100) + input_flow2 = fx.Flow('input2', bus='input_bus2', size=100) + output_flow1 = fx.Flow('output1', bus='output_bus1', size=100) + output_flow2 = fx.Flow('output2', bus='output_bus2', size=100) + + # Create a linear converter with multiple inputs/outputs and conversion factors + converter = fx.LinearConverter( + label='Converter', + inputs=[input_flow1, input_flow2], + outputs=[output_flow1, output_flow2], + conversion_factors=[ + {input_flow1.label: 0.8, output_flow1.label: 1.0}, # input1 -> output1 + {input_flow2.label: 0.5, output_flow2.label: 1.0}, # input2 -> output2 + {input_flow1.label: 0.2, output_flow2.label: 0.3} # input1 contributes to output2 + ] + ) + + # Add to flow system + flow_system.add_elements( + fx.Bus('input_bus1'), + fx.Bus('input_bus2'), + fx.Bus('output_bus1'), + fx.Bus('output_bus2'), + converter + ) + + # Create model + model = create_linopy_model(flow_system) + + # Check constraints for each conversion factor + assert 'Converter|conversion_0' in model.constraints + assert 'Converter|conversion_1' in model.constraints + assert 'Converter|conversion_2' in model.constraints + + # Check conversion constraint 1 (input1 * 0.8 == output1 * 1.0) + assert_conequal( + model.constraints['Converter|conversion_0'], + input_flow1.model.flow_rate * 0.8 == output_flow1.model.flow_rate * 1.0 + ) + + # Check conversion constraint 2 (input2 * 0.5 == output2 * 1.0) + assert_conequal( + model.constraints['Converter|conversion_1'], + input_flow2.model.flow_rate * 0.5 == output_flow2.model.flow_rate * 1.0 + ) + + # Check conversion constraint 3 (input1 * 0.2 == output2 * 0.3) + assert_conequal( + model.constraints['Converter|conversion_2'], + input_flow1.model.flow_rate * 0.2 == output_flow2.model.flow_rate * 0.3 + ) + + def test_linear_converter_with_on_off(self, basic_flow_system_linopy): + """Test a LinearConverter with OnOffParameters.""" + flow_system = basic_flow_system_linopy + + # Create input and output flows + input_flow = fx.Flow('input', bus='input_bus', size=100) + output_flow = fx.Flow('output', bus='output_bus', size=100) + + # Create OnOffParameters + on_off_params = fx.OnOffParameters( + on_hours_total_min=10, + on_hours_total_max=40, + effects_per_running_hour={'Costs': 5} + ) + + # Create a linear converter with OnOffParameters + converter = fx.LinearConverter( + label='Converter', + inputs=[input_flow], + outputs=[output_flow], + conversion_factors=[{input_flow.label: 0.8, output_flow.label: 1.0}], + on_off_parameters=on_off_params + ) + + # Add to flow system + flow_system.add_elements( + fx.Bus('input_bus'), + fx.Bus('output_bus'), + converter, + ) + + # Create model + model = create_linopy_model(flow_system) + + # Verify OnOff variables and constraints + assert 'Converter|on' in model.variables + assert 'Converter|on_hours_total' in model.variables + + # Check on_hours_total constraint + assert_conequal( + model.constraints['Converter|on_hours_total'], + converter.model.on_off.variables['Converter|on_hours_total'] == + (converter.model.on_off.variables['Converter|on'] * model.hours_per_step).sum() + ) + + # Check conversion constraint + assert_conequal( + model.constraints['Converter|conversion_0'], + input_flow.model.flow_rate * 0.8 == output_flow.model.flow_rate * 1.0 + ) + + # Check on_off effects + assert 'Converter->Costs(operation)' in model.constraints + assert_conequal( + model.constraints['Converter->Costs(operation)'], + model.variables['Converter->Costs(operation)'] == + converter.model.on_off.variables['Converter|on'] * model.hours_per_step * 5 + ) + + def test_linear_converter_multidimensional(self, basic_flow_system_linopy): + """Test LinearConverter with multiple inputs, outputs, and connections between them.""" + flow_system = basic_flow_system_linopy + + # Create a more complex setup with multiple flows + input_flow1 = fx.Flow('fuel', bus='fuel_bus', size=100) + input_flow2 = fx.Flow('electricity', bus='electricity_bus', size=50) + output_flow1 = fx.Flow('heat', bus='heat_bus', size=70) + output_flow2 = fx.Flow('cooling', bus='cooling_bus', size=30) + + # Create a CHP-like converter with more complex connections + converter = fx.LinearConverter( + label='MultiConverter', + inputs=[input_flow1, input_flow2], + outputs=[output_flow1, output_flow2], + conversion_factors=[ + # Fuel to heat (primary) + {input_flow1.label: 0.7, output_flow1.label: 1.0}, + # Electricity to cooling + {input_flow2.label: 0.3, output_flow2.label: 1.0}, + # Fuel also contributes to cooling + {input_flow1.label: 0.1, output_flow2.label: 0.5} + ] + ) + + # Add to flow system + flow_system.add_elements( + fx.Bus('fuel_bus'), + fx.Bus('electricity_bus'), + fx.Bus('heat_bus'), + fx.Bus('cooling_bus'), + converter + ) + + # Create model + model = create_linopy_model(flow_system) + + # Check all expected constraints + assert 'MultiConverter|conversion_0' in model.constraints + assert 'MultiConverter|conversion_1' in model.constraints + assert 'MultiConverter|conversion_2' in model.constraints + + # Check the conversion equations + assert_conequal( + model.constraints['MultiConverter|conversion_0'], + input_flow1.model.flow_rate * 0.7 == output_flow1.model.flow_rate * 1.0 + ) + + assert_conequal( + model.constraints['MultiConverter|conversion_1'], + input_flow2.model.flow_rate * 0.3 == output_flow2.model.flow_rate * 1.0 + ) + + assert_conequal( + model.constraints['MultiConverter|conversion_2'], + input_flow1.model.flow_rate * 0.1 == output_flow2.model.flow_rate * 0.5 + ) + + def test_edge_case_time_varying_conversion(self, basic_flow_system_linopy): + """Test edge case with extreme time-varying conversion factors.""" + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + + # Create fluctuating conversion efficiency (e.g., for a heat pump) + # Values range from very low (0.1) to very high (5.0) + fluctuating_cop = np.concatenate([ + np.linspace(0.1, 1.0, len(timesteps)//3), + np.linspace(1.0, 5.0, len(timesteps)//3), + np.linspace(5.0, 0.1, len(timesteps)//3 + len(timesteps)%3) + ]) + + # Create input and output flows + input_flow = fx.Flow('electricity', bus='electricity_bus', size=100) + output_flow = fx.Flow('heat', bus='heat_bus', size=500) # Higher maximum to allow for COP of 5 + + conversion_factors = [{ + input_flow.label: fluctuating_cop, + output_flow.label: np.ones(len(timesteps)) + }] + + # Create the converter + converter = fx.LinearConverter( + label='VariableConverter', + inputs=[input_flow], + outputs=[output_flow], + conversion_factors=conversion_factors + ) + + # Add to flow system + flow_system.add_elements( + fx.Bus('electricity_bus'), + fx.Bus('heat_bus'), + converter + ) + + # Create model + model = create_linopy_model(flow_system) + + # Check that the correct constraint was created + assert 'VariableConverter|conversion_0' in model.constraints + + # Verify the constraint has the time-varying coefficient + assert_conequal( + model.constraints['VariableConverter|conversion_0'], + input_flow.model.flow_rate * fluctuating_cop == output_flow.model.flow_rate * 1.0 + ) + + def test_piecewise_conversion(self, basic_flow_system_linopy): + """Test a LinearConverter with PiecewiseConversion.""" + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + + # Create input and output flows + input_flow = fx.Flow('input', bus='input_bus', size=100) + output_flow = fx.Flow('output', bus='output_bus', size=100) + + # Create pieces for piecewise conversion + # For input flow: two pieces from 0-50 and 50-100 + input_pieces = [ + fx.Piece(start=0, end=50), + fx.Piece(start=50, end=100) + ] + + # For output flow: two pieces from 0-30 and 30-90 + output_pieces = [ + fx.Piece(start=0, end=30), + fx.Piece(start=30, end=90) + ] + + # Create piecewise conversion + piecewise_conversion = fx.PiecewiseConversion({ + input_flow.label: fx.Piecewise(input_pieces), + output_flow.label: fx.Piecewise(output_pieces) + }) + + # Create a linear converter with piecewise conversion + converter = fx.LinearConverter( + label='Converter', + inputs=[input_flow], + outputs=[output_flow], + piecewise_conversion=piecewise_conversion + ) + + # Add to flow system + flow_system.add_elements( + fx.Bus('input_bus'), + fx.Bus('output_bus'), + converter + ) + + # Create model with the piecewise conversion + model = create_linopy_model(flow_system) + + # Verify that PiecewiseModel was created and added as a sub_model + assert converter.model.piecewise_conversion is not None + + # Get the PiecewiseModel instance + piecewise_model = converter.model.piecewise_conversion + + # Check that we have the expected pieces (2 in this case) + assert len(piecewise_model.pieces) == 2 + + # Verify that variables were created for each piece + for i, _ in enumerate(piecewise_model.pieces): + # Each piece should have lambda0, lambda1, and inside_piece variables + assert f'Converter|Piece_{i}|lambda0' in model.variables + assert f'Converter|Piece_{i}|lambda1' in model.variables + assert f'Converter|Piece_{i}|inside_piece' in model.variables + lambda0 = model.variables[f'Converter|Piece_{i}|lambda0'] + lambda1 = model.variables[f'Converter|Piece_{i}|lambda1'] + inside_piece = model.variables[f'Converter|Piece_{i}|inside_piece'] + + assert_var_equal(inside_piece, model.add_variables(binary=True, coords=(timesteps,))) + assert_var_equal(lambda0, model.add_variables(lower=0, upper=1, coords=(timesteps,))) + assert_var_equal(lambda1, model.add_variables(lower=0, upper=1, coords=(timesteps,))) + + # Check that the inside_piece constraint exists + assert f'Converter|Piece_{i}|inside_piece' in model.constraints + # Check the relationship between inside_piece and lambdas + assert_conequal(model.constraints[f'Converter|Piece_{i}|inside_piece'], inside_piece == lambda0 + lambda1) + + assert_conequal( + model.constraints['Converter|Converter(input)|flow_rate|lambda'], + model.variables['Converter(input)|flow_rate'] + == + model.variables['Converter|Piece_0|lambda0'] * 0 + + model.variables['Converter|Piece_0|lambda1'] * 50 + + model.variables['Converter|Piece_1|lambda0'] * 50 + + model.variables['Converter|Piece_1|lambda1'] * 100, + ) + + assert_conequal( + model.constraints['Converter|Converter(output)|flow_rate|lambda'], + model.variables['Converter(output)|flow_rate'] + == + model.variables['Converter|Piece_0|lambda0'] * 0 + + model.variables['Converter|Piece_0|lambda1'] * 30 + + model.variables['Converter|Piece_1|lambda0'] * 30 + + model.variables['Converter|Piece_1|lambda1'] * 90, + ) + + # Check that we enforce the constraint that only one segment can be active + assert 'Converter|Converter(input)|flow_rate|single_segment' in model.constraints + + # The constraint should enforce that the sum of inside_piece variables is limited + # If there's no on_off parameter, the right-hand side should be 1 + assert_conequal( + model.constraints['Converter|Converter(input)|flow_rate|single_segment'], + sum([model.variables[f'Converter|Piece_{i}|inside_piece'] + for i in range(len(piecewise_model.pieces))]) <= 1 + ) + + + def test_piecewise_conversion_with_onoff(self, basic_flow_system_linopy): + """Test a LinearConverter with PiecewiseConversion and OnOffParameters.""" + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + + # Create input and output flows + input_flow = fx.Flow('input', bus='input_bus', size=100) + output_flow = fx.Flow('output', bus='output_bus', size=100) + + # Create pieces for piecewise conversion + input_pieces = [ + fx.Piece(start=0, end=50), + fx.Piece(start=50, end=100) + ] + + output_pieces = [ + fx.Piece(start=0, end=30), + fx.Piece(start=30, end=90) + ] + + # Create piecewise conversion + piecewise_conversion = fx.PiecewiseConversion({ + input_flow.label: fx.Piecewise(input_pieces), + output_flow.label: fx.Piecewise(output_pieces) + }) + + # Create OnOffParameters + on_off_params = fx.OnOffParameters( + on_hours_total_min=10, + on_hours_total_max=40, + effects_per_running_hour={'Costs': 5} + ) + + # Create a linear converter with piecewise conversion and on/off parameters + converter = fx.LinearConverter( + label='Converter', + inputs=[input_flow], + outputs=[output_flow], + piecewise_conversion=piecewise_conversion, + on_off_parameters=on_off_params + ) + + # Add to flow system + flow_system.add_elements( + fx.Bus('input_bus'), + fx.Bus('output_bus'), + converter, + ) + + # Create model with the piecewise conversion + model = create_linopy_model(flow_system) + + # Verify that PiecewiseModel was created and added as a sub_model + assert converter.model.piecewise_conversion is not None + + # Get the PiecewiseModel instance + piecewise_model = converter.model.piecewise_conversion + + # Check that we have the expected pieces (2 in this case) + assert len(piecewise_model.pieces) == 2 + + # Verify that the on variable was used as the zero_point for the piecewise model + # When using OnOffParameters, the zero_point should be the on variable + assert 'Converter|on' in model.variables + assert piecewise_model.zero_point is not None # Should be a variable + + # Verify that variables were created for each piece + for i, _ in enumerate(piecewise_model.pieces): + # Each piece should have lambda0, lambda1, and inside_piece variables + assert f'Converter|Piece_{i}|lambda0' in model.variables + assert f'Converter|Piece_{i}|lambda1' in model.variables + assert f'Converter|Piece_{i}|inside_piece' in model.variables + lambda0 = model.variables[f'Converter|Piece_{i}|lambda0'] + lambda1 = model.variables[f'Converter|Piece_{i}|lambda1'] + inside_piece = model.variables[f'Converter|Piece_{i}|inside_piece'] + + assert_var_equal(inside_piece, model.add_variables(binary=True, coords=(timesteps,))) + assert_var_equal(lambda0, model.add_variables(lower=0, upper=1, coords=(timesteps,))) + assert_var_equal(lambda1, model.add_variables(lower=0, upper=1, coords=(timesteps,))) + + # Check that the inside_piece constraint exists + assert f'Converter|Piece_{i}|inside_piece' in model.constraints + # Check the relationship between inside_piece and lambdas + assert_conequal(model.constraints[f'Converter|Piece_{i}|inside_piece'], inside_piece == lambda0 + lambda1) + + assert_conequal( + model.constraints['Converter|Converter(input)|flow_rate|lambda'], + model.variables['Converter(input)|flow_rate'] + == + model.variables['Converter|Piece_0|lambda0'] * 0 + + model.variables['Converter|Piece_0|lambda1'] * 50 + + model.variables['Converter|Piece_1|lambda0'] * 50 + + model.variables['Converter|Piece_1|lambda1'] * 100, + ) + + assert_conequal( + model.constraints['Converter|Converter(output)|flow_rate|lambda'], + model.variables['Converter(output)|flow_rate'] + == + model.variables['Converter|Piece_0|lambda0'] * 0 + + model.variables['Converter|Piece_0|lambda1'] * 30 + + model.variables['Converter|Piece_1|lambda0'] * 30 + + model.variables['Converter|Piece_1|lambda1'] * 90, + ) + + # Check that we enforce the constraint that only one segment can be active + assert 'Converter|Converter(input)|flow_rate|single_segment' in model.constraints + + # The constraint should enforce that the sum of inside_piece variables is limited + assert_conequal( + model.constraints['Converter|Converter(input)|flow_rate|single_segment'], + sum([model.variables[f'Converter|Piece_{i}|inside_piece'] + for i in range(len(piecewise_model.pieces))]) <= model.variables['Converter|on'] + ) + + # Also check that the OnOff model is working correctly + assert 'Converter|on_hours_total' in model.constraints + assert_conequal( + model.constraints['Converter|on_hours_total'], + converter.model.on_off.variables['Converter|on_hours_total'] == + (converter.model.on_off.variables['Converter|on'] * model.hours_per_step).sum() + ) + + # Verify that the costs effect is applied + assert 'Converter->Costs(operation)' in model.constraints + assert_conequal( + model.constraints['Converter->Costs(operation)'], + model.variables['Converter->Costs(operation)'] == + converter.model.on_off.variables['Converter|on'] * model.hours_per_step * 5 + ) + + +if __name__ == '__main__': + pytest.main() diff --git a/tests/test_storage.py b/tests/test_storage.py new file mode 100644 index 000000000..3378f85b9 --- /dev/null +++ b/tests/test_storage.py @@ -0,0 +1,399 @@ +import numpy as np +import pandas as pd +import pytest +import xarray as xr + +import flixopt as fx + +from .conftest import assert_conequal, assert_var_equal, create_linopy_model + + +class TestStorageModel: + """Test that storage model variables and constraints are correctly generated.""" + + def test_basic_storage(self, basic_flow_system_linopy): + """Test that basic storage model variables and constraints are correctly generated.""" + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + timesteps_extra = flow_system.time_series_collection.timesteps_extra + + # Create a simple storage + storage = fx.Storage( + 'TestStorage', + charging=fx.Flow('Q_th_in', bus='Fernwärme', size=20), + discharging=fx.Flow('Q_th_out', bus='Fernwärme', size=20), + capacity_in_flow_hours=30, # 30 kWh storage capacity + initial_charge_state=0, # Start empty + prevent_simultaneous_charge_and_discharge=True, + ) + + flow_system.add_elements(storage) + model = create_linopy_model(flow_system) + + # Check that all expected variables exist - linopy model variables are accessed by indexing + expected_variables = { + 'TestStorage(Q_th_in)|flow_rate', + 'TestStorage(Q_th_in)|total_flow_hours', + 'TestStorage(Q_th_out)|flow_rate', + 'TestStorage(Q_th_out)|total_flow_hours', + 'TestStorage|charge_state', + 'TestStorage|netto_discharge', + } + for var_name in expected_variables: + assert var_name in model.variables, f"Missing variable: {var_name}" + + # Check that all expected constraints exist - linopy model constraints are accessed by indexing + expected_constraints = { + 'TestStorage(Q_th_in)|total_flow_hours', + 'TestStorage(Q_th_out)|total_flow_hours', + 'TestStorage|netto_discharge', + 'TestStorage|charge_state', + 'TestStorage|initial_charge_state', + } + for con_name in expected_constraints: + assert con_name in model.constraints, f"Missing constraint: {con_name}" + + # Check variable properties + assert_var_equal( + model['TestStorage(Q_th_in)|flow_rate'], + model.add_variables(lower=0, upper=20, coords=(timesteps,)) + ) + assert_var_equal( + model['TestStorage(Q_th_out)|flow_rate'], + model.add_variables(lower=0, upper=20, coords=(timesteps,)) + ) + assert_var_equal( + model['TestStorage|charge_state'], + model.add_variables(lower=0, upper=30, coords=(timesteps_extra,)) + ) + + # Check constraint formulations + assert_conequal( + model.constraints['TestStorage|netto_discharge'], + model.variables['TestStorage|netto_discharge'] == + model.variables['TestStorage(Q_th_out)|flow_rate'] - model.variables['TestStorage(Q_th_in)|flow_rate'] + ) + + charge_state = model.variables['TestStorage|charge_state'] + assert_conequal( + model.constraints['TestStorage|charge_state'], + charge_state.isel(time=slice(1, None)) + == charge_state.isel(time=slice(None, -1)) * model.hours_per_step + + model.variables['TestStorage(Q_th_in)|flow_rate'] * model.hours_per_step + - model.variables['TestStorage(Q_th_out)|flow_rate'] * model.hours_per_step, + ) + # Check initial charge state constraint + assert_conequal( + model.constraints['TestStorage|initial_charge_state'], + model.variables['TestStorage|charge_state'].isel(time=0) == 0 + ) + + def test_lossy_storage(self, basic_flow_system_linopy): + """Test that basic storage model variables and constraints are correctly generated.""" + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + timesteps_extra = flow_system.time_series_collection.timesteps_extra + + # Create a simple storage + storage = fx.Storage( + 'TestStorage', + charging=fx.Flow('Q_th_in', bus='Fernwärme', size=20), + discharging=fx.Flow('Q_th_out', bus='Fernwärme', size=20), + capacity_in_flow_hours=30, # 30 kWh storage capacity + initial_charge_state=0, # Start empty + eta_charge=0.9, # Charging efficiency + eta_discharge=0.8, # Discharging efficiency + relative_loss_per_hour=0.05, # 5% loss per hour + prevent_simultaneous_charge_and_discharge=True, + ) + + flow_system.add_elements(storage) + model = create_linopy_model(flow_system) + + # Check that all expected variables exist - linopy model variables are accessed by indexing + expected_variables = { + 'TestStorage(Q_th_in)|flow_rate', + 'TestStorage(Q_th_in)|total_flow_hours', + 'TestStorage(Q_th_out)|flow_rate', + 'TestStorage(Q_th_out)|total_flow_hours', + 'TestStorage|charge_state', + 'TestStorage|netto_discharge', + } + for var_name in expected_variables: + assert var_name in model.variables, f"Missing variable: {var_name}" + + # Check that all expected constraints exist - linopy model constraints are accessed by indexing + expected_constraints = { + 'TestStorage(Q_th_in)|total_flow_hours', + 'TestStorage(Q_th_out)|total_flow_hours', + 'TestStorage|netto_discharge', + 'TestStorage|charge_state', + 'TestStorage|initial_charge_state', + } + for con_name in expected_constraints: + assert con_name in model.constraints, f"Missing constraint: {con_name}" + + # Check variable properties + assert_var_equal( + model['TestStorage(Q_th_in)|flow_rate'], + model.add_variables(lower=0, upper=20, coords=(timesteps,)) + ) + assert_var_equal( + model['TestStorage(Q_th_out)|flow_rate'], + model.add_variables(lower=0, upper=20, coords=(timesteps,)) + ) + assert_var_equal( + model['TestStorage|charge_state'], + model.add_variables(lower=0, upper=30, coords=(timesteps_extra,)) + ) + + # Check constraint formulations + assert_conequal( + model.constraints['TestStorage|netto_discharge'], + model.variables['TestStorage|netto_discharge'] == + model.variables['TestStorage(Q_th_out)|flow_rate'] - model.variables['TestStorage(Q_th_in)|flow_rate'] + ) + + charge_state = model.variables['TestStorage|charge_state'] + rel_loss = 0.05 + hours_per_step = model.hours_per_step + charge_rate = model.variables['TestStorage(Q_th_in)|flow_rate'] + discharge_rate = model.variables['TestStorage(Q_th_out)|flow_rate'] + eff_charge = 0.9 + eff_discharge = 0.8 + + assert_conequal( + model.constraints['TestStorage|charge_state'], + charge_state.isel(time=slice(1, None)) + == charge_state.isel(time=slice(None, -1)) * (1 - rel_loss * hours_per_step) + + charge_rate * eff_charge * hours_per_step - discharge_rate * eff_discharge * hours_per_step, + ) + + # Check initial charge state constraint + assert_conequal( + model.constraints['TestStorage|initial_charge_state'], + model.variables['TestStorage|charge_state'].isel(time=0) == 0 + ) + + def test_storage_with_investment(self, basic_flow_system_linopy): + """Test storage with investment parameters.""" + flow_system = basic_flow_system_linopy + + # Create storage with investment parameters + storage = fx.Storage( + 'InvestStorage', + charging=fx.Flow('Q_th_in', bus='Fernwärme', size=20), + discharging=fx.Flow('Q_th_out', bus='Fernwärme', size=20), + capacity_in_flow_hours=fx.InvestParameters( + fix_effects=100, + specific_effects=10, + minimum_size=20, + maximum_size=100, + optional=True + ), + initial_charge_state=0, + eta_charge=0.9, + eta_discharge=0.9, + relative_loss_per_hour=0.05, + prevent_simultaneous_charge_and_discharge=True, + ) + + flow_system.add_elements(storage) + model = create_linopy_model(flow_system) + + # Check investment variables exist + for var_name in { + 'InvestStorage|charge_state', + 'InvestStorage|size', + 'InvestStorage|is_invested', + }: + assert var_name in model.variables, f"Missing investment variable: {var_name}" + + # Check investment constraints exist + for con_name in {'InvestStorage|is_invested_ub', 'InvestStorage|is_invested_lb'}: + assert con_name in model.constraints, f"Missing investment constraint: {con_name}" + + # Check variable properties + assert_var_equal( + model['InvestStorage|size'], + model.add_variables(lower=0, upper=100) + ) + assert_var_equal( + model['InvestStorage|is_invested'], + model.add_variables(binary=True) + ) + assert_conequal(model.constraints['InvestStorage|is_invested_ub'], + model.variables['InvestStorage|size'] <= model.variables['InvestStorage|is_invested'] * 100) + assert_conequal(model.constraints['InvestStorage|is_invested_lb'], + model.variables['InvestStorage|size'] >= model.variables['InvestStorage|is_invested'] * 20) + + def test_storage_with_final_state_constraints(self, basic_flow_system_linopy): + """Test storage with final state constraints.""" + flow_system = basic_flow_system_linopy + + # Create storage with final state constraints + storage = fx.Storage( + 'FinalStateStorage', + charging=fx.Flow('Q_th_in', bus='Fernwärme', size=20), + discharging=fx.Flow('Q_th_out', bus='Fernwärme', size=20), + capacity_in_flow_hours=30, + initial_charge_state=10, # Start with 10 kWh + minimal_final_charge_state=15, # End with at least 15 kWh + maximal_final_charge_state=25, # End with at most 25 kWh + eta_charge=0.9, + eta_discharge=0.9, + relative_loss_per_hour=0.05, + ) + + flow_system.add_elements(storage) + model = create_linopy_model(flow_system) + + # Check final state constraints exist + expected_constraints = { + 'FinalStateStorage|final_charge_min', + 'FinalStateStorage|final_charge_max', + } + + for con_name in expected_constraints: + assert con_name in model.constraints, f"Missing final state constraint: {con_name}" + + assert_conequal( + model.constraints['FinalStateStorage|initial_charge_state'], + model.variables['FinalStateStorage|charge_state'].isel(time=0) == 10, + ) + + # Check final state constraint formulations + assert_conequal( + model.constraints['FinalStateStorage|final_charge_min'], + model.variables['FinalStateStorage|charge_state'].isel(time=-1) >= 15 + ) + assert_conequal( + model.constraints['FinalStateStorage|final_charge_max'], + model.variables['FinalStateStorage|charge_state'].isel(time=-1) <= 25 + ) + + def test_storage_cyclic_initialization(self, basic_flow_system_linopy): + """Test storage with cyclic initialization.""" + flow_system = basic_flow_system_linopy + + # Create storage with cyclic initialization + storage = fx.Storage( + 'CyclicStorage', + charging=fx.Flow('Q_th_in', bus='Fernwärme', size=20), + discharging=fx.Flow('Q_th_out', bus='Fernwärme', size=20), + capacity_in_flow_hours=30, + initial_charge_state='lastValueOfSim', # Cyclic initialization + eta_charge=0.9, + eta_discharge=0.9, + relative_loss_per_hour=0.05, + ) + + flow_system.add_elements(storage) + model = create_linopy_model(flow_system) + + # Check cyclic constraint exists + assert 'CyclicStorage|initial_charge_state' in model.constraints, \ + "Missing cyclic initialization constraint" + + # Check cyclic constraint formulation + assert_conequal( + model.constraints['CyclicStorage|initial_charge_state'], + model.variables['CyclicStorage|charge_state'].isel(time=0) == + model.variables['CyclicStorage|charge_state'].isel(time=-1) + ) + + @pytest.mark.parametrize( + 'prevent_simultaneous', + [True, False], + ) + def test_simultaneous_charge_discharge(self, basic_flow_system_linopy, prevent_simultaneous): + """Test prevent_simultaneous_charge_and_discharge parameter.""" + flow_system = basic_flow_system_linopy + + # Create storage with or without simultaneous charge/discharge prevention + storage = fx.Storage( + 'SimultaneousStorage', + charging=fx.Flow('Q_th_in', bus='Fernwärme', size=20), + discharging=fx.Flow('Q_th_out', bus='Fernwärme', size=20), + capacity_in_flow_hours=30, + initial_charge_state=0, + eta_charge=0.9, + eta_discharge=0.9, + relative_loss_per_hour=0.05, + prevent_simultaneous_charge_and_discharge=prevent_simultaneous, + ) + + flow_system.add_elements(storage) + model = create_linopy_model(flow_system) + + # Binary variables should exist when preventing simultaneous operation + if prevent_simultaneous: + binary_vars = { + 'SimultaneousStorage(Q_th_in)|on', + 'SimultaneousStorage(Q_th_out)|on', + } + for var_name in binary_vars: + assert var_name in model.variables, f'Missing binary variable: {var_name}' + + # Check for constraints that enforce either charging or discharging + constraint_name = 'SimultaneousStorage|PreventSimultaneousUsage|prevent_simultaneous_use' + assert constraint_name in model.constraints, 'Missing constraint to prevent simultaneous operation' + + assert_conequal(model.constraints['SimultaneousStorage|PreventSimultaneousUsage|prevent_simultaneous_use'], + model.variables['SimultaneousStorage(Q_th_in)|on'] + model.variables['SimultaneousStorage(Q_th_out)|on'] <= 1.1) + + @pytest.mark.parametrize( + 'optional,minimum_size,expected_vars,expected_constraints', + [ + (True, None, {'InvestStorage|is_invested'}, {'InvestStorage|is_invested_lb'}), + (True, 20, {'InvestStorage|is_invested'}, {'InvestStorage|is_invested_lb'}), + (False, None, set(), set()), + (False, 20, set(), set()), + ], + ) + def test_investment_parameters(self, basic_flow_system_linopy, optional, minimum_size, expected_vars, expected_constraints): + """Test different investment parameter combinations.""" + flow_system = basic_flow_system_linopy + + # Create investment parameters + invest_params = { + 'fix_effects': 100, + 'specific_effects': 10, + 'optional': optional, + } + if minimum_size is not None: + invest_params['minimum_size'] = minimum_size + + # Create storage with specified investment parameters + storage = fx.Storage( + 'InvestStorage', + charging=fx.Flow('Q_th_in', bus='Fernwärme', size=20), + discharging=fx.Flow('Q_th_out', bus='Fernwärme', size=20), + capacity_in_flow_hours=fx.InvestParameters(**invest_params), + initial_charge_state=0, + eta_charge=0.9, + eta_discharge=0.9, + relative_loss_per_hour=0.05, + ) + + flow_system.add_elements(storage) + model = create_linopy_model(flow_system) + + # Check that expected variables exist + for var_name in expected_vars: + if optional: + assert var_name in model.variables, f"Expected variable {var_name} not found" + + # Check that expected constraints exist + for constraint_name in expected_constraints: + if optional: + assert constraint_name in model.constraints, f"Expected constraint {constraint_name} not found" + + # If optional is False, is_invested should be fixed to 1 + if not optional: + # Check that the is_invested variable exists and is fixed to 1 + if 'InvestStorage|is_invested' in model.variables: + var = model.variables['InvestStorage|is_invested'] + # Check if the lower and upper bounds are both 1 + assert var.upper == 1 and var.lower == 1, \ + "is_invested variable should be fixed to 1 when optional=False" From 9ed66566e22d2044e3fbd500d7cb8192c3579e2d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 11 Apr 2025 15:39:03 +0200 Subject: [PATCH 503/507] Feature/binary models (#235) Splitup the OnOffModel into smaller Submodels, improving modularity and reusability. Some variables were renamed slightly --- flixopt/features.py | 690 ++++++++++++++++------------- tests/test_flow.py | 79 ++-- tests/test_on_hours_computation.py | 12 +- 3 files changed, 426 insertions(+), 355 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index e34cb9a40..c2a62adb1 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -4,7 +4,7 @@ """ import logging -from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Tuple, Union +from typing import Dict, List, Optional, Tuple, Union import linopy import numpy as np @@ -12,7 +12,7 @@ from . import utils from .config import CONFIG from .core import NumericData, Scalar, TimeSeries -from .interface import InvestParameters, OnOffParameters, Piece, Piecewise, PiecewiseConversion, PiecewiseEffects +from .interface import InvestParameters, OnOffParameters, Piecewise from .structure import Model, SystemModel logger = logging.getLogger('flixopt') @@ -155,7 +155,7 @@ def _create_bounds_for_defining_variable(self): ) if self._on_variable is not None: raise ValueError( - f'Flow {self.label} has a fixed relative flow rate and an on_variable.' + f'Flow {self.label_full} has a fixed relative flow rate and an on_variable.' f'This combination is currently not supported.' ) return @@ -193,53 +193,52 @@ def _create_bounds_for_defining_variable(self): # anmerkung: Glg bei Spezialfall relative_minimum = 0 redundant zu OnOff ?? -class OnOffModel(Model): +class StateModel(Model): """ - Class for modeling the on and off state of a variable - If defining_bounds are given, creates sufficient lower bounds + Handles basic on/off binary states for defining variables """ def __init__( self, model: SystemModel, - on_off_parameters: OnOffParameters, label_of_element: str, defining_variables: List[linopy.Variable], defining_bounds: List[Tuple[NumericData, NumericData]], - previous_values: List[Optional[NumericData]], + previous_values: List[Optional[NumericData]] = None, + use_off: bool = True, + on_hours_total_min: Optional[NumericData] = 0, + on_hours_total_max: Optional[NumericData] = None, + effects_per_running_hour: Dict[str, NumericData] = None, label: Optional[str] = None, ): """ - Constructor for OnOffModel + Models binary state variables based on a continous variable. Args: - model: Reference to the SystemModel - on_off_parameters: Parameters for the OnOffModel - label_of_element: Label of the Parent - defining_variables: List of Variables that are used to define the OnOffModel + model: The SystemModel 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. + defining_variables: List of Variables that are used to define the state defining_bounds: List of Tuples, defining the absolute bounds of each defining variable previous_values: List of previous values of the defining variables + use_off: Whether to use the off state or not + on_hours_total_min: min. overall sum of operating hours. + on_hours_total_max: max. overall sum of operating hours. + effects_per_running_hour: Costs per operating hours label: Label of the OnOffModel """ super().__init__(model, label_of_element, label) assert len(defining_variables) == len(defining_bounds), 'Every defining Variable needs bounds to Model OnOff' - self.parameters = on_off_parameters self._defining_variables = defining_variables - # Ensure that no lower bound is below a certain threshold - self._defining_bounds = [(np.maximum(lb, CONFIG.modeling.EPSILON), ub) for lb, ub in defining_bounds] - self._previous_values = previous_values - - self.on: Optional[linopy.Variable] = None + self._defining_bounds = defining_bounds + self._previous_values = previous_values or [] + self._on_hours_total_min = on_hours_total_min if on_hours_total_min is not None else 0 + self._on_hours_total_max = on_hours_total_max if on_hours_total_max is not None else np.inf + self._use_off = use_off + self._effects_per_running_hour = effects_per_running_hour or {} + + self.on = None self.total_on_hours: Optional[linopy.Variable] = None - - self.consecutive_on_hours: Optional[linopy.Variable] = None - self.consecutive_off_hours: Optional[linopy.Variable] = None - - self.off: Optional[linopy.Variable] = None - - self.switch_on: Optional[linopy.Variable] = None - self.switch_off: Optional[linopy.Variable] = None - self.switch_on_nr: Optional[linopy.Variable] = None + self.off = None def do_modeling(self): self.on = self.add( @@ -253,8 +252,9 @@ def do_modeling(self): self.total_on_hours = self.add( self._model.add_variables( - lower=self.parameters.on_hours_total_min if self.parameters.on_hours_total_min is not None else 0, - upper=self.parameters.on_hours_total_max if self.parameters.on_hours_total_max is not None else np.inf, + lower=self._on_hours_total_min, + upper=self._on_hours_total_max, + coords=None, name=f'{self.label_full}|on_hours_total', ), 'on_hours_total', @@ -268,9 +268,10 @@ def do_modeling(self): 'on_hours_total', ) - self._add_on_constraints() + # Add defining constraints for each variable + self._add_defining_constraints() - if self.parameters.use_off: + if self._use_off: self.off = self.add( self._model.add_variables( name=f'{self.label_full}|off', @@ -280,69 +281,21 @@ def do_modeling(self): 'off', ) - # eq: var_on(t) + var_off(t) = 1 + # Constraint: on + off = 1 self.add(self._model.add_constraints(self.on + self.off == 1, name=f'{self.label_full}|off'), 'off') - if self.parameters.use_consecutive_on_hours: - self.consecutive_on_hours = self._get_duration_in_hours( - 'consecutive_on_hours', - self.on, - self.previous_consecutive_on_hours, - self.parameters.consecutive_on_hours_min, - self.parameters.consecutive_on_hours_max, - ) - - if self.parameters.use_consecutive_off_hours: - self.consecutive_off_hours = self._get_duration_in_hours( - 'consecutive_off_hours', - self.off, - self.previous_consecutive_off_hours, - self.parameters.consecutive_off_hours_min, - self.parameters.consecutive_off_hours_max, - ) - - if self.parameters.use_switch_on: - self.switch_on = self.add( - self._model.add_variables(binary=True, name=f'{self.label_full}|switch_on', coords=self._model.coords), - 'switch_on', - ) - - self.switch_off = self.add( - self._model.add_variables(binary=True, name=f'{self.label_full}|switch_off', coords=self._model.coords), - 'switch_off', - ) - - self.switch_on_nr = self.add( - self._model.add_variables( - lower=0, - upper=self.parameters.switch_on_total_max - if self.parameters.switch_on_total_max is not None - else np.inf, - name=f'{self.label_full}|switch_on_nr', - ), - 'switch_on_nr', - ) - - self._add_switch_constraints() - - self._create_shares() - - def _add_on_constraints(self): - assert self.on is not None, f'On variable of {self.label_full} must be defined to add constraints' - # % Bedingungen 1) und 2) müssen erfüllt sein: - - # % Anmerkung: Falls "abschnittsweise linear" gewählt, dann ist eigentlich nur Bedingung 1) noch notwendig - # % (und dann auch nur wenn erstes Piece bei Q_th=0 beginnt. Dann soll bei Q_th=0 (d.h. die Maschine ist Aus) On = 0 und segment1.onSeg = 0):) - # % Fazit: Wenn kein Performance-Verlust durch mehr Gleichungen, dann egal! + return self + def _add_defining_constraints(self): + """Add constraints that link defining variables to the on state""" nr_of_def_vars = len(self._defining_variables) - assert nr_of_def_vars > 0, 'Achtung: mindestens 1 Flow notwendig' if nr_of_def_vars == 1: + # Case for a single defining variable def_var = self._defining_variables[0] lb, ub = self._defining_bounds[0] - # eq: On(t) * max(epsilon, lower_bound) <= Q_th(t) + # Constraint: on * lower_bound <= def_var self.add( self._model.add_constraints( self.on * np.maximum(CONFIG.modeling.EPSILON, lb) <= def_var, name=f'{self.label_full}|on_con1' @@ -350,20 +303,16 @@ def _add_on_constraints(self): 'on_con1', ) - # eq: Q_th(t) <= Q_th_max * On(t) + # Constraint: on * upper_bound >= def_var self.add( - self._model.add_constraints( - self.on * np.maximum(CONFIG.modeling.EPSILON, ub) >= def_var, name=f'{self.label_full}|on_con2' - ), - 'on_con2', + self._model.add_constraints(self.on * ub >= def_var, name=f'{self.label_full}|on_con2'), 'on_con2' ) - - else: # Bei mehreren Leistungsvariablen: + else: + # Case for multiple defining variables ub = sum(bound[1] for bound in self._defining_bounds) / nr_of_def_vars lb = CONFIG.modeling.EPSILON #TODO: Can this be a bigger value? (maybe the smallest bound?) - # When all defining variables are 0, On is 0 - # eq: On(t) * Epsilon <= sum(alle Leistungen(t)) + # Constraint: on * epsilon <= sum(all_defining_variables) self.add( self._model.add_constraints( self.on * lb <= sum(self._defining_variables), name=f'{self.label_full}|on_con1' @@ -371,10 +320,8 @@ def _add_on_constraints(self): 'on_con1', ) - ## sum(alle Leistung) >0 -> On = 1|On=0 -> sum(Leistung)=0 - # eq: sum( Leistung(t,i)) - sum(Leistung_max(i)) * On(t) <= 0 - # --> damit Gleichungswerte nicht zu groß werden, noch durch nr_of_flows geteilt: - # eq: sum( Leistung(t,i) / nr_of_flows ) - sum(Leistung_max(i)) / nr_of_flows * On(t) <= 0 + # Constraint to ensure all variables are zero when off. + # Divide by nr_of_def_vars to improve numerical stability (smaller factors) self.add( self._model.add_constraints( self.on * ub >= sum([def_var / nr_of_def_vars for def_var in self._defining_variables]), @@ -383,285 +330,261 @@ def _add_on_constraints(self): 'on_con2', ) - if np.max(ub) > CONFIG.modeling.BIG_BINARY_BOUND: - logger.warning( - f'In "{self.label_full}", a binary definition was created with a big upper bound ' - f'({np.max(ub)}). This can lead to wrong results regarding the on and off variables. ' - f'Avoid this warning by reducing the size of {self.label_full} ' - f'(or the maximum_size of the corresponding InvestParameters). ' - f'If its a Component, you might need to adjust the sizes of all of its flows.' - ) + @property + def previous_states(self) -> np.ndarray: + """Computes the previous states {0, 1} of defining variables as a binary array from their previous values.""" + return StateModel.compute_previous_states(self._previous_values, epsilon=CONFIG.modeling.EPSILON) - def _get_duration_in_hours( - self, - variable_name: str, - binary_variable: linopy.Variable, - previous_duration: Scalar, - minimum_duration: Optional[TimeSeries], - maximum_duration: Optional[TimeSeries], - ) -> linopy.Variable: - """ - creates duration variable and adds constraints to a time-series variable to enforce duration limits based on - binary activity. - The minimum duration in the last time step is not restricted. - Previous values before t=0 are not recognised! + @property + def previous_on_states(self) -> np.ndarray: + return self.previous_states - Args: - variable_name: Label for the duration variable to be created. - binary_variable: Time-series binary variable (e.g., [0, 0, 1, 1, 1, 0, ...]) representing activity states. - minimum_duration: Minimum duration the activity must remain active once started. - If None, no minimum duration constraint is applied. - maximum_duration: Maximum duration the activity can remain active. - If None, the maximum duration is set to the total available time. + @property + def previous_off_states(self): + return 1 - self.previous_states - Returns: - The created duration variable representing consecutive active durations. + @staticmethod + def compute_previous_states(previous_values: List[NumericData], epsilon: float = 1e-5) -> np.ndarray: + """Computes the previous states {0, 1} of defining variables as a binary array from their previous values.""" + if not previous_values or all([val is None for val in previous_values]): + return np.array([0]) - Example: - binary_variable: [0, 0, 1, 1, 1, 1, 0, 1, 1, 1, 0, ...] - duration_in_hours: [0, 0, 1, 2, 3, 4, 0, 1, 2, 3, 0, ...] (only if dt_in_hours=1) + # Convert to 2D-array and compute binary on/off states + previous_values = np.array([values for values in previous_values if values is not None]) # Filter out None + if previous_values.ndim > 1: + return np.any(~np.isclose(previous_values, 0, atol=epsilon), axis=0).astype(int) - Here, duration_in_hours increments while binary_variable is 1. Minimum and maximum durations - can be enforced to constrain how long the activity remains active. + return (~np.isclose(previous_values, 0, atol=epsilon)).astype(int) - Notes: - - To count consecutive zeros instead of ones, use a transformed binary variable - (e.g., `1 - binary_variable`). - - Constraints ensure the duration variable properly resets or increments based on activity. - Raises: - AssertionError: If the binary_variable is None, indicating the duration constraints cannot be applied. +class SwitchStateModel(Model): + """ + Handles switch on/off transitions + """ - """ - assert binary_variable is not None, f'Duration Variable of {self.label_full} must be defined to add constraints' + def __init__( + self, + model: SystemModel, + label_of_element: str, + state_variable: linopy.Variable, + previous_state=0, + switch_on_max: Optional[Scalar] = None, + label: Optional[str] = None, + ): + super().__init__(model, label_of_element, label) + self._state_variable = state_variable + self.previous_state = previous_state + self._switch_on_max = switch_on_max if switch_on_max is not None else np.inf - mega = self._model.hours_per_step.sum() + previous_duration + self.switch_on = None + self.switch_off = None + self.switch_on_nr = None - if maximum_duration is not None: - first_step_max: Scalar = maximum_duration.isel(time=0) + def do_modeling(self): + """Create switch variables and constraints""" - if previous_duration + self._model.hours_per_step[0] > first_step_max: - logger.warning( - f'The maximum duration of "{variable_name}" is set to {maximum_duration.active_data}h, ' - f'but the consecutive_duration previous to this model is {previous_duration}h. ' - f'This forces "{binary_variable.name} = 0" in the first time step ' - f'(dt={self._model.hours_per_step[0]}h)!' - ) + # Create switch variables + self.switch_on = self.add( + self._model.add_variables(binary=True, name=f'{self.label_full}|switch_on', coords=self._model.coords), + 'switch_on', + ) + + self.switch_off = self.add( + self._model.add_variables(binary=True, name=f'{self.label_full}|switch_off', coords=self._model.coords), + 'switch_off', + ) - duration_in_hours = self.add( + # Create count variable for number of switches + self.switch_on_nr = self.add( self._model.add_variables( + upper=self._switch_on_max, lower=0, - upper=maximum_duration.active_data if maximum_duration is not None else mega, - coords=self._model.coords, - name=f'{self.label_full}|{variable_name}', + name=f'{self.label_full}|switch_on_nr', ), - variable_name, + 'switch_on_nr', ) - # 1) eq: duration(t) - On(t) * BIG <= 0 + # Add switch constraints for all entries after the first timestep self.add( self._model.add_constraints( - duration_in_hours <= binary_variable * mega, name=f'{self.label_full}|{variable_name}_con1' + self.switch_on.isel(time=slice(1, None)) - self.switch_off.isel(time=slice(1, None)) + == self._state_variable.isel(time=slice(1, None)) - self._state_variable.isel(time=slice(None, -1)), + name=f'{self.label_full}|switch_con', ), - f'{variable_name}_con1', + 'switch_con', ) - # 2a) eq: duration(t) - duration(t-1) <= dt(t) - # on(t)=1 -> duration(t) - duration(t-1) <= dt(t) - # on(t)=0 -> duration(t-1) >= negat. value + # Initial switch constraint self.add( self._model.add_constraints( - duration_in_hours.isel(time=slice(1, None)) - <= duration_in_hours.isel(time=slice(None, -1)) + self._model.hours_per_step.isel(time=slice(None, -1)), - name=f'{self.label_full}|{variable_name}_con2a', + self.switch_on.isel(time=0) - self.switch_off.isel(time=0) + == self._state_variable.isel(time=0) - self.previous_state, + name=f'{self.label_full}|initial_switch_con', ), - f'{variable_name}_con2a', + 'initial_switch_con', ) - # 2b) eq: dt(t) - BIG * ( 1-On(t) ) <= duration(t) - duration(t-1) - # eq: -duration(t) + duration(t-1) + On(t) * BIG <= -dt(t) + BIG - # with BIG = dt_in_hours_total. - # on(t)=1 -> duration(t)- duration(t-1) >= dt(t) - # on(t)=0 -> duration(t)- duration(t-1) >= negat. value + # Mutual exclusivity constraint + self.add( + self._model.add_constraints(self.switch_on + self.switch_off <= 1.1, name=f'{self.label_full}|switch_on_or_off'), + 'switch_on_or_off', + ) + # Total switch-on count constraint self.add( self._model.add_constraints( - duration_in_hours.isel(time=slice(1, None)) - >= duration_in_hours.isel(time=slice(None, -1)) - + self._model.hours_per_step.isel(time=slice(None, -1)) - + (binary_variable.isel(time=slice(1, None)) - 1) * mega, - name=f'{self.label_full}|{variable_name}_con2b', + self.switch_on_nr == self.switch_on.sum('time'), name=f'{self.label_full}|switch_on_nr' ), - f'{variable_name}_con2b', + 'switch_on_nr', ) - # 3) check minimum_duration before switchOff-step + return self - if minimum_duration is not None: - # Note: switchOff-step is when: On(t) - On(t+1) == 1 - # Note: (last on-time period (with last timestep of period t=n) is not checked and can be shorter) - # Note: (previous values before t=1 are not recognised!) - # eq: duration(t) >= minimum_duration(t) * [On(t) - On(t+1)] for t=1..(n-1) - # eq: -duration(t) + minimum_duration(t) * On(t) - minimum_duration(t) * On(t+1) <= 0 - self.add( - self._model.add_constraints( - duration_in_hours - >= (binary_variable.isel(time=slice(None, -1)) - binary_variable.isel(time=slice(1, None))) - * minimum_duration.isel(time=slice(None, -1)), - name=f'{self.label_full}|{variable_name}_minimum_duration', - ), - f'{variable_name}_minimum_duration', - ) - if 0 < previous_duration < minimum_duration.isel(time=0): - # Force the first step to be = 1, if the minimum_duration is not reached in previous_values - # Note: Only if the previous consecutive_duration is smaller than the minimum duration - # and the previous_duration is greater 0! - # eq: On(t=0) = 1 - self.add( - self._model.add_constraints( - binary_variable.isel(time=0) == 1, name=f'{self.label_full}|{variable_name}_minimum_inital' - ), - f'{variable_name}_minimum_inital', - ) +class ConsecutiveStateModel(Model): + """ + Handles tracking consecutive durations in a state + """ - # 4) first index: - # eq: duration(t=0)= dt(0) * On(0) - self.add( - self._model.add_constraints( - duration_in_hours.isel(time=0) - == self._model.hours_per_step.isel(time=0) * binary_variable.isel(time=0), - name=f'{self.label_full}|{variable_name}_initial', - ), - f'{variable_name}_initial', - ) + def __init__( + self, + model: SystemModel, + label_of_element: str, + state_variable: linopy.Variable, + minimum_duration: Optional[NumericData] = None, + maximum_duration: Optional[NumericData] = None, + previous_states: Optional[NumericData] = None, + label: Optional[str] = None, + ): + """ + Model and constraint the consecutive duration of a state variable. - return duration_in_hours + Args: + model: The SystemModel 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. + state_variable: The state variable that is used to model the duration. state = {0, 1} + minimum_duration: The minimum duration of the state variable. + maximum_duration: The maximum duration of the state variable. + previous_states: The previous states of the state variable. + label: The label of the model. Used to construct the full label of the model. + """ + super().__init__(model, label_of_element, label) + self._state_variable = state_variable + self._previous_states = previous_states + self._minimum_duration = minimum_duration + self._maximum_duration = maximum_duration - def _add_switch_constraints(self): - assert self.switch_on is not None, f'Switch On Variable of {self.label_full} must be defined to add constraints' - assert self.switch_off is not None, ( - f'Switch Off Variable of {self.label_full} must be defined to add constraints' - ) - assert self.switch_on_nr is not None, ( - f'Nr of Switch On Variable of {self.label_full} must be defined to add constraints' - ) - assert self.on is not None, f'On Variable of {self.label_full} must be defined to add constraints' - # % Schaltänderung aus On-Variable - # % SwitchOn(t)-SwitchOff(t) = On(t)-On(t-1) - self.add( - self._model.add_constraints( - self.switch_on.isel(time=slice(1, None)) - self.switch_off.isel(time=slice(1, None)) - == self.on.isel(time=slice(1, None)) - self.on.isel(time=slice(None, -1)), - name=f'{self.label_full}|switch_con', + if isinstance(self._minimum_duration, TimeSeries): + self._minimum_duration = self._minimum_duration.active_data + if isinstance(self._maximum_duration, TimeSeries): + self._maximum_duration = self._maximum_duration.active_data + + self.duration = None + + def do_modeling(self): + """Create consecutive duration variables and constraints""" + # Get the hours per step + hours_per_step = self._model.hours_per_step + mega = hours_per_step.sum('time') + self.previous_duration + + # Create the duration variable + self.duration = self.add( + self._model.add_variables( + lower=0, + upper=self._maximum_duration if self._maximum_duration is not None else mega, + coords=self._model.coords, + name=f'{self.label_full}|hours', ), - 'switch_con', + 'hours', ) - # Initital switch on - # eq: SwitchOn(t=0)-SwitchOff(t=0) = On(t=0) - On(t=-1) + + # Add constraints + + # Upper bound constraint self.add( self._model.add_constraints( - self.switch_on.isel(time=0) - self.switch_off.isel(time=0) - == self.on.isel(time=0) - self.previous_on_values[-1], - name=f'{self.label_full}|initial_switch_con', + self.duration <= self._state_variable * mega, name=f'{self.label_full}|con1' ), - 'initial_switch_con', + 'con1', ) - ## Entweder SwitchOff oder SwitchOn - # eq: SwitchOn(t) + SwitchOff(t) <= 1.1 + + # Forward constraint self.add( self._model.add_constraints( - self.switch_on + self.switch_off <= 1.1, name=f'{self.label_full}|switch_on_or_off' + self.duration.isel(time=slice(1, None)) + <= self.duration.isel(time=slice(None, -1)) + hours_per_step.isel(time=slice(None, -1)), + name=f'{self.label_full}|con2a', ), - 'switch_on_or_off', + 'con2a', ) - ## Anzahl Starts: - # eq: nrSwitchOn = sum(SwitchOn(t)) + # Backward constraint self.add( self._model.add_constraints( - self.switch_on_nr == self.switch_on.sum(), name=f'{self.label_full}|switch_on_nr' + self.duration.isel(time=slice(1, None)) + >= self.duration.isel(time=slice(None, -1)) + + hours_per_step.isel(time=slice(None, -1)) + + (self._state_variable.isel(time=slice(1, None)) - 1) * mega, + name=f'{self.label_full}|con2b', ), - 'switch_on_nr', + 'con2b', ) - def _create_shares(self): - # Anfahrkosten: - effects_per_switch_on = self.parameters.effects_per_switch_on - if effects_per_switch_on != {}: - self._model.effects.add_share_to_effects( - name=self.label_of_element, - expressions={effect: self.switch_on * factor for effect, factor in effects_per_switch_on.items()}, - target='operation', - ) - - # Betriebskosten: - effects_per_running_hour = self.parameters.effects_per_running_hour - if effects_per_running_hour != {}: - self._model.effects.add_share_to_effects( - name=self.label_of_element, - expressions={ - effect: self.on * factor * self._model.hours_per_step - for effect, factor in effects_per_running_hour.items() - }, - target='operation', + # Add minimum duration constraints if specified + if self._minimum_duration is not None: + self.add( + self._model.add_constraints( + self.duration + >= ( + self._state_variable.isel(time=slice(None, -1)) - self._state_variable.isel(time=slice(1, None)) + ) + * self._minimum_duration.isel(time=slice(None, -1)), + name=f'{self.label_full}|minimum', + ), + 'minimum', ) - @property - def previous_on_values(self) -> np.ndarray: - return self.compute_previous_on_states(self._previous_values) + # Handle initial condition + if 0 < self.previous_duration < self._minimum_duration.isel(time=0): + self.add( + self._model.add_constraints( + self._state_variable.isel(time=0) == 1, name=f'{self.label_full}|initial_minimum' + ), + 'initial_minimum', + ) - @property - def previous_off_values(self) -> np.ndarray: - return 1 - self.previous_on_values + # Set initial value + self.add( + self._model.add_constraints( + self.duration.isel(time=0) == + (hours_per_step.isel(time=0) + self.previous_duration) * self._state_variable.isel(time=0), + name=f'{self.label_full}|initial', + ), + 'initial', + ) - @property - def previous_consecutive_on_hours(self) -> Scalar: - return self.compute_consecutive_duration(self.previous_on_values, self._model.hours_per_step) + return self @property - def previous_consecutive_off_hours(self) -> Scalar: - return self.compute_consecutive_duration(self.previous_off_values, self._model.hours_per_step) - - @staticmethod - def compute_previous_on_states(previous_values: List[Optional[NumericData]], epsilon: float = 1e-5) -> np.ndarray: - """ - Computes the previous 'on' states {0, 1} of defining variables as a binary array from their previous values. - - Args: - previous_values: List of previous values of the defining variables. In Range [0, inf] or None (ignored) - epsilon: Tolerance for equality to determine "off" state, default is 1e-5. - - Returns: - A binary array (0 and 1) indicating the previous on/off states of the variables. - Returns `array([0])` if no previous values are available. - """ - for arr in previous_values: - if isinstance(arr, np.ndarray) and arr.ndim > 1: - raise ValueError('Only 1D arrays or None values are supported for previous_values') - - if not previous_values or all([val is None for val in previous_values]): - return np.array([0]) - else: # Convert to 2D-array and compute binary on/off states - previous_values = np.array([values for values in previous_values if values is not None]) # Filter out None - if previous_values.ndim > 1: - return np.any(~np.isclose(previous_values, 0, atol=epsilon), axis=0).astype(int) - else: - return (~np.isclose(previous_values, 0, atol=epsilon)).astype(int) + def previous_duration(self) -> Scalar: + """Computes the previous duration of the state variable""" + #TODO: Allow for other/dynamic timestep resolutions + return ConsecutiveStateModel.compute_consecutive_hours_in_state( + self._previous_states, self._model.hours_per_step.isel(time=0).item() + ) @staticmethod - def compute_consecutive_duration( + def compute_consecutive_hours_in_state( binary_values: NumericData, hours_per_timestep: Union[int, float, np.ndarray] ) -> Scalar: """ - Computes the final consecutive duration in State 'on' (=1) in hours, from a binary. - - hours_per_timestep is handled in a way, that maximizes compatability. - Its length must only be as long as the last consecutive duration in binary_values. + Computes the final consecutive duration in state 'on' (=1) in hours, from a binary array. Args: binary_values: An int or 1D binary array containing only `0`s and `1`s. hours_per_timestep: The duration of each timestep in hours. + If a scalar is provided, it is used for all timesteps. + If an array is provided, it must be as long as the last consecutive duration in binary_values. Returns: The duration of the binary variable in hours. @@ -699,6 +622,155 @@ def compute_consecutive_duration( return np.sum(binary_values[-nr_of_indexes_with_consecutive_ones:] * hours_per_timestep[-nr_of_indexes_with_consecutive_ones:]) +class OnOffModel(Model): + """ + Class for modeling the on and off state of a variable + Uses component models to create a modular implementation + """ + + def __init__( + self, + model: SystemModel, + on_off_parameters: OnOffParameters, + label_of_element: str, + defining_variables: List[linopy.Variable], + defining_bounds: List[Tuple[NumericData, NumericData]], + previous_values: List[Optional[NumericData]], + label: Optional[str] = None, + ): + """ + Constructor for OnOffModel + + Args: + model: Reference to the SystemModel + on_off_parameters: Parameters for the OnOffModel + label_of_element: Label of the Parent + defining_variables: List of Variables that are used to define the OnOffModel + defining_bounds: List of Tuples, defining the absolute bounds of each defining variable + previous_values: List of previous values of the defining variables + label: Label of the OnOffModel + """ + super().__init__(model, label_of_element, label) + self.parameters = on_off_parameters + self._defining_variables = defining_variables + self._defining_bounds = defining_bounds + self._previous_values = previous_values + + self.state_model = None + self.switch_state_model = None + self.consecutive_on_model = None + self.consecutive_off_model = None + + def do_modeling(self): + """Create all variables and constraints for the OnOffModel""" + + # Create binary state component + self.state_model = StateModel( + model=self._model, + label_of_element=self.label_of_element, + defining_variables=self._defining_variables, + defining_bounds=self._defining_bounds, + previous_values=self._previous_values, + use_off=self.parameters.use_off, + on_hours_total_min=self.parameters.on_hours_total_min, + on_hours_total_max=self.parameters.on_hours_total_max, + effects_per_running_hour=self.parameters.effects_per_running_hour, + ) + self.add(self.state_model) + self.state_model.do_modeling() + + # Create switch component if needed + if self.parameters.use_switch_on: + self.switch_state_model = SwitchStateModel( + model=self._model, + label_of_element=self.label_of_element, + state_variable=self.state_model.on, + previous_state=self.state_model.previous_on_states[-1], + switch_on_max=self.parameters.switch_on_total_max, + ) + self.add(self.switch_state_model) + self.switch_state_model.do_modeling() + + # Create consecutive on hours component if needed + if self.parameters.use_consecutive_on_hours: + self.consecutive_on_model = ConsecutiveStateModel( + model=self._model, + label_of_element=self.label_of_element, + state_variable=self.state_model.on, + minimum_duration=self.parameters.consecutive_on_hours_min, + maximum_duration=self.parameters.consecutive_on_hours_max, + previous_states=self.state_model.previous_on_states, + label='ConsecutiveOn', + ) + self.add(self.consecutive_on_model) + self.consecutive_on_model.do_modeling() + + # Create consecutive off hours component if needed + if self.parameters.use_consecutive_off_hours: + self.consecutive_off_model = ConsecutiveStateModel( + model=self._model, + label_of_element=self.label_of_element, + state_variable=self.state_model.off, + minimum_duration=self.parameters.consecutive_off_hours_min, + maximum_duration=self.parameters.consecutive_off_hours_max, + previous_states=self.state_model.previous_off_states, + label='ConsecutiveOff', + ) + self.add(self.consecutive_off_model) + self.consecutive_off_model.do_modeling() + + self._create_shares() + + def _create_shares(self): + if self.parameters.effects_per_running_hour: + self._model.effects.add_share_to_effects( + name=self.label_of_element, + expressions={ + effect: self.state_model.on * factor * self._model.hours_per_step + for effect, factor in self.parameters.effects_per_running_hour.items() + }, + target='operation', + ) + + if self.parameters.effects_per_switch_on: + self._model.effects.add_share_to_effects( + name=self.label_of_element, + expressions={ + effect: self.switch_state_model.switch_on * factor + for effect, factor in self.parameters.effects_per_switch_on.items() + }, + target='operation', + ) + + @property + def on(self): + return self.state_model.on + + @property + def off(self): + return self.state_model.off + + @property + def switch_on(self): + return self.switch_state_model.switch_on + + @property + def switch_off(self): + return self.switch_state_model.switch_off + + @property + def switch_on_nr(self): + return self.switch_state_model.switch_on_nr + + @property + def consecutive_on_hours(self): + return self.consecutive_on_model.duration + + @property + def consecutive_off_hours(self): + return self.consecutive_off_model.duration + + class PieceModel(Model): """Class for modeling a linear piece of one or more variables in parallel""" diff --git a/tests/test_flow.py b/tests/test_flow.py index 5026c6120..10266b1f3 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -568,52 +568,51 @@ def test_consecutive_on_hours(self, basic_flow_system_linopy): flow_system.add_elements( fx.Sink('Sink', sink=flow)) model = create_linopy_model(flow_system) - assert {'Sink(Wärme)|consecutive_on_hours', 'Sink(Wärme)|on'}.issubset(set(flow.model.variables)) + assert {'Sink(Wärme)|ConsecutiveOn|hours', 'Sink(Wärme)|on'}.issubset(set(flow.model.variables)) - assert { - 'Sink(Wärme)|consecutive_on_hours_con1', - 'Sink(Wärme)|consecutive_on_hours_con2a', - 'Sink(Wärme)|consecutive_on_hours_con2b', - 'Sink(Wärme)|consecutive_on_hours_initial', - 'Sink(Wärme)|consecutive_on_hours_minimum_duration' + assert {'Sink(Wärme)|ConsecutiveOn|con1', + 'Sink(Wärme)|ConsecutiveOn|con2a', + 'Sink(Wärme)|ConsecutiveOn|con2b', + 'Sink(Wärme)|ConsecutiveOn|initial', + 'Sink(Wärme)|ConsecutiveOn|minimum', }.issubset(set(flow.model.constraints)) assert_var_equal( - model.variables['Sink(Wärme)|consecutive_on_hours'], + model.variables['Sink(Wärme)|ConsecutiveOn|hours'], model.add_variables(lower=0, upper=8, coords=(timesteps,)) ) mega = model.hours_per_step.sum('time') assert_conequal( - model.constraints['Sink(Wärme)|consecutive_on_hours_con1'], - model.variables['Sink(Wärme)|consecutive_on_hours'] <= model.variables['Sink(Wärme)|on'] * mega + model.constraints['Sink(Wärme)|ConsecutiveOn|con1'], + model.variables['Sink(Wärme)|ConsecutiveOn|hours'] <= model.variables['Sink(Wärme)|on'] * mega ) assert_conequal( - model.constraints['Sink(Wärme)|consecutive_on_hours_con2a'], - model.variables['Sink(Wärme)|consecutive_on_hours'].isel(time=slice(1, None)) - <= model.variables['Sink(Wärme)|consecutive_on_hours'].isel(time=slice(None, -1)) + model.hours_per_step.isel(time=slice(None, -1)) + model.constraints['Sink(Wärme)|ConsecutiveOn|con2a'], + model.variables['Sink(Wärme)|ConsecutiveOn|hours'].isel(time=slice(1, None)) + <= model.variables['Sink(Wärme)|ConsecutiveOn|hours'].isel(time=slice(None, -1)) + model.hours_per_step.isel(time=slice(None, -1)) ) # eq: duration(t) >= duration(t - 1) + dt(t) + (On(t) - 1) * BIG assert_conequal( - model.constraints['Sink(Wärme)|consecutive_on_hours_con2b'], - model.variables['Sink(Wärme)|consecutive_on_hours'].isel(time=slice(1, None)) - >= model.variables['Sink(Wärme)|consecutive_on_hours'].isel(time=slice(None, -1)) + model.constraints['Sink(Wärme)|ConsecutiveOn|con2b'], + model.variables['Sink(Wärme)|ConsecutiveOn|hours'].isel(time=slice(1, None)) + >= model.variables['Sink(Wärme)|ConsecutiveOn|hours'].isel(time=slice(None, -1)) + model.hours_per_step.isel(time=slice(None, -1)) + (model.variables['Sink(Wärme)|on'].isel(time=slice(1, None)) - 1) * mega ) assert_conequal( - model.constraints['Sink(Wärme)|consecutive_on_hours_initial'], - model.variables['Sink(Wärme)|consecutive_on_hours'].isel(time=0) + model.constraints['Sink(Wärme)|ConsecutiveOn|initial'], + model.variables['Sink(Wärme)|ConsecutiveOn|hours'].isel(time=0) == model.variables['Sink(Wärme)|on'].isel(time=0) * model.hours_per_step.isel(time=0), ) assert_conequal( - model.constraints['Sink(Wärme)|consecutive_on_hours_minimum_duration'], - model.variables['Sink(Wärme)|consecutive_on_hours'] + model.constraints['Sink(Wärme)|ConsecutiveOn|minimum'], + model.variables['Sink(Wärme)|ConsecutiveOn|hours'] >= (model.variables['Sink(Wärme)|on'].isel(time=slice(None, -1)) - model.variables['Sink(Wärme)|on'].isel(time=slice(1, None))) * 2 ) @@ -635,52 +634,52 @@ def test_consecutive_off_hours(self, basic_flow_system_linopy): flow_system.add_elements( fx.Sink('Sink', sink=flow)) model = create_linopy_model(flow_system) - assert {'Sink(Wärme)|consecutive_off_hours', 'Sink(Wärme)|off'}.issubset(set(flow.model.variables)) + assert {'Sink(Wärme)|ConsecutiveOff|hours', 'Sink(Wärme)|off'}.issubset(set(flow.model.variables)) assert { - 'Sink(Wärme)|consecutive_off_hours_con1', - 'Sink(Wärme)|consecutive_off_hours_con2a', - 'Sink(Wärme)|consecutive_off_hours_con2b', - 'Sink(Wärme)|consecutive_off_hours_initial', - 'Sink(Wärme)|consecutive_off_hours_minimum_duration' + 'Sink(Wärme)|ConsecutiveOff|con1', + 'Sink(Wärme)|ConsecutiveOff|con2a', + 'Sink(Wärme)|ConsecutiveOff|con2b', + 'Sink(Wärme)|ConsecutiveOff|initial', + 'Sink(Wärme)|ConsecutiveOff|minimum' }.issubset(set(flow.model.constraints)) assert_var_equal( - model.variables['Sink(Wärme)|consecutive_off_hours'], + model.variables['Sink(Wärme)|ConsecutiveOff|hours'], model.add_variables(lower=0, upper=12, coords=(timesteps,)) ) mega = model.hours_per_step.sum('time') + 1 # previously off for 1h assert_conequal( - model.constraints['Sink(Wärme)|consecutive_off_hours_con1'], - model.variables['Sink(Wärme)|consecutive_off_hours'] <= model.variables['Sink(Wärme)|off'] * mega + model.constraints['Sink(Wärme)|ConsecutiveOff|con1'], + model.variables['Sink(Wärme)|ConsecutiveOff|hours'] <= model.variables['Sink(Wärme)|off'] * mega ) assert_conequal( - model.constraints['Sink(Wärme)|consecutive_off_hours_con2a'], - model.variables['Sink(Wärme)|consecutive_off_hours'].isel(time=slice(1, None)) - <= model.variables['Sink(Wärme)|consecutive_off_hours'].isel(time=slice(None, -1)) + model.hours_per_step.isel(time=slice(None, -1)) + model.constraints['Sink(Wärme)|ConsecutiveOff|con2a'], + model.variables['Sink(Wärme)|ConsecutiveOff|hours'].isel(time=slice(1, None)) + <= model.variables['Sink(Wärme)|ConsecutiveOff|hours'].isel(time=slice(None, -1)) + model.hours_per_step.isel(time=slice(None, -1)) ) # eq: duration(t) >= duration(t - 1) + dt(t) + (On(t) - 1) * BIG assert_conequal( - model.constraints['Sink(Wärme)|consecutive_off_hours_con2b'], - model.variables['Sink(Wärme)|consecutive_off_hours'].isel(time=slice(1, None)) - >= model.variables['Sink(Wärme)|consecutive_off_hours'].isel(time=slice(None, -1)) + model.constraints['Sink(Wärme)|ConsecutiveOff|con2b'], + model.variables['Sink(Wärme)|ConsecutiveOff|hours'].isel(time=slice(1, None)) + >= model.variables['Sink(Wärme)|ConsecutiveOff|hours'].isel(time=slice(None, -1)) + model.hours_per_step.isel(time=slice(None, -1)) + (model.variables['Sink(Wärme)|off'].isel(time=slice(1, None)) - 1) * mega ) assert_conequal( - model.constraints['Sink(Wärme)|consecutive_off_hours_initial'], - model.variables['Sink(Wärme)|consecutive_off_hours'].isel(time=0) - == model.variables['Sink(Wärme)|off'].isel(time=0) * model.hours_per_step.isel(time=0), + model.constraints['Sink(Wärme)|ConsecutiveOff|initial'], + model.variables['Sink(Wärme)|ConsecutiveOff|hours'].isel(time=0) + == model.variables['Sink(Wärme)|off'].isel(time=0) * (model.hours_per_step.isel(time=0)+1), ) assert_conequal( - model.constraints['Sink(Wärme)|consecutive_off_hours_minimum_duration'], - model.variables['Sink(Wärme)|consecutive_off_hours'] + model.constraints['Sink(Wärme)|ConsecutiveOff|minimum'], + model.variables['Sink(Wärme)|ConsecutiveOff|hours'] >= (model.variables['Sink(Wärme)|off'].isel(time=slice(None, -1)) - model.variables['Sink(Wärme)|off'].isel(time=slice(1, None))) * 4 ) diff --git a/tests/test_on_hours_computation.py b/tests/test_on_hours_computation.py index 5608155c0..a873bbd12 100644 --- a/tests/test_on_hours_computation.py +++ b/tests/test_on_hours_computation.py @@ -1,7 +1,7 @@ import numpy as np import pytest -from flixopt.features import OnOffModel +from flixopt.features import ConsecutiveStateModel, StateModel class TestComputeConsecutiveDuration: @@ -31,7 +31,7 @@ class TestComputeConsecutiveDuration: ]) def test_compute_duration(self, binary_values, hours_per_timestep, expected): """Test compute_consecutive_duration with various inputs.""" - result = OnOffModel.compute_consecutive_duration(binary_values, hours_per_timestep) + result = ConsecutiveStateModel.compute_consecutive_hours_in_state(binary_values, hours_per_timestep) assert np.isclose(result, expected) @pytest.mark.parametrize("binary_values, hours_per_timestep", [ @@ -41,7 +41,7 @@ def test_compute_duration(self, binary_values, hours_per_timestep, expected): def test_compute_duration_raises_error(self, binary_values, hours_per_timestep): """Test error conditions.""" with pytest.raises(TypeError): - OnOffModel.compute_consecutive_duration(binary_values, hours_per_timestep) + ConsecutiveStateModel.compute_consecutive_hours_in_state(binary_values, hours_per_timestep) class TestComputePreviousOnStates: @@ -76,7 +76,7 @@ class TestComputePreviousOnStates: ) def test_compute_previous_on_states(self, previous_values, expected): """Test compute_previous_on_states with various inputs.""" - result = OnOffModel.compute_previous_on_states(previous_values) + result = StateModel.compute_previous_states(previous_values) np.testing.assert_array_equal(result, expected) @pytest.mark.parametrize("previous_values, epsilon, expected", [ @@ -90,7 +90,7 @@ def test_compute_previous_on_states(self, previous_values, expected): ]) def test_compute_previous_on_states_with_epsilon(self, previous_values, epsilon, expected): """Test compute_previous_on_states with custom epsilon values.""" - result = OnOffModel.compute_previous_on_states(previous_values, epsilon) + result = StateModel.compute_previous_states(previous_values, epsilon) np.testing.assert_array_equal(result, expected) @pytest.mark.parametrize("previous_values, expected_shape", [ @@ -101,5 +101,5 @@ def test_compute_previous_on_states_with_epsilon(self, previous_values, epsilon, ]) def test_output_shapes(self, previous_values, expected_shape): """Test that output array has the correct shape.""" - result = OnOffModel.compute_previous_on_states(previous_values) + result = StateModel.compute_previous_states(previous_values) assert result.shape == expected_shape From d240a0bcafb58c2dc4a5c87c698e085a5f149805 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 11 Apr 2025 16:31:40 +0200 Subject: [PATCH 504/507] Update release notes and badges in README.md --- README.md | 6 +++--- docs/release-notes/v2.0.1.md | 2 +- docs/release-notes/v2.1.0.md | 31 +++++++++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 4 deletions(-) create mode 100644 docs/release-notes/v2.1.0.md diff --git a/README.md b/README.md index b65072551..1312dace9 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # FlixOpt: Energy and Material Flow Optimization Framework -[![📚 Documentation](https://img.shields.io/badge/📚_docs-online-brightgreen.svg)](https://flixopt.github.io/flixopt/latest/) -[![CI](https://github.com/flixOpt/flixopt/actions/workflows/python-app.yaml/badge.svg)](https://github.com/flixOpt/flixopt/actions/workflows/python-app.yaml) -[![PyPI version](https://badge.fury.io/py/flixopt.svg)](https://badge.fury.io/py/flixopt) +[![Documentation](https://img.shields.io/badge/docs-latest-brightgreen.svg)](https://flixopt.github.io/flixopt/latest/) +[![Build Status](https://github.com/flixOpt/flixopt/actions/workflows/python-app.yaml/badge.svg)](https://github.com/flixOpt/flixopt/actions/workflows/python-app.yaml) +[![PyPI version](https://img.shields.io/pypi/v/flixopt)](https://pypi.org/project/flixopt/) [![Python Versions](https://img.shields.io/pypi/pyversions/flixopt.svg)](https://pypi.org/project/flixopt/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) diff --git a/docs/release-notes/v2.0.1.md b/docs/release-notes/v2.0.1.md index 657bcca87..9b6884e48 100644 --- a/docs/release-notes/v2.0.1.md +++ b/docs/release-notes/v2.0.1.md @@ -1,6 +1,6 @@ # Release v2.0.1 -**Release Date:** 2025-04-20 +**Release Date:** 2025-04-10 ## Improvements diff --git a/docs/release-notes/v2.1.0.md b/docs/release-notes/v2.1.0.md new file mode 100644 index 000000000..09972c5f7 --- /dev/null +++ b/docs/release-notes/v2.1.0.md @@ -0,0 +1,31 @@ +# Release v2.1.0 + +**Release Date:** 2025-04-11 + +## Improvements + +* Add logger warning if relative_minimum is used without on_off_parameters in Flow, as this prevents the flow_rate from switching "OFF" +* Python 3.13 support added +* Greatly improved internal testing infrastructure by leveraging linopy's testing framework + +## Bug Fixes + +* Bugfixing the lower bound of `flow_rate` when using optional investments without OnOffParameters. +* Fixes a Bug that prevented divest effects from working. +* added lower bounds of 0 to two unbounded vars (only numerical better) + +## Breaking Changes + +* We restructured the modeling of the On/Off state of FLows or Components. This leads to slightly renaming of variables and constraints. + +### Variable renaming +* "...|consecutive_on_hours" is now "...|ConsecutiveOn|hours" +* "...|consecutive_off_hours" is now "...|ConsecutiveOff|hours" + +### Constraint renaming +* "...|consecutive_on_hours_con1" is now "...|ConsecutiveOn|con1" +* "...|consecutive_on_hours_con2a" is now "...|ConsecutiveOn|con2a" +* "...|consecutive_on_hours_con2b" is now "...|ConsecutiveOn|con2b" +* "...|consecutive_on_hours_initial" is now "...|ConsecutiveOn|initial" +* "...|consecutive_on_hours_minimum_duration" is now "...|ConsecutiveOn|minimum" +The same goes for "...|consecutive_off..." --> "...|ConsecutiveOff|..." \ No newline at end of file From 7ad1bf38813a11bceca65cba3f00ff844dd7a25b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 11 Apr 2025 17:57:13 +0200 Subject: [PATCH 505/507] Fix tests and docstrings (#242) * Bugfix testing fixture * Bugfix tests and add new tests to check for previous states/flow_rates * Bugfix tests and add new tests to check for previous states/flow_rates * Add comment for previous flow_rates * Add comment for OnOffParameters in Component and LinearConverter --- flixopt/components.py | 5 +- flixopt/elements.py | 7 ++- tests/conftest.py | 2 +- tests/test_flow.py | 139 +++++++++++++++++++++++++++++++++++++++++- tests/test_storage.py | 2 +- 5 files changed, 147 insertions(+), 8 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index 9b4e64625..bdac6d2fb 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -43,7 +43,10 @@ def __init__( label: The label of the Element. Used to identify it in the FlowSystem inputs: The input Flows outputs: The output Flows - on_off_parameters: Information about on and off states. See class OnOffParameters. + on_off_parameters: Information about on and off state of LinearConverter. + Component is On/Off, if all connected Flows are On/Off. This induces an On-Variable (binary) in all Flows! + If possible, use OnOffParameters in a single Flow instead to keep the number of binary variables low. + See class OnOffParameters. conversion_factors: linear relation between flows. Either 'conversion_factors' or 'piecewise_conversion' can be used! piecewise_conversion: Define a piecewise linear relation between flow rates of different flows. diff --git a/flixopt/elements.py b/flixopt/elements.py index 79a1ef075..a0bd8c91f 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -47,8 +47,8 @@ def __init__( inputs: input flows. outputs: output flows. on_off_parameters: Information about on and off state of Component. - Component is On/Off, if all connected Flows are On/Off. - Induces On-Variable in all FLows! + Component is On/Off, if all connected Flows are On/Off. This induces an On-Variable (binary) in all Flows! + If possible, use OnOffParameters in a single Flow instead to keep the number of binary variables low. See class OnOffParameters. prevent_simultaneous_flows: Define a Group of Flows. Only one them can be on at a time. Induces On-Variable in all Flows! If possible, use OnOffParameters in a single Flow instead. @@ -193,7 +193,8 @@ def __init__( (relative_minimum and relative_maximum are ignored) used for fixed load or supply profiles, i.g. heat demand, wind-power, solarthermal If the load-profile is just an upper limit, use relative_maximum instead. - previous_flow_rate: previous flow rate of the component. + previous_flow_rate: previous flow rate of the flow. Used to determine if and how long the + flow is already on / off. If None, the flow is considered to be off for one timestep. meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types. """ super().__init__(label, meta_data=meta_data) diff --git a/tests/conftest.py b/tests/conftest.py index 50dad5a82..5399be72a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -432,7 +432,7 @@ def timesteps_linopy(request): @pytest.fixture def basic_flow_system_linopy(timesteps_linopy) -> fx.FlowSystem: """Create basic elements for component testing""" - flow_system = fx.FlowSystem(pd.date_range('2020-01-01', periods=10, freq='h', name='time')) + flow_system = fx.FlowSystem(timesteps_linopy) thermal_load = np.array([np.random.random() for _ in range(10)]) * 180 p_el = (np.array([np.random.random() for _ in range(10)]) + 0.5) / 1.5 * 50 diff --git a/tests/test_flow.py b/tests/test_flow.py index 10266b1f3..2308dbd31 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -616,6 +616,73 @@ def test_consecutive_on_hours(self, basic_flow_system_linopy): >= (model.variables['Sink(Wärme)|on'].isel(time=slice(None, -1)) - model.variables['Sink(Wärme)|on'].isel(time=slice(1, None))) * 2 ) + def test_consecutive_on_hours_previous(self, basic_flow_system_linopy): + """Test flow with minimum and maximum consecutive on hours.""" + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + + flow = fx.Flow( + 'Wärme', + bus='Fernwärme', + size=100, + on_off_parameters=fx.OnOffParameters( + consecutive_on_hours_min=2, # Must run for at least 2 hours when turned on + consecutive_on_hours_max=8, # Can't run more than 8 consecutive hours + ), + previous_flow_rate=np.array([10, 20, 30, 0, 20, 20, 30]) # Previously on for 3 steps + ) + + flow_system.add_elements( fx.Sink('Sink', sink=flow)) + model = create_linopy_model(flow_system) + + assert {'Sink(Wärme)|ConsecutiveOn|hours', 'Sink(Wärme)|on'}.issubset(set(flow.model.variables)) + + assert {'Sink(Wärme)|ConsecutiveOn|con1', + 'Sink(Wärme)|ConsecutiveOn|con2a', + 'Sink(Wärme)|ConsecutiveOn|con2b', + 'Sink(Wärme)|ConsecutiveOn|initial', + 'Sink(Wärme)|ConsecutiveOn|minimum', + }.issubset(set(flow.model.constraints)) + + assert_var_equal( + model.variables['Sink(Wärme)|ConsecutiveOn|hours'], + model.add_variables(lower=0, upper=8, coords=(timesteps,)) + ) + + mega = model.hours_per_step.sum('time') + model.hours_per_step.isel(time=0) * 3 + + assert_conequal( + model.constraints['Sink(Wärme)|ConsecutiveOn|con1'], + model.variables['Sink(Wärme)|ConsecutiveOn|hours'] <= model.variables['Sink(Wärme)|on'] * mega + ) + + assert_conequal( + model.constraints['Sink(Wärme)|ConsecutiveOn|con2a'], + model.variables['Sink(Wärme)|ConsecutiveOn|hours'].isel(time=slice(1, None)) + <= model.variables['Sink(Wärme)|ConsecutiveOn|hours'].isel(time=slice(None, -1)) + model.hours_per_step.isel(time=slice(None, -1)) + ) + + # eq: duration(t) >= duration(t - 1) + dt(t) + (On(t) - 1) * BIG + assert_conequal( + model.constraints['Sink(Wärme)|ConsecutiveOn|con2b'], + model.variables['Sink(Wärme)|ConsecutiveOn|hours'].isel(time=slice(1, None)) + >= model.variables['Sink(Wärme)|ConsecutiveOn|hours'].isel(time=slice(None, -1)) + + model.hours_per_step.isel(time=slice(None, -1)) + + (model.variables['Sink(Wärme)|on'].isel(time=slice(1, None)) - 1) * mega + ) + + assert_conequal( + model.constraints['Sink(Wärme)|ConsecutiveOn|initial'], + model.variables['Sink(Wärme)|ConsecutiveOn|hours'].isel(time=0) + == model.variables['Sink(Wärme)|on'].isel(time=0) * (model.hours_per_step.isel(time=0) * (1 + 3)), + ) + + assert_conequal( + model.constraints['Sink(Wärme)|ConsecutiveOn|minimum'], + model.variables['Sink(Wärme)|ConsecutiveOn|hours'] + >= (model.variables['Sink(Wärme)|on'].isel(time=slice(None, -1)) - model.variables['Sink(Wärme)|on'].isel(time=slice(1, None))) * 2 + ) + def test_consecutive_off_hours(self, basic_flow_system_linopy): """Test flow with minimum and maximum consecutive off hours.""" flow_system = basic_flow_system_linopy @@ -649,7 +716,75 @@ def test_consecutive_off_hours(self, basic_flow_system_linopy): model.add_variables(lower=0, upper=12, coords=(timesteps,)) ) - mega = model.hours_per_step.sum('time') + 1 # previously off for 1h + mega = model.hours_per_step.sum('time') + model.hours_per_step.isel(time=0) * 1 # previously off for 1h + + assert_conequal( + model.constraints['Sink(Wärme)|ConsecutiveOff|con1'], + model.variables['Sink(Wärme)|ConsecutiveOff|hours'] <= model.variables['Sink(Wärme)|off'] * mega + ) + + assert_conequal( + model.constraints['Sink(Wärme)|ConsecutiveOff|con2a'], + model.variables['Sink(Wärme)|ConsecutiveOff|hours'].isel(time=slice(1, None)) + <= model.variables['Sink(Wärme)|ConsecutiveOff|hours'].isel(time=slice(None, -1)) + model.hours_per_step.isel(time=slice(None, -1)) + ) + + # eq: duration(t) >= duration(t - 1) + dt(t) + (On(t) - 1) * BIG + assert_conequal( + model.constraints['Sink(Wärme)|ConsecutiveOff|con2b'], + model.variables['Sink(Wärme)|ConsecutiveOff|hours'].isel(time=slice(1, None)) + >= model.variables['Sink(Wärme)|ConsecutiveOff|hours'].isel(time=slice(None, -1)) + + model.hours_per_step.isel(time=slice(None, -1)) + + (model.variables['Sink(Wärme)|off'].isel(time=slice(1, None)) - 1) * mega + ) + + assert_conequal( + model.constraints['Sink(Wärme)|ConsecutiveOff|initial'], + model.variables['Sink(Wärme)|ConsecutiveOff|hours'].isel(time=0) + == model.variables['Sink(Wärme)|off'].isel(time=0) * (model.hours_per_step.isel(time=0) * (1 + 1)), + ) + + assert_conequal( + model.constraints['Sink(Wärme)|ConsecutiveOff|minimum'], + model.variables['Sink(Wärme)|ConsecutiveOff|hours'] + >= (model.variables['Sink(Wärme)|off'].isel(time=slice(None, -1)) - model.variables['Sink(Wärme)|off'].isel(time=slice(1, None))) * 4 + ) + + def test_consecutive_off_hours_previous(self, basic_flow_system_linopy): + """Test flow with minimum and maximum consecutive off hours.""" + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + + flow = fx.Flow( + 'Wärme', + bus='Fernwärme', + size=100, + on_off_parameters=fx.OnOffParameters( + consecutive_off_hours_min=4, # Must stay off for at least 4 hours when shut down + consecutive_off_hours_max=12, # Can't be off for more than 12 consecutive hours + ), + previous_flow_rate=np.array([10, 20, 30, 0, 20, 0, 0]) # Previously off for 2 steps + ) + + flow_system.add_elements( fx.Sink('Sink', sink=flow)) + model = create_linopy_model(flow_system) + + assert {'Sink(Wärme)|ConsecutiveOff|hours', 'Sink(Wärme)|off'}.issubset(set(flow.model.variables)) + + assert { + 'Sink(Wärme)|ConsecutiveOff|con1', + 'Sink(Wärme)|ConsecutiveOff|con2a', + 'Sink(Wärme)|ConsecutiveOff|con2b', + 'Sink(Wärme)|ConsecutiveOff|initial', + 'Sink(Wärme)|ConsecutiveOff|minimum' + }.issubset(set(flow.model.constraints)) + + assert_var_equal( + model.variables['Sink(Wärme)|ConsecutiveOff|hours'], + model.add_variables(lower=0, upper=12, coords=(timesteps,)) + ) + + mega = model.hours_per_step.sum('time') + model.hours_per_step.isel(time=0) * 2 assert_conequal( model.constraints['Sink(Wärme)|ConsecutiveOff|con1'], @@ -674,7 +809,7 @@ def test_consecutive_off_hours(self, basic_flow_system_linopy): assert_conequal( model.constraints['Sink(Wärme)|ConsecutiveOff|initial'], model.variables['Sink(Wärme)|ConsecutiveOff|hours'].isel(time=0) - == model.variables['Sink(Wärme)|off'].isel(time=0) * (model.hours_per_step.isel(time=0)+1), + == model.variables['Sink(Wärme)|off'].isel(time=0) * (model.hours_per_step.isel(time=0) * (1+2)), ) assert_conequal( diff --git a/tests/test_storage.py b/tests/test_storage.py index 3378f85b9..b88defaf6 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -78,7 +78,7 @@ def test_basic_storage(self, basic_flow_system_linopy): assert_conequal( model.constraints['TestStorage|charge_state'], charge_state.isel(time=slice(1, None)) - == charge_state.isel(time=slice(None, -1)) * model.hours_per_step + == charge_state.isel(time=slice(None, -1)) + model.variables['TestStorage(Q_th_in)|flow_rate'] * model.hours_per_step - model.variables['TestStorage(Q_th_out)|flow_rate'] * model.hours_per_step, ) From a49f53a2e910a1531f4f5d7ac486b7321d461bf5 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 25 Apr 2025 23:06:06 +0200 Subject: [PATCH 506/507] BUGFIX:Typo in _ElementResults.constraints --- flixopt/results.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixopt/results.py b/flixopt/results.py index d9eb5a654..223e3708e 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -343,7 +343,7 @@ def constraints(self) -> linopy.Constraints: """ if self._calculation_results.model is None: raise ValueError('The linopy model is not available.') - return self._calculation_results.model.constraints[self._variable_names] + return self._calculation_results.model.constraints[self._constraint_names] def filter_solution(self, variable_dims: Optional[Literal['scalar', 'time']] = None) -> xr.Dataset: """ From 97a1aebe2c87b1289e9f3218ade66db350c5320d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 8 May 2025 18:23:19 +0200 Subject: [PATCH 507/507] Release notes --- docs/release-notes/v2.1.1.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 docs/release-notes/v2.1.1.md diff --git a/docs/release-notes/v2.1.1.md b/docs/release-notes/v2.1.1.md new file mode 100644 index 000000000..44e635f87 --- /dev/null +++ b/docs/release-notes/v2.1.1.md @@ -0,0 +1,11 @@ +# Release v2.1.1 + +**Release Date:** 2025-05-08 + +## Improvements + +* Improving docstring and tests + +## Bug Fixes + +* Fixing bug in the `_ElementResults.constraints` not returning the constraints but rather the variables