From 356df1c49f96e8ad23be8c1d3b0b26f64323c955 Mon Sep 17 00:00:00 2001 From: Kieran Roper <13541920+KRoperUK@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:06:36 +0100 Subject: [PATCH] docs(telemetry): document the seconds-vs-ms epoch boundary assumption _coerce_timestamp disambiguates seconds from milliseconds purely by magnitude (> 1e12 is treated as ms). This is safe for the data it parses -- a seconds epoch does not reach 1e12 until the year ~33658 and the cloud emits second-resolution TS -- but the assumption was undocumented, so a future reader could mistake the one-sided heuristic for a bug. Add a comment explaining why the threshold is safe and that the converse edge (a pre-2001 ms epoch) is intentionally not defended, and a test pinning each side of the 1e12 boundary (including that a near-boundary seconds value overflows datetime and is dropped rather than mis-parsed). Closes #121 --- dimplex_controller/telemetry.py | 9 +++++++++ tests/test_telemetry.py | 25 +++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/dimplex_controller/telemetry.py b/dimplex_controller/telemetry.py index 9ba6cd3..4a12479 100644 --- a/dimplex_controller/telemetry.py +++ b/dimplex_controller/telemetry.py @@ -117,6 +117,15 @@ def _coerce_timestamp(raw: Any) -> datetime | None: return raw if isinstance(raw, int | float): ts = float(raw) + # Seconds-vs-milliseconds disambiguation is magnitude-only, which is safe for + # the data this parses: a Unix *seconds* epoch does not reach 1e12 until the + # year ~33658, and the cloud emits second-resolution `TS` values, so anything + # above the threshold is a millisecond epoch. The converse edge is not + # defended — a millisecond epoch before 2001-09-09 (< 1e12) would be misread + # as seconds — because the cloud has never produced one. A value just below + # 1e12 read as seconds lands past datetime's representable range and is + # dropped by the guard below rather than mis-parsed. See + # dimplex-controller-py#121 for why this is intentional rather than a gap. if ts > 1e12: ts = ts / 1000.0 try: diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py index 322e6c2..a10f21b 100644 --- a/tests/test_telemetry.py +++ b/tests/test_telemetry.py @@ -61,6 +61,31 @@ def test_epoch_seconds_and_milliseconds(): assert out[1][0] == datetime.fromtimestamp(1717200000, tz=timezone.utc) +def test_epoch_seconds_ms_threshold_boundary(): + """Pin the magnitude threshold: values <= 1e12 are seconds, > 1e12 are ms. + + Documents the boundary assumption in dimplex-controller-py#121 — a seconds + epoch does not reach 1e12 until the year ~33658, so the cloud's second- + resolution ``TS`` is always read as seconds, and only a value above the + threshold is divided down from milliseconds. A value just below the + threshold, read as seconds, lands in year ~33658 which ``datetime`` cannot + represent, so it is dropped (the ``OverflowError``/``ValueError`` guard + returns ``None``) rather than silently mis-parsed. + """ + # Just below the threshold: read as SECONDS -> year ~33658 -> unrepresentable -> dropped. + below = parse_telemetry_points([{"timestamp": int(1e12) - 1, "value": 1.0}]) + assert below == [(None, 1.0)] + + # Just above the threshold: read as MILLISECONDS (divided by 1000) -> a real date. + above = parse_telemetry_points([{"timestamp": int(1e12) + 1, "value": 1.0}]) + assert above[0][0] == datetime.fromtimestamp((1e12 + 1) / 1000.0, tz=timezone.utc) + assert above[0][0].year < 2100 + + # A normal second-resolution epoch (the cloud's real shape) is read as seconds. + normal = parse_telemetry_points([{"timestamp": 1_717_200_000, "value": 1.0}]) + assert normal[0][0] == datetime.fromtimestamp(1_717_200_000, tz=timezone.utc) + + def test_bad_entries_skipped_but_partial_kept(): """A bad timestamp keeps the value; entries with no value at all are dropped.""" points = [