flixopt 1.0.12__py3-none-any.whl → 2.0.0__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 (72) 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/user-guide/Mathematical Notation/Bus.md +33 -0
  16. docs/user-guide/Mathematical Notation/Effects, Penalty & Objective.md +132 -0
  17. docs/user-guide/Mathematical Notation/Flow.md +26 -0
  18. docs/user-guide/Mathematical Notation/LinearConverter.md +21 -0
  19. docs/user-guide/Mathematical Notation/Piecewise.md +49 -0
  20. docs/user-guide/Mathematical Notation/Storage.md +44 -0
  21. docs/user-guide/Mathematical Notation/index.md +22 -0
  22. docs/user-guide/Mathematical Notation/others.md +3 -0
  23. docs/user-guide/index.md +124 -0
  24. {flixOpt → flixopt}/__init__.py +5 -2
  25. {flixOpt → flixopt}/aggregation.py +113 -140
  26. flixopt/calculation.py +455 -0
  27. {flixOpt → flixopt}/commons.py +7 -4
  28. flixopt/components.py +630 -0
  29. {flixOpt → flixopt}/config.py +9 -8
  30. {flixOpt → flixopt}/config.yaml +3 -3
  31. flixopt/core.py +914 -0
  32. flixopt/effects.py +386 -0
  33. flixopt/elements.py +529 -0
  34. flixopt/features.py +1042 -0
  35. flixopt/flow_system.py +409 -0
  36. flixopt/interface.py +265 -0
  37. flixopt/io.py +308 -0
  38. flixopt/linear_converters.py +331 -0
  39. flixopt/plotting.py +1337 -0
  40. flixopt/results.py +898 -0
  41. flixopt/solvers.py +77 -0
  42. flixopt/structure.py +630 -0
  43. flixopt/utils.py +62 -0
  44. flixopt-2.0.0.dist-info/METADATA +145 -0
  45. flixopt-2.0.0.dist-info/RECORD +56 -0
  46. {flixopt-1.0.12.dist-info → flixopt-2.0.0.dist-info}/WHEEL +1 -1
  47. flixopt-2.0.0.dist-info/top_level.txt +6 -0
  48. pics/architecture_flixOpt-pre2.0.0.png +0 -0
  49. pics/architecture_flixOpt.png +0 -0
  50. pics/flixopt-icon.svg +1 -0
  51. pics/pics.pptx +0 -0
  52. scripts/gen_ref_pages.py +54 -0
  53. site/release-notes/_template.txt +32 -0
  54. flixOpt/calculation.py +0 -629
  55. flixOpt/components.py +0 -614
  56. flixOpt/core.py +0 -182
  57. flixOpt/effects.py +0 -410
  58. flixOpt/elements.py +0 -489
  59. flixOpt/features.py +0 -942
  60. flixOpt/flow_system.py +0 -351
  61. flixOpt/interface.py +0 -203
  62. flixOpt/linear_converters.py +0 -325
  63. flixOpt/math_modeling.py +0 -1145
  64. flixOpt/plotting.py +0 -712
  65. flixOpt/results.py +0 -563
  66. flixOpt/solvers.py +0 -21
  67. flixOpt/structure.py +0 -733
  68. flixOpt/utils.py +0 -134
  69. flixopt-1.0.12.dist-info/METADATA +0 -174
  70. flixopt-1.0.12.dist-info/RECORD +0 -29
  71. flixopt-1.0.12.dist-info/top_level.txt +0 -3
  72. {flixopt-1.0.12.dist-info → flixopt-2.0.0.dist-info/licenses}/LICENSE +0 -0
