mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-23 04:31: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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user