Files
gps-denied-onboard/src/gps_denied_onboard/clock/tlog_derived.py
T
Oleksandr Bezdieniezhnykh 823c0f1b2e [AZ-398] Replay: FrameSource + Clock Protocols + Clock injection
Ship the two Layer-1 cross-cutting Protocols replay mode needs to leave
production C1-C5 components mode-agnostic (Invariant 1) and replay-
deterministic (Invariant 2). Live + replay binaries see the same
interfaces; only the strategy differs.

* Clock Protocol (monotonic_ns / time_ns / sleep_until_ns) +
  WallClock (live + REALTIME replay) + TlogDerivedClock (ASAP replay;
  advance-on-call; non-monotonic source → ClockOrderingError).
* FrameSource Protocol (next_frame -> NavCameraFrame | None / close)
  + LiveCameraFrameSource (cv2.VideoCapture device index) +
  VideoFileFrameSource (cv2.VideoCapture file).
* Build-flag gating: BUILD_VIDEO_FILE_FRAME_SOURCE,
  BUILD_LIVE_CAMERA_FRAME_SOURCE (constructor-time check; Tier-0 OFF
  refuses construction with FrameSourceConfigError).
* Composition-root factories: build_clock + build_frame_source.
* Injected Clock across every component that previously called
  time.monotonic_ns() / time.sleep() directly: c5_state (estimator,
  ESKF, fallback watcher, source-label SM, isam2 handle), c8_fc_adapter
  (inbound MAVLink + MSP2, AP outbound, iNav outbound, QGC GCS),
  c13_fdr writer, c12_operator_tooling httpx flights client. All
  constructors default to WallClock() so existing call sites keep
  live-binary behaviour without a wiring change.
* AC-4 CI guard (tests/_meta/test_no_direct_time_in_components.py)
  AST-scans components/**/*.py for direct time.monotonic_ns /
  time.time_ns / time.sleep references and fails loudly with file:line.
* Conformance + factory tests: tests/unit/clock + tests/unit/frame_source.
* Test fixture updates: FallbackWatcher / SourceLabelStateMachine
  clock_ns is now required (removed time.monotonic_ns default);
  test_az388 patches estimator._clock instead of a module-level time;
  test_az393 ardupilot adapter uses a _FixedClock test double.

Excluded per the task spec: TlogReplayFcAdapter (AZ-399), ReplaySink
(AZ-400), compose_replay (AZ-401), CLI (AZ-402), Docker/CI (AZ-403),
E2E fixture (AZ-404), IMU auto-sync (AZ-405).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 05:10:01 +03:00

93 lines
3.2 KiB
Python

"""``TlogDerivedClock`` strategy (AZ-398) — replay-only.
Advances ``monotonic_ns`` on each call to the next timestamp emitted by
the wrapped tlog-timestamp source. Out-of-order timestamps raise
:class:`ClockOrderingError` (AC-6) — replay determinism is hard-failed,
never silently smoothed.
The strategy is constructed by the replay composition root (AZ-401)
with a callable that yields tlog timestamps as the parser advances.
For unit tests, an iterator of pre-known timestamps suffices.
"""
from __future__ import annotations
from collections.abc import Callable, Iterable, Iterator
class ClockOrderingError(RuntimeError):
"""Raised when the tlog-timestamp source emits a non-monotonic value.
Replay must be deterministic; a strategy that silently smooths
backward jumps would mask a genuine recording corruption. The error
names the offending pair so the operator can correlate against the
tlog message stream.
"""
class TlogDerivedClock:
"""Replay :class:`Clock` strategy driven by the tlog timestamp stream.
The source can be either a callable returning ``int`` ns (typical
when wired against the live tlog parser, AZ-399) or an iterable of
pre-known ``int`` ns values (typical in unit tests). Both are normalised
to an internal :class:`Iterator` lazily.
Semantics:
- :meth:`monotonic_ns` pulls the next value from the source on every
call and returns it (advance-on-call). The most-recently-returned
value is cached for :meth:`time_ns` so the two methods stay aligned.
- :meth:`time_ns` returns the latest cached value; if no call to
:meth:`monotonic_ns` has happened yet, it returns 0 (the replay
composition root must pump at least one frame before any consumer
asks for wall-clock time).
- :meth:`sleep_until_ns` is a no-op (``pace=ASAP``).
"""
__slots__ = ("_source", "_last_ns")
def __init__(
self,
source: Callable[[], int] | Iterable[int],
) -> None:
if callable(source):
self._source: Iterator[int] = _iter_from_callable(source)
else:
self._source = iter(source)
self._last_ns = 0
def monotonic_ns(self) -> int:
try:
next_ns = next(self._source)
except StopIteration:
return self._last_ns
if next_ns < self._last_ns:
raise ClockOrderingError(
f"TlogDerivedClock: non-monotonic timestamp "
f"{next_ns} ns followed {self._last_ns} ns"
)
self._last_ns = next_ns
return next_ns
def time_ns(self) -> int:
return self._last_ns
def sleep_until_ns(self, target_ns: int) -> None:
"""No-op in ASAP pace (Invariant 6)."""
return None
def _iter_from_callable(source: Callable[[], int]) -> Iterator[int]:
"""Wrap a callable as an iterator that calls it on each ``next()``.
Used when the tlog parser exposes a ``next_timestamp_ns()``-style
hook; consumers should NOT pass a side-effectful callable that
blocks — the source is expected to be cheap (microsecond-class).
"""
while True:
yield source()
__all__ = ["ClockOrderingError", "TlogDerivedClock"]