mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 23:01:13 +00:00
[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>
This commit is contained in:
@@ -1,7 +1,12 @@
|
||||
"""Clock interface + concrete implementations.
|
||||
"""``Clock`` Protocol — public surface (AZ-398 v1.0.0).
|
||||
|
||||
The interface is bootstrap-stubbed here. `WallClock` (live) and `TlogDerivedClock`
|
||||
(replay) are owned by AZ-401 (E-DEMO-REPLAY).
|
||||
Re-exports the Protocol only; concrete strategies (``WallClock``,
|
||||
``TlogDerivedClock``) are NOT exported via ``__all__`` per AC-9 —
|
||||
composition-root code imports them from their concrete module paths
|
||||
so the lazy-import boundary stays explicit.
|
||||
|
||||
Components import :class:`Clock` and accept it via constructor
|
||||
injection (Invariant 2).
|
||||
"""
|
||||
|
||||
from gps_denied_onboard.clock.interface import Clock
|
||||
|
||||
@@ -1,20 +1,70 @@
|
||||
"""`Clock` Protocol.
|
||||
"""``Clock`` Protocol — replay/live-agnostic monotonic + wall-clock time.
|
||||
|
||||
R-DEMO-4: production C1-C5 paths bake real-time-cadence assumptions; injected
|
||||
Clock lets replay mode trip those timers consistently against tlog timestamps.
|
||||
Frozen at AZ-398 v1.0.0 per the replay contract:
|
||||
``_docs/02_document/contracts/replay/replay_protocol.md``.
|
||||
|
||||
Owned by AZ-401. Bootstrap ships the interface stub.
|
||||
The Protocol is Layer 1 cross-cutting per ``module-layout.md`` — every
|
||||
component that previously called :func:`time.monotonic_ns`,
|
||||
:func:`time.time_ns`, or :func:`time.sleep` MUST consume an injected
|
||||
:class:`Clock` instead (Invariant 2). The strategy is selected exactly
|
||||
once at composition time (Invariant — Single Clock per process):
|
||||
|
||||
- **Live / research / operator** binaries inject :class:`WallClock`.
|
||||
- **Replay** binary injects :class:`TlogDerivedClock` (ASAP) or
|
||||
:class:`WallClock` (REALTIME pace).
|
||||
|
||||
Mode-specific behaviour lives in the strategy; consumers see only the
|
||||
``Clock`` interface (R-DEMO-4 mitigation).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Protocol
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class Clock(Protocol):
|
||||
"""A monotonic clock abstraction."""
|
||||
"""Monotonic + wall-clock + sleep-until abstraction (AZ-398 v1.0.0).
|
||||
|
||||
def now(self) -> datetime: ...
|
||||
All three methods are non-blocking except :meth:`sleep_until_ns`,
|
||||
which honours the configured replay pace:
|
||||
|
||||
def monotonic(self) -> float: ...
|
||||
- ``WallClock.sleep_until_ns(t)`` blocks until ``time.monotonic_ns()``
|
||||
catches up to ``t`` (live + REALTIME replay).
|
||||
- ``TlogDerivedClock.sleep_until_ns(t)`` is a no-op (ASAP replay).
|
||||
|
||||
Strategies MUST guarantee :meth:`monotonic_ns` is non-decreasing
|
||||
across calls within the same process (Invariant 3 spirit).
|
||||
"""
|
||||
|
||||
def monotonic_ns(self) -> int:
|
||||
"""Return the strategy's monotonic time in nanoseconds.
|
||||
|
||||
For :class:`WallClock` this delegates to
|
||||
:func:`time.monotonic_ns`. For :class:`TlogDerivedClock` this
|
||||
returns the most recently advanced tlog timestamp (advance-on-
|
||||
call semantics — see AC-6).
|
||||
"""
|
||||
...
|
||||
|
||||
def time_ns(self) -> int:
|
||||
"""Return the strategy's UTC wall-clock time in nanoseconds.
|
||||
|
||||
Used for log timestamps that need calendar alignment (FDR
|
||||
records, STATUSTEXT). For :class:`WallClock` this is
|
||||
:func:`time.time_ns`; for :class:`TlogDerivedClock` this is the
|
||||
tlog message's wall-clock timestamp (the ``time_unix_usec`` /
|
||||
``time_boot_ms`` field, normalised to ns).
|
||||
"""
|
||||
...
|
||||
|
||||
def sleep_until_ns(self, target_ns: int) -> None:
|
||||
"""Block until :meth:`monotonic_ns` would return ``target_ns``.
|
||||
|
||||
Honours ``pace=REALTIME`` by sleeping the wall-clock delta; honours
|
||||
``pace=ASAP`` by no-op'ing. ``target_ns`` already in the past is a
|
||||
no-op (no exception, no negative sleep). The Protocol does not
|
||||
prescribe spurious-wakeup behaviour; strategies SHOULD use
|
||||
:func:`time.sleep` (which retries internally on POSIX).
|
||||
"""
|
||||
...
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""``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"]
|
||||
@@ -0,0 +1,42 @@
|
||||
"""``WallClock`` strategy (AZ-398) — live + REALTIME replay.
|
||||
|
||||
Thin :class:`Clock` adapter over the standard-library :mod:`time`
|
||||
module. Owned by ``clock/`` so the AC-4 AST scan over ``components/``
|
||||
remains clean: components MUST NOT call :func:`time.monotonic_ns`
|
||||
directly; they call :meth:`WallClock.monotonic_ns` via injection.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
|
||||
class WallClock:
|
||||
"""Default :class:`Clock` strategy backed by :mod:`time`.
|
||||
|
||||
Stateless; constructable without arguments. All three methods are
|
||||
trivially Liskov-clean over the Protocol.
|
||||
"""
|
||||
|
||||
__slots__ = ()
|
||||
|
||||
def monotonic_ns(self) -> int:
|
||||
return time.monotonic_ns()
|
||||
|
||||
def time_ns(self) -> int:
|
||||
return time.time_ns()
|
||||
|
||||
def sleep_until_ns(self, target_ns: int) -> None:
|
||||
"""Block until ``time.monotonic_ns() >= target_ns``.
|
||||
|
||||
A target already in the past is a no-op. Sub-millisecond
|
||||
oversleep is accepted (AC-5: ≤ 5 ms drift on a 100 ms sleep).
|
||||
"""
|
||||
now = time.monotonic_ns()
|
||||
delta_ns = target_ns - now
|
||||
if delta_ns <= 0:
|
||||
return
|
||||
time.sleep(delta_ns / 1_000_000_000.0)
|
||||
|
||||
|
||||
__all__ = ["WallClock"]
|
||||
Reference in New Issue
Block a user