-
Notifications
You must be signed in to change notification settings - Fork 9
/
time_series.py
473 lines (394 loc) · 15.8 KB
/
time_series.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
"""Contains TimeSeries class."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import numpy as np
import pandas as pd
import pint
import xarray as xr
from _warnings import warn
from weldx import Q_
from weldx import util as ut
from weldx.exceptions import WeldxException
from weldx.time import Time, TimeDependent, types_time_like, types_timestamp_like
from weldx.types import UnitLike
from weldx.util import check_matplotlib_available
from .math_expression import MathematicalExpression
if TYPE_CHECKING:
import matplotlib.axes
__all__ = [
"TimeSeries",
]
class TimeSeries(TimeDependent):
"""Describes the behaviour of a quantity in time."""
_valid_interpolations = (
"step",
"linear",
"nearest",
"zero",
"slinear",
"quadratic",
"cubic",
)
def __init__(
self,
data: pint.Quantity | MathematicalExpression,
time: types_time_like = None,
interpolation: str = None,
reference_time: types_timestamp_like = None,
):
"""Construct a TimSeries.
Parameters
----------
data:
Either a pint.Quantity or a weldx.MathematicalExpression. If a mathematical
expression is chosen, it is only allowed to have a single free variable,
which represents time.
time:
An instance of pandas.TimedeltaIndex if a quantity is passed and 'None'
otherwise.
interpolation:
A string defining the desired interpolation method. This is only relevant if
a quantity is passed as data. Currently supported interpolation methods are:
'step', 'linear'.
"""
self._data: MathematicalExpression | xr.DataArray = None
self._time_var_name: str | None = None
self._shape = None
self._units = None
self._interp_counter = 0
self._reference_time = None
if isinstance(data, (pint.Quantity, xr.DataArray)):
self._initialize_discrete(data, time, interpolation, reference_time)
elif isinstance(data, MathematicalExpression):
self._init_expression(data, reference_time)
else:
raise TypeError(f'The data type "{type(data)}" is not supported.')
def __eq__(self, other: Any) -> bool:
"""Return the result of a structural equality comparison with another object.
If the other object is not a 'TimeSeries' this function always returns 'False'.
Parameters
----------
other:
Other object.
Returns
-------
bool:
'True' if the compared object is also a 'TimeSeries' and equal to
this instance, 'False' otherwise
"""
if not isinstance(other, TimeSeries):
return False
if not isinstance(self.data, MathematicalExpression):
if not isinstance(other.data, pint.Quantity):
return False
return self._data.identical(other.data_array) # type: ignore
return self._data == other.data
__hash__ = None
def __repr__(self):
"""Give __repr__ output."""
representation = "<TimeSeries>"
if isinstance(self._data, xr.DataArray):
if self._data.shape[0] == 1:
representation += f"\nConstant value:\n\t{self.data.magnitude[0]}\n"
else:
representation += (
f"\nTime:\n\t{self.time}\n"
f"Values:\n\t{self.data.magnitude}\n"
f'Interpolation:\n\t{self._data.attrs["interpolation"]}\n'
)
else:
representation += self.data.__repr__().replace(
"<MathematicalExpression>", ""
)
return representation + f"Units:\n\t{self.units}\n"
@staticmethod
def _check_data_array(data_array: xr.DataArray):
"""Raise an exception if the 'DataArray' can't be used as 'self._data'."""
try:
ut.xr_check_coords(data_array, dict(time={"dtype": ["timedelta64[ns]"]}))
except (KeyError, TypeError, ValueError) as e:
raise type(e)(
"The provided 'DataArray' does not match the required pattern. It "
"needs to have a dimension called 'time' with coordinates of type "
"'timedelta64[ns]'. The error reported by the comparison function was:"
f"\n{e}"
) from e
if not isinstance(data_array.data, pint.Quantity):
raise TypeError("The data of the 'DataArray' must be a 'pint.Quantity'.")
@staticmethod
def _create_data_array(
data: pint.Quantity | xr.DataArray, time: Time
) -> xr.DataArray:
return (
xr.DataArray(data=data)
.rename({"dim_0": "time"})
.assign_coords({"time": time.as_data_array()})
)
def _initialize_discrete(
self,
data: pint.Quantity | xr.DataArray,
time: types_time_like = None,
interpolation: str = None,
reference_time=None,
):
"""Initialize the internal data with discrete values."""
# set default interpolation
if interpolation is None:
interpolation = "step"
if isinstance(data, xr.DataArray):
self._check_data_array(data)
data = data.transpose("time", ...)
self._data = data
else:
# expand dim for scalar input
data = Q_(data)
if not np.iterable(data):
data = np.expand_dims(data, 0)
# constant value case
if time is None:
time = pd.Timedelta(0)
time = Time(time, reference_time)
self._data = self._create_data_array(data, time)
self.interpolation = interpolation
def _init_expression(self, data, reference_time):
"""Initialize the internal data with a mathematical expression."""
if data.num_variables != 1:
raise WeldxException(
"The mathematical expression must have exactly 1 free "
"variable that represents time."
)
# check that the expression can be evaluated with a time quantity
time_var_name = data.get_variable_names()[0]
try:
eval_data = data.evaluate(**{time_var_name: Q_(1, "second")}).data
self._units = eval_data.units
if np.iterable(eval_data):
self._shape = eval_data.shape
else:
self._shape = (1,)
except pint.errors.DimensionalityError as de:
raise WeldxException(
"Expression can not be evaluated with "
'"weldx.Quantity(1, "seconds")"'
". Ensure that every parameter posses the correct unit."
) from de
# assign internal variables
self._data = data
self._time_var_name = time_var_name
if reference_time is not None:
self._reference_time = pd.Timestamp(reference_time)
# check that all parameters of the expression support time arrays
try:
self.interp_time(Q_([1, 2], "second"))
self.interp_time(Q_([1, 2, 3], "second"))
except Exception as e:
raise WeldxException(
"The expression can not be evaluated with arrays of time deltas. "
"Ensure that all parameters that are multiplied with the time "
"variable have an outer dimension of size 1. This dimension is "
"broadcast during multiplication. The original error message was:"
f' "{str(e)}"'
) from e
def _interp_time_discrete(self, time: Time) -> xr.DataArray:
"""Interpolate the time series if its data is composed of discrete values."""
data = self._data
if self.time is None and time.is_absolute:
data = data.weldx.reset_reference_time(time.reference_time) # type: ignore
return ut.xr_interp_like(
data,
time.as_data_array(),
method=self.interpolation,
assume_sorted=False,
broadcast_missing=False,
)
def _interp_time_expression(self, time: Time, time_unit: str) -> xr.DataArray:
"""Interpolate the time series if its data is a mathematical expression."""
time_q = time.as_quantity(unit=time_unit)
if len(time_q.m.shape) == 0:
time_q = np.expand_dims(time_q, 0) # type: ignore
time_xr = xr.DataArray(time_q, dims=["time"])
# evaluate expression
data = self._data.evaluate(**{self._time_var_name: time_xr})
return data.assign_coords({"time": time.as_data_array()})
@property
def data(self) -> pint.Quantity | MathematicalExpression:
"""Return the data of the TimeSeries.
This is either a set of discrete values/quantities or a mathematical expression.
Returns
-------
pint.Quantity:
Underlying data array.
MathematicalExpression:
A mathematical expression describing the time dependency
"""
if isinstance(self._data, xr.DataArray):
return self._data.data
return self._data
@property
def data_array(self) -> xr.DataArray | None:
"""Return the internal data as 'xarray.DataArray'.
If the TimeSeries contains an expression, 'None' is returned.
Returns
-------
xarray.DataArray:
The internal data as 'xarray.DataArray'
"""
if isinstance(self._data, xr.DataArray):
return self._data
return None
@property
def interpolation(self) -> str | None:
"""Return the interpolation.
Returns
-------
str:
Interpolation of the TimeSeries
"""
if isinstance(self._data, xr.DataArray):
return self._data.attrs["interpolation"]
return None
@interpolation.setter
def interpolation(self, interpolation):
"""Set the interpolation."""
if isinstance(self._data, xr.DataArray):
if interpolation not in self._valid_interpolations:
raise ValueError(
"A valid interpolation method must be specified if discrete "
f'values are used. "{interpolation}" is not supported'
)
if self.time is None and interpolation != "step":
interpolation = "step"
self.data_array.attrs["interpolation"] = interpolation
@property
def is_discrete(self) -> bool:
"""Return `True` if the time series is described by discrete values."""
return not self.is_expression
@property
def is_expression(self) -> bool:
"""Return `True` if the time series is described by an expression."""
return isinstance(self.data, MathematicalExpression)
@property
def time(self) -> None | Time:
"""Return the data's timestamps.
Returns
-------
pandas.TimedeltaIndex:
Timestamps of the data
"""
if isinstance(self._data, xr.DataArray) and len(self._data.time) > 1:
return Time(self._data.time.data, self.reference_time)
return None
@property
def reference_time(self) -> pd.Timestamp | None:
"""Get the reference time."""
if self.is_discrete:
return self._data.weldx.time_ref # type: ignore[union-attr]
return self._reference_time
def interp_time(
self, time: pd.TimedeltaIndex | pint.Quantity | Time, time_unit: str = "s"
) -> TimeSeries:
"""Interpolate the TimeSeries in time.
If the internal data consists of discrete values, an interpolation with the
prescribed interpolation method is performed. In case of mathematical
expression, the expression is evaluated for the given timestamps.
Parameters
----------
time:
The time values to be used for interpolation.
time_unit:
Only important if the time series is described by an expression and a
'pandas.TimedeltaIndex' is passed to this function. In this case, time is
converted to a quantity with the provided unit. Even though pint handles
unit prefixes automatically, the accuracy of the results can be heavily
influenced if the provided unit results in extreme large or
small values when compared to the parameters of the expression.
Returns
-------
TimeSeries :
A new `TimeSeries` object containing the interpolated data.
"""
if self._interp_counter > 0:
warn(
"The data of the time series has already been interpolated "
f"{self._interp_counter} time(s)."
)
# prepare timedelta values for internal interpolation
time = Time(time)
time_interp = Time(time, self.reference_time)
if isinstance(self._data, xr.DataArray):
dax = self._interp_time_discrete(time_interp)
ts = TimeSeries(data=dax.data, time=time, interpolation=self.interpolation)
else:
dax = self._interp_time_expression(time_interp, time_unit)
ts = TimeSeries(data=dax, interpolation=self.interpolation)
ts._interp_counter = self._interp_counter + 1
return ts
@check_matplotlib_available
def plot(
self,
time: pd.TimedeltaIndex | pint.Quantity = None,
axes: matplotlib.axes.Axes = None,
data_name: str = "values",
time_unit: UnitLike = None,
**mpl_kwargs,
) -> matplotlib.axes.Axes:
"""Plot the `TimeSeries`.
Parameters
----------
time :
The points in time that should be plotted. This is an optional parameter for
discrete `TimeSeries` but mandatory for expression based TimeSeries.
axes :
An optional matplotlib axes object
data_name :
Name of the data that will appear in the y-axis label
mpl_kwargs :
Key word arguments that are passed to the matplotlib plot function
time_unit :
The desired time unit for the plot. If `None` is provided, the internally
stored unit will be used.
Returns
-------
matplotlib.axes.Axes :
The matplotlib axes object that was used for the plot
"""
import matplotlib.pyplot as plt
if axes is None:
_, axes = plt.subplots()
if self.is_expression or time is not None:
return self.interp_time(time).plot(
axes=axes, data_name=data_name, time_unit=time_unit, **mpl_kwargs
)
time = Time(self.time, self.reference_time).as_quantity()
if time_unit is not None:
time = time.to(time_unit) # type: ignore[assignment]
axes.plot(time.m, self._data.data.m, **mpl_kwargs) # type: ignore
axes.set_xlabel(f"t in {time.u:~}")
y_unit_label = ""
if self.units not in ["", "dimensionless"]:
y_unit_label = f" in {self.units:~}"
axes.set_ylabel(data_name + y_unit_label)
return axes
@property
def shape(self) -> tuple:
"""Return the shape of the TimeSeries data.
For mathematical expressions, the shape does not contain the time axis.
Returns
-------
Tuple:
Tuple describing the data's shape
"""
if isinstance(self._data, xr.DataArray):
return self._data.shape
return self._shape
@property
def units(self) -> pint.Unit:
"""Return the units of the TimeSeries Data.
Returns
-------
pint.Unit:
The unit of the `TimeSeries`
"""
if isinstance(self._data, xr.DataArray):
return self._data.data.units
return self._units