[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:
Oleksandr Bezdieniezhnykh
2026-05-12 05:10:01 +03:00
parent 6c7d24f7e0
commit 823c0f1b2e
32 changed files with 1575 additions and 105 deletions
@@ -37,7 +37,6 @@ transitions.
from __future__ import annotations
import threading
import time
from collections.abc import Callable
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Final, Protocol, runtime_checkable
@@ -107,8 +106,8 @@ class FallbackWatcher:
*,
threshold_s: float,
fdr_client: FdrClient | None,
clock_ns: Callable[[], int],
producer_id: str = "c5_state",
clock_ns: Callable[[], int] = time.monotonic_ns,
) -> None:
if threshold_s <= 0.0:
raise ValueError(f"FallbackWatcher.threshold_s must be > 0; got {threshold_s}")
@@ -18,7 +18,6 @@ defensive trace.
from __future__ import annotations
import time
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
import gtsam
@@ -205,5 +204,10 @@ class ISam2GraphHandleImpl(ISam2GraphHandle):
anchor (``_last_anchor_ns`` is initialised to 0 in the
estimator constructor). This matches the C5 contract's
documented "no anchor yet" sentinel.
Reads the estimator's injected :class:`Clock` so replay /
unit-test runs see deterministic age values.
"""
return (time.monotonic_ns() - self._estimator._last_anchor_ns) // 1_000_000
return (
self._estimator._clock.monotonic_ns() - self._estimator._last_anchor_ns
) // 1_000_000
@@ -35,7 +35,6 @@ matrix simpler.
from __future__ import annotations
import threading
import time
from collections.abc import Callable
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Final, Protocol, runtime_checkable
@@ -154,8 +153,8 @@ class SourceLabelStateMachine:
spoof_promotion_visual_consistency_tol_m: float,
spoof_promotion_bounded_delta_m: float,
fdr_client: FdrClient | None,
clock_ns: Callable[[], int],
producer_id: str = "c5_state",
clock_ns: Callable[[], int] = time.monotonic_ns,
) -> None:
if spoof_promotion_min_stable_s <= 0.0:
raise ValueError(
@@ -47,7 +47,6 @@ filter; this module documents the deviation in the
from __future__ import annotations
import math
import time
from collections import deque
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any, Final, Literal
@@ -57,6 +56,7 @@ import numpy as np
from numpy.linalg import LinAlgError
from gps_denied_onboard._types.geo import LatLonAlt
from gps_denied_onboard.clock.wall_clock import WallClock
from gps_denied_onboard._types.state import (
EstimatorHealth,
EstimatorOutput,
@@ -89,9 +89,9 @@ from gps_denied_onboard.logging import get_logger
if TYPE_CHECKING:
from gps_denied_onboard._types.fc import GpsHealth, GpsSample
from gps_denied_onboard._types.nav import ImuWindow
from gps_denied_onboard._types.nav import ImuWindow, VioOutput
from gps_denied_onboard._types.pose import PoseEstimate
from gps_denied_onboard._types.vio import VioOutput
from gps_denied_onboard.clock import Clock
from gps_denied_onboard.config import Config
__all__ = [
@@ -162,6 +162,7 @@ class EskfStateEstimator(StateEstimator):
se3_utils: Any,
wgs_converter: Any,
fdr_client: Any,
clock: Clock | None = None,
) -> None:
block = self._extract_block(config)
self._config: Config = config
@@ -170,6 +171,7 @@ class EskfStateEstimator(StateEstimator):
self._se3_utils: Any = se3_utils
self._wgs_converter: Any = wgs_converter
self._fdr_client: Any = fdr_client
self._clock: Clock = clock if clock is not None else WallClock()
self._log = get_logger("c5_state.eskf_baseline")
self._nominal_pos: np.ndarray = np.zeros(3, dtype=np.float64)
@@ -215,6 +217,7 @@ class EskfStateEstimator(StateEstimator):
spoof_promotion_visual_consistency_tol_m=block.spoof_promotion_visual_consistency_tol_m,
spoof_promotion_bounded_delta_m=block.spoof_promotion_bounded_delta_m,
fdr_client=fdr_client,
clock_ns=self._clock.monotonic_ns,
producer_id="c5_state",
)
@@ -222,6 +225,7 @@ class EskfStateEstimator(StateEstimator):
self._fallback = FallbackWatcher(
threshold_s=block.no_estimate_fallback_s,
fdr_client=fdr_client,
clock_ns=self._clock.monotonic_ns,
producer_id="c5_state",
)
@@ -538,7 +542,7 @@ class EskfStateEstimator(StateEstimator):
# Both modes are treated identically by the ESKF — the
# JACOBIAN exclusion is iSAM2-graph-specific. AC-4.
self._last_anchor_ns = time.monotonic_ns()
self._last_anchor_ns = self._clock.monotonic_ns()
residual_pos = meas_pose[:3, 3] - self._nominal_pos
meas_R = meas_pose[:3, :3]
@@ -612,7 +616,7 @@ class EskfStateEstimator(StateEstimator):
def current_estimate(self) -> EstimatorOutput:
"""Forward-time estimate. ``smoothed=False`` (Invariant 7)."""
now_ns = time.monotonic_ns()
now_ns = self._clock.monotonic_ns()
self._fallback.check_and_engage(now_ns)
cov6 = self._pose_covariance_6x6()
@@ -629,7 +633,7 @@ class EskfStateEstimator(StateEstimator):
)
raise
emitted_at = time.monotonic_ns()
emitted_at = self._clock.monotonic_ns()
position_wgs84 = self._enu_pose_to_wgs84()
orientation = _quat_to_quat_dto(self._nominal_q)
velocity_world = (
@@ -864,7 +868,7 @@ class EskfStateEstimator(StateEstimator):
return
try:
machine.notify_satellite_anchor(
now_ns=time.monotonic_ns(),
now_ns=self._clock.monotonic_ns(),
gps_consistency_delta_m=None,
)
except Exception as exc:
@@ -31,7 +31,6 @@ there.
from __future__ import annotations
import math
import time
from collections import deque
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any, Final, Literal
@@ -43,6 +42,7 @@ import numpy as np
from numpy.linalg import LinAlgError
from gps_denied_onboard._types.geo import LatLonAlt
from gps_denied_onboard.clock.wall_clock import WallClock
from gps_denied_onboard._types.state import (
EstimatorHealth,
EstimatorOutput,
@@ -79,9 +79,9 @@ from gps_denied_onboard.logging import get_logger
if TYPE_CHECKING:
from gps_denied_onboard._types.fc import GpsHealth, GpsSample
from gps_denied_onboard._types.nav import ImuWindow
from gps_denied_onboard._types.nav import ImuWindow, VioOutput
from gps_denied_onboard._types.pose import PoseEstimate
from gps_denied_onboard._types.vio import VioOutput
from gps_denied_onboard.clock import Clock
from gps_denied_onboard.config import Config
__all__ = [
@@ -148,6 +148,7 @@ class GtsamIsam2StateEstimator(StateEstimator):
se3_utils: Any,
wgs_converter: Any,
fdr_client: Any,
clock: Clock | None = None,
) -> None:
block = self._extract_block(config)
@@ -157,6 +158,7 @@ class GtsamIsam2StateEstimator(StateEstimator):
self._se3_utils: Any = se3_utils
self._wgs_converter: Any = wgs_converter
self._fdr_client: Any = fdr_client
self._clock: Clock = clock if clock is not None else WallClock()
self._isam2 = gtsam.ISAM2(gtsam.ISAM2Params())
window_seconds: float = block.keyframe_window_size * _FRAME_PERIOD_S
@@ -224,6 +226,7 @@ class GtsamIsam2StateEstimator(StateEstimator):
spoof_promotion_visual_consistency_tol_m=block.spoof_promotion_visual_consistency_tol_m,
spoof_promotion_bounded_delta_m=block.spoof_promotion_bounded_delta_m,
fdr_client=fdr_client,
clock_ns=self._clock.monotonic_ns,
producer_id="c5_state",
)
# AC-NEW-8 rolling window of ``(ts_monotonic_ns, cov_norm)``
@@ -255,6 +258,7 @@ class GtsamIsam2StateEstimator(StateEstimator):
self._fallback = FallbackWatcher(
threshold_s=block.no_estimate_fallback_s,
fdr_client=fdr_client,
clock_ns=self._clock.monotonic_ns,
producer_id="c5_state",
)
@@ -481,7 +485,7 @@ class GtsamIsam2StateEstimator(StateEstimator):
# AC-6 / Invariant 11a: do NOT advance ``_last_added_ts_ns`` —
# this is a pre-takeoff seed, not a measurement; the first
# subsequent ``add_*`` call still sees the unguarded baseline.
ts_ns = time.monotonic_ns()
ts_ns = self._clock.monotonic_ns()
try:
handle.add_factor(factor)
self._values.insert(prior_key, prior_pose)
@@ -734,7 +738,7 @@ class GtsamIsam2StateEstimator(StateEstimator):
# Both paths update the anchor freshness sentinel. The C5
# contract documents this — even the throttled JACOBIAN path
# counts as a recent anchor for AC-1.3 binning.
self._last_anchor_ns = time.monotonic_ns()
self._last_anchor_ns = self._clock.monotonic_ns()
if mode == "marginals":
gtsam_pose = _pose_se3_to_gtsam(self._pose_estimate_to_matrix(pose))
@@ -923,7 +927,7 @@ class GtsamIsam2StateEstimator(StateEstimator):
# AZ-388: AC-5.2 entry hook. Engages fallback if the
# threshold has elapsed since the last successful estimate.
# Idempotent / rate-limited.
self._fallback.check_and_engage(time.monotonic_ns())
self._fallback.check_and_engage(self._clock.monotonic_ns())
if self._last_committed_pose_key is None:
raise EstimatorFatalError(
"current_estimate: no committed pose key yet (graph empty); "
@@ -975,7 +979,7 @@ class GtsamIsam2StateEstimator(StateEstimator):
velocity_world = self._latest_velocity_or_zero()
last_anchor_age_ms = int(handle.last_anchor_age_ms())
source_label = self._derive_source_label()
emitted_at = time.monotonic_ns()
emitted_at = self._clock.monotonic_ns()
self._record_cov_norm_sample(emitted_at, covariance)
if self._isam2_state == IsamState.INIT:
@@ -1063,7 +1067,7 @@ class GtsamIsam2StateEstimator(StateEstimator):
last_anchor_age_ms = int(handle.last_anchor_age_ms())
source_label = self._derive_source_label()
emitted_at = time.monotonic_ns()
emitted_at = self._clock.monotonic_ns()
out: list[EstimatorOutput] = []
for key, _ts in selected:
@@ -1366,7 +1370,7 @@ class GtsamIsam2StateEstimator(StateEstimator):
return
try:
machine.notify_satellite_anchor(
now_ns=time.monotonic_ns(),
now_ns=self._clock.monotonic_ns(),
gps_consistency_delta_m=None,
)
except Exception as exc: