Time Series

solvcon.timeseries holds kernels over recorded time series. A series is a pair of one-dimensional arrays of the same length. The first holds the timestamps in integer nanoseconds as a non-decreasing SimpleArrayUint64; the second holds the values sampled at them as a SimpleArray. Each kernel reads its input and returns new arrays. The C++ kernels live in cpp/solvcon/timeseries/ in the namespace solvcon::timeseries.

A kernel raises TypeError when times is not a SimpleArrayUint64 or values is a class the kernel does not take. It raises ValueError for an array that is not one-dimensional or carries ghost elements, for arrays of different length, or for a decreasing timestamp. Sample lookup on the timestamps is searchsorted, which the reduce page describes.

Repeated Timestamps

A recorded log can stamp two messages in the same nanosecond, so a repeated timestamp is valid input. A group of equal timestamps resolves to its last sample. deriv is the exception: a zero time step has no derivative, so deriv raises ValueError. dedup_last collapses a repeat, so its result is valid input to deriv.

The merge_sorted_unique Function

merge_sorted_unique(*arrays) merges sorted SimpleArrayUint64 timestamp arrays into one sorted array that holds every distinct timestamp once. This is the union time grid that compares two signals sampled at different rates:

from solvcon import timeseries as ts

t1 = solvcon.SimpleArrayUint64(array=np.array([0, 10, 20], dtype='uint64'))
t2 = solvcon.SimpleArrayUint64(array=np.array([5, 10, 10], dtype='uint64'))
assert ts.merge_sorted_unique(t1, t2).ndarray.tolist() == [0, 5, 10, 20]

No argument, or only empty arrays, gives an empty result. Every argument must be a SimpleArrayUint64; another class raises TypeError, and a ValueError names the offending array by its position.

The dedup_last Function

dedup_last(times, values) keeps the last sample of every group of equal timestamps, so the result timestamps are strictly increasing:

times = solvcon.SimpleArrayUint64(array=np.array([0, 1, 1, 2], dtype='uint64'))
speed = solvcon.SimpleArrayFloat64(array=np.array([0.5, 1.0, 1.5, 2.0]))
t_u, speed_u = ts.dedup_last(times, speed)
assert t_u.ndarray.tolist() == [0, 1, 2]
assert speed_u.ndarray.tolist() == [0.5, 1.5, 2.0]

The kernel takes every typed SimpleArray class, and the result values keep the class of the input values. A series without a repeat comes back as a copy.

The deriv Function

deriv(times, values) differentiates a series by the backward difference (x_i - x_{i-1}) / (t_i - t_{i-1}) and returns (times[1:], derivatives):

times = solvcon.SimpleArrayUint64(array=np.array([0, 10, 30], dtype='uint64'))
speed = solvcon.SimpleArrayFloat64(array=np.array([1.0, 3.0, 2.0]))
t_acc, acc = ts.deriv(times, speed)
assert t_acc.ndarray.tolist() == [10, 30]
assert acc.ndarray.tolist() == [0.2, -0.05]  # (3.0-1.0)/10 and (2.0-3.0)/20

The first sample has no predecessor, so n samples give n - 1 derivatives and fewer than two samples give empty arrays. The output timestamps are strictly increasing, so a second deriv chains directly. The kernel takes the real-number classes only; SimpleArrayBool and the complex classes raise TypeError. A SimpleArrayFloat32 input gives SimpleArrayFloat32 derivatives, and every other class gives SimpleArrayFloat64.

Trailing Windows

movavg and held answer at every timestamp of the series from the trailing half-open window (t - span, t]. span is a length in integer nanoseconds and must be positive; a zero span raises ValueError. A span wider than the whole log is valid. Every sample of a group of equal timestamps is in the window together and gets the same answer. Both kernels return the tuple (times, results) with the input timestamps, so a chain of kernels keeps its length and time base.

The movavg Function

movavg(times, values, span) takes the arithmetic mean of the samples in the window at each timestamp, which smooths a noisy signal:

times = solvcon.SimpleArrayUint64(
    array=np.array([0, 10, 20, 30], dtype='uint64'))
noisy = solvcon.SimpleArrayFloat64(array=np.array([1.0, 5.0, 1.0, 5.0]))
t_s, smooth = ts.movavg(times, noisy, span=20)
assert t_s.ndarray.tolist() == [0, 10, 20, 30]
# The window at t=20 is (0, 20] and averages 5.0 and 1.0.
assert smooth.ndarray.tolist() == [1.0, 3.0, 3.0, 3.0]

The kernel takes the real-number classes only, and the result class follows the same rule as deriv. The sweep carries one running sum, so one non-finite sample makes every later mean NaN; drop such a sample first.

The held Function

held(times, values, span) reports whether a SimpleArrayBool series was true over the whole window (t - span, t] at every timestamp and returns a SimpleArrayBool. The boundary sample, the last one stamped at or before t - span, must be true as well. No boundary sample exists over the first span of the log, so the answer there is false:

times = solvcon.SimpleArrayUint64(
    array=np.array([0, 10, 20, 30, 40], dtype='uint64'))
brake = solvcon.SimpleArrayBool(
    array=np.array([True, True, False, True, True]))
t_h, brake_held = ts.held(times, brake, span=10)

# t=0: no boundary sample, answer is False
# t=10: boundary at 0 is True, window (0, 10] holds True, answer is True
# t=20: window (10, 20] holds the False at 20, answer is False
# t=30: boundary at 20 is False, answer is False
# t=40: boundary at 30 is True, window (30, 40] holds True, answer is True
assert t_h.ndarray.tolist() == [0, 10, 20, 30, 40]
assert brake_held.ndarray.tolist() == [False, True, False, False, True]

The true_intervals Function

true_intervals(times, values) run-length encodes a SimpleArrayBool series into the intervals where it was true. The result is a SimpleArrayUint64 of shape (nrun, 3) whose columns are the start, the end, and the duration in nanoseconds; a series that is never true gives shape (0, 3). A run starts at the timestamp of the sample that turns the series true and ends at the timestamp of the sample that turns it false again, so each row spans the half-open interval [start, end):

times = solvcon.SimpleArrayUint64(
    array=np.array([0, 10, 20, 30, 40], dtype='uint64'))
over = solvcon.SimpleArrayBool(
    array=np.array([False, True, True, False, True]))
runs = ts.true_intervals(times, over)
assert runs.ndarray.tolist() == [[10, 30, 20], [40, 40, 0]]
assert int(runs.ndarray[:, 2].sum()) == 20  # total time over the limit

Benchmarks

profiling/profile_timeseries.py times every kernel against the pure-Python loop it replaces on an hour-long log sampled at 100 Hz. make pyprof runs it with the other profiling scripts and writes the table to profiling/results/.