tslite-og 2.1.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.
- tslite.py +1953 -0
- tslite_og-2.1.0.dist-info/METADATA +215 -0
- tslite_og-2.1.0.dist-info/RECORD +6 -0
- tslite_og-2.1.0.dist-info/WHEEL +5 -0
- tslite_og-2.1.0.dist-info/licenses/LICENSE +202 -0
- tslite_og-2.1.0.dist-info/top_level.txt +1 -0
tslite.py
ADDED
|
@@ -0,0 +1,1953 @@
|
|
|
1
|
+
#!/usr/local/bin/python
|
|
2
|
+
r"""
|
|
3
|
+
_ _ _ _
|
|
4
|
+
| |_ ___| (_) |_ ___
|
|
5
|
+
| __/ __| | | __/ _ \
|
|
6
|
+
| |_\__ \ | | || __/
|
|
7
|
+
\__|___/_|_|\__\___| v2.1.0
|
|
8
|
+
a lightweight timeseries library
|
|
9
|
+
|
|
10
|
+
tslite: lightweight, portable time-series processing since 2009.
|
|
11
|
+
|
|
12
|
+
Author: Gunnar Leffler.
|
|
13
|
+
Copyright 2009-2026 Gunnar Leffler.
|
|
14
|
+
SPDX-License-Identifier: Apache-2.0
|
|
15
|
+
|
|
16
|
+
Distributed on PyPI as tslite-og.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
__version__ = "2.1.0"
|
|
20
|
+
|
|
21
|
+
import datetime
|
|
22
|
+
import json
|
|
23
|
+
import math
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import struct
|
|
27
|
+
import dateutil.parser as dateparser
|
|
28
|
+
from dateutil.relativedelta import relativedelta
|
|
29
|
+
from functools import wraps
|
|
30
|
+
from bisect import bisect_left, bisect_right
|
|
31
|
+
from statistics import pvariance
|
|
32
|
+
from zoneinfo import ZoneInfo
|
|
33
|
+
from decimal import Decimal, ROUND_DOWN, localcontext
|
|
34
|
+
|
|
35
|
+
# NumPy is optional; the core library does not require it.
|
|
36
|
+
try:
|
|
37
|
+
import numpy as np
|
|
38
|
+
except ImportError:
|
|
39
|
+
_NUMPY_AVAILABLE = False
|
|
40
|
+
else:
|
|
41
|
+
_NUMPY_AVAILABLE = True
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
import sqlite3
|
|
45
|
+
except ImportError:
|
|
46
|
+
_SQLITE3_AVAILABLE = False
|
|
47
|
+
else:
|
|
48
|
+
_SQLITE3_AVAILABLE = True
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def requires_numpy(f):
|
|
52
|
+
"""Require NumPy while keeping the core library usable without it."""
|
|
53
|
+
@wraps(f)
|
|
54
|
+
def wrapper(*args, **kwargs):
|
|
55
|
+
if _NUMPY_AVAILABLE:
|
|
56
|
+
return f(*args, **kwargs)
|
|
57
|
+
else:
|
|
58
|
+
raise Warning("Numpy not available. Cannot call %s" % f.__name__)
|
|
59
|
+
|
|
60
|
+
return wrapper
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def requires_SQLITE3(f):
|
|
64
|
+
@wraps(f)
|
|
65
|
+
def wrapper(*args, **kwargs):
|
|
66
|
+
if _SQLITE3_AVAILABLE:
|
|
67
|
+
return f(*args, **kwargs)
|
|
68
|
+
else:
|
|
69
|
+
raise Warning("SQLITE3 not available. Cannot call %s" % f.__name__)
|
|
70
|
+
|
|
71
|
+
return wrapper
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# A numeric reference point, not a timezone interpretation of observations.
|
|
75
|
+
_EPOCH = datetime.datetime(1970, 1, 1)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _storage_time(timestamp, tz):
|
|
79
|
+
"""Interpret a naive clock reading in an explicit zone, returning naive UTC.
|
|
80
|
+
|
|
81
|
+
Reject nonexistent and ambiguous local readings rather than guessing at DST.
|
|
82
|
+
"""
|
|
83
|
+
if tz is None:
|
|
84
|
+
return timestamp
|
|
85
|
+
if timestamp.tzinfo is not None:
|
|
86
|
+
raise TypeError("Storage expects naive timestamps")
|
|
87
|
+
zone = ZoneInfo(tz)
|
|
88
|
+
candidates = set()
|
|
89
|
+
for fold in (0, 1):
|
|
90
|
+
instant = timestamp.replace(tzinfo=zone, fold=fold).astimezone(datetime.timezone.utc)
|
|
91
|
+
if instant.astimezone(zone).replace(tzinfo=None) == timestamp:
|
|
92
|
+
candidates.add(instant.replace(tzinfo=None))
|
|
93
|
+
if len(candidates) != 1:
|
|
94
|
+
raise ValueError(f"Ambiguous or nonexistent local timestamp: {timestamp} in {tz}")
|
|
95
|
+
return candidates.pop()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _loaded_time(timestamp, tz):
|
|
99
|
+
"""Return a naive clock reading in the requested zone (or unchanged)."""
|
|
100
|
+
if tz is None:
|
|
101
|
+
return timestamp
|
|
102
|
+
return timestamp.replace(tzinfo=datetime.timezone.utc).astimezone(
|
|
103
|
+
ZoneInfo(tz)).replace(tzinfo=None, fold=0)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _epoch_microseconds(timestamp):
|
|
107
|
+
"""Encode a naive timestamp exactly, without consulting the machine timezone."""
|
|
108
|
+
elapsed = timestamp - _EPOCH
|
|
109
|
+
return (elapsed.days * 86400 + elapsed.seconds) * 1000000 + elapsed.microseconds
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _from_epoch_microseconds(timestamp):
|
|
113
|
+
"""Decode integer microseconds to the original naive clock reading."""
|
|
114
|
+
return _EPOCH + datetime.timedelta(microseconds=timestamp)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _epoch_seconds(timestamp):
|
|
118
|
+
"""Encode a naive timestamp as fractional seconds for legacy binary/math use."""
|
|
119
|
+
return (timestamp - _EPOCH).total_seconds()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _finite_values(records):
|
|
123
|
+
"""Extract usable observations without treating missing values as zero."""
|
|
124
|
+
return [value for _, value in records if value is not None and math.isfinite(value)]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _finite_mean(records):
|
|
128
|
+
values = _finite_values(records)
|
|
129
|
+
return math.fsum(value / len(values) for value in values) if values else math.nan
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class timeseries:
|
|
133
|
+
|
|
134
|
+
def __init__(self, data=None):
|
|
135
|
+
"""Construct sorted timestamp/value observations, preserving missing values.
|
|
136
|
+
|
|
137
|
+
Accepts iterable records with datetime or parseable string timestamps.
|
|
138
|
+
Numeric values are converted to float; explicit None values retain their
|
|
139
|
+
timestamps. Records shorter than two fields are ignored. Duplicate timestamps
|
|
140
|
+
use the last supplied value, including None. Input records are not aliased.
|
|
141
|
+
"""
|
|
142
|
+
self.status = "OK"
|
|
143
|
+
#Data is a nested list with the following structure [datetime, float value,``]
|
|
144
|
+
self.data = []
|
|
145
|
+
self.decimals = 3
|
|
146
|
+
if data is not None:
|
|
147
|
+
for row in data:
|
|
148
|
+
if len(row) > 1:
|
|
149
|
+
self.safeinsert(row[0], row[1])
|
|
150
|
+
|
|
151
|
+
@classmethod
|
|
152
|
+
def createRegular(cls, start, end, interval, value=0):
|
|
153
|
+
"""Create a constant-valued series on a regular time grid.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
start (datetime.datetime): First timestamp.
|
|
157
|
+
end (datetime.datetime): Last allowed timestamp. Included if on the grid.
|
|
158
|
+
interval (str | datetime.timedelta): Positive duration, such as '1h',
|
|
159
|
+
'1.5h', or '1d6h'. As in TD(), 'Y' means 365 days.
|
|
160
|
+
value (float): Constant numeric value, converted to float. Defaults to zero.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
result (timeseries): A new series. Equal bounds produce one observation;
|
|
164
|
+
an off-grid end never produces an observation beyond the bound.
|
|
165
|
+
|
|
166
|
+
Raises:
|
|
167
|
+
TypeError: If bounds are not datetimes, interval has an unsupported
|
|
168
|
+
type, or naive and aware bounds are mixed.
|
|
169
|
+
ValueError: If end precedes start or interval is malformed/nonpositive.
|
|
170
|
+
"""
|
|
171
|
+
if not isinstance(start, datetime.datetime) or not isinstance(end, datetime.datetime):
|
|
172
|
+
raise TypeError("start and end must be datetimes")
|
|
173
|
+
if end < start:
|
|
174
|
+
raise ValueError("end must not precede start")
|
|
175
|
+
|
|
176
|
+
result = cls()
|
|
177
|
+
interval = result.TD(interval, positive=True)
|
|
178
|
+
|
|
179
|
+
value = float(value)
|
|
180
|
+
timestamp = start
|
|
181
|
+
while timestamp <= end:
|
|
182
|
+
result.insert(timestamp, value)
|
|
183
|
+
# Avoid stepping beyond end, including overflow near datetime.max.
|
|
184
|
+
if end - timestamp < interval:
|
|
185
|
+
break
|
|
186
|
+
timestamp += interval
|
|
187
|
+
return result
|
|
188
|
+
|
|
189
|
+
#========================================================================
|
|
190
|
+
# IO and data manipulation methods
|
|
191
|
+
#========================================================================
|
|
192
|
+
|
|
193
|
+
def __str__(self):
|
|
194
|
+
'''Equivalent to toString() in other languages
|
|
195
|
+
returns a tab delineated timeseries'''
|
|
196
|
+
output = ""
|
|
197
|
+
template = "%s\t%.3f\n"
|
|
198
|
+
for line in self.data:
|
|
199
|
+
try:
|
|
200
|
+
output += template % (line[0].strftime("%d-%b-%Y %H%M"), line[1])
|
|
201
|
+
except Exception:
|
|
202
|
+
output += "%s\t\t\n" % line[0].strftime("%d-%b-%Y %H%M")
|
|
203
|
+
return output
|
|
204
|
+
|
|
205
|
+
def __getitem__(self, idx):
|
|
206
|
+
"""Return a record or list slice using normal Python indexing.
|
|
207
|
+
|
|
208
|
+
Negative indices are supported; out-of-range integer indices raise
|
|
209
|
+
IndexError. Records are the underlying mutable [timestamp, value] lists.
|
|
210
|
+
"""
|
|
211
|
+
return self.data[idx]
|
|
212
|
+
|
|
213
|
+
def __iter__(self):
|
|
214
|
+
"""Return an iterator over observations in timestamp order.
|
|
215
|
+
|
|
216
|
+
Each item is the stored mutable [timestamp, value] record, not a copy.
|
|
217
|
+
"""
|
|
218
|
+
return iter(self.data)
|
|
219
|
+
|
|
220
|
+
def __len__(self):
|
|
221
|
+
return (len(self.data))
|
|
222
|
+
|
|
223
|
+
def __eq__(self, other):
|
|
224
|
+
"""Compare timestamps and values exactly, in observation order.
|
|
225
|
+
|
|
226
|
+
Use same_data() to ignore timestamps, or is_close() for explicit
|
|
227
|
+
tolerances. NaN values do not compare equal, even within the same series.
|
|
228
|
+
"""
|
|
229
|
+
if not isinstance(other, timeseries):
|
|
230
|
+
return NotImplemented
|
|
231
|
+
if len(self.data) != len(other.data):
|
|
232
|
+
return False
|
|
233
|
+
return all(a[0] == b[0] and a[1] == b[1]
|
|
234
|
+
for a, b in zip(self.data, other.data))
|
|
235
|
+
|
|
236
|
+
def same_data(self, other, precision=6):
|
|
237
|
+
"""Compare value sequences, ignoring timestamps.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
other (timeseries): Series with the same number of observations.
|
|
241
|
+
precision (int): Decimal places used to format values before comparison,
|
|
242
|
+
preserving the legacy equality rule (not an absolute tolerance).
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
result (bool): Whether the value sequences match in observation order.
|
|
246
|
+
"""
|
|
247
|
+
if not isinstance(precision, int) or isinstance(precision, bool) or precision < 0:
|
|
248
|
+
raise ValueError("precision must be a nonnegative integer")
|
|
249
|
+
if not isinstance(other, timeseries):
|
|
250
|
+
return False
|
|
251
|
+
if len(self) != len(other):
|
|
252
|
+
return False
|
|
253
|
+
fmt = f'.{precision}f'
|
|
254
|
+
return all(format(a[1], fmt) == format(b[1], fmt)
|
|
255
|
+
for a, b in zip(self.data, other.data))
|
|
256
|
+
|
|
257
|
+
def is_close(self, other, *, time_tolerance=datetime.timedelta(0),
|
|
258
|
+
value_tolerance=1e-6, relative_tolerance=0.0):
|
|
259
|
+
"""Compare aligned observations with explicit tolerances.
|
|
260
|
+
|
|
261
|
+
Observations are paired by position; lengths must match. This does not
|
|
262
|
+
interpolate, shift, or search for matching timestamps. Inputs are unchanged.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
other (timeseries): Series to compare.
|
|
266
|
+
time_tolerance (str | float | datetime.timedelta): Inclusive timestamp
|
|
267
|
+
tolerance. Accepts a
|
|
268
|
+
timedelta, numeric seconds, or a duration string such as '1s' or
|
|
269
|
+
'2m30s'. Must be finite and nonnegative.
|
|
270
|
+
value_tolerance (float): Nonnegative finite absolute value tolerance.
|
|
271
|
+
relative_tolerance (float): Nonnegative finite relative value tolerance.
|
|
272
|
+
Values match when their difference is no greater than the larger
|
|
273
|
+
of value_tolerance and relative_tolerance times the larger magnitude.
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
result (bool): Whether all paired timestamps and values are close. Two empty
|
|
277
|
+
series match. NaN values do not match; equal infinities do.
|
|
278
|
+
|
|
279
|
+
Mixing naive and aware timestamps raises TypeError rather than guessing
|
|
280
|
+
a timezone.
|
|
281
|
+
|
|
282
|
+
Raises:
|
|
283
|
+
ValueError: If a tolerance is invalid.
|
|
284
|
+
"""
|
|
285
|
+
if isinstance(time_tolerance, str):
|
|
286
|
+
text = time_tolerance.strip()
|
|
287
|
+
if not re.fullmatch(r'(?:\d+(?:\.\d+)?[wdhms])+', text):
|
|
288
|
+
raise ValueError("invalid time tolerance")
|
|
289
|
+
units = {'w': 604800, 'd': 86400, 'h': 3600, 'm': 60, 's': 1}
|
|
290
|
+
seconds = sum(float(number) * units[unit] for number, unit in
|
|
291
|
+
re.findall(r'(\d+(?:\.\d+)?)([wdhms])', text))
|
|
292
|
+
elif isinstance(time_tolerance, datetime.timedelta):
|
|
293
|
+
seconds = time_tolerance.total_seconds()
|
|
294
|
+
else:
|
|
295
|
+
try:
|
|
296
|
+
seconds = float(time_tolerance)
|
|
297
|
+
except (TypeError, ValueError):
|
|
298
|
+
raise ValueError("invalid time tolerance") from None
|
|
299
|
+
try:
|
|
300
|
+
absolute = float(value_tolerance)
|
|
301
|
+
relative = float(relative_tolerance)
|
|
302
|
+
except (TypeError, ValueError):
|
|
303
|
+
raise ValueError("invalid value tolerance") from None
|
|
304
|
+
if any(not math.isfinite(t) or t < 0 for t in (seconds, absolute, relative)):
|
|
305
|
+
raise ValueError("tolerances must be finite and nonnegative")
|
|
306
|
+
if not isinstance(other, timeseries) or len(self) != len(other):
|
|
307
|
+
return False
|
|
308
|
+
for a, b in zip(self.data, other.data):
|
|
309
|
+
if abs((a[0] - b[0]).total_seconds()) > seconds:
|
|
310
|
+
return False
|
|
311
|
+
if not math.isclose(a[1], b[1], rel_tol=relative, abs_tol=absolute):
|
|
312
|
+
return False
|
|
313
|
+
return True
|
|
314
|
+
|
|
315
|
+
def toDict(self):
|
|
316
|
+
'''Turns self.data into a dictionary for efficiency purposes'''
|
|
317
|
+
output = {}
|
|
318
|
+
for i in range(len(self.data)):
|
|
319
|
+
output[self.data[i][0]] = i
|
|
320
|
+
return output
|
|
321
|
+
|
|
322
|
+
def timestamps(self):
|
|
323
|
+
output = []
|
|
324
|
+
for i in range(len(self.data)):
|
|
325
|
+
output.append(self.data[i][0])
|
|
326
|
+
return output
|
|
327
|
+
|
|
328
|
+
def values(self):
|
|
329
|
+
output = []
|
|
330
|
+
for i in range(len(self.data)):
|
|
331
|
+
output.append(self.data[i][1])
|
|
332
|
+
return output
|
|
333
|
+
|
|
334
|
+
def toPlot(self):
|
|
335
|
+
'''Format timeseries for plotting by returning:
|
|
336
|
+
x: Timestamps
|
|
337
|
+
y: Values
|
|
338
|
+
Matplotlib Example: plt.plot(*timeseries.toPlot())
|
|
339
|
+
'''
|
|
340
|
+
return self.timestamps(), list(self.values())
|
|
341
|
+
|
|
342
|
+
def saveTSV(self, path):
|
|
343
|
+
'''Outputs the timeseries to a tab separated file'''
|
|
344
|
+
f = open(path, "w")
|
|
345
|
+
f.write(str(self))
|
|
346
|
+
f.close()
|
|
347
|
+
|
|
348
|
+
def loadTSV(self, path):
|
|
349
|
+
'''Reads a timeseries from a tsv file. Hash (#) can be used as comments
|
|
350
|
+
Format <Datetime>\t<value>
|
|
351
|
+
This method mutates the object, and also returns a pointer to self.
|
|
352
|
+
'''
|
|
353
|
+
lines = (line.rstrip("\n") for line in open(path, "r"))
|
|
354
|
+
return self.fromTSV(lines)
|
|
355
|
+
|
|
356
|
+
def fromTSV(self, lines):
|
|
357
|
+
'''reads a timeseries from a TSV string
|
|
358
|
+
This method mutates the object, and also returns a pointer to self.
|
|
359
|
+
'''
|
|
360
|
+
count = 0
|
|
361
|
+
for s in lines:
|
|
362
|
+
count += 1
|
|
363
|
+
s = re.sub(r'#.*', '', s) # Strip comments
|
|
364
|
+
if re.match(r'\S', s): # Ignore blank lines
|
|
365
|
+
tokens = s.split("\t")
|
|
366
|
+
try:
|
|
367
|
+
if len(tokens) > 1:
|
|
368
|
+
self.safeinsert(tokens[0], float(tokens[1]))
|
|
369
|
+
except Exception:
|
|
370
|
+
self.status = "Error Parsing line %u" % (count)
|
|
371
|
+
return self
|
|
372
|
+
|
|
373
|
+
def saveBinary(self, path, tz=None):
|
|
374
|
+
'''Outputs the timeseries to a binary file'''
|
|
375
|
+
output = self.toBinary(tz=tz)
|
|
376
|
+
with open(path, "wb") as f:
|
|
377
|
+
f.write(output)
|
|
378
|
+
|
|
379
|
+
def toBinary(self, tz=None):
|
|
380
|
+
"""Return a bytearray of native double epoch-second/value records.
|
|
381
|
+
|
|
382
|
+
Fractional seconds are preserved to the precision of a 64-bit float.
|
|
383
|
+
Contemporary dates round-trip at microsecond resolution; precision decreases
|
|
384
|
+
farther from the epoch. Whole-second records keep the existing layout and
|
|
385
|
+
bytes when previously written in GMT. Naive clock readings are preserved
|
|
386
|
+
across machines; input observations are unchanged. Aware inputs raise TypeError.
|
|
387
|
+
With tz, interpret naive inputs in that IANA timezone and store UTC instants.
|
|
388
|
+
Ambiguous or nonexistent local readings raise ValueError. None values are
|
|
389
|
+
encoded as NaN; the legacy double format cannot distinguish the two.
|
|
390
|
+
"""
|
|
391
|
+
output = bytearray()
|
|
392
|
+
for timestamp, value in self.data:
|
|
393
|
+
seconds = _epoch_seconds(_storage_time(timestamp, tz))
|
|
394
|
+
output.extend(struct.pack("dd", seconds, math.nan if value is None else value))
|
|
395
|
+
return output
|
|
396
|
+
|
|
397
|
+
def loadBinary(self, path, tz=None):
|
|
398
|
+
'''Reads the timeseries from a binary file and inserts values into self'''
|
|
399
|
+
buf = bytearray(os.path.getsize(path))
|
|
400
|
+
with open(path, "rb") as f:
|
|
401
|
+
f.readinto(buf)
|
|
402
|
+
self.fromBinary(buf, tz=tz)
|
|
403
|
+
f.close()
|
|
404
|
+
return self
|
|
405
|
+
|
|
406
|
+
def fromBinary(self, buf, tz=None):
|
|
407
|
+
"""Insert native double epoch-second/value records and return self.
|
|
408
|
+
|
|
409
|
+
Existing whole-second files and fractional-second records use the same
|
|
410
|
+
layout. Seconds are decoded by naive arithmetic, not machine-local time.
|
|
411
|
+
Float timestamps cannot preserve every microsecond throughout datetime's
|
|
412
|
+
full date range. With tz, decode UTC instants to naive readings in that
|
|
413
|
+
IANA timezone. Use the original writer's zone for old local-encoded files.
|
|
414
|
+
Repeated local clock labels use the usual last-insert-wins behavior.
|
|
415
|
+
Missing values encoded as NaN load as NaN, not None. Complete records are
|
|
416
|
+
retained if an incomplete trailing record exists; ignored trailing bytes
|
|
417
|
+
produce a warning in self.status. Otherwise successful reading sets status
|
|
418
|
+
to OK. Invalid complete records still raise their decoding error.
|
|
419
|
+
"""
|
|
420
|
+
size = struct.calcsize("dd")
|
|
421
|
+
trailing = len(buf) % size
|
|
422
|
+
complete = len(buf) - trailing
|
|
423
|
+
for i in range(0, complete, size):
|
|
424
|
+
seconds, value = struct.unpack("dd", buf[i:i + size])
|
|
425
|
+
self.insert(_loaded_time(_EPOCH + datetime.timedelta(seconds=seconds), tz), value)
|
|
426
|
+
self.status = (f"Warning: binary input has an incomplete trailing record; "
|
|
427
|
+
f"ignored {trailing} byte(s), read {complete // size} complete record(s)"
|
|
428
|
+
if trailing else "OK")
|
|
429
|
+
return self
|
|
430
|
+
|
|
431
|
+
@requires_SQLITE3
|
|
432
|
+
def SQLITE3connect(self, dbPath):
|
|
433
|
+
'''
|
|
434
|
+
Get a SQLITE 3 database connection
|
|
435
|
+
dbPath - path to the database file
|
|
436
|
+
'''
|
|
437
|
+
#initialize with Default Configuration
|
|
438
|
+
self.status = "OK"
|
|
439
|
+
#Database Cursors
|
|
440
|
+
dbconn = None
|
|
441
|
+
try:
|
|
442
|
+
dbconn = sqlite3.connect(dbPath)
|
|
443
|
+
if not dbconn:
|
|
444
|
+
self.status = f"\nCould not connect to {dbPath}\n"
|
|
445
|
+
except Exception as e:
|
|
446
|
+
self.status = f"\nCould not connect to {dbPath}\n {str (e)}"
|
|
447
|
+
return dbconn
|
|
448
|
+
|
|
449
|
+
@requires_SQLITE3
|
|
450
|
+
def SQLITE3disconnect(self, dbconn):
|
|
451
|
+
'''Disconnect from a SQLITE3 database connection '''
|
|
452
|
+
dbconn.close()
|
|
453
|
+
|
|
454
|
+
@requires_SQLITE3
|
|
455
|
+
def loadSQLITE3(self, conn, tsid, start_time=None, end_time=None, tz=None):
|
|
456
|
+
"""Load a new series from a SQLite table storing integer epoch microseconds.
|
|
457
|
+
|
|
458
|
+
Either optional datetime bound may be supplied; both endpoints are inclusive.
|
|
459
|
+
Rows are read in timestamp order. Naive timestamp values and microsecond
|
|
460
|
+
bounds are preserved independently of the machine timezone; metadata belongs
|
|
461
|
+
to the application. With tz, decode UTC instants into naive readings in
|
|
462
|
+
that IANA timezone; bounds are interpreted in the same zone. Repeated local
|
|
463
|
+
labels use last-insert-wins. Errors are recorded on this object's status.
|
|
464
|
+
"""
|
|
465
|
+
ts = timeseries()
|
|
466
|
+
cur = conn.cursor()
|
|
467
|
+
try:
|
|
468
|
+
# Quote a single literal identifier, not SQL supplied by the caller.
|
|
469
|
+
table = '"' + tsid.replace('"', '""') + '"'
|
|
470
|
+
sql = "SELECT * FROM " + table
|
|
471
|
+
conditions = []
|
|
472
|
+
parameters = []
|
|
473
|
+
for bound, comparison in [(start_time, '>='), (end_time, '<=')]:
|
|
474
|
+
if bound is not None:
|
|
475
|
+
conditions.append("timestamp " + comparison + " ?")
|
|
476
|
+
parameters.append(_epoch_microseconds(_storage_time(bound, tz)))
|
|
477
|
+
if conditions:
|
|
478
|
+
sql += " WHERE " + " AND ".join(conditions)
|
|
479
|
+
sql += " ORDER BY timestamp"
|
|
480
|
+
cur.execute(sql, parameters)
|
|
481
|
+
for row in cur:
|
|
482
|
+
ts.insert(_loaded_time(_from_epoch_microseconds(row[0]), tz), row[1])
|
|
483
|
+
except Exception as error:
|
|
484
|
+
self.status = f"Could not read {tsid}: {error}"
|
|
485
|
+
finally:
|
|
486
|
+
cur.close()
|
|
487
|
+
return ts
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
@requires_SQLITE3
|
|
491
|
+
def saveSQLITE3(self, conn, tsid, replace_table=False, tz=None):
|
|
492
|
+
"""Store observations atomically in a SQLite table; return None.
|
|
493
|
+
|
|
494
|
+
Naive timestamps use integer microseconds from a naive epoch, preserving
|
|
495
|
+
datetime precision without applying a timezone. Aware timestamps are not
|
|
496
|
+
implicitly converted; failures are reported in status. With tz, interpret
|
|
497
|
+
naive readings in that IANA timezone and store UTC instants. Ambiguous or
|
|
498
|
+
nonexistent local readings fail rather than guessing. New timestamps are inserted; existing
|
|
499
|
+
values are updated only when different (using SQL's null-safe IS NOT).
|
|
500
|
+
Unchanged rows are not updated or deleted. With replace_table=True, the
|
|
501
|
+
table is dropped and recreated, as before.
|
|
502
|
+
Table names are literal identifiers and retain legacy uppercasing.
|
|
503
|
+
|
|
504
|
+
A savepoint isolates this write. Without an existing transaction, success
|
|
505
|
+
commits the save; inside one, the caller still owns commit or rollback.
|
|
506
|
+
Failures undo this save only and are recorded in self.status. Interrupts
|
|
507
|
+
also undo the save, then propagate. Input observations are unchanged.
|
|
508
|
+
"""
|
|
509
|
+
cur = None
|
|
510
|
+
try:
|
|
511
|
+
table = '"' + tsid.upper().replace('"', '""') + '"'
|
|
512
|
+
cur = conn.cursor()
|
|
513
|
+
cur.execute("SAVEPOINT tslite_save")
|
|
514
|
+
try:
|
|
515
|
+
if replace_table:
|
|
516
|
+
cur.execute("DROP TABLE IF EXISTS " + table)
|
|
517
|
+
cur.execute("CREATE TABLE IF NOT EXISTS " + table +
|
|
518
|
+
" (timestamp INTEGER PRIMARY KEY, val REAL)")
|
|
519
|
+
rows = ((_epoch_microseconds(_storage_time(timestamp, tz)), value)
|
|
520
|
+
for timestamp, value in self.data)
|
|
521
|
+
sql = ("INSERT INTO " + table + " (timestamp, val) VALUES (?, ?) "
|
|
522
|
+
"ON CONFLICT(timestamp) DO UPDATE SET val = excluded.val "
|
|
523
|
+
"WHERE val IS NOT excluded.val")
|
|
524
|
+
cur.executemany(sql, rows)
|
|
525
|
+
cur.execute("RELEASE SAVEPOINT tslite_save")
|
|
526
|
+
except BaseException:
|
|
527
|
+
# Include interrupts in cleanup, but let them propagate to the caller.
|
|
528
|
+
cur.execute("ROLLBACK TO SAVEPOINT tslite_save")
|
|
529
|
+
cur.execute("RELEASE SAVEPOINT tslite_save")
|
|
530
|
+
raise
|
|
531
|
+
self.status = "OK"
|
|
532
|
+
except Exception as error:
|
|
533
|
+
self.status = f"Could not store {tsid}: {error}"
|
|
534
|
+
finally:
|
|
535
|
+
if cur is not None:
|
|
536
|
+
cur.close()
|
|
537
|
+
|
|
538
|
+
def getStatus(self):
|
|
539
|
+
'''exceptions get dropped into self.status
|
|
540
|
+
This method gets status message of object and resets self.status to "OK" '''
|
|
541
|
+
s = self.status
|
|
542
|
+
self.status = "OK"
|
|
543
|
+
return s
|
|
544
|
+
|
|
545
|
+
def findValue(self, timestamp):
|
|
546
|
+
''' returns a value at a given timestamp
|
|
547
|
+
returns None type if not found'''
|
|
548
|
+
idx = self.findIndex(timestamp)
|
|
549
|
+
if idx != -1:
|
|
550
|
+
return self.data[idx][1]
|
|
551
|
+
else:
|
|
552
|
+
return None
|
|
553
|
+
|
|
554
|
+
def findIndex(self, key):
|
|
555
|
+
''' returns the index of a given timestamp
|
|
556
|
+
returns -1 if not found'''
|
|
557
|
+
imin = 0
|
|
558
|
+
imax = len(self.data) - 1
|
|
559
|
+
while (imax >= imin):
|
|
560
|
+
imid = imin + ((imax - imin) // 2)
|
|
561
|
+
if (self.data[imid][0] == key):
|
|
562
|
+
return imid
|
|
563
|
+
elif (self.data[imid][0] < key):
|
|
564
|
+
imin = imid + 1 #change min index to search upper subarray
|
|
565
|
+
else:
|
|
566
|
+
imax = imid - 1
|
|
567
|
+
#change max index to search lower subarray
|
|
568
|
+
return -1 # Key not found
|
|
569
|
+
|
|
570
|
+
def findClosestIndex(self, key):
|
|
571
|
+
"""Return the nearest timestamp's index, or -1 for an empty series.
|
|
572
|
+
|
|
573
|
+
Exact matches return their index. Outside the series range, return the
|
|
574
|
+
nearest endpoint. Equal-distance ties choose the earlier observation.
|
|
575
|
+
Distances retain full timedelta precision; observations are unchanged.
|
|
576
|
+
"""
|
|
577
|
+
if not self.data:
|
|
578
|
+
return -1
|
|
579
|
+
pos = bisect_left(self.data, key, key=lambda row: row[0])
|
|
580
|
+
if pos == 0:
|
|
581
|
+
return 0
|
|
582
|
+
if pos == len(self.data):
|
|
583
|
+
return pos - 1
|
|
584
|
+
if key - self.data[pos - 1][0] <= self.data[pos][0] - key:
|
|
585
|
+
return pos - 1
|
|
586
|
+
return pos
|
|
587
|
+
|
|
588
|
+
def safeinsert(self, datestamp, value):
|
|
589
|
+
'''takes raw input and attempts to make it work'''
|
|
590
|
+
if isinstance(datestamp, str):
|
|
591
|
+
datestamp = dateparser.parse(datestamp, fuzzy=True)
|
|
592
|
+
self.insert(datestamp, None if value is None else float(value))
|
|
593
|
+
|
|
594
|
+
def insert(self, datestamp, value):
|
|
595
|
+
"""Insert an observation in timestamp order, replacing an exact match.
|
|
596
|
+
|
|
597
|
+
Newer timestamps use a fast append path. Other timestamps use binary search
|
|
598
|
+
for their insertion position; middle insertion still shifts later records.
|
|
599
|
+
"""
|
|
600
|
+
if not self.data or datestamp > self.data[-1][0]:
|
|
601
|
+
self.data.append([datestamp, value])
|
|
602
|
+
return
|
|
603
|
+
i = bisect_left(self.data, datestamp, key=lambda row: row[0])
|
|
604
|
+
if i < len(self.data) and datestamp == self.data[i][0]:
|
|
605
|
+
self.data[i] = [datestamp, value]
|
|
606
|
+
else:
|
|
607
|
+
self.data.insert(i, [datestamp, value])
|
|
608
|
+
|
|
609
|
+
def truncate(self, precision):
|
|
610
|
+
"""Return a new timeseries with values truncated to precision decimal places.
|
|
611
|
+
|
|
612
|
+
Digits are discarded toward zero, not rounded, including for negative
|
|
613
|
+
values. Precision must be a nonnegative integer. Timestamps and nonfinite
|
|
614
|
+
values are preserved; the input series is unchanged.
|
|
615
|
+
"""
|
|
616
|
+
if not isinstance(precision, int) or isinstance(precision, bool) or precision < 0:
|
|
617
|
+
raise ValueError("precision must be a nonnegative integer")
|
|
618
|
+
output = timeseries()
|
|
619
|
+
for timestamp, value in self.data:
|
|
620
|
+
if math.isfinite(value):
|
|
621
|
+
# Use decimal text to avoid float multiplication turning 1.15 into
|
|
622
|
+
# 114.999... at two places. ROUND_DOWN means toward zero in Decimal.
|
|
623
|
+
decimal = Decimal(str(value))
|
|
624
|
+
with localcontext() as context:
|
|
625
|
+
context.prec = max(28, len(decimal.as_tuple().digits),
|
|
626
|
+
decimal.adjusted() + precision + 1)
|
|
627
|
+
unit = Decimal((0, (1,), -precision))
|
|
628
|
+
value = float(decimal.quantize(unit, rounding=ROUND_DOWN))
|
|
629
|
+
output.insert(timestamp, value)
|
|
630
|
+
return output
|
|
631
|
+
|
|
632
|
+
def round(self, precision):
|
|
633
|
+
'''Rounds values in timeseries to a given number of decimal places
|
|
634
|
+
'''
|
|
635
|
+
output = timeseries()
|
|
636
|
+
for slice in self.data:
|
|
637
|
+
output.insert(slice[0], round(slice[1], precision))
|
|
638
|
+
return output
|
|
639
|
+
|
|
640
|
+
def merge(self, other):
|
|
641
|
+
"""Return the sorted union of observations, preferring usable values.
|
|
642
|
+
|
|
643
|
+
At an exact timestamp match, finite data wins over None/NaN/infinity on
|
|
644
|
+
either side. If both values are usable or both unusable, other wins. Zero
|
|
645
|
+
is usable. Unmatched observations are retained, including missing values.
|
|
646
|
+
Inputs are unchanged and output records independent. This conflict policy
|
|
647
|
+
applies only to merge(); insert() remains unconditional replacement.
|
|
648
|
+
"""
|
|
649
|
+
output = timeseries()
|
|
650
|
+
left = right = 0
|
|
651
|
+
while left < len(self.data) and right < len(other.data):
|
|
652
|
+
a = self.data[left]
|
|
653
|
+
b = other.data[right]
|
|
654
|
+
if a[0] < b[0]:
|
|
655
|
+
selected = a
|
|
656
|
+
left += 1
|
|
657
|
+
elif b[0] < a[0]:
|
|
658
|
+
selected = b
|
|
659
|
+
right += 1
|
|
660
|
+
else:
|
|
661
|
+
a_usable = a[1] is not None and math.isfinite(a[1])
|
|
662
|
+
b_usable = b[1] is not None and math.isfinite(b[1])
|
|
663
|
+
selected = a if a_usable and not b_usable else b
|
|
664
|
+
left += 1
|
|
665
|
+
right += 1
|
|
666
|
+
output.data.append(selected[:])
|
|
667
|
+
output.data.extend(self.data[i][:] for i in range(left, len(self.data)))
|
|
668
|
+
output.data.extend(other.data[i][:] for i in range(right, len(other.data)))
|
|
669
|
+
return output
|
|
670
|
+
|
|
671
|
+
def diff(self, other, precision=6):
|
|
672
|
+
"""Return incoming observations that are new or differ at decimal precision.
|
|
673
|
+
|
|
674
|
+
Values are compared using fixed-point formatting (rounding, not truncation)
|
|
675
|
+
at precision decimal places; stored values are unchanged. Only other's
|
|
676
|
+
records are returned: new timestamps and changed matches. Left-only records
|
|
677
|
+
are omitted. None matches None, NaN matches NaN, and infinities match only
|
|
678
|
+
with the same sign. Missing versus numeric values differ. Output records
|
|
679
|
+
are independent; inputs are unchanged.
|
|
680
|
+
"""
|
|
681
|
+
if isinstance(precision, bool) or not isinstance(precision, int) or precision < 0:
|
|
682
|
+
raise ValueError("precision must be a nonnegative integer")
|
|
683
|
+
output = timeseries()
|
|
684
|
+
for slice in self.data:
|
|
685
|
+
i = other.findIndex(slice[0])
|
|
686
|
+
if i == -1:
|
|
687
|
+
continue
|
|
688
|
+
oslice = other.data[i]
|
|
689
|
+
left, right = slice[1], oslice[1]
|
|
690
|
+
if left is None or right is None:
|
|
691
|
+
different = (left is None) != (right is None)
|
|
692
|
+
else:
|
|
693
|
+
different = format(left, f'.{precision}f') != format(right, f'.{precision}f')
|
|
694
|
+
if different:
|
|
695
|
+
output.insert(oslice[0], oslice[1])
|
|
696
|
+
for slice in other.data:
|
|
697
|
+
i = self.findIndex(slice[0])
|
|
698
|
+
if i == -1:
|
|
699
|
+
output.insert(slice[0], slice[1])
|
|
700
|
+
return output
|
|
701
|
+
|
|
702
|
+
def toHTML(self, css="", thead=""):
|
|
703
|
+
'''like __str__ only it outputs a HTML table'''
|
|
704
|
+
output = "<table " + css + ">"
|
|
705
|
+
output += thead
|
|
706
|
+
for line in self.data:
|
|
707
|
+
try:
|
|
708
|
+
output += "<tr><td>%s</td><td> %.2f</td></tr>" % (
|
|
709
|
+
line[0].strftime("%d-%b-%Y %H%M"), line[1])
|
|
710
|
+
except Exception:
|
|
711
|
+
output += "<tr><td>%s</td><td> </td></tr>" % line[0].strftime(
|
|
712
|
+
"%d-%b-%Y %H%M")
|
|
713
|
+
return output + "</table>"
|
|
714
|
+
|
|
715
|
+
def toJS(self, var, timefmt=None, decimals=None, strict=False):
|
|
716
|
+
"""Return a JavaScript variable declaration wrapping toJSON().
|
|
717
|
+
|
|
718
|
+
The caller supplies a valid JavaScript identifier as var. Formatting and
|
|
719
|
+
nonfinite-value behavior are the same as toJSON().
|
|
720
|
+
"""
|
|
721
|
+
return "var %s = %s;\n" % (var, self.toJSON(
|
|
722
|
+
timefmt=timefmt, decimals=decimals, strict=strict))
|
|
723
|
+
|
|
724
|
+
def toJSON(self, timefmt=None, decimals=None, strict=False):
|
|
725
|
+
"""Return JSON timestamp/value pairs, preserving precision by default.
|
|
726
|
+
|
|
727
|
+
Timestamps use ISO format, including subseconds when present. Supply a
|
|
728
|
+
strftime timefmt to override this. Values retain full float precision unless
|
|
729
|
+
decimals is a nonnegative integer, in which case round() controls display
|
|
730
|
+
rounding. Trailing zeroes are not guaranteed for JSON numbers. self.decimals
|
|
731
|
+
does not affect serialization. No timezone conversion is performed.
|
|
732
|
+
|
|
733
|
+
Missing (None), NaN, and infinite values export as null, preserving their
|
|
734
|
+
timestamps for plotting gaps. Pass strict=True to reject NaN/infinity with
|
|
735
|
+
ValueError instead; None remains valid null. fromJSON() preserves null-valued
|
|
736
|
+
records as None. NaN/infinity distinctions are lost when exported as null.
|
|
737
|
+
Invalid formatting arguments or malformed records still raise errors.
|
|
738
|
+
Input observations are unchanged. Returns a string.
|
|
739
|
+
"""
|
|
740
|
+
if decimals is not None and (isinstance(decimals, bool) or
|
|
741
|
+
not isinstance(decimals, int) or decimals < 0):
|
|
742
|
+
raise ValueError("decimals must be a nonnegative integer or None")
|
|
743
|
+
output = []
|
|
744
|
+
for timestamp, value in self.data:
|
|
745
|
+
label = timestamp.isoformat() if timefmt is None else timestamp.strftime(timefmt)
|
|
746
|
+
if value is not None and not math.isfinite(value):
|
|
747
|
+
if strict:
|
|
748
|
+
raise ValueError(f"Nonfinite value at {label}: {value}")
|
|
749
|
+
value = None
|
|
750
|
+
if decimals is not None and value is not None:
|
|
751
|
+
value = round(value, decimals)
|
|
752
|
+
output.append([label, value])
|
|
753
|
+
return json.dumps(output, allow_nan=False)
|
|
754
|
+
|
|
755
|
+
def fromJSON(self,s):
|
|
756
|
+
ts = timeseries()
|
|
757
|
+
try:
|
|
758
|
+
j = json.loads(s)
|
|
759
|
+
ts = timeseries(j)
|
|
760
|
+
except Exception as e:
|
|
761
|
+
self.status = str(e)
|
|
762
|
+
return ts
|
|
763
|
+
|
|
764
|
+
def minDate(self, timefmt="%m/%d/%Y %k:%M:%S"):
|
|
765
|
+
try:
|
|
766
|
+
return self.data[0][0].strftime(timefmt)
|
|
767
|
+
except Exception:
|
|
768
|
+
return ""
|
|
769
|
+
|
|
770
|
+
#========================================================================
|
|
771
|
+
# computational methods
|
|
772
|
+
#========================================================================
|
|
773
|
+
|
|
774
|
+
def interpolateValue(self, x0, y0, x1, y1, x):
|
|
775
|
+
'''interpolate values'''
|
|
776
|
+
m = (y1 - y0) / (x1 - x0)
|
|
777
|
+
output = y0 + (x - x0) * m
|
|
778
|
+
return output
|
|
779
|
+
|
|
780
|
+
def interpolate(self, interval, anchor=None):
|
|
781
|
+
"""Return linear interpolation at regular time steps.
|
|
782
|
+
|
|
783
|
+
Interval must be positive. Output stops at the last observation, including
|
|
784
|
+
it only if it coincides with an output step; no extrapolation is performed.
|
|
785
|
+
Exact timestamp matches retain their source values without recalculation.
|
|
786
|
+
Without anchor, steps start at the first observation; singleton or short-span
|
|
787
|
+
input returns a copy of that observation. An optional datetime anchor sets
|
|
788
|
+
alignment instead: output timestamps are anchor + n * interval for integer
|
|
789
|
+
n, restricted to the observed range. The anchor may precede or follow the
|
|
790
|
+
data. If no aligned step falls within the range, output is empty.
|
|
791
|
+
Empty input returns empty. Records are independent of the input.
|
|
792
|
+
Processing errors are recorded in self.status and return completed steps.
|
|
793
|
+
"""
|
|
794
|
+
interval = self.TD(interval, positive=True)
|
|
795
|
+
output = timeseries()
|
|
796
|
+
if not self.data:
|
|
797
|
+
return output
|
|
798
|
+
try:
|
|
799
|
+
first = self.data[0][0]
|
|
800
|
+
if anchor is not None:
|
|
801
|
+
# Ceiling division finds the first aligned step at or after the data,
|
|
802
|
+
# including when the anchor lies later than the observations.
|
|
803
|
+
offset_steps = -((anchor - first) // interval)
|
|
804
|
+
first = anchor + offset_steps * interval
|
|
805
|
+
steps = (self.data[-1][0] - first) // interval
|
|
806
|
+
pos = 0
|
|
807
|
+
for step in range(steps + 1):
|
|
808
|
+
timestamp = first + step * interval
|
|
809
|
+
while pos + 1 < len(self.data) and self.data[pos + 1][0] <= timestamp:
|
|
810
|
+
pos += 1
|
|
811
|
+
left_time, left_value = self.data[pos]
|
|
812
|
+
if timestamp == left_time:
|
|
813
|
+
value = left_value
|
|
814
|
+
else:
|
|
815
|
+
right_time, right_value = self.data[pos + 1]
|
|
816
|
+
fraction = (timestamp - left_time) / (right_time - left_time)
|
|
817
|
+
value = left_value + fraction * (right_value - left_value)
|
|
818
|
+
output.data.append([timestamp, value])
|
|
819
|
+
except Exception as e:
|
|
820
|
+
self.status = str(e)
|
|
821
|
+
return output
|
|
822
|
+
|
|
823
|
+
def average(self, interval):
|
|
824
|
+
"""Return interval means labeled at the beginning of each half-open bucket.
|
|
825
|
+
|
|
826
|
+
Buckets start at the first observation. Finite observations have equal weight;
|
|
827
|
+
None/NaN/infinity are excluded and reported with a source-status warning.
|
|
828
|
+
All-invalid observed buckets yield NaN; empty buckets are omitted and final
|
|
829
|
+
partial buckets included. Success without skips resets status to OK. Interval
|
|
830
|
+
must be positive. Inputs are unchanged; output records are independent.
|
|
831
|
+
Processing errors set self.status and return completed buckets.
|
|
832
|
+
"""
|
|
833
|
+
interval = self.TD(interval, positive=True)
|
|
834
|
+
output = timeseries()
|
|
835
|
+
skipped = 0
|
|
836
|
+
if not self.data:
|
|
837
|
+
self.status = 'OK'
|
|
838
|
+
return output
|
|
839
|
+
try:
|
|
840
|
+
start = self.data[0][0]
|
|
841
|
+
buckets = {}
|
|
842
|
+
for timestamp, value in self.data:
|
|
843
|
+
bucket = start + ((timestamp - start) // interval) * interval
|
|
844
|
+
values = buckets.setdefault(bucket, [])
|
|
845
|
+
if value is None or not math.isfinite(value):
|
|
846
|
+
skipped += 1
|
|
847
|
+
else:
|
|
848
|
+
values.append(value)
|
|
849
|
+
for timestamp, values in buckets.items():
|
|
850
|
+
mean = math.fsum(value / len(values) for value in values) if values else math.nan
|
|
851
|
+
output.data.append([timestamp, mean])
|
|
852
|
+
self.status = (f"Warning: average skipped {skipped} missing/nonfinite "
|
|
853
|
+
"observation(s); averages may be incomplete" if skipped else "OK")
|
|
854
|
+
except Exception as e:
|
|
855
|
+
self.status = str(e)
|
|
856
|
+
return output
|
|
857
|
+
|
|
858
|
+
def globalAverage(self):
|
|
859
|
+
"""Return the simple arithmetic mean as a [timestamp, value] record.
|
|
860
|
+
|
|
861
|
+
Finite observations have equal weight, regardless of timestamp spacing.
|
|
862
|
+
None, NaN, and infinity are excluded; no usable values yield NaN. The timestamp
|
|
863
|
+
is the final observation's, even if invalid. Empty input returns None. The
|
|
864
|
+
result is a new record; input data is unchanged.
|
|
865
|
+
"""
|
|
866
|
+
if not self.data:
|
|
867
|
+
return None
|
|
868
|
+
return [self.data[-1][0], _finite_mean(self.data)]
|
|
869
|
+
|
|
870
|
+
def globalMax(self):
|
|
871
|
+
"""Return the global maximum as a new [timestamp, value] record.
|
|
872
|
+
|
|
873
|
+
None/NaN/infinity are skipped with a warning in self.status. Retains the
|
|
874
|
+
actual maximum's timestamp; ties select the earliest observation. Returns
|
|
875
|
+
None if no usable observations exist. Otherwise status is OK when no values
|
|
876
|
+
were skipped. The input is unchanged and the returned record is independent.
|
|
877
|
+
"""
|
|
878
|
+
return self._global_extreme(max, 'globalMax')
|
|
879
|
+
|
|
880
|
+
def globalMin(self):
|
|
881
|
+
"""Return the global minimum as a new [timestamp, value] record.
|
|
882
|
+
|
|
883
|
+
None/NaN/infinity are skipped with a warning in self.status. Retains the
|
|
884
|
+
actual minimum's timestamp; ties select the earliest observation. Returns
|
|
885
|
+
None if no usable observations exist. Otherwise status is OK when no values
|
|
886
|
+
were skipped. The input is unchanged and the returned record is independent.
|
|
887
|
+
"""
|
|
888
|
+
return self._global_extreme(min, 'globalMin')
|
|
889
|
+
|
|
890
|
+
def _global_extreme(self, select, method):
|
|
891
|
+
valid = [row for row in self.data
|
|
892
|
+
if row[1] is not None and math.isfinite(row[1])]
|
|
893
|
+
skipped = len(self.data) - len(valid)
|
|
894
|
+
self.status = (f"Warning: {method} skipped {skipped} missing/nonfinite "
|
|
895
|
+
"observation(s); extremum may be incomplete" if skipped else "OK")
|
|
896
|
+
return select(valid, key=lambda row: row[1])[:] if valid else None
|
|
897
|
+
|
|
898
|
+
def _linear_fit(self):
|
|
899
|
+
"""Return origin, mean elapsed seconds, mean value, slope, and correlation."""
|
|
900
|
+
valid = [(t, v) for t, v in self.data if v is not None and math.isfinite(v)]
|
|
901
|
+
if not valid:
|
|
902
|
+
origin = self.data[0][0] if self.data else _EPOCH
|
|
903
|
+
return origin, 0.0, 0.0, 0.0, 0.0
|
|
904
|
+
origin = valid[0][0]
|
|
905
|
+
x = [(t - origin).total_seconds() for t, _ in valid]
|
|
906
|
+
y = [v for _, v in valid]
|
|
907
|
+
n = len(valid)
|
|
908
|
+
mean_x = math.fsum(value / n for value in x)
|
|
909
|
+
mean_y = math.fsum(value / n for value in y)
|
|
910
|
+
dx = [value - mean_x for value in x]
|
|
911
|
+
dy = [value - mean_y for value in y]
|
|
912
|
+
# Normalize centered values to keep squared sums in a safe numeric range.
|
|
913
|
+
scale_x = max(abs(value) for value in dx)
|
|
914
|
+
scale_y = max(abs(value) for value in dy)
|
|
915
|
+
if scale_x == 0 or scale_y == 0:
|
|
916
|
+
return origin, mean_x, mean_y, 0.0, 0.0
|
|
917
|
+
dx = [value / scale_x for value in dx]
|
|
918
|
+
dy = [value / scale_y for value in dy]
|
|
919
|
+
xx = math.fsum(value * value for value in dx)
|
|
920
|
+
yy = math.fsum(value * value for value in dy)
|
|
921
|
+
xy = math.fsum(a * b for a, b in zip(dx, dy))
|
|
922
|
+
slope = (xy / xx) * scale_y / scale_x
|
|
923
|
+
correlation = (xy / math.sqrt(xx)) / math.sqrt(yy)
|
|
924
|
+
return origin, mean_x, mean_y, slope, max(-1.0, min(1.0, correlation))
|
|
925
|
+
|
|
926
|
+
def linreg(self):
|
|
927
|
+
"""Return least-squares (slope, epoch intercept, correlation).
|
|
928
|
+
|
|
929
|
+
The line is y = slope * x + intercept, with x in naive seconds since 1970.
|
|
930
|
+
Internally, relative timestamps and centered sums avoid epoch cancellation.
|
|
931
|
+
None, NaN, and infinite values are excluded. No usable observations return
|
|
932
|
+
(0, 0, 0); one usable observation or constant values yield a horizontal line.
|
|
933
|
+
Undefined correlation is reported as 0 by convention. Inputs are unchanged.
|
|
934
|
+
"""
|
|
935
|
+
origin, mean_x, mean_y, slope, correlation = self._linear_fit()
|
|
936
|
+
if slope == 0:
|
|
937
|
+
return 0.0, mean_y, correlation
|
|
938
|
+
intercept = mean_y - slope * (_epoch_seconds(origin) + mean_x)
|
|
939
|
+
return slope, intercept, correlation
|
|
940
|
+
|
|
941
|
+
def trendline(self):
|
|
942
|
+
"""Return fitted values at every original timestamp, with independent records.
|
|
943
|
+
|
|
944
|
+
Fit excludes None/NaN/infinite values, but predictions include their timestamps.
|
|
945
|
+
Evaluate the centered relative-time fit directly, avoiding epoch cancellation.
|
|
946
|
+
Empty input returns empty; no usable values yield zeros. A single usable value
|
|
947
|
+
yields a horizontal line. Inputs are unchanged; correlation follows linreg().
|
|
948
|
+
"""
|
|
949
|
+
output = timeseries()
|
|
950
|
+
origin, mean_x, mean_y, slope, _ = self._linear_fit()
|
|
951
|
+
for timestamp, _ in self.data:
|
|
952
|
+
value = mean_y if slope == 0 else mean_y + slope * (
|
|
953
|
+
(timestamp - origin).total_seconds() - mean_x)
|
|
954
|
+
output.data.append([timestamp, value])
|
|
955
|
+
return output
|
|
956
|
+
|
|
957
|
+
def variance(self):
|
|
958
|
+
"""Return the population variance as a [timestamp, value] record.
|
|
959
|
+
|
|
960
|
+
Finite observations have equal weight; None/NaN/infinity are excluded. The
|
|
961
|
+
denominator is the usable count, not count minus one. No usable values yield
|
|
962
|
+
NaN; one usable value yields zero. The timestamp is the final observation's.
|
|
963
|
+
Empty input returns None; the input series is unchanged.
|
|
964
|
+
"""
|
|
965
|
+
if not self.data:
|
|
966
|
+
return None
|
|
967
|
+
values = _finite_values(self.data)
|
|
968
|
+
return [self.data[-1][0], pvariance(values) if values else math.nan]
|
|
969
|
+
|
|
970
|
+
def stddev(self):
|
|
971
|
+
"""Return the population standard deviation as a [timestamp, value] record.
|
|
972
|
+
|
|
973
|
+
The value is the square root of variance(), labeled with the final
|
|
974
|
+
observation's timestamp. None/NaN/infinity are excluded; no usable values
|
|
975
|
+
yield NaN. Empty input returns None; the input is unchanged.
|
|
976
|
+
"""
|
|
977
|
+
s = self.variance()
|
|
978
|
+
if s != None:
|
|
979
|
+
s[1] = math.sqrt(s[1])
|
|
980
|
+
return s
|
|
981
|
+
|
|
982
|
+
def movingstddev(self, interval):
|
|
983
|
+
"""Return a new timeseries of population standard deviations by window.
|
|
984
|
+
|
|
985
|
+
Inclusive windows advance by interval from the first timestamp while their start is
|
|
986
|
+
before the final timestamp. Nonempty windows are labeled with their last
|
|
987
|
+
observation; empty windows are skipped. If adjacent windows have the same
|
|
988
|
+
label, the later window replaces the earlier result, as with insert().
|
|
989
|
+
None/NaN/infinity are excluded from each calculation; all-invalid windows
|
|
990
|
+
yield NaN and retain their labels. A singleton series has no windows.
|
|
991
|
+
"""
|
|
992
|
+
output = timeseries()
|
|
993
|
+
interval = self.TD(interval, positive=True)
|
|
994
|
+
if self.data:
|
|
995
|
+
pointer = self.data[0][0]
|
|
996
|
+
while pointer < self.data[-1][0]:
|
|
997
|
+
stddev = self.subSlice(pointer, pointer + interval).stddev()
|
|
998
|
+
if stddev is not None:
|
|
999
|
+
output.insert(stddev[0], stddev[1])
|
|
1000
|
+
pointer += interval
|
|
1001
|
+
return output
|
|
1002
|
+
|
|
1003
|
+
def subSlice(self, starttime, endtime):
|
|
1004
|
+
"""Return independent observations between inclusive datetime bounds.
|
|
1005
|
+
|
|
1006
|
+
Empty, reversed, or nonoverlapping ranges return an empty timeseries. No
|
|
1007
|
+
observations are filled or interpolated, and the input is unchanged.
|
|
1008
|
+
"""
|
|
1009
|
+
output = timeseries()
|
|
1010
|
+
first = bisect_left(self.data, starttime, key=lambda row: row[0])
|
|
1011
|
+
stop = bisect_right(self.data, endtime, key=lambda row: row[0])
|
|
1012
|
+
output.data = [self.data[i][:] for i in range(first, stop)]
|
|
1013
|
+
return output
|
|
1014
|
+
|
|
1015
|
+
def getFiscalYear(self, start_date):
|
|
1016
|
+
"""Return available observations in the year beginning at start_date.
|
|
1017
|
+
|
|
1018
|
+
Accepts a date string (for example, "Oct 1 2025"), date, or datetime.
|
|
1019
|
+
Dates start at midnight; datetimes retain their time of day. The start is
|
|
1020
|
+
included and its anniversary one calendar year later is excluded. A
|
|
1021
|
+
February 29 start ends at February 28 of the following year. No complete
|
|
1022
|
+
year of data is required; missing observations are not filled. Returns a
|
|
1023
|
+
new timeseries with independent records, leaving the input unchanged.
|
|
1024
|
+
"""
|
|
1025
|
+
if isinstance(start_date, str):
|
|
1026
|
+
start_date = dateparser.parse(start_date,
|
|
1027
|
+
default=datetime.datetime(2000, 1, 1))
|
|
1028
|
+
elif isinstance(start_date, datetime.date) and not isinstance(
|
|
1029
|
+
start_date, datetime.datetime):
|
|
1030
|
+
start_date = datetime.datetime.combine(start_date, datetime.time())
|
|
1031
|
+
if not isinstance(start_date, datetime.datetime):
|
|
1032
|
+
raise TypeError("start_date must be a date string, date, or datetime")
|
|
1033
|
+
end_date = start_date + relativedelta(years=1)
|
|
1034
|
+
return self.subSlice(start_date, end_date - datetime.timedelta(microseconds=1))
|
|
1035
|
+
|
|
1036
|
+
def getWY(self, WY):
|
|
1037
|
+
"""Return available observations for the October–September year ending in WY.
|
|
1038
|
+
|
|
1039
|
+
Equivalent to getFiscalYear() starting October 1 of WY - 1. Includes all
|
|
1040
|
+
of September 30 and returns independent records without filling gaps.
|
|
1041
|
+
"""
|
|
1042
|
+
return self.getFiscalYear(datetime.datetime(year=WY - 1, month=10, day=1))
|
|
1043
|
+
|
|
1044
|
+
def averageWY(self):
|
|
1045
|
+
"""Return calendar-position means labeled in the latest available water year.
|
|
1046
|
+
|
|
1047
|
+
Output uses only timestamps present in the latest October–September year.
|
|
1048
|
+
Each position matches month, day, hour, minute, second, and microsecond across
|
|
1049
|
+
all available years, including the latest; observations have equal weight.
|
|
1050
|
+
February 29 matches leap days only. Absent positions are not invented or
|
|
1051
|
+
shifted. None/NaN/infinity are excluded; all-invalid positions yield NaN.
|
|
1052
|
+
Skipped matching observations warn in self.status, otherwise status is OK.
|
|
1053
|
+
Empty input returns empty; output records are independent, inputs unchanged.
|
|
1054
|
+
"""
|
|
1055
|
+
output = timeseries()
|
|
1056
|
+
if not self.data:
|
|
1057
|
+
self.status = 'OK'
|
|
1058
|
+
return output
|
|
1059
|
+
|
|
1060
|
+
def position(timestamp):
|
|
1061
|
+
return (timestamp.month, timestamp.day, timestamp.hour, timestamp.minute,
|
|
1062
|
+
timestamp.second, timestamp.microsecond)
|
|
1063
|
+
|
|
1064
|
+
latest = self.data[-1][0]
|
|
1065
|
+
latest_year = latest.year + (latest.month >= 10)
|
|
1066
|
+
targets = [timestamp for timestamp, _ in self.data
|
|
1067
|
+
if timestamp.year + (timestamp.month >= 10) == latest_year]
|
|
1068
|
+
groups = {position(timestamp): [] for timestamp in targets}
|
|
1069
|
+
skipped = 0
|
|
1070
|
+
for timestamp, value in self.data:
|
|
1071
|
+
key = position(timestamp)
|
|
1072
|
+
if key not in groups:
|
|
1073
|
+
continue
|
|
1074
|
+
if value is None or not math.isfinite(value):
|
|
1075
|
+
skipped += 1
|
|
1076
|
+
else:
|
|
1077
|
+
groups[key].append(value)
|
|
1078
|
+
for timestamp in targets:
|
|
1079
|
+
values = groups[position(timestamp)]
|
|
1080
|
+
mean = math.fsum(value / len(values) for value in values) if values else math.nan
|
|
1081
|
+
output.data.append([timestamp, mean])
|
|
1082
|
+
self.status = (f"Warning: averageWY skipped {skipped} missing/nonfinite "
|
|
1083
|
+
"observation(s); averages may be incomplete" if skipped else "OK")
|
|
1084
|
+
return output
|
|
1085
|
+
|
|
1086
|
+
def runningTotal(self):
|
|
1087
|
+
"""Return a new timeseries of running totals (partial sums).
|
|
1088
|
+
|
|
1089
|
+
Each value sums usable observations up to and including that timestamp.
|
|
1090
|
+
None/NaN/infinity are skipped, carrying the known subtotal (initially zero).
|
|
1091
|
+
Skips warn in self.status that totals may be incomplete; otherwise status
|
|
1092
|
+
is OK. Original timestamps are preserved; the input series is unchanged.
|
|
1093
|
+
"""
|
|
1094
|
+
output = timeseries()
|
|
1095
|
+
total = 0
|
|
1096
|
+
skipped = 0
|
|
1097
|
+
for timestamp, value in self.data:
|
|
1098
|
+
if value is None or not math.isfinite(value):
|
|
1099
|
+
skipped += 1
|
|
1100
|
+
else:
|
|
1101
|
+
total += value
|
|
1102
|
+
output.data.append([timestamp, total])
|
|
1103
|
+
self._total_status('runningTotal', skipped)
|
|
1104
|
+
return output
|
|
1105
|
+
|
|
1106
|
+
def _total_status(self, method, skipped):
|
|
1107
|
+
"""Report deliberately incomplete totals, rather than a processing failure."""
|
|
1108
|
+
self.status = (f"Warning: {method} skipped {skipped} missing/nonfinite "
|
|
1109
|
+
"observation(s); totals may be incomplete" if skipped else "OK")
|
|
1110
|
+
|
|
1111
|
+
def accumulate(self, interval, override_startTime=None):
|
|
1112
|
+
"""Return interval totals, labeled at the beginning of half-open buckets.
|
|
1113
|
+
|
|
1114
|
+
Buckets start at override_startTime or the first observation. Earlier data
|
|
1115
|
+
is excluded. Empty buckets are omitted. None/NaN/infinity are skipped with
|
|
1116
|
+
a warning in self.status; all-invalid observed buckets yield NaN. Otherwise
|
|
1117
|
+
status is OK. Interval must be positive; inputs are unchanged. Processing
|
|
1118
|
+
errors set status and return completed buckets.
|
|
1119
|
+
"""
|
|
1120
|
+
interval = self.TD(interval, positive=True)
|
|
1121
|
+
output = timeseries()
|
|
1122
|
+
skipped = 0
|
|
1123
|
+
if not self.data:
|
|
1124
|
+
self._total_status('accumulate', 0)
|
|
1125
|
+
return output
|
|
1126
|
+
try:
|
|
1127
|
+
start = override_startTime if override_startTime is not None else self.data[0][0]
|
|
1128
|
+
buckets = {}
|
|
1129
|
+
for timestamp, value in self.data:
|
|
1130
|
+
if timestamp < start:
|
|
1131
|
+
continue
|
|
1132
|
+
bucket = start + ((timestamp - start) // interval) * interval
|
|
1133
|
+
total, usable = buckets.get(bucket, (0, 0))
|
|
1134
|
+
if value is None or not math.isfinite(value):
|
|
1135
|
+
skipped += 1
|
|
1136
|
+
else:
|
|
1137
|
+
total += value
|
|
1138
|
+
usable += 1
|
|
1139
|
+
buckets[bucket] = total, usable
|
|
1140
|
+
for timestamp, (total, usable) in buckets.items():
|
|
1141
|
+
output.data.append([timestamp, total if usable else math.nan])
|
|
1142
|
+
self._total_status('accumulate', skipped)
|
|
1143
|
+
except Exception as e:
|
|
1144
|
+
self.status = str(e)
|
|
1145
|
+
return output
|
|
1146
|
+
|
|
1147
|
+
def accumulateWY(self, interval, incrTS, offset=datetime.timedelta(days=0)):
|
|
1148
|
+
"""Return a new series extending cumulative water-year totals.
|
|
1149
|
+
|
|
1150
|
+
Increment timestamps label period beginnings. Buckets are [start, start +
|
|
1151
|
+
interval), and cumulative results retain the bucket's beginning label.
|
|
1152
|
+
The last existing cumulative record already includes its entire period;
|
|
1153
|
+
new buckets begin one interval later. With no existing records, start at
|
|
1154
|
+
the first increment with a zero balance. Empty buckets are omitted.
|
|
1155
|
+
|
|
1156
|
+
Reset before adding the first bucket in a new water year, even across gaps.
|
|
1157
|
+
A bucket belongs wholly to the year containing its start; it is not split
|
|
1158
|
+
at the boundary. Offset shifts the October 1 midnight reset by a timedelta
|
|
1159
|
+
(for example, six hours). It does not shift bucket labels or alignment.
|
|
1160
|
+
Existing observations and both inputs are unchanged. Processing errors are
|
|
1161
|
+
recorded in self.status, preserving the status-based API. None/NaN/infinity
|
|
1162
|
+
increments are skipped, carrying known subtotals through all-invalid buckets.
|
|
1163
|
+
An unusable seed is replaced by zero for extension only. Skipped increments
|
|
1164
|
+
or seed warn in self.status that totals may be incomplete; otherwise status
|
|
1165
|
+
is OK. Existing records are preserved, even if invalid.
|
|
1166
|
+
"""
|
|
1167
|
+
interval = self.TD(interval, positive=True)
|
|
1168
|
+
output = timeseries()
|
|
1169
|
+
output.data = [row[:] for row in self.data]
|
|
1170
|
+
skipped = 0
|
|
1171
|
+
if not incrTS.data:
|
|
1172
|
+
self._total_status('accumulateWY', 0)
|
|
1173
|
+
return output
|
|
1174
|
+
|
|
1175
|
+
def water_year(timestamp):
|
|
1176
|
+
shifted = timestamp - offset
|
|
1177
|
+
return shifted.year + (shifted.month >= 10)
|
|
1178
|
+
|
|
1179
|
+
try:
|
|
1180
|
+
if self.data:
|
|
1181
|
+
last_time, total = self.data[-1]
|
|
1182
|
+
if total is None or not math.isfinite(total):
|
|
1183
|
+
total = 0
|
|
1184
|
+
skipped += 1
|
|
1185
|
+
start = last_time + interval
|
|
1186
|
+
previous_year = water_year(last_time)
|
|
1187
|
+
else:
|
|
1188
|
+
start = incrTS.data[0][0]
|
|
1189
|
+
total = 0
|
|
1190
|
+
previous_year = water_year(start)
|
|
1191
|
+
|
|
1192
|
+
# Group only observed buckets, so large gaps require no empty-bin walk.
|
|
1193
|
+
buckets = {}
|
|
1194
|
+
for timestamp, value in incrTS.data:
|
|
1195
|
+
if timestamp < start:
|
|
1196
|
+
continue
|
|
1197
|
+
bucket = start + ((timestamp - start) // interval) * interval
|
|
1198
|
+
buckets.setdefault(bucket, 0)
|
|
1199
|
+
if value is None or not math.isfinite(value):
|
|
1200
|
+
skipped += 1
|
|
1201
|
+
else:
|
|
1202
|
+
buckets[bucket] += value
|
|
1203
|
+
for timestamp, value in buckets.items():
|
|
1204
|
+
year = water_year(timestamp)
|
|
1205
|
+
if year != previous_year:
|
|
1206
|
+
total = 0
|
|
1207
|
+
total += value
|
|
1208
|
+
output.insert(timestamp, total)
|
|
1209
|
+
previous_year = year
|
|
1210
|
+
self._total_status('accumulateWY', skipped)
|
|
1211
|
+
except Exception as e:
|
|
1212
|
+
self.status = str(e)
|
|
1213
|
+
return output
|
|
1214
|
+
|
|
1215
|
+
def firstdifference(self):
|
|
1216
|
+
"""Return a new timeseries of first differences, equivalent to simpledelta().
|
|
1217
|
+
|
|
1218
|
+
Each value is the current observation minus the previous one, labeled
|
|
1219
|
+
with the later timestamp. Empty/singleton input returns an empty series;
|
|
1220
|
+
the input series is unchanged.
|
|
1221
|
+
"""
|
|
1222
|
+
return self.simpledelta()
|
|
1223
|
+
|
|
1224
|
+
def simpledelta(self):
|
|
1225
|
+
"""Return a new timeseries of successive value differences (first differences).
|
|
1226
|
+
|
|
1227
|
+
Each value is the current observation minus the previous one, labeled
|
|
1228
|
+
with the later timestamp. These are differences, not rates: elapsed time
|
|
1229
|
+
does not divide the values. Empty/singleton input returns an empty series;
|
|
1230
|
+
the input series is unchanged.
|
|
1231
|
+
"""
|
|
1232
|
+
output = timeseries()
|
|
1233
|
+
if len(self.data) < 2:
|
|
1234
|
+
return output
|
|
1235
|
+
try:
|
|
1236
|
+
for i in range(1, len(self.data)):
|
|
1237
|
+
d = self.data[i][1] - self.data[i - 1][1]
|
|
1238
|
+
output.insert(self.data[i][0], d)
|
|
1239
|
+
except Exception as e:
|
|
1240
|
+
self.status = str(e)
|
|
1241
|
+
return output
|
|
1242
|
+
|
|
1243
|
+
def maxmin(self, interval, cmp):
|
|
1244
|
+
"""Return beginning-labeled extrema for half-open interval buckets.
|
|
1245
|
+
|
|
1246
|
+
Buckets are anchored at the first observation. cmp(candidate, current)
|
|
1247
|
+
selects the replacement: use > for maxima or < for minima. Strict
|
|
1248
|
+
comparisons retain the earlier observation on ties. None/NaN/infinity are
|
|
1249
|
+
skipped with a source-status warning; all-invalid observed buckets yield
|
|
1250
|
+
NaN. Empty buckets are omitted and final partial buckets included. Interval
|
|
1251
|
+
must be positive. Inputs are unchanged and output records independent.
|
|
1252
|
+
Success without skips sets status to OK; processing errors set status and
|
|
1253
|
+
return an empty series.
|
|
1254
|
+
"""
|
|
1255
|
+
interval = self.TD(interval, positive=True)
|
|
1256
|
+
output = timeseries()
|
|
1257
|
+
skipped = 0
|
|
1258
|
+
if not self.data:
|
|
1259
|
+
self.status = 'OK'
|
|
1260
|
+
return output
|
|
1261
|
+
try:
|
|
1262
|
+
start = self.data[0][0]
|
|
1263
|
+
buckets = {}
|
|
1264
|
+
for timestamp, value in self.data:
|
|
1265
|
+
bucket = start + ((timestamp - start) // interval) * interval
|
|
1266
|
+
current = buckets.setdefault(bucket, None)
|
|
1267
|
+
if value is None or not math.isfinite(value):
|
|
1268
|
+
skipped += 1
|
|
1269
|
+
elif current is None or cmp(value, current):
|
|
1270
|
+
buckets[bucket] = value
|
|
1271
|
+
output.data = [[timestamp, value if value is not None else math.nan]
|
|
1272
|
+
for timestamp, value in buckets.items()]
|
|
1273
|
+
self.status = (f"Warning: maxmin skipped {skipped} missing/nonfinite "
|
|
1274
|
+
"observation(s); extrema may be incomplete" if skipped else "OK")
|
|
1275
|
+
except Exception as e:
|
|
1276
|
+
self.status = str(e)
|
|
1277
|
+
return timeseries()
|
|
1278
|
+
return output
|
|
1279
|
+
|
|
1280
|
+
@requires_numpy
|
|
1281
|
+
def savitzky_golay(self, window_size, order, deriv=0, rate=1):
|
|
1282
|
+
'''Savitzky-Golay Smoothing filter using numpy
|
|
1283
|
+
window_size: number of items in window integer
|
|
1284
|
+
order: polynomial order
|
|
1285
|
+
deriv: defaults to 0
|
|
1286
|
+
rate : defaults to 1
|
|
1287
|
+
'''
|
|
1288
|
+
try:
|
|
1289
|
+
window_size = abs(int(window_size))
|
|
1290
|
+
order = abs(int(order))
|
|
1291
|
+
except ValueError:
|
|
1292
|
+
raise ValueError("SGFilter:window size and order must be of type int")
|
|
1293
|
+
|
|
1294
|
+
if window_size % 2 != 1 or window_size < 1:
|
|
1295
|
+
raise TypeError("SGFilter:window size must be positive number")
|
|
1296
|
+
if window_size < order + 2:
|
|
1297
|
+
raise TypeError(
|
|
1298
|
+
"SGFilter:window size is too small for the polynomials order")
|
|
1299
|
+
|
|
1300
|
+
if not self.data:
|
|
1301
|
+
return timeseries()
|
|
1302
|
+
|
|
1303
|
+
half_window = (window_size - 1) // 2
|
|
1304
|
+
# Fit polynomial coefficients at offsets around the center observation.
|
|
1305
|
+
offsets = range(-half_window, half_window + 1)
|
|
1306
|
+
design = np.array([[offset**power for power in range(order + 1)]
|
|
1307
|
+
for offset in offsets], dtype=float)
|
|
1308
|
+
weights = np.linalg.pinv(design)[deriv] * rate**deriv * math.factorial(deriv)
|
|
1309
|
+
|
|
1310
|
+
# Repeat endpoint values so every observation has a full window.
|
|
1311
|
+
values = np.array(self.values(), dtype=float)
|
|
1312
|
+
padded = np.pad(values, half_window, mode='edge')
|
|
1313
|
+
filtered = np.convolve(weights[::-1], padded, mode='valid')
|
|
1314
|
+
return timeseries(zip(self.timestamps(), filtered))
|
|
1315
|
+
|
|
1316
|
+
@requires_numpy
|
|
1317
|
+
def remove_stddev_outliers(self, threshold=1.5):
|
|
1318
|
+
'''Remove Outliers using Standard Deviation'''
|
|
1319
|
+
good = []
|
|
1320
|
+
_data = []
|
|
1321
|
+
a = []
|
|
1322
|
+
|
|
1323
|
+
# get the slice of values from timeseries
|
|
1324
|
+
# y = np.array(self.data)
|
|
1325
|
+
# a = y[:,1]
|
|
1326
|
+
for rec in self.data:
|
|
1327
|
+
a.append(rec[1])
|
|
1328
|
+
|
|
1329
|
+
avg = np.average(a)
|
|
1330
|
+
|
|
1331
|
+
# pad the first element with average
|
|
1332
|
+
a = [avg] + a
|
|
1333
|
+
out = [None] * len(a)
|
|
1334
|
+
good.append(a[0])
|
|
1335
|
+
|
|
1336
|
+
std = np.std(a)
|
|
1337
|
+
stdm = std * threshold
|
|
1338
|
+
|
|
1339
|
+
for n in range(1, len(a)):
|
|
1340
|
+
dev = abs(a[n] - good[-1])
|
|
1341
|
+
|
|
1342
|
+
if dev < stdm:
|
|
1343
|
+
good.append(a[n])
|
|
1344
|
+
out[n] = a[n]
|
|
1345
|
+
else:
|
|
1346
|
+
out[n] = None
|
|
1347
|
+
|
|
1348
|
+
# remove the first (avg) element
|
|
1349
|
+
out = out[1:]
|
|
1350
|
+
|
|
1351
|
+
# replace the values, only non nulls
|
|
1352
|
+
# these will get interpolated later
|
|
1353
|
+
for x in range(0, len(self.data)):
|
|
1354
|
+
if out[x] != None:
|
|
1355
|
+
_data.append([self.data[x][0], out[x]])
|
|
1356
|
+
|
|
1357
|
+
return timeseries(_data)
|
|
1358
|
+
|
|
1359
|
+
def rollingaverage(self, interval, *, require_full_window=False):
|
|
1360
|
+
"""Return a new timeseries of forward-looking moving averages.
|
|
1361
|
+
|
|
1362
|
+
Observations have equal weight. Windows include both endpoints and average
|
|
1363
|
+
available observations, even when the window extends beyond the series. Set require_full_window=True
|
|
1364
|
+
to omit incomplete trailing windows. Each result is labeled with the
|
|
1365
|
+
requested window end, which may be beyond the final observation. None, NaN,
|
|
1366
|
+
and infinity are excluded; all-invalid windows yield NaN. Interval must be
|
|
1367
|
+
positive. Input records are unchanged.
|
|
1368
|
+
"""
|
|
1369
|
+
interval = self.TD(interval, positive=True)
|
|
1370
|
+
_data = []
|
|
1371
|
+
if self.data == []:
|
|
1372
|
+
return timeseries()
|
|
1373
|
+
try:
|
|
1374
|
+
i = 0
|
|
1375
|
+
count = len(self.data)
|
|
1376
|
+
while i < count:
|
|
1377
|
+
startTime = self.data[i][0]
|
|
1378
|
+
endTime = startTime + interval
|
|
1379
|
+
if require_full_window and endTime > self.data[-1][0]:
|
|
1380
|
+
break
|
|
1381
|
+
n = 0
|
|
1382
|
+
while self.data[i + n][0] <= endTime:
|
|
1383
|
+
n += 1
|
|
1384
|
+
if i + n >= count:
|
|
1385
|
+
break
|
|
1386
|
+
_data.append([endTime, _finite_mean(self.data[i:i + n])])
|
|
1387
|
+
i += 1
|
|
1388
|
+
except Exception as e:
|
|
1389
|
+
self.status = str(e)
|
|
1390
|
+
return timeseries(_data)
|
|
1391
|
+
|
|
1392
|
+
def movingaverage(self, interval, *, require_full_window=False):
|
|
1393
|
+
"""Return a new timeseries of backward-looking moving averages.
|
|
1394
|
+
|
|
1395
|
+
Observations have equal weight. Windows include both endpoints and average
|
|
1396
|
+
available observations, even when the window starts before the series. Set require_full_window=True
|
|
1397
|
+
to omit incomplete leading windows. Each result retains the observation
|
|
1398
|
+
timestamp (the window end). Interval must be positive. Input records are
|
|
1399
|
+
unchanged. None/NaN/infinity are excluded; all-invalid windows yield NaN.
|
|
1400
|
+
Gaps do not require a complete sequence of observations.
|
|
1401
|
+
"""
|
|
1402
|
+
interval = self.TD(interval, positive=True)
|
|
1403
|
+
_data = []
|
|
1404
|
+
if self.data == []:
|
|
1405
|
+
return timeseries()
|
|
1406
|
+
try:
|
|
1407
|
+
i = len(self.data) - 1
|
|
1408
|
+
while i >= 0:
|
|
1409
|
+
endTime = self.data[i][0]
|
|
1410
|
+
startTime = endTime - interval
|
|
1411
|
+
if require_full_window and startTime < self.data[0][0]:
|
|
1412
|
+
break
|
|
1413
|
+
|
|
1414
|
+
n = 0
|
|
1415
|
+
while self.data[i - n][0] >= startTime:
|
|
1416
|
+
n += 1
|
|
1417
|
+
if i - n < 0:
|
|
1418
|
+
break
|
|
1419
|
+
_data.append([endTime, _finite_mean(self.data[i - n + 1:i + 1])])
|
|
1420
|
+
i -= 1
|
|
1421
|
+
except Exception as e:
|
|
1422
|
+
self.status = str(e)
|
|
1423
|
+
return timeseries(_data)
|
|
1424
|
+
|
|
1425
|
+
def centerMovingAverage(self, interval):
|
|
1426
|
+
"""Return a new timeseries of centered moving averages.
|
|
1427
|
+
|
|
1428
|
+
Windows include both endpoints and extend half the interval in each
|
|
1429
|
+
direction. Observations have equal weight; edge windows average whatever
|
|
1430
|
+
is available. Each result keeps its observation's timestamp. Interval must
|
|
1431
|
+
be positive, as a duration string or timedelta. None/NaN/infinity are excluded;
|
|
1432
|
+
all-invalid windows yield NaN. Input records are unchanged.
|
|
1433
|
+
"""
|
|
1434
|
+
interval = self.TD(interval, positive=True)
|
|
1435
|
+
_data = []
|
|
1436
|
+
if not self.data:
|
|
1437
|
+
return timeseries()
|
|
1438
|
+
try:
|
|
1439
|
+
left = right = 0
|
|
1440
|
+
total = 0
|
|
1441
|
+
usable = 0
|
|
1442
|
+
half = interval / 2
|
|
1443
|
+
for timestamp, value in self.data:
|
|
1444
|
+
startTime = timestamp - half
|
|
1445
|
+
endTime = timestamp + half
|
|
1446
|
+
while right < len(self.data) and self.data[right][0] <= endTime:
|
|
1447
|
+
value = self.data[right][1]
|
|
1448
|
+
if value is not None and math.isfinite(value):
|
|
1449
|
+
total += value
|
|
1450
|
+
usable += 1
|
|
1451
|
+
right += 1
|
|
1452
|
+
while left < right and self.data[left][0] < startTime:
|
|
1453
|
+
value = self.data[left][1]
|
|
1454
|
+
if value is not None and math.isfinite(value):
|
|
1455
|
+
total -= value
|
|
1456
|
+
usable -= 1
|
|
1457
|
+
left += 1
|
|
1458
|
+
if usable == 0:
|
|
1459
|
+
total = 0
|
|
1460
|
+
_data.append([timestamp, total / usable if usable else math.nan])
|
|
1461
|
+
except Exception as e:
|
|
1462
|
+
self.status = str(e)
|
|
1463
|
+
return timeseries(_data)
|
|
1464
|
+
|
|
1465
|
+
def percent(self, denom):
|
|
1466
|
+
"""Return 100 * self / denom at exact matching timestamps.
|
|
1467
|
+
|
|
1468
|
+
Uses the same pointwise policy as div(): missing/nonfinite values, zero
|
|
1469
|
+
denominators, and overflow yield NaN without dropping other matched results.
|
|
1470
|
+
Unmatched timestamps are omitted. Inputs are unchanged and output records
|
|
1471
|
+
independent. Unexpected errors are reported in self.status, as in operation().
|
|
1472
|
+
"""
|
|
1473
|
+
return self.operation(lambda numerator, denominator: 100 * (numerator / denominator), denom)
|
|
1474
|
+
|
|
1475
|
+
def snap(self, interval, buffer, starttime=None):
|
|
1476
|
+
"""Return nearest observed values on a regular grid, without interpolation.
|
|
1477
|
+
|
|
1478
|
+
Grid points run from starttime (or the first observation) through the last
|
|
1479
|
+
observation's timestamp. Each independently selects the closest observation
|
|
1480
|
+
within the inclusive +/- buffer. Ties choose the earlier observation; missing
|
|
1481
|
+
grid points are omitted. An observation may supply multiple grid points.
|
|
1482
|
+
Inputs are unchanged. Interval must be positive and buffer nonnegative.
|
|
1483
|
+
|
|
1484
|
+
Buffers greater than half the interval are honored, with a warning in
|
|
1485
|
+
self.status because search windows overlap. No Python warning is emitted.
|
|
1486
|
+
Success otherwise resets status to OK; processing errors set status and
|
|
1487
|
+
return an empty series, preserving the existing error-reporting convention.
|
|
1488
|
+
"""
|
|
1489
|
+
output = timeseries()
|
|
1490
|
+
interval = self.TD(interval, positive=True)
|
|
1491
|
+
buffer = self.TD(buffer)
|
|
1492
|
+
if buffer < datetime.timedelta(0):
|
|
1493
|
+
raise ValueError("buffer must be nonnegative")
|
|
1494
|
+
self.status = ("Warning: snap buffer exceeds half the interval; "
|
|
1495
|
+
"overlapping windows may reuse observations"
|
|
1496
|
+
if buffer > interval / 2 else "OK")
|
|
1497
|
+
if not self.data:
|
|
1498
|
+
return output
|
|
1499
|
+
try:
|
|
1500
|
+
endtime = self.data[-1][0]
|
|
1501
|
+
t = starttime if starttime is not None else self.data[0][0]
|
|
1502
|
+
while t <= endtime:
|
|
1503
|
+
pos = bisect_left(self.data, t, key=lambda row: row[0])
|
|
1504
|
+
# Only the immediate neighbors can be closest. Earlier wins a tie:
|
|
1505
|
+
# snap selects an observation, rather than inventing an average value.
|
|
1506
|
+
candidates = self.data[max(0, pos - 1):min(len(self.data), pos + 1)]
|
|
1507
|
+
closest = min(candidates, key=lambda row: abs(row[0] - t))
|
|
1508
|
+
if abs(closest[0] - t) <= buffer:
|
|
1509
|
+
output.data.append([t, closest[1]])
|
|
1510
|
+
t += interval
|
|
1511
|
+
except Exception as e:
|
|
1512
|
+
self.status = str(e)
|
|
1513
|
+
return timeseries()
|
|
1514
|
+
return output
|
|
1515
|
+
|
|
1516
|
+
def filldown(self, interval, starttime=None, offset=None, _endtime=None):
|
|
1517
|
+
"""Return a new timeseries carrying previous values forward on an interval.
|
|
1518
|
+
|
|
1519
|
+
Steps before the first observation are omitted: no value is invented.
|
|
1520
|
+
An explicit starttime sets step alignment, even if it precedes the data.
|
|
1521
|
+
_endtime overrides the final timestamp; offset
|
|
1522
|
+
extends to the daily boundary at that offset, stopping there when already
|
|
1523
|
+
aligned. Interval must be positive.
|
|
1524
|
+
Empty input returns an empty series even with explicit bounds: there is
|
|
1525
|
+
no value to carry. The input series is unchanged.
|
|
1526
|
+
"""
|
|
1527
|
+
interval = self.TD(interval, positive=True)
|
|
1528
|
+
ts = timeseries()
|
|
1529
|
+
if not self.data:
|
|
1530
|
+
return ts
|
|
1531
|
+
try:
|
|
1532
|
+
i = 0
|
|
1533
|
+
endtime = self.data[-1][0]
|
|
1534
|
+
if _endtime != None:
|
|
1535
|
+
endtime = _endtime
|
|
1536
|
+
if offset is not None:
|
|
1537
|
+
boundary = endtime.replace(hour=0, minute=0, second=0, microsecond=0) + offset
|
|
1538
|
+
if boundary < endtime:
|
|
1539
|
+
day = datetime.timedelta(days=1)
|
|
1540
|
+
boundary += -((boundary - endtime) // day) * day
|
|
1541
|
+
endtime = boundary
|
|
1542
|
+
t = starttime if starttime is not None else self.data[0][0]
|
|
1543
|
+
if t < self.data[0][0]:
|
|
1544
|
+
# Skip unsupported leading steps without changing the requested alignment.
|
|
1545
|
+
t += -((t - self.data[0][0]) // interval) * interval
|
|
1546
|
+
while t <= endtime:
|
|
1547
|
+
while i < len(self.data) and self.data[i][0] <= t:
|
|
1548
|
+
i += 1
|
|
1549
|
+
if i:
|
|
1550
|
+
ts.data.append([t, self.data[i - 1][1]])
|
|
1551
|
+
t += interval
|
|
1552
|
+
except Exception as e:
|
|
1553
|
+
self.status = str(e)
|
|
1554
|
+
return timeseries()
|
|
1555
|
+
return ts
|
|
1556
|
+
|
|
1557
|
+
def fillMissing(self, interval, value, starttime=None, endtime=None):
|
|
1558
|
+
"""Return observations within inclusive bounds, filling missing regular steps.
|
|
1559
|
+
|
|
1560
|
+
Bounds default to the first and last source timestamps. Regular steps start
|
|
1561
|
+
at starttime; existing observations, including off-step records, are retained
|
|
1562
|
+
only within the bounds and are never overwritten. Missing steps receive the
|
|
1563
|
+
caller's value. Interval must be positive. Empty input requires both bounds
|
|
1564
|
+
to generate observations; reversed bounds return empty. Inputs are unchanged
|
|
1565
|
+
and output records are independent. Errors set self.status and return empty.
|
|
1566
|
+
"""
|
|
1567
|
+
interval = self.TD(interval, positive=True)
|
|
1568
|
+
ts = timeseries()
|
|
1569
|
+
if not self.data and (starttime is None or endtime is None):
|
|
1570
|
+
return ts
|
|
1571
|
+
if starttime is None:
|
|
1572
|
+
starttime = self.data[0][0]
|
|
1573
|
+
if endtime is None:
|
|
1574
|
+
endtime = self.data[-1][0]
|
|
1575
|
+
try:
|
|
1576
|
+
first = bisect_left(self.data, starttime, key=lambda row: row[0])
|
|
1577
|
+
stop = bisect_right(self.data, endtime, key=lambda row: row[0])
|
|
1578
|
+
i = first
|
|
1579
|
+
t = starttime
|
|
1580
|
+
# Merge existing observations and regular steps in timestamp order.
|
|
1581
|
+
while t <= endtime:
|
|
1582
|
+
while i < stop and self.data[i][0] < t:
|
|
1583
|
+
ts.data.append(self.data[i][:])
|
|
1584
|
+
i += 1
|
|
1585
|
+
if i < stop and self.data[i][0] == t:
|
|
1586
|
+
ts.data.append(self.data[i][:])
|
|
1587
|
+
i += 1
|
|
1588
|
+
else:
|
|
1589
|
+
ts.data.append([t, value])
|
|
1590
|
+
# Avoid stepping beyond datetime.max after the final supported step.
|
|
1591
|
+
if endtime - t < interval:
|
|
1592
|
+
break
|
|
1593
|
+
t += interval
|
|
1594
|
+
while i < stop:
|
|
1595
|
+
ts.data.append(self.data[i][:])
|
|
1596
|
+
i += 1
|
|
1597
|
+
except Exception as e:
|
|
1598
|
+
self.status = str(e)
|
|
1599
|
+
return timeseries()
|
|
1600
|
+
return ts
|
|
1601
|
+
|
|
1602
|
+
def timeshift(self, tdelta):
|
|
1603
|
+
''' Shifts each timestamp a given time interval
|
|
1604
|
+
tdelta: timedelta to shift
|
|
1605
|
+
returns a timeseries object '''
|
|
1606
|
+
_data = []
|
|
1607
|
+
if self.data == []:
|
|
1608
|
+
return timeseries()
|
|
1609
|
+
try:
|
|
1610
|
+
for line in self.data:
|
|
1611
|
+
_data.append([line[0] + tdelta, line[1]])
|
|
1612
|
+
except Exception as e:
|
|
1613
|
+
self.status = str(e)
|
|
1614
|
+
return timeseries()
|
|
1615
|
+
return timeseries(_data)
|
|
1616
|
+
|
|
1617
|
+
def subtract(self, operand):
|
|
1618
|
+
'''Subtracts an operand timeseries or constant from self'''
|
|
1619
|
+
return self.operation(lambda x, y: x - y, operand)
|
|
1620
|
+
|
|
1621
|
+
def add(self, operand):
|
|
1622
|
+
'''Subtracts an operand timeseries or constant from self'''
|
|
1623
|
+
return self.operation(lambda x, y: x + y, operand)
|
|
1624
|
+
|
|
1625
|
+
def mul(self, operand):
|
|
1626
|
+
'''multiplies an operand timeseries or constant to self'''
|
|
1627
|
+
return self.operation(lambda x, y: x * y, operand)
|
|
1628
|
+
|
|
1629
|
+
def div(self, operand):
|
|
1630
|
+
'''divides an self by an operand timeseries or constant'''
|
|
1631
|
+
return self.operation(lambda x, y: x / y, operand)
|
|
1632
|
+
|
|
1633
|
+
def operation(self, op, operand):
|
|
1634
|
+
"""Return pointwise results for a numeric scalar or exactly matching series.
|
|
1635
|
+
|
|
1636
|
+
op is a callable taking two values. Series operands contribute only exact
|
|
1637
|
+
timestamp matches; a matched None value is not an absent timestamp. Missing
|
|
1638
|
+
or nonfinite operands, division by zero, overflow, and nonfinite results
|
|
1639
|
+
produce NaN at that timestamp without discarding other results. Inputs are
|
|
1640
|
+
unchanged and records are independent. Unexpected errors still set status
|
|
1641
|
+
and return an empty series, preserving the existing error convention.
|
|
1642
|
+
"""
|
|
1643
|
+
output = timeseries()
|
|
1644
|
+
if not self.data:
|
|
1645
|
+
return output
|
|
1646
|
+
|
|
1647
|
+
def calculate(left, right):
|
|
1648
|
+
if (left is None or right is None or
|
|
1649
|
+
not math.isfinite(left) or not math.isfinite(right)):
|
|
1650
|
+
return math.nan
|
|
1651
|
+
try:
|
|
1652
|
+
value = op(left, right)
|
|
1653
|
+
return value if value is not None and math.isfinite(value) else math.nan
|
|
1654
|
+
except (ZeroDivisionError, OverflowError):
|
|
1655
|
+
return math.nan
|
|
1656
|
+
|
|
1657
|
+
try:
|
|
1658
|
+
if type(operand) is float or type(operand) is int:
|
|
1659
|
+
for timestamp, value in self.data:
|
|
1660
|
+
output.data.append([timestamp, calculate(value, operand)])
|
|
1661
|
+
else:
|
|
1662
|
+
for timestamp, value in self.data:
|
|
1663
|
+
index = operand.findIndex(timestamp)
|
|
1664
|
+
if index != -1:
|
|
1665
|
+
output.data.append([timestamp, calculate(value, operand.data[index][1])])
|
|
1666
|
+
except Exception as e:
|
|
1667
|
+
self.status = str(e)
|
|
1668
|
+
return timeseries()
|
|
1669
|
+
return output
|
|
1670
|
+
|
|
1671
|
+
|
|
1672
|
+
def cull(self, op, operand):
|
|
1673
|
+
''' culls data from self
|
|
1674
|
+
op: lambda function to perform
|
|
1675
|
+
example: lambda x,y: x > y
|
|
1676
|
+
would return values in this timeseries that are greater than y
|
|
1677
|
+
operand: could be a timeseries or a float
|
|
1678
|
+
returns a timeseries object
|
|
1679
|
+
'''
|
|
1680
|
+
_data = []
|
|
1681
|
+
if self.data == []:
|
|
1682
|
+
return timeseries()
|
|
1683
|
+
try:
|
|
1684
|
+
if type(operand) is float or type(operand) is int:
|
|
1685
|
+
for line in self.data:
|
|
1686
|
+
if op(line[1], operand):
|
|
1687
|
+
_data.append(line)
|
|
1688
|
+
else:
|
|
1689
|
+
for line in self.data:
|
|
1690
|
+
val = operand.findValue(line[0])
|
|
1691
|
+
if val != None:
|
|
1692
|
+
if op(line[1], val):
|
|
1693
|
+
_data.append(line)
|
|
1694
|
+
except Exception as e:
|
|
1695
|
+
self.status = str(e)
|
|
1696
|
+
return timeseries()
|
|
1697
|
+
return timeseries(_data)
|
|
1698
|
+
|
|
1699
|
+
def cullAbove(self, operand):
|
|
1700
|
+
'''Culls values from self above operand
|
|
1701
|
+
operand: accepts timeseries or a scalar
|
|
1702
|
+
returns a timeseries object
|
|
1703
|
+
'''
|
|
1704
|
+
return self.cull (lambda x, y: x < y,operand)
|
|
1705
|
+
|
|
1706
|
+
def cullBelow(self, operand):
|
|
1707
|
+
'''Culls values from self below to input
|
|
1708
|
+
operand: accepts timeseries or a scalar
|
|
1709
|
+
returns a timeseries object
|
|
1710
|
+
'''
|
|
1711
|
+
return self.cull (lambda x, y: x > y,operand)
|
|
1712
|
+
|
|
1713
|
+
def cullEqual(self, operand):
|
|
1714
|
+
'''Culls values from self that are equal to input
|
|
1715
|
+
operand: accepts timeseries or a scalar
|
|
1716
|
+
returns a timeseries object
|
|
1717
|
+
'''
|
|
1718
|
+
return self.cull (lambda x, y: x != y, operand)
|
|
1719
|
+
|
|
1720
|
+
def cut(self, other):
|
|
1721
|
+
''' cuts a timeseries from self where datetimes intersect with
|
|
1722
|
+
other.
|
|
1723
|
+
returns a timeseries object
|
|
1724
|
+
'''
|
|
1725
|
+
output = timeseries()
|
|
1726
|
+
if self.data == []:
|
|
1727
|
+
return timeseries()
|
|
1728
|
+
try:
|
|
1729
|
+
for line in other.data:
|
|
1730
|
+
val = self.findValue(line[0])
|
|
1731
|
+
if val != None:
|
|
1732
|
+
output.insert(line[0], line[1])
|
|
1733
|
+
except Exception as e:
|
|
1734
|
+
self.status = str(e)
|
|
1735
|
+
return timeseries()
|
|
1736
|
+
return output
|
|
1737
|
+
|
|
1738
|
+
def TD(self, input, *, positive=False):
|
|
1739
|
+
"""Return a timedelta parsed from a duration string, or pass one through.
|
|
1740
|
+
|
|
1741
|
+
Units are w, Y (365 days), d, h, m, and s. Fractional and signed
|
|
1742
|
+
components are supported, e.g. '1d 6h', '.5s', or '-1h30m' (minus
|
|
1743
|
+
one hour plus thirty minutes). Zero/negative durations are valid unless
|
|
1744
|
+
positive=True, which requires a duration strictly greater than zero.
|
|
1745
|
+
Malformed/out-of-range strings raise ValueError; other types raise
|
|
1746
|
+
TypeError. No partial duration is returned after a parse failure.
|
|
1747
|
+
"""
|
|
1748
|
+
if isinstance(input, datetime.timedelta):
|
|
1749
|
+
if positive and input <= datetime.timedelta(0):
|
|
1750
|
+
raise ValueError("interval must be positive")
|
|
1751
|
+
return input
|
|
1752
|
+
if not isinstance(input, str):
|
|
1753
|
+
raise TypeError("duration must be a string or timedelta")
|
|
1754
|
+
text = ''.join(input.split())
|
|
1755
|
+
component = r'([+-]?(?:\d+(?:\.\d*)?|\.\d+))([wYdhms])'
|
|
1756
|
+
if not re.fullmatch(f'(?:{component})+', text):
|
|
1757
|
+
raise ValueError(f"invalid duration: {input!r}")
|
|
1758
|
+
seconds_per_unit = {'w': 604800, 'Y': 31536000, 'd': 86400,
|
|
1759
|
+
'h': 3600, 'm': 60, 's': 1}
|
|
1760
|
+
output = datetime.timedelta(0)
|
|
1761
|
+
try:
|
|
1762
|
+
for number, unit in re.findall(component, text):
|
|
1763
|
+
output += datetime.timedelta(seconds=float(number) * seconds_per_unit[unit])
|
|
1764
|
+
except (OverflowError, ValueError) as error:
|
|
1765
|
+
raise ValueError(f"duration out of range: {input!r}") from error
|
|
1766
|
+
if positive and output <= datetime.timedelta(0):
|
|
1767
|
+
raise ValueError("interval must be positive")
|
|
1768
|
+
return output
|
|
1769
|
+
|
|
1770
|
+
def parseTimedelta(self, input, *, positive=False):
|
|
1771
|
+
return self.TD(input, positive=positive)
|
|
1772
|
+
|
|
1773
|
+
|
|
1774
|
+
class rdb:
|
|
1775
|
+
#construtor rewrites a path to a RDB file
|
|
1776
|
+
def __init__(self, path):
|
|
1777
|
+
#initialize with Default Configuration
|
|
1778
|
+
self.status = "OK"
|
|
1779
|
+
#format = INDEP SHIFT DEP STOR
|
|
1780
|
+
self.data = []
|
|
1781
|
+
if path:
|
|
1782
|
+
self.loadRDB(path)
|
|
1783
|
+
|
|
1784
|
+
def __str__(self):
|
|
1785
|
+
output = "INDEP SHIFT DEP STOR\n16N 16N 16N 1S\n"
|
|
1786
|
+
for line in self.data:
|
|
1787
|
+
output += "{}\n".format("\t".join(line))
|
|
1788
|
+
return output
|
|
1789
|
+
|
|
1790
|
+
def loadRDB(self, path):
|
|
1791
|
+
self.path = path
|
|
1792
|
+
try:
|
|
1793
|
+
self.data = self.parseRDB(open(path, "r"))
|
|
1794
|
+
except Exception as e:
|
|
1795
|
+
self.status = "\n{}".format(str(e))
|
|
1796
|
+
|
|
1797
|
+
def parseRDB(self, stream):
|
|
1798
|
+
output = []
|
|
1799
|
+
try:
|
|
1800
|
+
for line in stream:
|
|
1801
|
+
if (not "#" in line) and (len(line) > 1):
|
|
1802
|
+
output.append(line.strip().split("\t"))
|
|
1803
|
+
except Exception as e:
|
|
1804
|
+
self.status = "\n%s" % str(e)
|
|
1805
|
+
return output[2:]
|
|
1806
|
+
|
|
1807
|
+
def saveRDB(self, path):
|
|
1808
|
+
open(path, "w").write(str(self))
|
|
1809
|
+
|
|
1810
|
+
#interpolate values
|
|
1811
|
+
#This may be changed to a higher order interpolation in the future
|
|
1812
|
+
def interpolateValue(self, x0, y0, x1, y1, x):
|
|
1813
|
+
m = (y1 - y0) / (x1 - x0)
|
|
1814
|
+
output = y0 + (x - x0) * m
|
|
1815
|
+
return output
|
|
1816
|
+
|
|
1817
|
+
def rate(self, indep):
|
|
1818
|
+
""" Rate a single value based on linear interpolation. """
|
|
1819
|
+
data = self.data
|
|
1820
|
+
index = len(data) - 2
|
|
1821
|
+
for i in range(len(data) - 1):
|
|
1822
|
+
if indep < float(data[i + 1][0]):
|
|
1823
|
+
index = i
|
|
1824
|
+
break
|
|
1825
|
+
return self.interpolateValue(
|
|
1826
|
+
float(data[index][0]),
|
|
1827
|
+
float(data[index][2]),
|
|
1828
|
+
float(data[index + 1][0]), float(data[index + 1][2]), indep)
|
|
1829
|
+
|
|
1830
|
+
def reverseRate(self, indep):
|
|
1831
|
+
""" Reverse rate a single value based on linear interpolation
|
|
1832
|
+
This switches the domain and range of the RDB and rates it.
|
|
1833
|
+
"""
|
|
1834
|
+
data = self.data
|
|
1835
|
+
index = len(data) - 2
|
|
1836
|
+
for i in range(len(data) - 1):
|
|
1837
|
+
if indep < float(data[i + 1][2]):
|
|
1838
|
+
index = i
|
|
1839
|
+
break
|
|
1840
|
+
return self.interpolateValue(
|
|
1841
|
+
float(data[index][2]),
|
|
1842
|
+
float(data[index][0]),
|
|
1843
|
+
float(data[index + 1][2]), float(data[index + 1][0]), indep)
|
|
1844
|
+
|
|
1845
|
+
def makeRating(self, indepTS, depTS, precision=2, factor=1):
|
|
1846
|
+
""" Creates a rating table based on two timeseries
|
|
1847
|
+
precision is for how many decimal places to consider
|
|
1848
|
+
factor to adjust dependent variable (kcfs->cfs, fudge factor, etc)"""
|
|
1849
|
+
self.data = []
|
|
1850
|
+
fmt = '.' + str(precision) + 'f'
|
|
1851
|
+
ratingMap = {}
|
|
1852
|
+
for t in indepTS.timestamps():
|
|
1853
|
+
indep = indepTS.findValue(t)
|
|
1854
|
+
dep = depTS.findValue(t)
|
|
1855
|
+
if dep != None and indep != None:
|
|
1856
|
+
key = float(format(indep, fmt))
|
|
1857
|
+
if key in ratingMap:
|
|
1858
|
+
ratingMap[key].append(dep * factor)
|
|
1859
|
+
else:
|
|
1860
|
+
ratingMap[key] = [dep * factor]
|
|
1861
|
+
keys = sorted(ratingMap.keys())
|
|
1862
|
+
for key in keys:
|
|
1863
|
+
indep = sum(ratingMap[key]) / float(len(ratingMap[key]))
|
|
1864
|
+
self.data.append([format(key, fmt), "0.00", format(indep, fmt), ""])
|
|
1865
|
+
|
|
1866
|
+
def rateTS(self, ts):
|
|
1867
|
+
""" Generates a new time series with rated values from another """
|
|
1868
|
+
output = []
|
|
1869
|
+
for line in ts.data:
|
|
1870
|
+
output.append([line[0], self.rate(line[1])])
|
|
1871
|
+
return timeseries(output)
|
|
1872
|
+
|
|
1873
|
+
def reverseRateTS(self, ts):
|
|
1874
|
+
""" Generates a new time series with reverse-rated values from another """
|
|
1875
|
+
output = []
|
|
1876
|
+
for line in ts.data:
|
|
1877
|
+
output.append([line[0], self.reverseRate(line[1])])
|
|
1878
|
+
return timeseries(output)
|
|
1879
|
+
|
|
1880
|
+
|
|
1881
|
+
class tablegrid:
|
|
1882
|
+
#construtor rewrites a path to a RDB file
|
|
1883
|
+
def __init__(self, path):
|
|
1884
|
+
#initialize with Default Configuration
|
|
1885
|
+
self.status = "OK"
|
|
1886
|
+
self.path = None
|
|
1887
|
+
self.data = self.loadTable(path)
|
|
1888
|
+
|
|
1889
|
+
def loadTable(self, path):
|
|
1890
|
+
output = []
|
|
1891
|
+
try:
|
|
1892
|
+
self.path = path
|
|
1893
|
+
input = open(path, "r")
|
|
1894
|
+
for line in input:
|
|
1895
|
+
if (not "#" in line) and (len(line) > 1):
|
|
1896
|
+
row2 = []
|
|
1897
|
+
if "," in line:
|
|
1898
|
+
row1 = line.strip().split(",")
|
|
1899
|
+
else:
|
|
1900
|
+
row1 = line.strip().split("\t")
|
|
1901
|
+
for n in row1:
|
|
1902
|
+
row2.append(
|
|
1903
|
+
float(n)) #Iterate and convert entries to floating point number
|
|
1904
|
+
output.append(row2)
|
|
1905
|
+
except Exception as e:
|
|
1906
|
+
self.status = "\n%s" % str(e)
|
|
1907
|
+
return output
|
|
1908
|
+
|
|
1909
|
+
def tableLookup(self, arr, colval, rowval):
|
|
1910
|
+
#Seek the coordinates
|
|
1911
|
+
output = 0
|
|
1912
|
+
x = 0
|
|
1913
|
+
y = 0
|
|
1914
|
+
maxcols = len(arr[0]) - 1
|
|
1915
|
+
maxrows = len(arr) - 1
|
|
1916
|
+
x = maxcols - 1 #default to end of list if not found
|
|
1917
|
+
y = maxrows - 1
|
|
1918
|
+
i = 1
|
|
1919
|
+
while (i < maxcols): #seeking coordinates for column values
|
|
1920
|
+
if arr[0][i + 1] > colval:
|
|
1921
|
+
x = i
|
|
1922
|
+
i = maxcols #exit loop
|
|
1923
|
+
i += 1
|
|
1924
|
+
i = 1
|
|
1925
|
+
while (i < maxrows): #seeking coorinate for row values
|
|
1926
|
+
if arr[i + 1][0] > rowval:
|
|
1927
|
+
y = i
|
|
1928
|
+
i = maxrows #exit loop
|
|
1929
|
+
i += 1
|
|
1930
|
+
if ((x == maxcols) or (y == maxrows)):
|
|
1931
|
+
output = arr[y][x]
|
|
1932
|
+
else:
|
|
1933
|
+
output = self.bilinear(arr[0][x], arr[y][0], arr[0][x + 1], arr[y + 1][0],
|
|
1934
|
+
arr[y][x], arr[y + 1][x], arr[y][x + 1],
|
|
1935
|
+
arr[y + 1][x + 1], colval, rowval)
|
|
1936
|
+
return output
|
|
1937
|
+
|
|
1938
|
+
def bilinear(self, x1, y1, x2, y2, fQ11, fQ12, fQ21, fQ22, x, y):
|
|
1939
|
+
retval = (fQ11 / ((x2 - x1) * (y2 - y1))) * (x2 - x) * (y2 - y) + (fQ21 / (
|
|
1940
|
+
(x2 - x1) * (y2 - y1))) * (x - x1) * (y2 - y) + (fQ12 / (
|
|
1941
|
+
(x2 - x1) * (y2 - y1))) * (x2 - x) * (y - y1) + (fQ22 / (
|
|
1942
|
+
(x2 - x1) * (y2 - y1))) * (x - x1) * (y - y1)
|
|
1943
|
+
return retval
|
|
1944
|
+
|
|
1945
|
+
#this takes 2 timseries objects and rates them
|
|
1946
|
+
def rateTS(self, cols, rows):
|
|
1947
|
+
output = []
|
|
1948
|
+
for line in cols.data:
|
|
1949
|
+
rowval = rows.findValue(line[0])
|
|
1950
|
+
if rowval != None:
|
|
1951
|
+
output.append(
|
|
1952
|
+
[line[0], self.tableLookup(self.data, line[1], rowval)])
|
|
1953
|
+
return timeseries(output)
|