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.

Files changed (73) hide show
  1. docs/examples/00-Minimal Example.md +5 -0
  2. docs/examples/01-Basic Example.md +5 -0
  3. docs/examples/02-Complex Example.md +10 -0
  4. docs/examples/03-Calculation Modes.md +5 -0
  5. docs/examples/index.md +5 -0
  6. docs/faq/contribute.md +49 -0
  7. docs/faq/index.md +3 -0
  8. docs/images/architecture_flixOpt-pre2.0.0.png +0 -0
  9. docs/images/architecture_flixOpt.png +0 -0
  10. docs/images/flixopt-icon.svg +1 -0
  11. docs/javascripts/mathjax.js +18 -0
  12. docs/release-notes/_template.txt +32 -0
  13. docs/release-notes/index.md +7 -0
  14. docs/release-notes/v2.0.0.md +93 -0
  15. docs/release-notes/v2.0.1.md +12 -0
  16. docs/user-guide/Mathematical Notation/Bus.md +33 -0
  17. docs/user-guide/Mathematical Notation/Effects, Penalty & Objective.md +132 -0
  18. docs/user-guide/Mathematical Notation/Flow.md +26 -0
  19. docs/user-guide/Mathematical Notation/LinearConverter.md +21 -0
  20. docs/user-guide/Mathematical Notation/Piecewise.md +49 -0
  21. docs/user-guide/Mathematical Notation/Storage.md +44 -0
  22. docs/user-guide/Mathematical Notation/index.md +22 -0
  23. docs/user-guide/Mathematical Notation/others.md +3 -0
  24. docs/user-guide/index.md +124 -0
  25. {flixOpt → flixopt}/__init__.py +5 -2
  26. {flixOpt → flixopt}/aggregation.py +113 -140
  27. flixopt/calculation.py +455 -0
  28. {flixOpt → flixopt}/commons.py +7 -4
  29. flixopt/components.py +630 -0
  30. {flixOpt → flixopt}/config.py +9 -8
  31. {flixOpt → flixopt}/config.yaml +3 -3
  32. flixopt/core.py +970 -0
  33. flixopt/effects.py +386 -0
  34. flixopt/elements.py +534 -0
  35. flixopt/features.py +1042 -0
  36. flixopt/flow_system.py +409 -0
  37. flixopt/interface.py +265 -0
  38. flixopt/io.py +308 -0
  39. flixopt/linear_converters.py +331 -0
  40. flixopt/plotting.py +1340 -0
  41. flixopt/results.py +898 -0
  42. flixopt/solvers.py +77 -0
  43. flixopt/structure.py +630 -0
  44. flixopt/utils.py +62 -0
  45. flixopt-2.0.1.dist-info/METADATA +145 -0
  46. flixopt-2.0.1.dist-info/RECORD +57 -0
  47. {flixopt-1.0.12.dist-info → flixopt-2.0.1.dist-info}/WHEEL +1 -1
  48. flixopt-2.0.1.dist-info/top_level.txt +6 -0
  49. pics/architecture_flixOpt-pre2.0.0.png +0 -0
  50. pics/architecture_flixOpt.png +0 -0
  51. pics/flixopt-icon.svg +1 -0
  52. pics/pics.pptx +0 -0
  53. scripts/gen_ref_pages.py +54 -0
  54. site/release-notes/_template.txt +32 -0
  55. flixOpt/calculation.py +0 -629
  56. flixOpt/components.py +0 -614
  57. flixOpt/core.py +0 -182
  58. flixOpt/effects.py +0 -410
  59. flixOpt/elements.py +0 -489
  60. flixOpt/features.py +0 -942
  61. flixOpt/flow_system.py +0 -351
  62. flixOpt/interface.py +0 -203
  63. flixOpt/linear_converters.py +0 -325
  64. flixOpt/math_modeling.py +0 -1145
  65. flixOpt/plotting.py +0 -712
  66. flixOpt/results.py +0 -563
  67. flixOpt/solvers.py +0 -21
  68. flixOpt/structure.py +0 -733
  69. flixOpt/utils.py +0 -134
  70. flixopt-1.0.12.dist-info/METADATA +0 -174
  71. flixopt-1.0.12.dist-info/RECORD +0 -29
  72. flixopt-1.0.12.dist-info/top_level.txt +0 -3
  73. {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}')