flixopt/core.py ADDED
@@ -0,0 +1,914 @@
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).all().item()
430
+ return NotImplemented
431
+
432
+ def __array_ufunc__(self, ufunc, method, *inputs, **kwargs):
433
+ """
434
+ Handle NumPy universal functions.
435
+
436
+ This allows NumPy functions to work with TimeSeries objects.
437
+ """
438
+ # Convert any TimeSeries inputs to their active_data
439
+ inputs = [x.active_data if isinstance(x, TimeSeries) else x for x in inputs]
440
+ return getattr(ufunc, method)(*inputs, **kwargs)
441
+
442
+ def __repr__(self):
443
+ """
444
+ Get a string representation of the TimeSeries.
445
+
446
+ Returns:
447
+ String showing TimeSeries details
448
+ """
449
+ attrs = {
450
+ 'name': self.name,
451
+ 'aggregation_weight': self.aggregation_weight,
452
+ 'aggregation_group': self.aggregation_group,
453
+ 'needs_extra_timestep': self.needs_extra_timestep,
454
+ 'shape': self.active_data.shape,
455
+ 'time_range': f'{self.active_timesteps[0]} to {self.active_timesteps[-1]}',
456
+ }
457
+ attr_str = ', '.join(f'{k}={repr(v)}' for k, v in attrs.items())
458
+ return f'TimeSeries({attr_str})'
459
+
460
+ def __str__(self):
461
+ """
462
+ Get a human-readable string representation.
463
+
464
+ Returns:
465
+ Descriptive string with statistics
466
+ """
467
+ return f"TimeSeries '{self.name}': {self.stats}"
468
+
469
+
470
+ class TimeSeriesCollection:
471
+ """
472
+ Collection of TimeSeries objects with shared timestep management.
473
+
474
+ TimeSeriesCollection handles multiple TimeSeries objects with synchronized
475
+ timesteps, provides operations on collections, and manages extra timesteps.
476
+ """
477
+
478
+ def __init__(
479
+ self,
480
+ timesteps: pd.DatetimeIndex,
481
+ hours_of_last_timestep: Optional[float] = None,
482
+ hours_of_previous_timesteps: Optional[Union[float, np.ndarray]] = None,
483
+ ):
484
+ """
485
+ Args:
486
+ timesteps: The timesteps of the Collection.
487
+ hours_of_last_timestep: The duration of the last time step. Uses the last time interval if not specified
488
+ hours_of_previous_timesteps: The duration of previous timesteps.
489
+ If None, the first time increment of time_series is used.
490
+ This is needed to calculate previous durations (for example consecutive_on_hours).
491
+ If you use an array, take care that its long enough to cover all previous values!
492
+ """
493
+ # Prepare and validate timesteps
494
+ self._validate_timesteps(timesteps)
495
+ self.hours_of_previous_timesteps = self._calculate_hours_of_previous_timesteps(
496
+ timesteps, hours_of_previous_timesteps
497
+ )
498
+
499
+ # Set up timesteps and hours
500
+ self.all_timesteps = timesteps
501
+ self.all_timesteps_extra = self._create_timesteps_with_extra(timesteps, hours_of_last_timestep)
502
+ self.all_hours_per_timestep = self.calculate_hours_per_timestep(self.all_timesteps_extra)
503
+
504
+ # Active timestep tracking
505
+ self._active_timesteps = None
506
+ self._active_timesteps_extra = None
507
+ self._active_hours_per_timestep = None
508
+
509
+ # Dictionary of time series by name
510
+ self.time_series_data: Dict[str, TimeSeries] = {}
511
+
512
+ # Aggregation
513
+ self.group_weights: Dict[str, float] = {}
514
+ self.weights: Dict[str, float] = {}
515
+
516
+ @classmethod
517
+ def with_uniform_timesteps(
518
+ cls, start_time: pd.Timestamp, periods: int, freq: str, hours_per_step: Optional[float] = None
519
+ ) -> 'TimeSeriesCollection':
520
+ """Create a collection with uniform timesteps."""
521
+ timesteps = pd.date_range(start_time, periods=periods, freq=freq, name='time')
522
+ return cls(timesteps, hours_of_previous_timesteps=hours_per_step)
523
+
524
+ def create_time_series(
525
+ self, data: Union[NumericData, TimeSeriesData], name: str, needs_extra_timestep: bool = False
526
+ ) -> TimeSeries:
527
+ """
528
+ Creates a TimeSeries from the given data and adds it to the collection.
529
+
530
+ Args:
531
+ data: The data to create the TimeSeries from.
532
+ name: The name of the TimeSeries.
533
+ needs_extra_timestep: Whether to create an additional timestep at the end of the timesteps.
534
+ The data to create the TimeSeries from.
535
+
536
+ Returns:
537
+ The created TimeSeries.
538
+
539
+ """
540
+ # Check for duplicate name
541
+ if name in self.time_series_data:
542
+ raise ValueError(f"TimeSeries '{name}' already exists in this collection")
543
+
544
+ # Determine which timesteps to use
545
+ timesteps_to_use = self.timesteps_extra if needs_extra_timestep else self.timesteps
546
+
547
+ # Create the time series
548
+ if isinstance(data, TimeSeriesData):
549
+ time_series = TimeSeries.from_datasource(
550
+ name=name,
551
+ data=data.data,
552
+ timesteps=timesteps_to_use,
553
+ aggregation_weight=data.agg_weight,
554
+ aggregation_group=data.agg_group,
555
+ needs_extra_timestep=needs_extra_timestep,
556
+ )
557
+ # Connect the user time series to the created TimeSeries
558
+ data.label = name
559
+ else:
560
+ time_series = TimeSeries.from_datasource(
561
+ name=name, data=data, timesteps=timesteps_to_use, needs_extra_timestep=needs_extra_timestep
562
+ )
563
+
564
+ # Add to the collection
565
+ self.add_time_series(time_series)
566
+
567
+ return time_series
568
+
569
+ def calculate_aggregation_weights(self) -> Dict[str, float]:
570
+ """Calculate and return aggregation weights for all time series."""
571
+ self.group_weights = self._calculate_group_weights()
572
+ self.weights = self._calculate_weights()
573
+
574
+ if np.all(np.isclose(list(self.weights.values()), 1, atol=1e-6)):
575
+ logger.info('All Aggregation weights were set to 1')
576
+
577
+ return self.weights
578
+
579
+ def activate_timesteps(self, active_timesteps: Optional[pd.DatetimeIndex] = None):
580
+ """
581
+ Update active timesteps for the collection and all time series.
582
+ If no arguments are provided, the active timesteps are reset.
583
+
584
+ Args:
585
+ active_timesteps: The active timesteps of the model.
586
+ If None, the all timesteps of the TimeSeriesCollection are taken.
587
+ """
588
+ if active_timesteps is None:
589
+ return self.reset()
590
+
591
+ if not np.all(np.isin(active_timesteps, self.all_timesteps)):
592
+ raise ValueError('active_timesteps must be a subset of the timesteps of the TimeSeriesCollection')
593
+
594
+ # Calculate derived timesteps
595
+ self._active_timesteps = active_timesteps
596
+ first_ts_index = np.where(self.all_timesteps == active_timesteps[0])[0][0]
597
+ last_ts_idx = np.where(self.all_timesteps == active_timesteps[-1])[0][0]
598
+ self._active_timesteps_extra = self.all_timesteps_extra[first_ts_index : last_ts_idx + 2]
599
+ self._active_hours_per_timestep = self.all_hours_per_timestep.isel(time=slice(first_ts_index, last_ts_idx + 1))
600
+
601
+ # Update all time series
602
+ self._update_time_series_timesteps()
603
+
604
+ def reset(self):
605
+ """Reset active timesteps to defaults for all time series."""
606
+ self._active_timesteps = None
607
+ self._active_timesteps_extra = None
608
+ self._active_hours_per_timestep = None
609
+
610
+ for time_series in self.time_series_data.values():
611
+ time_series.reset()
612
+
613
+ def restore_data(self):
614
+ """Restore original data for all time series."""
615
+ for time_series in self.time_series_data.values():
616
+ time_series.restore_data()
617
+
618
+ def add_time_series(self, time_series: TimeSeries):
619
+ """Add an existing TimeSeries to the collection."""
620
+ if time_series.name in self.time_series_data:
621
+ raise ValueError(f"TimeSeries '{time_series.name}' already exists in this collection")
622
+
623
+ self.time_series_data[time_series.name] = time_series
624
+
625
+ def insert_new_data(self, data: pd.DataFrame, include_extra_timestep: bool = False):
626
+ """
627
+ Update time series with new data from a DataFrame.
628
+
629
+ Args:
630
+ data: DataFrame containing new data with timestamps as index
631
+ include_extra_timestep: Whether the provided data already includes the extra timestep, by default False
632
+ """
633
+ if not isinstance(data, pd.DataFrame):
634
+ raise TypeError(f'data must be a pandas DataFrame, got {type(data).__name__}')
635
+
636
+ # Check if the DataFrame index matches the expected timesteps
637
+ expected_timesteps = self.timesteps_extra if include_extra_timestep else self.timesteps
638
+ if not data.index.equals(expected_timesteps):
639
+ raise ValueError(
640
+ f'DataFrame index must match {"collection timesteps with extra timestep" if include_extra_timestep else "collection timesteps"}'
641
+ )
642
+
643
+ for name, ts in self.time_series_data.items():
644
+ if name in data.columns:
645
+ if not ts.needs_extra_timestep:
646
+ # For time series without extra timestep
647
+ if include_extra_timestep:
648
+ # If data includes extra timestep but series doesn't need it, exclude the last point
649
+ ts.stored_data = data[name].iloc[:-1]
650
+ else:
651
+ # Use data as is
652
+ ts.stored_data = data[name]
653
+ else:
654
+ # For time series with extra timestep
655
+ if include_extra_timestep:
656
+ # Data already includes extra timestep
657
+ ts.stored_data = data[name]
658
+ else:
659
+ # Need to add extra timestep - extrapolate from the last value
660
+ extra_step_value = data[name].iloc[-1]
661
+ extra_step_index = pd.DatetimeIndex([self.timesteps_extra[-1]], name='time')
662
+ extra_step_series = pd.Series([extra_step_value], index=extra_step_index)
663
+
664
+ # Combine the regular data with the extra timestep
665
+ ts.stored_data = pd.concat([data[name], extra_step_series])
666
+
667
+ logger.debug(f'Updated data for {name}')
668
+
669
+ def to_dataframe(
670
+ self, filtered: Literal['all', 'constant', 'non_constant'] = 'non_constant', include_extra_timestep: bool = True
671
+ ) -> pd.DataFrame:
672
+ """
673
+ Convert collection to DataFrame with optional filtering and timestep control.
674
+
675
+ Args:
676
+ filtered: Filter time series by variability, by default 'non_constant'
677
+ include_extra_timestep: Whether to include the extra timestep in the result, by default True
678
+
679
+ Returns:
680
+ DataFrame representation of the collection
681
+ """
682
+ include_constants = filtered != 'non_constant'
683
+ ds = self.to_dataset(include_constants=include_constants)
684
+
685
+ if not include_extra_timestep:
686
+ ds = ds.isel(time=slice(None, -1))
687
+
688
+ df = ds.to_dataframe()
689
+
690
+ # Apply filtering
691
+ if filtered == 'all':
692
+ return df
693
+ elif filtered == 'constant':
694
+ return df.loc[:, df.nunique() == 1]
695
+ elif filtered == 'non_constant':
696
+ return df.loc[:, df.nunique() > 1]
697
+ else:
698
+ raise ValueError("filtered must be one of: 'all', 'constant', 'non_constant'")
699
+
700
+ def to_dataset(self, include_constants: bool = True) -> xr.Dataset:
701
+ """
702
+ Combine all time series into a single Dataset with all timesteps.
703
+
704
+ Args:
705
+ include_constants: Whether to include time series with constant values, by default True
706
+
707
+ Returns:
708
+ Dataset containing all selected time series with all timesteps
709
+ """
710
+ # Determine which series to include
711
+ if include_constants:
712
+ series_to_include = self.time_series_data.values()
713
+ else:
714
+ series_to_include = self.non_constants
715
+
716
+ # Create individual datasets and merge them
717
+ ds = xr.merge([ts.active_data.to_dataset(name=ts.name) for ts in series_to_include])
718
+
719
+ # Ensure the correct time coordinates
720
+ ds = ds.reindex(time=self.timesteps_extra)
721
+
722
+ ds.attrs.update(
723
+ {
724
+ 'timesteps_extra': f'{self.timesteps_extra[0]} ... {self.timesteps_extra[-1]} | len={len(self.timesteps_extra)}',
725
+ 'hours_per_timestep': self._format_stats(self.hours_per_timestep),
726
+ }
727
+ )
728
+
729
+ return ds
730
+
731
+ def _update_time_series_timesteps(self):
732
+ """Update active timesteps for all time series."""
733
+ for ts in self.time_series_data.values():
734
+ if ts.needs_extra_timestep:
735
+ ts.active_timesteps = self.timesteps_extra
736
+ else:
737
+ ts.active_timesteps = self.timesteps
738
+
739
+ @staticmethod
740
+ def _validate_timesteps(timesteps: pd.DatetimeIndex):
741
+ """Validate timesteps format and rename if needed."""
742
+ if not isinstance(timesteps, pd.DatetimeIndex):
743
+ raise TypeError('timesteps must be a pandas DatetimeIndex')
744
+
745
+ if len(timesteps) < 2:
746
+ raise ValueError('timesteps must contain at least 2 timestamps')
747
+
748
+ # Ensure timesteps has the required name
749
+ if timesteps.name != 'time':
750
+ logger.warning('Renamed timesteps to "time" (was "%s")', timesteps.name)
751
+ timesteps.name = 'time'
752
+
753
+ @staticmethod
754
+ def _create_timesteps_with_extra(
755
+ timesteps: pd.DatetimeIndex, hours_of_last_timestep: Optional[float]
756
+ ) -> pd.DatetimeIndex:
757
+ """Create timesteps with an extra step at the end."""
758
+ if hours_of_last_timestep is not None:
759
+ # Create the extra timestep using the specified duration
760
+ last_date = pd.DatetimeIndex([timesteps[-1] + pd.Timedelta(hours=hours_of_last_timestep)], name='time')
761
+ else:
762
+ # Use the last interval as the extra timestep duration
763
+ last_date = pd.DatetimeIndex([timesteps[-1] + (timesteps[-1] - timesteps[-2])], name='time')
764
+
765
+ # Combine with original timesteps
766
+ return pd.DatetimeIndex(timesteps.append(last_date), name='time')
767
+
768
+ @staticmethod
769
+ def _calculate_hours_of_previous_timesteps(
770
+ timesteps: pd.DatetimeIndex, hours_of_previous_timesteps: Optional[Union[float, np.ndarray]]
771
+ ) -> Union[float, np.ndarray]:
772
+ """Calculate duration of regular timesteps."""
773
+ if hours_of_previous_timesteps is not None:
774
+ return hours_of_previous_timesteps
775
+
776
+ # Calculate from the first interval
777
+ first_interval = timesteps[1] - timesteps[0]
778
+ return first_interval.total_seconds() / 3600 # Convert to hours
779
+
780
+ @staticmethod
781
+ def calculate_hours_per_timestep(timesteps_extra: pd.DatetimeIndex) -> xr.DataArray:
782
+ """Calculate duration of each timestep."""
783
+ # Calculate differences between consecutive timestamps
784
+ hours_per_step = np.diff(timesteps_extra) / pd.Timedelta(hours=1)
785
+
786
+ return xr.DataArray(
787
+ data=hours_per_step, coords={'time': timesteps_extra[:-1]}, dims=('time',), name='hours_per_step'
788
+ )
789
+
790
+ def _calculate_group_weights(self) -> Dict[str, float]:
791
+ """Calculate weights for aggregation groups."""
792
+ # Count series in each group
793
+ groups = [ts.aggregation_group for ts in self.time_series_data.values() if ts.aggregation_group is not None]
794
+ group_counts = Counter(groups)
795
+
796
+ # Calculate weight for each group (1/count)
797
+ return {group: 1 / count for group, count in group_counts.items()}
798
+
799
+ def _calculate_weights(self) -> Dict[str, float]:
800
+ """Calculate weights for all time series."""
801
+ # Calculate weight for each time series
802
+ weights = {}
803
+ for name, ts in self.time_series_data.items():
804
+ if ts.aggregation_group is not None:
805
+ # Use group weight
806
+ weights[name] = self.group_weights.get(ts.aggregation_group, 1)
807
+ else:
808
+ # Use individual weight or default to 1
809
+ weights[name] = ts.aggregation_weight or 1
810
+
811
+ return weights
812
+
813
+ def _format_stats(self, data) -> str:
814
+ """Format statistics for a data array."""
815
+ if hasattr(data, 'values'):
816
+ values = data.values
817
+ else:
818
+ values = np.asarray(data)
819
+
820
+ mean_val = np.mean(values)
821
+ min_val = np.min(values)
822
+ max_val = np.max(values)
823
+
824
+ return f'mean: {mean_val:.2f}, min: {min_val:.2f}, max: {max_val:.2f}'
825
+
826
+ def __getitem__(self, name: str) -> TimeSeries:
827
+ """Get a TimeSeries by name."""
828
+ try:
829
+ return self.time_series_data[name]
830
+ except KeyError as e:
831
+ raise KeyError(f'TimeSeries "{name}" not found in the TimeSeriesCollection') from e
832
+
833
+ def __iter__(self) -> Iterator[TimeSeries]:
834
+ """Iterate through all TimeSeries in the collection."""
835
+ return iter(self.time_series_data.values())
836
+
837
+ def __len__(self) -> int:
838
+ """Get the number of TimeSeries in the collection."""
839
+ return len(self.time_series_data)
840
+
841
+ def __contains__(self, item: Union[str, TimeSeries]) -> bool:
842
+ """Check if a TimeSeries exists in the collection."""
843
+ if isinstance(item, str):
844
+ return item in self.time_series_data
845
+ elif isinstance(item, TimeSeries):
846
+ return item in self.time_series_data.values()
847
+ return False
848
+
849
+ @property
850
+ def non_constants(self) -> List[TimeSeries]:
851
+ """Get time series with varying values."""
852
+ return [ts for ts in self.time_series_data.values() if not ts.all_equal]
853
+
854
+ @property
855
+ def constants(self) -> List[TimeSeries]:
856
+ """Get time series with constant values."""
857
+ return [ts for ts in self.time_series_data.values() if ts.all_equal]
858
+
859
+ @property
860
+ def timesteps(self) -> pd.DatetimeIndex:
861
+ """Get the active timesteps."""
862
+ return self.all_timesteps if self._active_timesteps is None else self._active_timesteps
863
+
864
+ @property
865
+ def timesteps_extra(self) -> pd.DatetimeIndex:
866
+ """Get the active timesteps with extra step."""
867
+ return self.all_timesteps_extra if self._active_timesteps_extra is None else self._active_timesteps_extra
868
+
869
+ @property
870
+ def hours_per_timestep(self) -> xr.DataArray:
871
+ """Get the duration of each active timestep."""
872
+ return (
873
+ self.all_hours_per_timestep if self._active_hours_per_timestep is None else self._active_hours_per_timestep
874
+ )
875
+
876
+ @property
877
+ def hours_of_last_timestep(self) -> float:
878
+ """Get the duration of the last timestep."""
879
+ return float(self.hours_per_timestep[-1].item())
880
+
881
+ def __repr__(self):
882
+ return f'TimeSeriesCollection:\n{self.to_dataset()}'
883
+
884
+ def __str__(self):
885
+ longest_name = max([time_series.name for time_series in self.time_series_data], key=len)
886
+
887
+ stats_summary = '\n'.join(
888
+ [
889
+ f' - {time_series.name:<{len(longest_name)}}: {get_numeric_stats(time_series.active_data)}'
890
+ for time_series in self.time_series_data
891
+ ]
892
+ )
893
+
894
+ return (
895
+ f'TimeSeriesCollection with {len(self.time_series_data)} series\n'
896
+ f' Time Range: {self.timesteps[0]} → {self.timesteps[-1]}\n'
897
+ f' No. of timesteps: {len(self.timesteps)} + 1 extra\n'
898
+ f' Hours per timestep: {get_numeric_stats(self.hours_per_timestep)}\n'
899
+ f' Time Series Data:\n'
900
+ f'{stats_summary}'
901
+ )
902
+
903
+
904
+ def get_numeric_stats(data: xr.DataArray, decimals: int = 2, padd: int = 10) -> str:
905
+ """Calculates the mean, median, min, max, and standard deviation of a numeric DataArray."""
906
+ format_spec = f'>{padd}.{decimals}f' if padd else f'.{decimals}f'
907
+ if np.unique(data).size == 1:
908
+ return f'{data.max().item():{format_spec}} (constant)'
909
+ mean = data.mean().item()
910
+ median = data.median().item()
911
+ min_val = data.min().item()
912
+ max_val = data.max().item()
913
+ std = data.std().item()
914
+ return f'{mean:{format_spec}} (mean), {median:{format_spec}} (median), {min_val:{format_spec}} (min), {max_val:{format_spec}} (max), {std:{format_spec}} (std)'