flixopt 1.0.12__py3-none-any.whl → 2.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of flixopt might be problematic. Click here for more details.
- docs/examples/00-Minimal Example.md +5 -0
- docs/examples/01-Basic Example.md +5 -0
- docs/examples/02-Complex Example.md +10 -0
- docs/examples/03-Calculation Modes.md +5 -0
- docs/examples/index.md +5 -0
- docs/faq/contribute.md +49 -0
- docs/faq/index.md +3 -0
- docs/images/architecture_flixOpt-pre2.0.0.png +0 -0
- docs/images/architecture_flixOpt.png +0 -0
- docs/images/flixopt-icon.svg +1 -0
- docs/javascripts/mathjax.js +18 -0
- docs/release-notes/_template.txt +32 -0
- docs/release-notes/index.md +7 -0
- docs/release-notes/v2.0.0.md +93 -0
- docs/release-notes/v2.0.1.md +12 -0
- docs/user-guide/Mathematical Notation/Bus.md +33 -0
- docs/user-guide/Mathematical Notation/Effects, Penalty & Objective.md +132 -0
- docs/user-guide/Mathematical Notation/Flow.md +26 -0
- docs/user-guide/Mathematical Notation/LinearConverter.md +21 -0
- docs/user-guide/Mathematical Notation/Piecewise.md +49 -0
- docs/user-guide/Mathematical Notation/Storage.md +44 -0
- docs/user-guide/Mathematical Notation/index.md +22 -0
- docs/user-guide/Mathematical Notation/others.md +3 -0
- docs/user-guide/index.md +124 -0
- {flixOpt → flixopt}/__init__.py +5 -2
- {flixOpt → flixopt}/aggregation.py +113 -140
- flixopt/calculation.py +455 -0
- {flixOpt → flixopt}/commons.py +7 -4
- flixopt/components.py +630 -0
- {flixOpt → flixopt}/config.py +9 -8
- {flixOpt → flixopt}/config.yaml +3 -3
- flixopt/core.py +970 -0
- flixopt/effects.py +386 -0
- flixopt/elements.py +534 -0
- flixopt/features.py +1042 -0
- flixopt/flow_system.py +409 -0
- flixopt/interface.py +265 -0
- flixopt/io.py +308 -0
- flixopt/linear_converters.py +331 -0
- flixopt/plotting.py +1340 -0
- flixopt/results.py +898 -0
- flixopt/solvers.py +77 -0
- flixopt/structure.py +630 -0
- flixopt/utils.py +62 -0
- flixopt-2.0.1.dist-info/METADATA +145 -0
- flixopt-2.0.1.dist-info/RECORD +57 -0
- {flixopt-1.0.12.dist-info → flixopt-2.0.1.dist-info}/WHEEL +1 -1
- flixopt-2.0.1.dist-info/top_level.txt +6 -0
- pics/architecture_flixOpt-pre2.0.0.png +0 -0
- pics/architecture_flixOpt.png +0 -0
- pics/flixopt-icon.svg +1 -0
- pics/pics.pptx +0 -0
- scripts/gen_ref_pages.py +54 -0
- site/release-notes/_template.txt +32 -0
- flixOpt/calculation.py +0 -629
- flixOpt/components.py +0 -614
- flixOpt/core.py +0 -182
- flixOpt/effects.py +0 -410
- flixOpt/elements.py +0 -489
- flixOpt/features.py +0 -942
- flixOpt/flow_system.py +0 -351
- flixOpt/interface.py +0 -203
- flixOpt/linear_converters.py +0 -325
- flixOpt/math_modeling.py +0 -1145
- flixOpt/plotting.py +0 -712
- flixOpt/results.py +0 -563
- flixOpt/solvers.py +0 -21
- flixOpt/structure.py +0 -733
- flixOpt/utils.py +0 -134
- flixopt-1.0.12.dist-info/METADATA +0 -174
- flixopt-1.0.12.dist-info/RECORD +0 -29
- flixopt-1.0.12.dist-info/top_level.txt +0 -3
- {flixopt-1.0.12.dist-info → flixopt-2.0.1.dist-info/licenses}/LICENSE +0 -0
flixopt/structure.py
ADDED
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module contains the core structure of the flixopt framework.
|
|
3
|
+
These classes are not directly used by the end user, but are used by other modules.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import inspect
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import pathlib
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from io import StringIO
|
|
12
|
+
from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, Union
|
|
13
|
+
|
|
14
|
+
import linopy
|
|
15
|
+
import numpy as np
|
|
16
|
+
import pandas as pd
|
|
17
|
+
import xarray as xr
|
|
18
|
+
from rich.console import Console
|
|
19
|
+
from rich.pretty import Pretty
|
|
20
|
+
|
|
21
|
+
from .config import CONFIG
|
|
22
|
+
from .core import NumericData, Scalar, TimeSeries, TimeSeriesCollection, TimeSeriesData
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING: # for type checking and preventing circular imports
|
|
25
|
+
from .effects import EffectCollectionModel
|
|
26
|
+
from .flow_system import FlowSystem
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger('flixopt')
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
CLASS_REGISTRY = {}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def register_class_for_io(cls):
|
|
35
|
+
"""Register a class for serialization/deserialization."""
|
|
36
|
+
name = cls.__name__
|
|
37
|
+
if name in CLASS_REGISTRY:
|
|
38
|
+
raise ValueError(
|
|
39
|
+
f'Class {name} already registered! Use a different Name for the class! '
|
|
40
|
+
f'This error should only happen in developement'
|
|
41
|
+
)
|
|
42
|
+
CLASS_REGISTRY[name] = cls
|
|
43
|
+
return cls
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class SystemModel(linopy.Model):
|
|
47
|
+
"""
|
|
48
|
+
The SystemModel is the linopy Model that is used to create the mathematical model of the flow_system.
|
|
49
|
+
It is used to create and store the variables and constraints for the flow_system.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(self, flow_system: 'FlowSystem'):
|
|
53
|
+
"""
|
|
54
|
+
Args:
|
|
55
|
+
flow_system: The flow_system that is used to create the model.
|
|
56
|
+
"""
|
|
57
|
+
super().__init__(force_dim_names=True)
|
|
58
|
+
self.flow_system = flow_system
|
|
59
|
+
self.time_series_collection = flow_system.time_series_collection
|
|
60
|
+
self.effects: Optional[EffectCollectionModel] = None
|
|
61
|
+
|
|
62
|
+
def do_modeling(self):
|
|
63
|
+
self.effects = self.flow_system.effects.create_model(self)
|
|
64
|
+
self.effects.do_modeling()
|
|
65
|
+
component_models = [component.create_model(self) for component in self.flow_system.components.values()]
|
|
66
|
+
bus_models = [bus.create_model(self) for bus in self.flow_system.buses.values()]
|
|
67
|
+
for component_model in component_models:
|
|
68
|
+
component_model.do_modeling()
|
|
69
|
+
for bus_model in bus_models: # Buses after Components, because FlowModels are created in ComponentModels
|
|
70
|
+
bus_model.do_modeling()
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def solution(self):
|
|
74
|
+
solution = super().solution
|
|
75
|
+
solution.attrs = {
|
|
76
|
+
'Components': {
|
|
77
|
+
comp.label_full: comp.model.results_structure()
|
|
78
|
+
for comp in sorted(
|
|
79
|
+
self.flow_system.components.values(), key=lambda component: component.label_full.upper()
|
|
80
|
+
)
|
|
81
|
+
},
|
|
82
|
+
'Buses': {
|
|
83
|
+
bus.label_full: bus.model.results_structure()
|
|
84
|
+
for bus in sorted(self.flow_system.buses.values(), key=lambda bus: bus.label_full.upper())
|
|
85
|
+
},
|
|
86
|
+
'Effects': {
|
|
87
|
+
effect.label_full: effect.model.results_structure()
|
|
88
|
+
for effect in sorted(self.flow_system.effects, key=lambda effect: effect.label_full.upper())
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
return solution.reindex(time=self.time_series_collection.timesteps_extra)
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def hours_per_step(self):
|
|
95
|
+
return self.time_series_collection.hours_per_timestep
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def hours_of_previous_timesteps(self):
|
|
99
|
+
return self.time_series_collection.hours_of_previous_timesteps
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def coords(self) -> Tuple[pd.DatetimeIndex]:
|
|
103
|
+
return (self.time_series_collection.timesteps,)
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def coords_extra(self) -> Tuple[pd.DatetimeIndex]:
|
|
107
|
+
return (self.time_series_collection.timesteps_extra,)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class Interface:
|
|
111
|
+
"""
|
|
112
|
+
This class is used to collect arguments about a Model. Its the base class for all Elements and Models in flixopt.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
def transform_data(self, flow_system: 'FlowSystem'):
|
|
116
|
+
"""Transforms the data of the interface to match the FlowSystem's dimensions"""
|
|
117
|
+
raise NotImplementedError('Every Interface needs a transform_data() method')
|
|
118
|
+
|
|
119
|
+
def infos(self, use_numpy: bool = True, use_element_label: bool = False) -> Dict:
|
|
120
|
+
"""
|
|
121
|
+
Generate a dictionary representation of the object's constructor arguments.
|
|
122
|
+
Excludes default values and empty dictionaries and lists.
|
|
123
|
+
Converts data to be compatible with JSON.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
use_numpy: Whether to convert NumPy arrays to lists. Defaults to True.
|
|
127
|
+
If True, numeric numpy arrays (`np.ndarray`) are preserved as-is.
|
|
128
|
+
If False, they are converted to lists.
|
|
129
|
+
use_element_label: Whether to use the element label instead of the infos of the element. Defaults to False.
|
|
130
|
+
Note that Elements used as keys in dictionaries are always converted to their labels.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
A dictionary representation of the object's constructor arguments.
|
|
134
|
+
|
|
135
|
+
"""
|
|
136
|
+
# Get the constructor arguments and their default values
|
|
137
|
+
init_params = sorted(
|
|
138
|
+
inspect.signature(self.__init__).parameters.items(),
|
|
139
|
+
key=lambda x: (x[0].lower() != 'label', x[0].lower()), # Prioritize 'label'
|
|
140
|
+
)
|
|
141
|
+
# Build a dict of attribute=value pairs, excluding defaults
|
|
142
|
+
details = {'class': ':'.join([cls.__name__ for cls in self.__class__.__mro__])}
|
|
143
|
+
for name, param in init_params:
|
|
144
|
+
if name == 'self':
|
|
145
|
+
continue
|
|
146
|
+
value, default = getattr(self, name, None), param.default
|
|
147
|
+
# Ignore default values and empty dicts and list
|
|
148
|
+
if np.all(value == default) or (isinstance(value, (dict, list)) and not value):
|
|
149
|
+
continue
|
|
150
|
+
details[name] = copy_and_convert_datatypes(value, use_numpy, use_element_label)
|
|
151
|
+
return details
|
|
152
|
+
|
|
153
|
+
def to_json(self, path: Union[str, pathlib.Path]):
|
|
154
|
+
"""
|
|
155
|
+
Saves the element to a json file.
|
|
156
|
+
This not meant to be reloaded and recreate the object, but rather used to document or compare the object.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
path: The path to the json file.
|
|
160
|
+
"""
|
|
161
|
+
data = get_compact_representation(self.infos(use_numpy=True, use_element_label=True))
|
|
162
|
+
with open(path, 'w', encoding='utf-8') as f:
|
|
163
|
+
json.dump(data, f, indent=4, ensure_ascii=False)
|
|
164
|
+
|
|
165
|
+
def to_dict(self) -> Dict:
|
|
166
|
+
"""Convert the object to a dictionary representation."""
|
|
167
|
+
data = {'__class__': self.__class__.__name__}
|
|
168
|
+
|
|
169
|
+
# Get the constructor parameters
|
|
170
|
+
init_params = inspect.signature(self.__init__).parameters
|
|
171
|
+
|
|
172
|
+
for name in init_params:
|
|
173
|
+
if name == 'self':
|
|
174
|
+
continue
|
|
175
|
+
|
|
176
|
+
value = getattr(self, name, None)
|
|
177
|
+
data[name] = self._serialize_value(value)
|
|
178
|
+
|
|
179
|
+
return data
|
|
180
|
+
|
|
181
|
+
def _serialize_value(self, value: Any):
|
|
182
|
+
"""Helper method to serialize a value based on its type."""
|
|
183
|
+
if value is None:
|
|
184
|
+
return None
|
|
185
|
+
elif isinstance(value, Interface):
|
|
186
|
+
return value.to_dict()
|
|
187
|
+
elif isinstance(value, (list, tuple)):
|
|
188
|
+
return self._serialize_list(value)
|
|
189
|
+
elif isinstance(value, dict):
|
|
190
|
+
return self._serialize_dict(value)
|
|
191
|
+
else:
|
|
192
|
+
return value
|
|
193
|
+
|
|
194
|
+
def _serialize_list(self, items):
|
|
195
|
+
"""Serialize a list of items."""
|
|
196
|
+
return [self._serialize_value(item) for item in items]
|
|
197
|
+
|
|
198
|
+
def _serialize_dict(self, d):
|
|
199
|
+
"""Serialize a dictionary of items."""
|
|
200
|
+
return {k: self._serialize_value(v) for k, v in d.items()}
|
|
201
|
+
|
|
202
|
+
@classmethod
|
|
203
|
+
def _deserialize_dict(cls, data: Dict) -> Union[Dict, 'Interface']:
|
|
204
|
+
if '__class__' in data:
|
|
205
|
+
class_name = data.pop('__class__')
|
|
206
|
+
try:
|
|
207
|
+
class_type = CLASS_REGISTRY[class_name]
|
|
208
|
+
if issubclass(class_type, Interface):
|
|
209
|
+
# Use _deserialize_dict to process the arguments
|
|
210
|
+
processed_data = {k: cls._deserialize_value(v) for k, v in data.items()}
|
|
211
|
+
return class_type(**processed_data)
|
|
212
|
+
else:
|
|
213
|
+
raise ValueError(f'Class "{class_name}" is not an Interface.')
|
|
214
|
+
except (AttributeError, KeyError) as e:
|
|
215
|
+
raise ValueError(f'Class "{class_name}" could not get reconstructed.') from e
|
|
216
|
+
else:
|
|
217
|
+
return {k: cls._deserialize_value(v) for k, v in data.items()}
|
|
218
|
+
|
|
219
|
+
@classmethod
|
|
220
|
+
def _deserialize_list(cls, data: List) -> List:
|
|
221
|
+
return [cls._deserialize_value(value) for value in data]
|
|
222
|
+
|
|
223
|
+
@classmethod
|
|
224
|
+
def _deserialize_value(cls, value: Any):
|
|
225
|
+
"""Helper method to deserialize a value based on its type."""
|
|
226
|
+
if value is None:
|
|
227
|
+
return None
|
|
228
|
+
elif isinstance(value, dict):
|
|
229
|
+
return cls._deserialize_dict(value)
|
|
230
|
+
elif isinstance(value, list):
|
|
231
|
+
return cls._deserialize_list(value)
|
|
232
|
+
return value
|
|
233
|
+
|
|
234
|
+
@classmethod
|
|
235
|
+
def from_dict(cls, data: Dict) -> 'Interface':
|
|
236
|
+
"""
|
|
237
|
+
Create an instance from a dictionary representation.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
data: Dictionary containing the data for the object.
|
|
241
|
+
"""
|
|
242
|
+
return cls._deserialize_dict(data)
|
|
243
|
+
|
|
244
|
+
def __repr__(self):
|
|
245
|
+
# Get the constructor arguments and their current values
|
|
246
|
+
init_signature = inspect.signature(self.__init__)
|
|
247
|
+
init_args = init_signature.parameters
|
|
248
|
+
|
|
249
|
+
# Create a dictionary with argument names and their values
|
|
250
|
+
args_str = ', '.join(f'{name}={repr(getattr(self, name, None))}' for name in init_args if name != 'self')
|
|
251
|
+
return f'{self.__class__.__name__}({args_str})'
|
|
252
|
+
|
|
253
|
+
def __str__(self):
|
|
254
|
+
return get_str_representation(self.infos(use_numpy=True, use_element_label=True))
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class Element(Interface):
|
|
258
|
+
"""This class is the basic Element of flixopt. Every Element has a label"""
|
|
259
|
+
|
|
260
|
+
def __init__(self, label: str, meta_data: Dict = None):
|
|
261
|
+
"""
|
|
262
|
+
Args:
|
|
263
|
+
label: The label of the element
|
|
264
|
+
meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types.
|
|
265
|
+
"""
|
|
266
|
+
self.label = Element._valid_label(label)
|
|
267
|
+
self.meta_data = meta_data if meta_data is not None else {}
|
|
268
|
+
self.model: Optional[ElementModel] = None
|
|
269
|
+
|
|
270
|
+
def _plausibility_checks(self) -> None:
|
|
271
|
+
"""This function is used to do some basic plausibility checks for each Element during initialization"""
|
|
272
|
+
raise NotImplementedError('Every Element needs a _plausibility_checks() method')
|
|
273
|
+
|
|
274
|
+
def create_model(self, model: SystemModel) -> 'ElementModel':
|
|
275
|
+
raise NotImplementedError('Every Element needs a create_model() method')
|
|
276
|
+
|
|
277
|
+
@property
|
|
278
|
+
def label_full(self) -> str:
|
|
279
|
+
return self.label
|
|
280
|
+
|
|
281
|
+
@staticmethod
|
|
282
|
+
def _valid_label(label: str) -> str:
|
|
283
|
+
"""
|
|
284
|
+
Checks if the label is valid. If not, it is replaced by the default label
|
|
285
|
+
|
|
286
|
+
Raises
|
|
287
|
+
------
|
|
288
|
+
ValueError
|
|
289
|
+
If the label is not valid
|
|
290
|
+
"""
|
|
291
|
+
not_allowed = ['(', ')', '|', '->', '\\', '-slash-'] # \\ is needed to check for \
|
|
292
|
+
if any([sign in label for sign in not_allowed]):
|
|
293
|
+
raise ValueError(
|
|
294
|
+
f'Label "{label}" is not valid. Labels cannot contain the following characters: {not_allowed}. '
|
|
295
|
+
f'Use any other symbol instead'
|
|
296
|
+
)
|
|
297
|
+
if label.endswith(' '):
|
|
298
|
+
logger.warning(f'Label "{label}" ends with a space. This will be removed.')
|
|
299
|
+
return label.rstrip()
|
|
300
|
+
return label
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
class Model:
|
|
304
|
+
"""Stores Variables and Constraints."""
|
|
305
|
+
|
|
306
|
+
def __init__(
|
|
307
|
+
self, model: SystemModel, label_of_element: str, label: Optional[str] = None, label_full: Optional[str] = None
|
|
308
|
+
):
|
|
309
|
+
"""
|
|
310
|
+
Args:
|
|
311
|
+
model: The SystemModel that is used to create the model.
|
|
312
|
+
label_of_element: The label of the parent (Element). Used to construct the full label of the model.
|
|
313
|
+
label: The label of the model. Used to construct the full label of the model.
|
|
314
|
+
label_full: The full label of the model. Can overwrite the full label constructed from the other labels.
|
|
315
|
+
"""
|
|
316
|
+
self._model = model
|
|
317
|
+
self.label_of_element = label_of_element
|
|
318
|
+
self._label = label
|
|
319
|
+
self._label_full = label_full
|
|
320
|
+
|
|
321
|
+
self._variables_direct: List[str] = []
|
|
322
|
+
self._constraints_direct: List[str] = []
|
|
323
|
+
self.sub_models: List[Model] = []
|
|
324
|
+
|
|
325
|
+
self._variables_short: Dict[str, str] = {}
|
|
326
|
+
self._constraints_short: Dict[str, str] = {}
|
|
327
|
+
self._sub_models_short: Dict[str, str] = {}
|
|
328
|
+
logger.debug(f'Created {self.__class__.__name__} "{self._label}"')
|
|
329
|
+
|
|
330
|
+
def do_modeling(self):
|
|
331
|
+
raise NotImplementedError('Every Model needs a do_modeling() method')
|
|
332
|
+
|
|
333
|
+
def add(
|
|
334
|
+
self, item: Union[linopy.Variable, linopy.Constraint, 'Model'], short_name: Optional[str] = None
|
|
335
|
+
) -> Union[linopy.Variable, linopy.Constraint, 'Model']:
|
|
336
|
+
"""
|
|
337
|
+
Add a variable, constraint or sub-model to the model
|
|
338
|
+
|
|
339
|
+
Args:
|
|
340
|
+
item: The variable, constraint or sub-model to add to the model
|
|
341
|
+
short_name: The short name of the variable, constraint or sub-model. If not provided, the full name is used.
|
|
342
|
+
"""
|
|
343
|
+
# TODO: Check uniquenes of short names
|
|
344
|
+
if isinstance(item, linopy.Variable):
|
|
345
|
+
self._variables_direct.append(item.name)
|
|
346
|
+
self._variables_short[item.name] = short_name or item.name
|
|
347
|
+
elif isinstance(item, linopy.Constraint):
|
|
348
|
+
self._constraints_direct.append(item.name)
|
|
349
|
+
self._constraints_short[item.name] = short_name or item.name
|
|
350
|
+
elif isinstance(item, Model):
|
|
351
|
+
self.sub_models.append(item)
|
|
352
|
+
self._sub_models_short[item.label_full] = short_name or item.label_full
|
|
353
|
+
else:
|
|
354
|
+
raise ValueError(
|
|
355
|
+
f'Item must be a linopy.Variable, linopy.Constraint or flixopt.structure.Model, got {type(item)}'
|
|
356
|
+
)
|
|
357
|
+
return item
|
|
358
|
+
|
|
359
|
+
def filter_variables(
|
|
360
|
+
self,
|
|
361
|
+
filter_by: Optional[Literal['binary', 'continuous', 'integer']] = None,
|
|
362
|
+
length: Literal['scalar', 'time'] = None,
|
|
363
|
+
):
|
|
364
|
+
if filter_by is None:
|
|
365
|
+
all_variables = self.variables
|
|
366
|
+
elif filter_by == 'binary':
|
|
367
|
+
all_variables = self.variables.binaries
|
|
368
|
+
elif filter_by == 'integer':
|
|
369
|
+
all_variables = self.variables.integers
|
|
370
|
+
elif filter_by == 'continuous':
|
|
371
|
+
all_variables = self.variables.continuous
|
|
372
|
+
else:
|
|
373
|
+
raise ValueError(f'Invalid filter_by "{filter_by}", must be one of "binary", "continous", "integer"')
|
|
374
|
+
if length is None:
|
|
375
|
+
return all_variables
|
|
376
|
+
elif length == 'scalar':
|
|
377
|
+
return all_variables[[name for name in all_variables if all_variables[name].ndim == 0]]
|
|
378
|
+
elif length == 'time':
|
|
379
|
+
return all_variables[[name for name in all_variables if 'time' in all_variables[name].dims]]
|
|
380
|
+
raise ValueError(f'Invalid length "{length}", must be one of "scalar", "time" or None')
|
|
381
|
+
|
|
382
|
+
@property
|
|
383
|
+
def label(self) -> str:
|
|
384
|
+
return self._label if self._label is not None else self.label_of_element
|
|
385
|
+
|
|
386
|
+
@property
|
|
387
|
+
def label_full(self) -> str:
|
|
388
|
+
"""Used to construct the names of variables and constraints"""
|
|
389
|
+
if self._label_full is not None:
|
|
390
|
+
return self._label_full
|
|
391
|
+
elif self._label is not None:
|
|
392
|
+
return f'{self.label_of_element}|{self.label}'
|
|
393
|
+
return self.label_of_element
|
|
394
|
+
|
|
395
|
+
@property
|
|
396
|
+
def variables_direct(self) -> linopy.Variables:
|
|
397
|
+
return self._model.variables[self._variables_direct]
|
|
398
|
+
|
|
399
|
+
@property
|
|
400
|
+
def constraints_direct(self) -> linopy.Constraints:
|
|
401
|
+
return self._model.constraints[self._constraints_direct]
|
|
402
|
+
|
|
403
|
+
@property
|
|
404
|
+
def _variables(self) -> List[str]:
|
|
405
|
+
all_variables = self._variables_direct.copy()
|
|
406
|
+
for sub_model in self.sub_models:
|
|
407
|
+
for variable in sub_model._variables:
|
|
408
|
+
if variable in all_variables:
|
|
409
|
+
raise KeyError(
|
|
410
|
+
f"Duplicate key found: '{variable}' in both {self.label_full} and {sub_model.label_full}!"
|
|
411
|
+
)
|
|
412
|
+
all_variables.append(variable)
|
|
413
|
+
return all_variables
|
|
414
|
+
|
|
415
|
+
@property
|
|
416
|
+
def _constraints(self) -> List[str]:
|
|
417
|
+
all_constraints = self._constraints_direct.copy()
|
|
418
|
+
for sub_model in self.sub_models:
|
|
419
|
+
for constraint in sub_model._constraints:
|
|
420
|
+
if constraint in all_constraints:
|
|
421
|
+
raise KeyError(f"Duplicate key found: '{constraint}' in both main model and submodel!")
|
|
422
|
+
all_constraints.append(constraint)
|
|
423
|
+
return all_constraints
|
|
424
|
+
|
|
425
|
+
@property
|
|
426
|
+
def variables(self) -> linopy.Variables:
|
|
427
|
+
return self._model.variables[self._variables]
|
|
428
|
+
|
|
429
|
+
@property
|
|
430
|
+
def constraints(self) -> linopy.Constraints:
|
|
431
|
+
return self._model.constraints[self._constraints]
|
|
432
|
+
|
|
433
|
+
@property
|
|
434
|
+
def all_sub_models(self) -> List['Model']:
|
|
435
|
+
return [model for sub_model in self.sub_models for model in [sub_model] + sub_model.all_sub_models]
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
class ElementModel(Model):
|
|
439
|
+
"""Stores the mathematical Variables and Constraints for Elements"""
|
|
440
|
+
|
|
441
|
+
def __init__(self, model: SystemModel, element: Element):
|
|
442
|
+
"""
|
|
443
|
+
Args:
|
|
444
|
+
model: The SystemModel that is used to create the model.
|
|
445
|
+
element: The element this model is created for.
|
|
446
|
+
"""
|
|
447
|
+
super().__init__(model, label_of_element=element.label_full, label=element.label, label_full=element.label_full)
|
|
448
|
+
self.element = element
|
|
449
|
+
|
|
450
|
+
def results_structure(self):
|
|
451
|
+
return {
|
|
452
|
+
'label': self.label,
|
|
453
|
+
'label_full': self.label_full,
|
|
454
|
+
'variables': list(self.variables),
|
|
455
|
+
'constraints': list(self.constraints),
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def copy_and_convert_datatypes(data: Any, use_numpy: bool = True, use_element_label: bool = False) -> Any:
|
|
460
|
+
"""
|
|
461
|
+
Converts values in a nested data structure into JSON-compatible types while preserving or transforming numpy arrays
|
|
462
|
+
and custom `Element` objects based on the specified options.
|
|
463
|
+
|
|
464
|
+
The function handles various data types and transforms them into a consistent, readable format:
|
|
465
|
+
- Primitive types (`int`, `float`, `str`, `bool`, `None`) are returned as-is.
|
|
466
|
+
- Numpy scalars are converted to their corresponding Python scalar types.
|
|
467
|
+
- Collections (`list`, `tuple`, `set`, `dict`) are recursively processed to ensure all elements are compatible.
|
|
468
|
+
- Numpy arrays are preserved or converted to lists, depending on `use_numpy`.
|
|
469
|
+
- Custom `Element` objects can be represented either by their `label` or their initialization parameters as a dictionary.
|
|
470
|
+
- Timestamps (`datetime`) are converted to ISO 8601 strings.
|
|
471
|
+
|
|
472
|
+
Args:
|
|
473
|
+
data: The input data to process, which may be deeply nested and contain a mix of types.
|
|
474
|
+
use_numpy: If `True`, numeric numpy arrays (`np.ndarray`) are preserved as-is. If `False`, they are converted to lists.
|
|
475
|
+
Default is `True`.
|
|
476
|
+
use_element_label: If `True`, `Element` objects are represented by their `label`. If `False`, they are converted into a dictionary
|
|
477
|
+
based on their initialization parameters. Default is `False`.
|
|
478
|
+
|
|
479
|
+
Returns:
|
|
480
|
+
A transformed version of the input data, containing only JSON-compatible types:
|
|
481
|
+
- `int`, `float`, `str`, `bool`, `None`
|
|
482
|
+
- `list`, `dict`
|
|
483
|
+
- `np.ndarray` (if `use_numpy=True`. This is NOT JSON-compatible)
|
|
484
|
+
|
|
485
|
+
Raises:
|
|
486
|
+
TypeError: If the data cannot be converted to the specified types.
|
|
487
|
+
|
|
488
|
+
Examples:
|
|
489
|
+
>>> copy_and_convert_datatypes({'a': np.array([1, 2, 3]), 'b': Element(label='example')})
|
|
490
|
+
{'a': array([1, 2, 3]), 'b': {'class': 'Element', 'label': 'example'}}
|
|
491
|
+
|
|
492
|
+
>>> copy_and_convert_datatypes({'a': np.array([1, 2, 3]), 'b': Element(label='example')}, use_numpy=False)
|
|
493
|
+
{'a': [1, 2, 3], 'b': {'class': 'Element', 'label': 'example'}}
|
|
494
|
+
|
|
495
|
+
Notes:
|
|
496
|
+
- The function gracefully handles unexpected types by issuing a warning and returning a deep copy of the data.
|
|
497
|
+
- Empty collections (lists, dictionaries) and default parameter values in `Element` objects are omitted from the output.
|
|
498
|
+
- Numpy arrays with non-numeric data types are automatically converted to lists.
|
|
499
|
+
"""
|
|
500
|
+
if isinstance(data, np.integer): # This must be checked before checking for regular int and float!
|
|
501
|
+
return int(data)
|
|
502
|
+
elif isinstance(data, np.floating):
|
|
503
|
+
return float(data)
|
|
504
|
+
|
|
505
|
+
elif isinstance(data, (int, float, str, bool, type(None))):
|
|
506
|
+
return data
|
|
507
|
+
elif isinstance(data, datetime):
|
|
508
|
+
return data.isoformat()
|
|
509
|
+
|
|
510
|
+
elif isinstance(data, (tuple, set)):
|
|
511
|
+
return copy_and_convert_datatypes([item for item in data], use_numpy, use_element_label)
|
|
512
|
+
elif isinstance(data, dict):
|
|
513
|
+
return {
|
|
514
|
+
copy_and_convert_datatypes(key, use_numpy, use_element_label=True): copy_and_convert_datatypes(
|
|
515
|
+
value, use_numpy, use_element_label
|
|
516
|
+
)
|
|
517
|
+
for key, value in data.items()
|
|
518
|
+
}
|
|
519
|
+
elif isinstance(data, list): # Shorten arrays/lists to be readable
|
|
520
|
+
if use_numpy and all([isinstance(value, (int, float)) for value in data]):
|
|
521
|
+
return np.array([item for item in data])
|
|
522
|
+
else:
|
|
523
|
+
return [copy_and_convert_datatypes(item, use_numpy, use_element_label) for item in data]
|
|
524
|
+
|
|
525
|
+
elif isinstance(data, np.ndarray):
|
|
526
|
+
if not use_numpy:
|
|
527
|
+
return copy_and_convert_datatypes(data.tolist(), use_numpy, use_element_label)
|
|
528
|
+
elif use_numpy and np.issubdtype(data.dtype, np.number):
|
|
529
|
+
return data
|
|
530
|
+
else:
|
|
531
|
+
logger.critical(
|
|
532
|
+
f'An np.array with non-numeric content was found: {data=}.It will be converted to a list instead'
|
|
533
|
+
)
|
|
534
|
+
return copy_and_convert_datatypes(data.tolist(), use_numpy, use_element_label)
|
|
535
|
+
|
|
536
|
+
elif isinstance(data, TimeSeries):
|
|
537
|
+
return copy_and_convert_datatypes(data.active_data, use_numpy, use_element_label)
|
|
538
|
+
elif isinstance(data, TimeSeriesData):
|
|
539
|
+
return copy_and_convert_datatypes(data.data, use_numpy, use_element_label)
|
|
540
|
+
|
|
541
|
+
elif isinstance(data, Interface):
|
|
542
|
+
if use_element_label and isinstance(data, Element):
|
|
543
|
+
return data.label
|
|
544
|
+
return data.infos(use_numpy, use_element_label)
|
|
545
|
+
elif isinstance(data, xr.DataArray):
|
|
546
|
+
# TODO: This is a temporary basic work around
|
|
547
|
+
return copy_and_convert_datatypes(data.values, use_numpy, use_element_label)
|
|
548
|
+
else:
|
|
549
|
+
raise TypeError(f'copy_and_convert_datatypes() did get unexpected data of type "{type(data)}": {data=}')
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def get_compact_representation(data: Any, array_threshold: int = 50, decimals: int = 2) -> Dict:
|
|
553
|
+
"""
|
|
554
|
+
Generate a compact json serializable representation of deeply nested data.
|
|
555
|
+
Numpy arrays are statistically described if they exceed a threshold and converted to lists.
|
|
556
|
+
|
|
557
|
+
Args:
|
|
558
|
+
data (Any): The data to format and represent.
|
|
559
|
+
array_threshold (int): Maximum length of NumPy arrays to display. Longer arrays are statistically described.
|
|
560
|
+
decimals (int): Number of decimal places in which to describe the arrays.
|
|
561
|
+
|
|
562
|
+
Returns:
|
|
563
|
+
Dict: A dictionary representation of the data
|
|
564
|
+
"""
|
|
565
|
+
|
|
566
|
+
def format_np_array_if_found(value: Any) -> Any:
|
|
567
|
+
"""Recursively processes the data, formatting NumPy arrays."""
|
|
568
|
+
if isinstance(value, (int, float, str, bool, type(None))):
|
|
569
|
+
return value
|
|
570
|
+
elif isinstance(value, np.ndarray):
|
|
571
|
+
return describe_numpy_arrays(value)
|
|
572
|
+
elif isinstance(value, dict):
|
|
573
|
+
return {format_np_array_if_found(k): format_np_array_if_found(v) for k, v in value.items()}
|
|
574
|
+
elif isinstance(value, (list, tuple, set)):
|
|
575
|
+
return [format_np_array_if_found(v) for v in value]
|
|
576
|
+
else:
|
|
577
|
+
logger.warning(
|
|
578
|
+
f'Unexpected value found when trying to format numpy array numpy array: {type(value)=}; {value=}'
|
|
579
|
+
)
|
|
580
|
+
return value
|
|
581
|
+
|
|
582
|
+
def describe_numpy_arrays(arr: np.ndarray) -> Union[str, List]:
|
|
583
|
+
"""Shortens NumPy arrays if they exceed the specified length."""
|
|
584
|
+
|
|
585
|
+
def normalized_center_of_mass(array: Any) -> float:
|
|
586
|
+
# position in array (0 bis 1 normiert)
|
|
587
|
+
positions = np.linspace(0, 1, len(array)) # weights w_i
|
|
588
|
+
# mass center
|
|
589
|
+
if np.sum(array) == 0:
|
|
590
|
+
return np.nan
|
|
591
|
+
else:
|
|
592
|
+
return np.sum(positions * array) / np.sum(array)
|
|
593
|
+
|
|
594
|
+
if arr.size > array_threshold: # Calculate basic statistics
|
|
595
|
+
fmt = f'.{decimals}f'
|
|
596
|
+
return (
|
|
597
|
+
f'Array (min={np.min(arr):{fmt}}, max={np.max(arr):{fmt}}, mean={np.mean(arr):{fmt}}, '
|
|
598
|
+
f'median={np.median(arr):{fmt}}, std={np.std(arr):{fmt}}, len={len(arr)}, '
|
|
599
|
+
f'center={normalized_center_of_mass(arr):{fmt}})'
|
|
600
|
+
)
|
|
601
|
+
else:
|
|
602
|
+
return np.around(arr, decimals=decimals).tolist()
|
|
603
|
+
|
|
604
|
+
# Process the data to handle NumPy arrays
|
|
605
|
+
formatted_data = format_np_array_if_found(copy_and_convert_datatypes(data, use_numpy=True))
|
|
606
|
+
|
|
607
|
+
return formatted_data
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def get_str_representation(data: Any, array_threshold: int = 50, decimals: int = 2) -> str:
|
|
611
|
+
"""
|
|
612
|
+
Generate a string representation of deeply nested data using `rich.print`.
|
|
613
|
+
NumPy arrays are shortened to the specified length and converted to strings.
|
|
614
|
+
|
|
615
|
+
Args:
|
|
616
|
+
data (Any): The data to format and represent.
|
|
617
|
+
array_threshold (int): Maximum length of NumPy arrays to display. Longer arrays are statistically described.
|
|
618
|
+
decimals (int): Number of decimal places in which to describe the arrays.
|
|
619
|
+
|
|
620
|
+
Returns:
|
|
621
|
+
str: The formatted string representation of the data.
|
|
622
|
+
"""
|
|
623
|
+
|
|
624
|
+
formatted_data = get_compact_representation(data, array_threshold, decimals)
|
|
625
|
+
|
|
626
|
+
# Use Rich to format and print the data
|
|
627
|
+
with StringIO() as output_buffer:
|
|
628
|
+
console = Console(file=output_buffer, width=1000) # Adjust width as needed
|
|
629
|
+
console.print(Pretty(formatted_data, expand_all=True, indent_guides=True))
|
|
630
|
+
return output_buffer.getvalue()
|
flixopt/utils.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module contains several utility functions used throughout the flixopt framework.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any, Dict, List, Literal, Optional, Union
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import xarray as xr
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger('flixopt')
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def is_number(number_alias: Union[int, float, str]):
|
|
15
|
+
"""Returns True is string is a number."""
|
|
16
|
+
try:
|
|
17
|
+
float(number_alias)
|
|
18
|
+
return True
|
|
19
|
+
except ValueError:
|
|
20
|
+
return False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def round_floats(obj, decimals=2):
|
|
24
|
+
if isinstance(obj, dict):
|
|
25
|
+
return {k: round_floats(v, decimals) for k, v in obj.items()}
|
|
26
|
+
elif isinstance(obj, list):
|
|
27
|
+
return [round_floats(v, decimals) for v in obj]
|
|
28
|
+
elif isinstance(obj, float):
|
|
29
|
+
return round(obj, decimals)
|
|
30
|
+
return obj
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def convert_dataarray(
|
|
34
|
+
data: xr.DataArray, mode: Literal['py', 'numpy', 'xarray', 'structure']
|
|
35
|
+
) -> Union[List, np.ndarray, xr.DataArray, str]:
|
|
36
|
+
"""
|
|
37
|
+
Convert a DataArray to a different format.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
data: The DataArray to convert.
|
|
41
|
+
mode: The mode to convert to.
|
|
42
|
+
- 'py': Convert to python native types (for json)
|
|
43
|
+
- 'numpy': Convert to numpy array
|
|
44
|
+
- 'xarray': Convert to xarray.DataArray
|
|
45
|
+
- 'structure': Convert to strings (for structure, storing variable names)
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
The converted data.
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
ValueError: If the mode is unknown.
|
|
52
|
+
"""
|
|
53
|
+
if mode == 'numpy':
|
|
54
|
+
return data.values
|
|
55
|
+
elif mode == 'py':
|
|
56
|
+
return data.values.tolist()
|
|
57
|
+
elif mode == 'xarray':
|
|
58
|
+
return data
|
|
59
|
+
elif mode == 'structure':
|
|
60
|
+
return f':::{data.name}'
|
|
61
|
+
else:
|
|
62
|
+
raise ValueError(f'Unknown mode {mode}')
|