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/core.py ADDED
@@ -0,0 +1,970 @@
1
+ """
2
+ This module contains the core functionality of the flixopt framework.
3
+ It provides Datatypes, logging functionality, and some functions to transform data structures.
4
+ """
5
+
6
+ import inspect
7
+ import json
8
+ import logging
9
+ import pathlib
10
+ from collections import Counter
11
+ from typing import Any, Dict, Iterator, List, Literal, Optional, Tuple, Union
12
+
13
+ import numpy as np
14
+ import pandas as pd
15
+ import xarray as xr
16
+
17
+ logger = logging.getLogger('flixopt')
18
+
19
+ Scalar = Union[int, float]
20
+ """A type representing a single number, either integer or float."""
21
+
22
+ NumericData = Union[int, float, np.integer, np.floating, np.ndarray, pd.Series, pd.DataFrame, xr.DataArray]
23
+ """Represents any form of numeric data, from simple scalars to complex data structures."""
24
+
25
+ NumericDataTS = Union[NumericData, 'TimeSeriesData']
26
+ """Represents either standard numeric data or TimeSeriesData."""
27
+
28
+
29
+ class PlausibilityError(Exception):
30
+ """Error for a failing Plausibility check."""
31
+
32
+ pass
33
+
34
+
35
+ class ConversionError(Exception):
36
+ """Base exception for data conversion errors."""
37
+
38
+ pass
39
+
40
+
41
+ class DataConverter:
42
+ """
43
+ Converts various data types into xarray.DataArray with a timesteps index.
44
+
45
+ Supports: scalars, arrays, Series, DataFrames, and DataArrays.
46
+ """
47
+
48
+ @staticmethod
49
+ def as_dataarray(data: NumericData, timesteps: pd.DatetimeIndex) -> xr.DataArray:
50
+ """Convert data to xarray.DataArray with specified timesteps index."""
51
+ if not isinstance(timesteps, pd.DatetimeIndex) or len(timesteps) == 0:
52
+ raise ValueError(f'Timesteps must be a non-empty DatetimeIndex, got {type(timesteps).__name__}')
53
+ if not timesteps.name == 'time':
54
+ raise ConversionError(f'DatetimeIndex is not named correctly. Must be named "time", got {timesteps.name=}')
55
+
56
+ coords = [timesteps]
57
+ dims = ['time']
58
+ expected_shape = (len(timesteps),)
59
+
60
+ try:
61
+ if isinstance(data, (int, float, np.integer, np.floating)):
62
+ return xr.DataArray(data, coords=coords, dims=dims)
63
+ elif isinstance(data, pd.DataFrame):
64
+ if not data.index.equals(timesteps):
65
+ raise ConversionError("DataFrame index doesn't match timesteps index")
66
+ if not len(data.columns) == 1:
67
+ raise ConversionError('DataFrame must have exactly one column')
68
+ return xr.DataArray(data.values.flatten(), coords=coords, dims=dims)
69
+ elif isinstance(data, pd.Series):
70
+ if not data.index.equals(timesteps):
71
+ raise ConversionError("Series index doesn't match timesteps index")
72
+ return xr.DataArray(data.values, coords=coords, dims=dims)
73
+ elif isinstance(data, np.ndarray):
74
+ if data.ndim != 1:
75
+ raise ConversionError(f'Array must be 1-dimensional, got {data.ndim}')
76
+ elif data.shape[0] != expected_shape[0]:
77
+ raise ConversionError(f"Array shape {data.shape} doesn't match expected {expected_shape}")
78
+ return xr.DataArray(data, coords=coords, dims=dims)
79
+ elif isinstance(data, xr.DataArray):
80
+ if data.dims != tuple(dims):
81
+ raise ConversionError(f"DataArray dimensions {data.dims} don't match expected {dims}")
82
+ if data.sizes[dims[0]] != len(coords[0]):
83
+ raise ConversionError(
84
+ f"DataArray length {data.sizes[dims[0]]} doesn't match expected {len(coords[0])}"
85
+ )
86
+ return data.copy(deep=True)
87
+ else:
88
+ raise ConversionError(f'Unsupported type: {type(data).__name__}')
89
+ except Exception as e:
90
+ if isinstance(e, ConversionError):
91
+ raise
92
+ raise ConversionError(f'Converting data {type(data)} to xarray.Dataset raised an error: {str(e)}') from e
93
+
94
+
95
+ class TimeSeriesData:
96
+ # TODO: Move to Interface.py
97
+ def __init__(self, data: NumericData, agg_group: Optional[str] = None, agg_weight: Optional[float] = None):
98
+ """
99
+ timeseries class for transmit timeseries AND special characteristics of timeseries,
100
+ i.g. to define weights needed in calculation_type 'aggregated'
101
+ EXAMPLE solar:
102
+ you have several solar timeseries. These should not be overweighted
103
+ compared to the remaining timeseries (i.g. heat load, price)!
104
+ fixed_relative_profile_solar1 = TimeSeriesData(sol_array_1, type = 'solar')
105
+ fixed_relative_profile_solar2 = TimeSeriesData(sol_array_2, type = 'solar')
106
+ fixed_relative_profile_solar3 = TimeSeriesData(sol_array_3, type = 'solar')
107
+ --> this 3 series of same type share one weight, i.e. internally assigned each weight = 1/3
108
+ (instead of standard weight = 1)
109
+
110
+ Args:
111
+ data: The timeseries data, which can be a scalar, array, or numpy array.
112
+ agg_group: The group this TimeSeriesData is a part of. agg_weight is split between members of a group. Default is None.
113
+ agg_weight: The weight for calculation_type 'aggregated', should be between 0 and 1. Default is None.
114
+
115
+ Raises:
116
+ Exception: If both agg_group and agg_weight are set, an exception is raised.
117
+ """
118
+ self.data = data
119
+ self.agg_group = agg_group
120
+ self.agg_weight = agg_weight
121
+ if (agg_group is not None) and (agg_weight is not None):
122
+ raise ValueError('Either <agg_group> or explicit <agg_weigth> can be used. Not both!')
123
+ self.label: Optional[str] = None
124
+
125
+ def __repr__(self):
126
+ # Get the constructor arguments and their current values
127
+ init_signature = inspect.signature(self.__init__)
128
+ init_args = init_signature.parameters
129
+
130
+ # Create a dictionary with argument names and their values
131
+ args_str = ', '.join(f'{name}={repr(getattr(self, name, None))}' for name in init_args if name != 'self')
132
+ return f'{self.__class__.__name__}({args_str})'
133
+
134
+ def __str__(self):
135
+ return str(self.data)
136
+
137
+
138
+ class TimeSeries:
139
+ """
140
+ A class representing time series data with active and stored states.
141
+
142
+ TimeSeries provides a way to store time-indexed data and work with temporal subsets.
143
+ It supports arithmetic operations, aggregation, and JSON serialization.
144
+
145
+ Attributes:
146
+ name (str): The name of the time series
147
+ aggregation_weight (Optional[float]): Weight used for aggregation
148
+ aggregation_group (Optional[str]): Group name for shared aggregation weighting
149
+ needs_extra_timestep (bool): Whether this series needs an extra timestep
150
+ """
151
+
152
+ @classmethod
153
+ def from_datasource(
154
+ cls,
155
+ data: NumericData,
156
+ name: str,
157
+ timesteps: pd.DatetimeIndex,
158
+ aggregation_weight: Optional[float] = None,
159
+ aggregation_group: Optional[str] = None,
160
+ needs_extra_timestep: bool = False,
161
+ ) -> 'TimeSeries':
162
+ """
163
+ Initialize the TimeSeries from multiple data sources.
164
+
165
+ Args:
166
+ data: The time series data
167
+ name: The name of the TimeSeries
168
+ timesteps: The timesteps of the TimeSeries
169
+ aggregation_weight: The weight in aggregation calculations
170
+ aggregation_group: Group this TimeSeries belongs to for aggregation weight sharing
171
+ needs_extra_timestep: Whether this series requires an extra timestep
172
+
173
+ Returns:
174
+ A new TimeSeries instance
175
+ """
176
+ return cls(
177
+ DataConverter.as_dataarray(data, timesteps),
178
+ name,
179
+ aggregation_weight,
180
+ aggregation_group,
181
+ needs_extra_timestep,
182
+ )
183
+
184
+ @classmethod
185
+ def from_json(cls, data: Optional[Dict[str, Any]] = None, path: Optional[str] = None) -> 'TimeSeries':
186
+ """
187
+ Load a TimeSeries from a dictionary or json file.
188
+
189
+ Args:
190
+ data: Dictionary containing TimeSeries data
191
+ path: Path to a JSON file containing TimeSeries data
192
+
193
+ Returns:
194
+ A new TimeSeries instance
195
+
196
+ Raises:
197
+ ValueError: If both path and data are provided or neither is provided
198
+ """
199
+ if (path is None and data is None) or (path is not None and data is not None):
200
+ raise ValueError("Exactly one of 'path' or 'data' must be provided")
201
+
202
+ if path is not None:
203
+ with open(path, 'r') as f:
204
+ data = json.load(f)
205
+
206
+ # Convert ISO date strings to datetime objects
207
+ data['data']['coords']['time']['data'] = pd.to_datetime(data['data']['coords']['time']['data'])
208
+
209
+ # Create the TimeSeries instance
210
+ return cls(
211
+ data=xr.DataArray.from_dict(data['data']),
212
+ name=data['name'],
213
+ aggregation_weight=data['aggregation_weight'],
214
+ aggregation_group=data['aggregation_group'],
215
+ needs_extra_timestep=data['needs_extra_timestep'],
216
+ )
217
+
218
+ def __init__(
219
+ self,
220
+ data: xr.DataArray,
221
+ name: str,
222
+ aggregation_weight: Optional[float] = None,
223
+ aggregation_group: Optional[str] = None,
224
+ needs_extra_timestep: bool = False,
225
+ ):
226
+ """
227
+ Initialize a TimeSeries with a DataArray.
228
+
229
+ Args:
230
+ data: The DataArray containing time series data
231
+ name: The name of the TimeSeries
232
+ aggregation_weight: The weight in aggregation calculations
233
+ aggregation_group: Group this TimeSeries belongs to for weight sharing
234
+ needs_extra_timestep: Whether this series requires an extra timestep
235
+
236
+ Raises:
237
+ ValueError: If data doesn't have a 'time' index or has more than 1 dimension
238
+ """
239
+ if 'time' not in data.indexes:
240
+ raise ValueError(f'DataArray must have a "time" index. Got {data.indexes}')
241
+ if data.ndim > 1:
242
+ raise ValueError(f'Number of dimensions of DataArray must be 1. Got {data.ndim}')
243
+
244
+ self.name = name
245
+ self.aggregation_weight = aggregation_weight
246
+ self.aggregation_group = aggregation_group
247
+ self.needs_extra_timestep = needs_extra_timestep
248
+
249
+ # Data management
250
+ self._stored_data = data.copy(deep=True)
251
+ self._backup = self._stored_data.copy(deep=True)
252
+ self._active_timesteps = self._stored_data.indexes['time']
253
+ self._active_data = None
254
+ self._update_active_data()
255
+
256
+ def reset(self):
257
+ """
258
+ Reset active timesteps to the full set of stored timesteps.
259
+ """
260
+ self.active_timesteps = None
261
+
262
+ def restore_data(self):
263
+ """
264
+ Restore stored_data from the backup and reset active timesteps.
265
+ """
266
+ self._stored_data = self._backup.copy(deep=True)
267
+ self.reset()
268
+
269
+ def to_json(self, path: Optional[pathlib.Path] = None) -> Dict[str, Any]:
270
+ """
271
+ Save the TimeSeries to a dictionary or JSON file.
272
+
273
+ Args:
274
+ path: Optional path to save JSON file
275
+
276
+ Returns:
277
+ Dictionary representation of the TimeSeries
278
+ """
279
+ data = {
280
+ 'name': self.name,
281
+ 'aggregation_weight': self.aggregation_weight,
282
+ 'aggregation_group': self.aggregation_group,
283
+ 'needs_extra_timestep': self.needs_extra_timestep,
284
+ 'data': self.active_data.to_dict(),
285
+ }
286
+
287
+ # Convert datetime objects to ISO strings
288
+ data['data']['coords']['time']['data'] = [date.isoformat() for date in data['data']['coords']['time']['data']]
289
+
290
+ # Save to file if path is provided
291
+ if path is not None:
292
+ indent = 4 if len(self.active_timesteps) <= 480 else None
293
+ with open(path, 'w', encoding='utf-8') as f:
294
+ json.dump(data, f, indent=indent, ensure_ascii=False)
295
+
296
+ return data
297
+
298
+ @property
299
+ def stats(self) -> str:
300
+ """
301
+ Return a statistical summary of the active data.
302
+
303
+ Returns:
304
+ String representation of data statistics
305
+ """
306
+ return get_numeric_stats(self.active_data, padd=0)
307
+
308
+ def _update_active_data(self):
309
+ """
310
+ Update the active data based on active_timesteps.
311
+ """
312
+ self._active_data = self._stored_data.sel(time=self.active_timesteps)
313
+
314
+ @property
315
+ def all_equal(self) -> bool:
316
+ """Check if all values in the series are equal."""
317
+ return np.unique(self.active_data.values).size == 1
318
+
319
+ @property
320
+ def active_timesteps(self) -> pd.DatetimeIndex:
321
+ """Get the current active timesteps."""
322
+ return self._active_timesteps
323
+
324
+ @active_timesteps.setter
325
+ def active_timesteps(self, timesteps: Optional[pd.DatetimeIndex]):
326
+ """
327
+ Set active_timesteps and refresh active_data.
328
+
329
+ Args:
330
+ timesteps: New timesteps to activate, or None to use all stored timesteps
331
+
332
+ Raises:
333
+ TypeError: If timesteps is not a pandas DatetimeIndex or None
334
+ """
335
+ if timesteps is None:
336
+ self._active_timesteps = self.stored_data.indexes['time']
337
+ elif isinstance(timesteps, pd.DatetimeIndex):
338
+ self._active_timesteps = timesteps
339
+ else:
340
+ raise TypeError('active_timesteps must be a pandas DatetimeIndex or None')
341
+
342
+ self._update_active_data()
343
+
344
+ @property
345
+ def active_data(self) -> xr.DataArray:
346
+ """Get a view of stored_data based on active_timesteps."""
347
+ return self._active_data
348
+
349
+ @property
350
+ def stored_data(self) -> xr.DataArray:
351
+ """Get a copy of the full stored data."""
352
+ return self._stored_data.copy()
353
+
354
+ @stored_data.setter
355
+ def stored_data(self, value: NumericData):
356
+ """
357
+ Update stored_data and refresh active_data.
358
+
359
+ Args:
360
+ value: New data to store
361
+ """
362
+ new_data = DataConverter.as_dataarray(value, timesteps=self.active_timesteps)
363
+
364
+ # Skip if data is unchanged to avoid overwriting backup
365
+ if new_data.equals(self._stored_data):
366
+ return
367
+
368
+ self._stored_data = new_data
369
+ self.active_timesteps = None # Reset to full timeline
370
+
371
+ @property
372
+ def sel(self):
373
+ return self.active_data.sel
374
+
375
+ @property
376
+ def isel(self):
377
+ return self.active_data.isel
378
+
379
+ def _apply_operation(self, other, op):
380
+ """Apply an operation between this TimeSeries and another object."""
381
+ if isinstance(other, TimeSeries):
382
+ other = other.active_data
383
+ return op(self.active_data, other)
384
+
385
+ def __add__(self, other):
386
+ return self._apply_operation(other, lambda x, y: x + y)
387
+
388
+ def __sub__(self, other):
389
+ return self._apply_operation(other, lambda x, y: x - y)
390
+
391
+ def __mul__(self, other):
392
+ return self._apply_operation(other, lambda x, y: x * y)
393
+
394
+ def __truediv__(self, other):
395
+ return self._apply_operation(other, lambda x, y: x / y)
396
+
397
+ def __radd__(self, other):
398
+ return other + self.active_data
399
+
400
+ def __rsub__(self, other):
401
+ return other - self.active_data
402
+
403
+ def __rmul__(self, other):
404
+ return other * self.active_data
405
+
406
+ def __rtruediv__(self, other):
407
+ return other / self.active_data
408
+
409
+ def __neg__(self) -> xr.DataArray:
410
+ return -self.active_data
411
+
412
+ def __pos__(self) -> xr.DataArray:
413
+ return +self.active_data
414
+
415
+ def __abs__(self) -> xr.DataArray:
416
+ return abs(self.active_data)
417
+
418
+ def __gt__(self, other):
419
+ """
420
+ Compare if this TimeSeries is greater than another.
421
+
422
+ Args:
423
+ other: Another TimeSeries to compare with
424
+
425
+ Returns:
426
+ True if all values in this TimeSeries are greater than other
427
+ """
428
+ if isinstance(other, TimeSeries):
429
+ return self.active_data > other.active_data
430
+ return self.active_data > other
431
+
432
+ def __ge__(self, other):
433
+ """
434
+ Compare if this TimeSeries is greater than or equal to another.
435
+
436
+ Args:
437
+ other: Another TimeSeries to compare with
438
+
439
+ Returns:
440
+ True if all values in this TimeSeries are greater than or equal to other
441
+ """
442
+ if isinstance(other, TimeSeries):
443
+ return self.active_data >= other.active_data
444
+ return self.active_data >= other
445
+
446
+ def __lt__(self, other):
447
+ """
448
+ Compare if this TimeSeries is less than another.
449
+
450
+ Args:
451
+ other: Another TimeSeries to compare with
452
+
453
+ Returns:
454
+ True if all values in this TimeSeries are less than other
455
+ """
456
+ if isinstance(other, TimeSeries):
457
+ return self.active_data < other.active_data
458
+ return self.active_data < other
459
+
460
+ def __le__(self, other):
461
+ """
462
+ Compare if this TimeSeries is less than or equal to another.
463
+
464
+ Args:
465
+ other: Another TimeSeries to compare with
466
+
467
+ Returns:
468
+ True if all values in this TimeSeries are less than or equal to other
469
+ """
470
+ if isinstance(other, TimeSeries):
471
+ return self.active_data <= other.active_data
472
+ return self.active_data <= other
473
+
474
+ def __eq__(self, other):
475
+ """
476
+ Compare if this TimeSeries is equal to another.
477
+
478
+ Args:
479
+ other: Another TimeSeries to compare with
480
+
481
+ Returns:
482
+ True if all values in this TimeSeries are equal to other
483
+ """
484
+ if isinstance(other, TimeSeries):
485
+ return self.active_data == other.active_data
486
+ return self.active_data == other
487
+
488
+ def __array_ufunc__(self, ufunc, method, *inputs, **kwargs):
489
+ """
490
+ Handle NumPy universal functions.
491
+
492
+ This allows NumPy functions to work with TimeSeries objects.
493
+ """
494
+ # Convert any TimeSeries inputs to their active_data
495
+ inputs = [x.active_data if isinstance(x, TimeSeries) else x for x in inputs]
496
+ return getattr(ufunc, method)(*inputs, **kwargs)
497
+
498
+ def __repr__(self):
499
+ """
500
+ Get a string representation of the TimeSeries.
501
+
502
+ Returns:
503
+ String showing TimeSeries details
504
+ """
505
+ attrs = {
506
+ 'name': self.name,
507
+ 'aggregation_weight': self.aggregation_weight,
508
+ 'aggregation_group': self.aggregation_group,
509
+ 'needs_extra_timestep': self.needs_extra_timestep,
510
+ 'shape': self.active_data.shape,
511
+ 'time_range': f'{self.active_timesteps[0]} to {self.active_timesteps[-1]}',
512
+ }
513
+ attr_str = ', '.join(f'{k}={repr(v)}' for k, v in attrs.items())
514
+ return f'TimeSeries({attr_str})'
515
+
516
+ def __str__(self):
517
+ """
518
+ Get a human-readable string representation.
519
+
520
+ Returns:
521
+ Descriptive string with statistics
522
+ """
523
+ return f"TimeSeries '{self.name}': {self.stats}"
524
+
525
+
526
+ class TimeSeriesCollection:
527
+ """
528
+ Collection of TimeSeries objects with shared timestep management.
529
+
530
+ TimeSeriesCollection handles multiple TimeSeries objects with synchronized
531
+ timesteps, provides operations on collections, and manages extra timesteps.
532
+ """
533
+
534
+ def __init__(
535
+ self,
536
+ timesteps: pd.DatetimeIndex,
537
+ hours_of_last_timestep: Optional[float] = None,
538
+ hours_of_previous_timesteps: Optional[Union[float, np.ndarray]] = None,
539
+ ):
540
+ """
541
+ Args:
542
+ timesteps: The timesteps of the Collection.
543
+ hours_of_last_timestep: The duration of the last time step. Uses the last time interval if not specified
544
+ hours_of_previous_timesteps: The duration of previous timesteps.
545
+ If None, the first time increment of time_series is used.
546
+ This is needed to calculate previous durations (for example consecutive_on_hours).
547
+ If you use an array, take care that its long enough to cover all previous values!
548
+ """
549
+ # Prepare and validate timesteps
550
+ self._validate_timesteps(timesteps)
551
+ self.hours_of_previous_timesteps = self._calculate_hours_of_previous_timesteps(
552
+ timesteps, hours_of_previous_timesteps
553
+ )
554
+
555
+ # Set up timesteps and hours
556
+ self.all_timesteps = timesteps
557
+ self.all_timesteps_extra = self._create_timesteps_with_extra(timesteps, hours_of_last_timestep)
558
+ self.all_hours_per_timestep = self.calculate_hours_per_timestep(self.all_timesteps_extra)
559
+
560
+ # Active timestep tracking
561
+ self._active_timesteps = None
562
+ self._active_timesteps_extra = None
563
+ self._active_hours_per_timestep = None
564
+
565
+ # Dictionary of time series by name
566
+ self.time_series_data: Dict[str, TimeSeries] = {}
567
+
568
+ # Aggregation
569
+ self.group_weights: Dict[str, float] = {}
570
+ self.weights: Dict[str, float] = {}
571
+
572
+ @classmethod
573
+ def with_uniform_timesteps(
574
+ cls, start_time: pd.Timestamp, periods: int, freq: str, hours_per_step: Optional[float] = None
575
+ ) -> 'TimeSeriesCollection':
576
+ """Create a collection with uniform timesteps."""
577
+ timesteps = pd.date_range(start_time, periods=periods, freq=freq, name='time')
578
+ return cls(timesteps, hours_of_previous_timesteps=hours_per_step)
579
+
580
+ def create_time_series(
581
+ self, data: Union[NumericData, TimeSeriesData], name: str, needs_extra_timestep: bool = False
582
+ ) -> TimeSeries:
583
+ """
584
+ Creates a TimeSeries from the given data and adds it to the collection.
585
+
586
+ Args:
587
+ data: The data to create the TimeSeries from.
588
+ name: The name of the TimeSeries.
589
+ needs_extra_timestep: Whether to create an additional timestep at the end of the timesteps.
590
+ The data to create the TimeSeries from.
591
+
592
+ Returns:
593
+ The created TimeSeries.
594
+
595
+ """
596
+ # Check for duplicate name
597
+ if name in self.time_series_data:
598
+ raise ValueError(f"TimeSeries '{name}' already exists in this collection")
599
+
600
+ # Determine which timesteps to use
601
+ timesteps_to_use = self.timesteps_extra if needs_extra_timestep else self.timesteps
602
+
603
+ # Create the time series
604
+ if isinstance(data, TimeSeriesData):
605
+ time_series = TimeSeries.from_datasource(
606
+ name=name,
607
+ data=data.data,
608
+ timesteps=timesteps_to_use,
609
+ aggregation_weight=data.agg_weight,
610
+ aggregation_group=data.agg_group,
611
+ needs_extra_timestep=needs_extra_timestep,
612
+ )
613
+ # Connect the user time series to the created TimeSeries
614
+ data.label = name
615
+ else:
616
+ time_series = TimeSeries.from_datasource(
617
+ name=name, data=data, timesteps=timesteps_to_use, needs_extra_timestep=needs_extra_timestep
618
+ )
619
+
620
+ # Add to the collection
621
+ self.add_time_series(time_series)
622
+
623
+ return time_series
624
+
625
+ def calculate_aggregation_weights(self) -> Dict[str, float]:
626
+ """Calculate and return aggregation weights for all time series."""
627
+ self.group_weights = self._calculate_group_weights()
628
+ self.weights = self._calculate_weights()
629
+
630
+ if np.all(np.isclose(list(self.weights.values()), 1, atol=1e-6)):
631
+ logger.info('All Aggregation weights were set to 1')
632
+
633
+ return self.weights
634
+
635
+ def activate_timesteps(self, active_timesteps: Optional[pd.DatetimeIndex] = None):
636
+ """
637
+ Update active timesteps for the collection and all time series.
638
+ If no arguments are provided, the active timesteps are reset.
639
+
640
+ Args:
641
+ active_timesteps: The active timesteps of the model.
642
+ If None, the all timesteps of the TimeSeriesCollection are taken.
643
+ """
644
+ if active_timesteps is None:
645
+ return self.reset()
646
+
647
+ if not np.all(np.isin(active_timesteps, self.all_timesteps)):
648
+ raise ValueError('active_timesteps must be a subset of the timesteps of the TimeSeriesCollection')
649
+
650
+ # Calculate derived timesteps
651
+ self._active_timesteps = active_timesteps
652
+ first_ts_index = np.where(self.all_timesteps == active_timesteps[0])[0][0]
653
+ last_ts_idx = np.where(self.all_timesteps == active_timesteps[-1])[0][0]
654
+ self._active_timesteps_extra = self.all_timesteps_extra[first_ts_index : last_ts_idx + 2]
655
+ self._active_hours_per_timestep = self.all_hours_per_timestep.isel(time=slice(first_ts_index, last_ts_idx + 1))
656
+
657
+ # Update all time series
658
+ self._update_time_series_timesteps()
659
+
660
+ def reset(self):
661
+ """Reset active timesteps to defaults for all time series."""
662
+ self._active_timesteps = None
663
+ self._active_timesteps_extra = None
664
+ self._active_hours_per_timestep = None
665
+
666
+ for time_series in self.time_series_data.values():
667
+ time_series.reset()
668
+
669
+ def restore_data(self):
670
+ """Restore original data for all time series."""
671
+ for time_series in self.time_series_data.values():
672
+ time_series.restore_data()
673
+
674
+ def add_time_series(self, time_series: TimeSeries):
675
+ """Add an existing TimeSeries to the collection."""
676
+ if time_series.name in self.time_series_data:
677
+ raise ValueError(f"TimeSeries '{time_series.name}' already exists in this collection")
678
+
679
+ self.time_series_data[time_series.name] = time_series
680
+
681
+ def insert_new_data(self, data: pd.DataFrame, include_extra_timestep: bool = False):
682
+ """
683
+ Update time series with new data from a DataFrame.
684
+
685
+ Args:
686
+ data: DataFrame containing new data with timestamps as index
687
+ include_extra_timestep: Whether the provided data already includes the extra timestep, by default False
688
+ """
689
+ if not isinstance(data, pd.DataFrame):
690
+ raise TypeError(f'data must be a pandas DataFrame, got {type(data).__name__}')
691
+
692
+ # Check if the DataFrame index matches the expected timesteps
693
+ expected_timesteps = self.timesteps_extra if include_extra_timestep else self.timesteps
694
+ if not data.index.equals(expected_timesteps):
695
+ raise ValueError(
696
+ f'DataFrame index must match {"collection timesteps with extra timestep" if include_extra_timestep else "collection timesteps"}'
697
+ )
698
+
699
+ for name, ts in self.time_series_data.items():
700
+ if name in data.columns:
701
+ if not ts.needs_extra_timestep:
702
+ # For time series without extra timestep
703
+ if include_extra_timestep:
704
+ # If data includes extra timestep but series doesn't need it, exclude the last point
705
+ ts.stored_data = data[name].iloc[:-1]
706
+ else:
707
+ # Use data as is
708
+ ts.stored_data = data[name]
709
+ else:
710
+ # For time series with extra timestep
711
+ if include_extra_timestep:
712
+ # Data already includes extra timestep
713
+ ts.stored_data = data[name]
714
+ else:
715
+ # Need to add extra timestep - extrapolate from the last value
716
+ extra_step_value = data[name].iloc[-1]
717
+ extra_step_index = pd.DatetimeIndex([self.timesteps_extra[-1]], name='time')
718
+ extra_step_series = pd.Series([extra_step_value], index=extra_step_index)
719
+
720
+ # Combine the regular data with the extra timestep
721
+ ts.stored_data = pd.concat([data[name], extra_step_series])
722
+
723
+ logger.debug(f'Updated data for {name}')
724
+
725
+ def to_dataframe(
726
+ self, filtered: Literal['all', 'constant', 'non_constant'] = 'non_constant', include_extra_timestep: bool = True
727
+ ) -> pd.DataFrame:
728
+ """
729
+ Convert collection to DataFrame with optional filtering and timestep control.
730
+
731
+ Args:
732
+ filtered: Filter time series by variability, by default 'non_constant'
733
+ include_extra_timestep: Whether to include the extra timestep in the result, by default True
734
+
735
+ Returns:
736
+ DataFrame representation of the collection
737
+ """
738
+ include_constants = filtered != 'non_constant'
739
+ ds = self.to_dataset(include_constants=include_constants)
740
+
741
+ if not include_extra_timestep:
742
+ ds = ds.isel(time=slice(None, -1))
743
+
744
+ df = ds.to_dataframe()
745
+
746
+ # Apply filtering
747
+ if filtered == 'all':
748
+ return df
749
+ elif filtered == 'constant':
750
+ return df.loc[:, df.nunique() == 1]
751
+ elif filtered == 'non_constant':
752
+ return df.loc[:, df.nunique() > 1]
753
+ else:
754
+ raise ValueError("filtered must be one of: 'all', 'constant', 'non_constant'")
755
+
756
+ def to_dataset(self, include_constants: bool = True) -> xr.Dataset:
757
+ """
758
+ Combine all time series into a single Dataset with all timesteps.
759
+
760
+ Args:
761
+ include_constants: Whether to include time series with constant values, by default True
762
+
763
+ Returns:
764
+ Dataset containing all selected time series with all timesteps
765
+ """
766
+ # Determine which series to include
767
+ if include_constants:
768
+ series_to_include = self.time_series_data.values()
769
+ else:
770
+ series_to_include = self.non_constants
771
+
772
+ # Create individual datasets and merge them
773
+ ds = xr.merge([ts.active_data.to_dataset(name=ts.name) for ts in series_to_include])
774
+
775
+ # Ensure the correct time coordinates
776
+ ds = ds.reindex(time=self.timesteps_extra)
777
+
778
+ ds.attrs.update(
779
+ {
780
+ 'timesteps_extra': f'{self.timesteps_extra[0]} ... {self.timesteps_extra[-1]} | len={len(self.timesteps_extra)}',
781
+ 'hours_per_timestep': self._format_stats(self.hours_per_timestep),
782
+ }
783
+ )
784
+
785
+ return ds
786
+
787
+ def _update_time_series_timesteps(self):
788
+ """Update active timesteps for all time series."""
789
+ for ts in self.time_series_data.values():
790
+ if ts.needs_extra_timestep:
791
+ ts.active_timesteps = self.timesteps_extra
792
+ else:
793
+ ts.active_timesteps = self.timesteps
794
+
795
+ @staticmethod
796
+ def _validate_timesteps(timesteps: pd.DatetimeIndex):
797
+ """Validate timesteps format and rename if needed."""
798
+ if not isinstance(timesteps, pd.DatetimeIndex):
799
+ raise TypeError('timesteps must be a pandas DatetimeIndex')
800
+
801
+ if len(timesteps) < 2:
802
+ raise ValueError('timesteps must contain at least 2 timestamps')
803
+
804
+ # Ensure timesteps has the required name
805
+ if timesteps.name != 'time':
806
+ logger.warning('Renamed timesteps to "time" (was "%s")', timesteps.name)
807
+ timesteps.name = 'time'
808
+
809
+ @staticmethod
810
+ def _create_timesteps_with_extra(
811
+ timesteps: pd.DatetimeIndex, hours_of_last_timestep: Optional[float]
812
+ ) -> pd.DatetimeIndex:
813
+ """Create timesteps with an extra step at the end."""
814
+ if hours_of_last_timestep is not None:
815
+ # Create the extra timestep using the specified duration
816
+ last_date = pd.DatetimeIndex([timesteps[-1] + pd.Timedelta(hours=hours_of_last_timestep)], name='time')
817
+ else:
818
+ # Use the last interval as the extra timestep duration
819
+ last_date = pd.DatetimeIndex([timesteps[-1] + (timesteps[-1] - timesteps[-2])], name='time')
820
+
821
+ # Combine with original timesteps
822
+ return pd.DatetimeIndex(timesteps.append(last_date), name='time')
823
+
824
+ @staticmethod
825
+ def _calculate_hours_of_previous_timesteps(
826
+ timesteps: pd.DatetimeIndex, hours_of_previous_timesteps: Optional[Union[float, np.ndarray]]
827
+ ) -> Union[float, np.ndarray]:
828
+ """Calculate duration of regular timesteps."""
829
+ if hours_of_previous_timesteps is not None:
830
+ return hours_of_previous_timesteps
831
+
832
+ # Calculate from the first interval
833
+ first_interval = timesteps[1] - timesteps[0]
834
+ return first_interval.total_seconds() / 3600 # Convert to hours
835
+
836
+ @staticmethod
837
+ def calculate_hours_per_timestep(timesteps_extra: pd.DatetimeIndex) -> xr.DataArray:
838
+ """Calculate duration of each timestep."""
839
+ # Calculate differences between consecutive timestamps
840
+ hours_per_step = np.diff(timesteps_extra) / pd.Timedelta(hours=1)
841
+
842
+ return xr.DataArray(
843
+ data=hours_per_step, coords={'time': timesteps_extra[:-1]}, dims=('time',), name='hours_per_step'
844
+ )
845
+
846
+ def _calculate_group_weights(self) -> Dict[str, float]:
847
+ """Calculate weights for aggregation groups."""
848
+ # Count series in each group
849
+ groups = [ts.aggregation_group for ts in self.time_series_data.values() if ts.aggregation_group is not None]
850
+ group_counts = Counter(groups)
851
+
852
+ # Calculate weight for each group (1/count)
853
+ return {group: 1 / count for group, count in group_counts.items()}
854
+
855
+ def _calculate_weights(self) -> Dict[str, float]:
856
+ """Calculate weights for all time series."""
857
+ # Calculate weight for each time series
858
+ weights = {}
859
+ for name, ts in self.time_series_data.items():
860
+ if ts.aggregation_group is not None:
861
+ # Use group weight
862
+ weights[name] = self.group_weights.get(ts.aggregation_group, 1)
863
+ else:
864
+ # Use individual weight or default to 1
865
+ weights[name] = ts.aggregation_weight or 1
866
+
867
+ return weights
868
+
869
+ def _format_stats(self, data) -> str:
870
+ """Format statistics for a data array."""
871
+ if hasattr(data, 'values'):
872
+ values = data.values
873
+ else:
874
+ values = np.asarray(data)
875
+
876
+ mean_val = np.mean(values)
877
+ min_val = np.min(values)
878
+ max_val = np.max(values)
879
+
880
+ return f'mean: {mean_val:.2f}, min: {min_val:.2f}, max: {max_val:.2f}'
881
+
882
+ def __getitem__(self, name: str) -> TimeSeries:
883
+ """Get a TimeSeries by name."""
884
+ try:
885
+ return self.time_series_data[name]
886
+ except KeyError as e:
887
+ raise KeyError(f'TimeSeries "{name}" not found in the TimeSeriesCollection') from e
888
+
889
+ def __iter__(self) -> Iterator[TimeSeries]:
890
+ """Iterate through all TimeSeries in the collection."""
891
+ return iter(self.time_series_data.values())
892
+
893
+ def __len__(self) -> int:
894
+ """Get the number of TimeSeries in the collection."""
895
+ return len(self.time_series_data)
896
+
897
+ def __contains__(self, item: Union[str, TimeSeries]) -> bool:
898
+ """Check if a TimeSeries exists in the collection."""
899
+ if isinstance(item, str):
900
+ return item in self.time_series_data
901
+ elif isinstance(item, TimeSeries):
902
+ return any([item is ts for ts in self.time_series_data.values()])
903
+ return False
904
+
905
+ @property
906
+ def non_constants(self) -> List[TimeSeries]:
907
+ """Get time series with varying values."""
908
+ return [ts for ts in self.time_series_data.values() if not ts.all_equal]
909
+
910
+ @property
911
+ def constants(self) -> List[TimeSeries]:
912
+ """Get time series with constant values."""
913
+ return [ts for ts in self.time_series_data.values() if ts.all_equal]
914
+
915
+ @property
916
+ def timesteps(self) -> pd.DatetimeIndex:
917
+ """Get the active timesteps."""
918
+ return self.all_timesteps if self._active_timesteps is None else self._active_timesteps
919
+
920
+ @property
921
+ def timesteps_extra(self) -> pd.DatetimeIndex:
922
+ """Get the active timesteps with extra step."""
923
+ return self.all_timesteps_extra if self._active_timesteps_extra is None else self._active_timesteps_extra
924
+
925
+ @property
926
+ def hours_per_timestep(self) -> xr.DataArray:
927
+ """Get the duration of each active timestep."""
928
+ return (
929
+ self.all_hours_per_timestep if self._active_hours_per_timestep is None else self._active_hours_per_timestep
930
+ )
931
+
932
+ @property
933
+ def hours_of_last_timestep(self) -> float:
934
+ """Get the duration of the last timestep."""
935
+ return float(self.hours_per_timestep[-1].item())
936
+
937
+ def __repr__(self):
938
+ return f'TimeSeriesCollection:\n{self.to_dataset()}'
939
+
940
+ def __str__(self):
941
+ longest_name = max([time_series.name for time_series in self.time_series_data], key=len)
942
+
943
+ stats_summary = '\n'.join(
944
+ [
945
+ f' - {time_series.name:<{len(longest_name)}}: {get_numeric_stats(time_series.active_data)}'
946
+ for time_series in self.time_series_data
947
+ ]
948
+ )
949
+
950
+ return (
951
+ f'TimeSeriesCollection with {len(self.time_series_data)} series\n'
952
+ f' Time Range: {self.timesteps[0]} → {self.timesteps[-1]}\n'
953
+ f' No. of timesteps: {len(self.timesteps)} + 1 extra\n'
954
+ f' Hours per timestep: {get_numeric_stats(self.hours_per_timestep)}\n'
955
+ f' Time Series Data:\n'
956
+ f'{stats_summary}'
957
+ )
958
+
959
+
960
+ def get_numeric_stats(data: xr.DataArray, decimals: int = 2, padd: int = 10) -> str:
961
+ """Calculates the mean, median, min, max, and standard deviation of a numeric DataArray."""
962
+ format_spec = f'>{padd}.{decimals}f' if padd else f'.{decimals}f'
963
+ if np.unique(data).size == 1:
964
+ return f'{data.max().item():{format_spec}} (constant)'
965
+ mean = data.mean().item()
966
+ median = data.median().item()
967
+ min_val = data.min().item()
968
+ max_val = data.max().item()
969
+ std = data.std().item()
970
+ return f'{mean:{format_spec}} (mean), {median:{format_spec}} (median), {min_val:{format_spec}} (min), {max_val:{format_spec}} (max), {std:{format_spec}} (std)'