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 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>&nbsp;&nbsp;%.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)