Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions dimplex_controller/telemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
25 changes: 25 additions & 0 deletions tests/test_telemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
Loading