from __future__ import annotations
from typing import Sequence
import numpy as np
import pandas as pd
# -----------------------------------------------------------------------------
# helpers
# -----------------------------------------------------------------------------
[docs]
def _prepare_df(df: pd.DataFrame):
if not isinstance(df.index, pd.DatetimeIndex):
raise TypeError("df must have a DatetimeIndex")
if df.index.has_duplicates:
raise ValueError("Duplicate timestamps in input")
t = df.index.to_numpy(dtype="datetime64[ns]")
order = np.argsort(t, kind="mergesort")
df = df.iloc[order].copy()
return df, df.index.to_numpy(dtype="datetime64[ns]"), df.to_numpy(dtype=float)
[docs]
def _hysteretic_quantize(X: np.ndarray, q: float, hyst: float) -> np.ndarray:
if q <= 0:
raise ValueError("qwidth must be > 0")
if hyst <= 0:
raise ValueError("hyst must be > 0")
half = hyst * q
Xq = np.empty_like(X, float)
Xq[0] = np.round(X[0] / q) * q
for i in range(1, len(X)):
y = Xq[i - 1]
d = X[i] - y
n = np.zeros_like(y)
up = d > half
dn = d < -half
n[up] = np.ceil((d[up] - half) / q)
n[dn] = -np.ceil(((-d[dn]) - half) / q)
Xq[i] = y + n * q
return Xq
[docs]
def _segments_from_bool(b: np.ndarray):
if len(b) == 0:
return [], [], []
change = np.empty(len(b), bool)
change[0] = True
change[1:] = b[1:] != b[:-1]
s = np.flatnonzero(change)
e = np.r_[s[1:], len(b)]
return s, e, b[s]
[docs]
def _stable_state_from_raw(t, raw, enter, exit):
s, e, v = _segments_from_bool(raw)
stable = np.zeros_like(raw, bool)
dur = (t[e - 1] - t[s]).astype("timedelta64[ns]")
for si, ei, vi, di in zip(s, e, v, dur):
if vi and di >= enter:
stable[si:ei] = True
s2, e2, v2 = _segments_from_bool(stable)
dur2 = (t[e2 - 1] - t[s2]).astype("timedelta64[ns]")
for k in range(1, len(s2) - 1):
if (not v2[k]) and v2[k - 1] and v2[k + 1] and dur2[k] < exit:
stable[s2[k] : e2[k]] = True
return stable
[docs]
def _change_points_mask(X):
keep = np.zeros(len(X), bool)
if len(X):
keep[0] = True
keep[1:] = np.any(X[1:] != X[:-1], axis=1)
return keep
[docs]
def _preserve_boundary_mask(stable):
keep = np.zeros(len(stable), bool)
if len(stable) == 0:
return keep
keep[0] = True
b = np.flatnonzero(stable[1:] != stable[:-1]) + 1 # boundary indices
keep[b] = True
keep[b - 1] = True # NEW: also keep the sample just before the boundary
return keep
[docs]
def _heartbeat_mask(t, freq):
if freq is None:
return np.zeros(len(t), bool)
key = t.floor(freq).to_numpy()
change = np.empty(len(t), bool)
change[0] = True
change[1:] = key[1:] != key[:-1]
s = np.flatnonzero(change)
e = np.r_[s[1:], len(t)]
keep = np.zeros(len(t), bool)
keep[e - 1] = True
return keep
[docs]
def _emit_df(t, cols, X, keep, force_keep=None):
idx = np.flatnonzero(keep)
out = pd.DataFrame(X[idx], index=t[idx], columns=cols)
if len(out) <= 1:
return out
v = out.to_numpy()
km = np.ones(len(out), dtype=bool)
# Drop consecutive duplicates by value
km[1:] = np.any(v[1:] != v[:-1], axis=1)
# But never drop explicitly forced rows (e.g. exit points)
if force_keep is not None:
fk = force_keep[idx]
km[1:] |= fk[1:]
return out.loc[km]
[docs]
def _grid_force_pre_exit_from_raw(
t_raw: np.ndarray,
stable_raw: np.ndarray,
t_grid: pd.DatetimeIndex,
grid: str,
) -> np.ndarray:
"""Mark pre-exit + exit ticks on the grid based on *raw* stable transitions.
In grid mode, exits can occur inside the final grid bin, so a grid-derived stable mask
(from ffill/bfill) may never show the exit. This helper maps each raw True->False
transition to:
exit_tick = floor(exit_time, grid)
pre_exit_tick = exit_tick - one grid interval
Any such ticks that exist in t_grid are marked True.
"""
keep = np.zeros(len(t_grid), dtype=bool)
if len(stable_raw) < 2:
return keep
off = pd.tseries.frequencies.to_offset(grid)
exit_i = np.flatnonzero(stable_raw[:-1] & (~stable_raw[1:]))
if len(exit_i) == 0:
return keep
for i in exit_i:
exit_time = pd.Timestamp(t_raw[i + 1])
g_exit = exit_time.floor(grid)
g_pre = g_exit - off
for tt in (g_pre, g_exit):
j = t_grid.get_indexer([tt])[0]
if j != -1:
keep[j] = True
return keep
# -----------------------------------------------------------------------------
# public API
# -----------------------------------------------------------------------------
[docs]
def ts_coarsen(
df: pd.DataFrame,
*,
grid: str | None = "1min",
qwidth: float | None = None,
use_original_vals: bool = True,
heartbeat_freq: str | None = "120min",
preserve_vals: Sequence[float] = (),
preserve_eps: float | None = None,
preserve_enter_dwell: str = "2min",
preserve_exit_dwell: str = "30s",
hyst: float = 1.0,
) -> pd.DataFrame:
"""
Coarsen a multivariate time series by gridding, preserving semantic states,
hysteretic quantization, and thinning.
Parameters
----------
df : pd.DataFrame
Input time series with DatetimeIndex and numeric columns.
grid : str or None
Pandas resample frequency. If None, operate on original timestamps.
The grid roughly defines the output resolution. It will be refined based
on preservation and coarasened based on quantization, but loss due to the grid
is not recoverable.
qwidth : float or None
Post-grid quantization width. None disables quantization.
use_original_vals : bool
If True, emit raw gridded values at kept timestamps after gridding; otherwise emit
quantized values used for change detection. Setting this to False will give a more "digitize" feel.
Recomment True for most use cases, and required with gridding,
but False can be useful for debugging or if you want to quantize without gridding.
heartbeat_freq : str or None
Periodic keep-alive frequency preventing long gaps. None disables heartbeat.
preserve_vals : sequence of float
Values (e.g. 0.0, 1.0) defining semantic states to preserve, kind of like
a combination of snapping and preservation of entry/exit points.
If a preserve value is within preserve_eps of the raw value, it is considered
to be in that state for preservation purposes.
preserve_eps : float or None
Tolerance around preserve values. Defaults to 0.5*qwidth or 0.0.
preserve_enter_dwell : str
Minimum dwell time to enter a preserved state.
preserve_exit_dwell : str
Maximum gap to bridge between preserved state segments.
hyst : float
Hysteresis factor for quantization (multiplier on qwidth). Default of 0.5
is intermediate -- quantization only occurs if value changes by more than half qwidth.
hyst > 1, as it will give a choppy feel. Low is fine and 0 disbles.
Returns
-------
pd.DataFrame
Coarsened time series with preserved semantic information.
"""
if grid is not None and not use_original_vals:
raise ValueError(
"use_original_vals=False is not supported when grid is set; "
"quantization is only used for thinning in grid mode."
)
df, t, X = _prepare_df(df)
enter = np.timedelta64(pd.Timedelta(preserve_enter_dwell).value, "ns")
exit = np.timedelta64(pd.Timedelta(preserve_exit_dwell).value, "ns")
do_preserve = len(preserve_vals) > 0
if preserve_eps is None:
preserve_eps = 0.5 * qwidth if qwidth is not None else 0.0
if do_preserve:
stable_by_v = []
for v in preserve_vals:
raw = np.all(np.abs(X - v) <= preserve_eps, axis=1)
stable_by_v.append(_stable_state_from_raw(t, raw, enter, exit))
stable_any = np.logical_or.reduce(stable_by_v)
else:
stable_any = np.zeros(len(t), bool)
stable_by_v = []
if grid is not None:
dfg = df.resample(grid).ffill().bfill()
t2 = dfg.index
Xraw = dfg.to_numpy(float, copy=True)
if do_preserve:
masks = []
for sb in stable_by_v:
s = pd.Series(sb, index=df.index).astype("int8")
masks.append(
s.resample(grid)
.ffill()
.fillna(0)
.astype("int8")
.astype(bool)
.to_numpy()
)
for v, m in zip(preserve_vals, masks):
Xraw[m, :] = v
stable_any_g = np.zeros(len(t2), dtype=bool)
for v in preserve_vals:
stable_any_g |= np.all(np.abs(Xraw - v) <= preserve_eps, axis=1)
else:
stable_any_g = np.zeros(len(t2), bool)
# boundary + pre-exit (grid semantics)
boundary = _preserve_boundary_mask(stable_any_g)
# Pre-exit semantics must consider raw transitions: the raw exit can occur
# within the last grid bin, so stable_any_g may not show an exit at all.
pre_exit_raw = np.zeros(len(t2), dtype=bool)
if do_preserve:
for sb in stable_by_v:
pre_exit_raw |= _grid_force_pre_exit_from_raw(t, sb, t2, grid)
Xdec = _hysteretic_quantize(Xraw, qwidth, hyst) if qwidth is not None else Xraw
must_keep = boundary | pre_exit_raw | _heartbeat_mask(t2, heartbeat_freq)
keep = _change_points_mask(Xdec) | must_keep
# Always emit working (gridded) values; quantization is only for thinning.
return _emit_df(t2, dfg.columns, Xraw, keep, force_keep=must_keep)
# ------------------------------------------------------------------
# no-grid path
# ------------------------------------------------------------------
Xdec = _hysteretic_quantize(X, qwidth, hyst) if qwidth is not None else X
boundary = _preserve_boundary_mask(stable_any)
hb = _heartbeat_mask(df.index, heartbeat_freq)
keep = _change_points_mask(Xdec) | boundary | hb
keep &= (~stable_any) | boundary
Xw = X if use_original_vals else Xdec
force_keep = boundary | hb
return _emit_df(df.index, df.columns, Xw, keep, force_keep=force_keep)