Skip to content

Commit b93aefc

Browse files
fix(timeseries): epoch and mixed-offset timestamps, MAD zero-scale, stable anomaly columns, cdc tz (#360)
Time-series streaming parsed the timestamp column with a bare pd.to_datetime(errors="coerce"). Integer epoch seconds were read as nanoseconds (1970), unparseable strings silently became NaT, and a batch with mixed UTC offsets (a DST change) raised ValueError. - #227: numeric timestamp and event-time columns are read as epochs. The unit comes from the new TimeSeriesCleanConfig.timestamp_unit ("s"/"ms"/"us"/"ns"), or is inferred from the median magnitude (<1e11 s, <1e14 ms, <1e17 us, else ns); a "timeseries_timestamp_parse" action records the unit. Values that still fail to parse keep their row and are recorded in report.coerced_cells / coerced_rows (masked for sensitive_columns) with a medium-risk action and a report warning. - #250: string parsing goes through coerce_datetimes(). When the plain parse raises, returns an object column, or loses values, it retries with utc=True and, on pandas >= 2 (which infers one format from the first value), with format="ISO8601", keeping the attempt that loses the fewest values. Clean batches still pay for a single parse, and a single-offset or naive batch keeps its zone. - #290: anomaly_window_size=1 is rejected in the config ("must be 0 or >= 2") instead of failing inside pandas on the first batch. - #291: MAD treated a zero MAD as a flat window, so the minority value of any sparse or two-valued series was flagged and, with cap, overwritten by the median. A window is now flat only when every other point in it is identical. A zero MAD in a non-flat window gives no scale, so the point is not flagged and its fences are NaN. A spike in a truly flat window is still flagged and capped. - #248 part b: anomaly target columns are chosen from the (locked) numeric role, not from the batch dtype, so a numeric column holding a stray string still emits <col>_anomaly. It is scored with pd.to_numeric(errors="coerce"): non-numeric cells are neither flagged nor capped. Interpolation and seasonal imputation still require a numeric dtype. - #233 part 4: cdc_profile parses event times with the same coerce_datetimes() helper, so mixed offsets no longer crash cummax. The default now is the current UTC time instead of local wall-clock time for naive events. now= and watermark= are converted to the event times' zone, reading naive values (and naive events) as UTC. Closes #227 Closes #250 Closes #290 Closes #291 Refs #248 (part b: time-series anomaly columns) Refs #233 (part 4: cdc_profile)
1 parent 59eb326 commit b93aefc

4 files changed

Lines changed: 462 additions & 27 deletions

File tree

‎src/freshdata/cdc.py‎

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727

2828
import pandas as pd
2929

30-
from .streaming._timeseries import to_timedelta
30+
from .streaming._timeseries import coerce_datetimes, to_timedelta
3131

3232
__all__ = ["CDCDefect", "CDCReport", "cdc_profile"]
3333

@@ -156,6 +156,31 @@ def _sample_keys(
156156
return tuple(str(v) for v in vals)
157157

158158

159+
def _parse_event_time(raw: pd.Series) -> pd.Series:
160+
"""Parse the event-time column; unparseable values become ``NaT``.
161+
162+
Datetime columns pass through. Mixed UTC offsets (e.g. across a DST change)
163+
or a mix of naive and offset-aware values are normalised to UTC, reading
164+
naive values as UTC (see :func:`~freshdata.streaming._timeseries.coerce_datetimes`).
165+
"""
166+
return coerce_datetimes(raw)
167+
168+
169+
def _to_event_tz(value: object, tz: Any) -> pd.Timestamp:
170+
"""Express a ``now`` / ``watermark`` reference in the event times' zone.
171+
172+
Naive references and naive event times are both read as UTC, so a naive
173+
value compared with tz-aware events is localised to UTC first, and an
174+
aware value compared with naive events becomes naive UTC.
175+
"""
176+
stamp = pd.Timestamp(value)
177+
if tz is None:
178+
return stamp if stamp.tz is None else stamp.tz_convert("UTC").tz_localize(None)
179+
if stamp.tz is None:
180+
stamp = stamp.tz_localize("UTC")
181+
return stamp.tz_convert(tz)
182+
183+
159184
def _running_max(ts: pd.Series, key_series: pd.Series | None) -> pd.Series:
160185
"""Per-key (or global) running max of event time, in arrival order."""
161186
if key_series is not None:
@@ -214,7 +239,8 @@ def _freshness(
214239
max_ts = ts.max()
215240
if pd.isna(max_ts):
216241
return None
217-
reference = now if now is not None else pd.Timestamp.now(tz=max_ts.tz)
242+
# The default reference is the current UTC time (naive event times are UTC).
243+
reference = now if now is not None else _to_event_tz(pd.Timestamp.now(tz="UTC"), max_ts.tz)
218244
freshness_seconds = float((reference - max_ts).total_seconds())
219245
if stale_after_td is not None and freshness_seconds > stale_after_td.total_seconds():
220246
defects.append(
@@ -274,6 +300,8 @@ def cdc_profile(
274300
now, stale_after:
275301
Reference 'now' and the max age before the batch is ``stale``. ``now``
276302
defaults to the current UTC time; pass it explicitly for determinism.
303+
Naive ``now`` / ``watermark`` values and naive event times are read as
304+
UTC; references are converted to the event times' zone before comparing.
277305
replay_threshold:
278306
Fraction of (duplicate + late) rows above which a ``replay_risk`` batch
279307
warning is raised.
@@ -292,10 +320,10 @@ def cdc_profile(
292320
n_rows = len(df)
293321
lateness_td = to_timedelta(lateness) or pd.Timedelta(0)
294322
stale_after_td = to_timedelta(stale_after)
295-
now_ts = pd.Timestamp(now) if now is not None else None
296323

297-
raw = df[event_time]
298-
ts = raw if pd.api.types.is_datetime64_any_dtype(raw) else pd.to_datetime(raw, errors="coerce")
324+
ts = _parse_event_time(df[event_time])
325+
event_tz = ts.dt.tz
326+
now_ts = _to_event_tz(now, event_tz) if now is not None else None
299327

300328
defects: list[CDCDefect] = []
301329

@@ -315,7 +343,7 @@ def cdc_profile(
315343

316344
# 2) ordering: out-of-order + late (relative to watermark / running watermark).
317345
if watermark is not None:
318-
wm = pd.Timestamp(watermark)
346+
wm = _to_event_tz(watermark, event_tz)
319347
late_mask = ts < wm
320348
n_late = int(late_mask.sum())
321349
counts = {"out_of_order": 0, "late": n_late}

0 commit comments

Comments
 (0)