flixopt 1.0.12__py3-none-any.whl → 2.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of flixopt might be problematic. Click here for more details.
- docs/examples/00-Minimal Example.md +5 -0
- docs/examples/01-Basic Example.md +5 -0
- docs/examples/02-Complex Example.md +10 -0
- docs/examples/03-Calculation Modes.md +5 -0
- docs/examples/index.md +5 -0
- docs/faq/contribute.md +49 -0
- docs/faq/index.md +3 -0
- docs/images/architecture_flixOpt-pre2.0.0.png +0 -0
- docs/images/architecture_flixOpt.png +0 -0
- docs/images/flixopt-icon.svg +1 -0
- docs/javascripts/mathjax.js +18 -0
- docs/release-notes/_template.txt +32 -0
- docs/release-notes/index.md +7 -0
- docs/release-notes/v2.0.0.md +93 -0
- docs/release-notes/v2.0.1.md +12 -0
- docs/user-guide/Mathematical Notation/Bus.md +33 -0
- docs/user-guide/Mathematical Notation/Effects, Penalty & Objective.md +132 -0
- docs/user-guide/Mathematical Notation/Flow.md +26 -0
- docs/user-guide/Mathematical Notation/LinearConverter.md +21 -0
- docs/user-guide/Mathematical Notation/Piecewise.md +49 -0
- docs/user-guide/Mathematical Notation/Storage.md +44 -0
- docs/user-guide/Mathematical Notation/index.md +22 -0
- docs/user-guide/Mathematical Notation/others.md +3 -0
- docs/user-guide/index.md +124 -0
- {flixOpt → flixopt}/__init__.py +5 -2
- {flixOpt → flixopt}/aggregation.py +113 -140
- flixopt/calculation.py +455 -0
- {flixOpt → flixopt}/commons.py +7 -4
- flixopt/components.py +630 -0
- {flixOpt → flixopt}/config.py +9 -8
- {flixOpt → flixopt}/config.yaml +3 -3
- flixopt/core.py +970 -0
- flixopt/effects.py +386 -0
- flixopt/elements.py +534 -0
- flixopt/features.py +1042 -0
- flixopt/flow_system.py +409 -0
- flixopt/interface.py +265 -0
- flixopt/io.py +308 -0
- flixopt/linear_converters.py +331 -0
- flixopt/plotting.py +1340 -0
- flixopt/results.py +898 -0
- flixopt/solvers.py +77 -0
- flixopt/structure.py +630 -0
- flixopt/utils.py +62 -0
- flixopt-2.0.1.dist-info/METADATA +145 -0
- flixopt-2.0.1.dist-info/RECORD +57 -0
- {flixopt-1.0.12.dist-info → flixopt-2.0.1.dist-info}/WHEEL +1 -1
- flixopt-2.0.1.dist-info/top_level.txt +6 -0
- pics/architecture_flixOpt-pre2.0.0.png +0 -0
- pics/architecture_flixOpt.png +0 -0
- pics/flixopt-icon.svg +1 -0
- pics/pics.pptx +0 -0
- scripts/gen_ref_pages.py +54 -0
- site/release-notes/_template.txt +32 -0
- flixOpt/calculation.py +0 -629
- flixOpt/components.py +0 -614
- flixOpt/core.py +0 -182
- flixOpt/effects.py +0 -410
- flixOpt/elements.py +0 -489
- flixOpt/features.py +0 -942
- flixOpt/flow_system.py +0 -351
- flixOpt/interface.py +0 -203
- flixOpt/linear_converters.py +0 -325
- flixOpt/math_modeling.py +0 -1145
- flixOpt/plotting.py +0 -712
- flixOpt/results.py +0 -563
- flixOpt/solvers.py +0 -21
- flixOpt/structure.py +0 -733
- flixOpt/utils.py +0 -134
- flixopt-1.0.12.dist-info/METADATA +0 -174
- flixopt-1.0.12.dist-info/RECORD +0 -29
- flixopt-1.0.12.dist-info/top_level.txt +0 -3
- {flixopt-1.0.12.dist-info → flixopt-2.0.1.dist-info/licenses}/LICENSE +0 -0
flixopt/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)'
|