mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 08:41:12 +00:00
bd41956164
Mid-flight fixtures (Derkachi) and stationary-still scenarios (FT-P-01) have no take-off spike for the IMU detector and produce false-positive video motion onsets, so the AC-9 frame-window validator rejects every plausible offset. Add an operator-acknowledged opt-out: a new ReplayConfig.skip_auto_sync_validation flag that suppresses validation, paired with a hard requirement that time_offset_ms also be set (silent-zero guard at both schema and adapter layers). Wired through schema -> CLI (--skip-auto-sync) -> composition root -> ReplayInputAdapter; Derkachi e2e fixture now passes time_offset_ms=0 + skip_auto_sync=True by default since the synth tlog and the video share the same t=0 anchor by construction. 5 new unit tests: * schema gate rejects skip=True without manual offset * schema gate accepts the legal pair * default field value is False (default-construction safety) * adapter constructor mirrors the schema gate * adapter open() bypasses validate_offset_or_fail when flag is set All 38 unit tests in test_az401 + test_az405 pass on Mac. Co-authored-by: Cursor <cursoragent@cursor.com>
805 lines
25 KiB
Python
805 lines
25 KiB
Python
"""AZ-405 — ``ReplayInputAdapter`` coordinator unit tests.
|
||
|
||
Covers AC-6 (low-confidence WARN-and-proceed), AC-7 (AC-8 hard-fail),
|
||
AC-8 (manual override bypass), AC-11 (open() returns a complete
|
||
bundle), AC-12 (idempotent close), and AC-13 (R-DEMO-3 fail-fast on
|
||
missing tlog message types).
|
||
|
||
Synthetic videos use the same OpenCV-driven fixture pattern as
|
||
``tests/unit/frame_source/test_protocol_conformance.py``; the tlog
|
||
side is faked via the coordinator's ``tlog_source_factory`` injection
|
||
point so tests run without a real pymavlink connection.
|
||
|
||
Style: every test follows the Arrange / Act / Assert pattern.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from pathlib import Path
|
||
from types import SimpleNamespace
|
||
from typing import Any
|
||
from unittest import mock
|
||
|
||
import cv2
|
||
import numpy as np
|
||
import pytest
|
||
|
||
from gps_denied_onboard._types.calibration import CameraCalibration
|
||
from gps_denied_onboard._types.fc import FcKind
|
||
from gps_denied_onboard.clock.tlog_derived import TlogDerivedClock
|
||
from gps_denied_onboard.clock.wall_clock import WallClock
|
||
from gps_denied_onboard.components.c8_fc_adapter.tlog_replay_adapter import (
|
||
ReplayPace,
|
||
TlogReplayFcAdapter,
|
||
)
|
||
from gps_denied_onboard.frame_source.video_file import VideoFileFrameSource
|
||
from gps_denied_onboard.replay_input.errors import ReplayInputAdapterError
|
||
from gps_denied_onboard.replay_input.interface import (
|
||
AutoSyncConfig,
|
||
AutoSyncDecision,
|
||
ReplayInputBundle,
|
||
)
|
||
from gps_denied_onboard.replay_input.tlog_video_adapter import ReplayInputAdapter
|
||
|
||
|
||
# ---------------------------------------------------------------------
|
||
# Fixtures
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _enable_build_flags(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Both downstream strategies are gated by build flags (AZ-398 / AZ-399)."""
|
||
monkeypatch.setenv("BUILD_VIDEO_FILE_FRAME_SOURCE", "ON")
|
||
monkeypatch.setenv("BUILD_TLOG_REPLAY_ADAPTER", "ON")
|
||
|
||
|
||
@pytest.fixture
|
||
def fake_fdr_client() -> mock.MagicMock:
|
||
return mock.MagicMock(name="FdrClient")
|
||
|
||
|
||
@pytest.fixture
|
||
def fake_wgs_converter() -> mock.MagicMock:
|
||
return mock.MagicMock(name="WgsConverter")
|
||
|
||
|
||
@pytest.fixture
|
||
def camera_calibration() -> CameraCalibration:
|
||
return CameraCalibration(
|
||
camera_id="az405-test",
|
||
intrinsics_3x3=None,
|
||
distortion=None,
|
||
body_to_camera_se3=None,
|
||
acquisition_method="synthetic",
|
||
)
|
||
|
||
|
||
def _make_synthetic_video(path: Path, n_frames: int = 60, fps: int = 30) -> Path:
|
||
"""Write a 64×48 BGR MP4V file at ``path`` and return it."""
|
||
fourcc = cv2.VideoWriter_fourcc(*"mp4v")
|
||
writer = cv2.VideoWriter(str(path), fourcc, fps, (64, 48))
|
||
if not writer.isOpened():
|
||
raise RuntimeError(f"OpenCV could not open writer at {path!s}")
|
||
try:
|
||
for i in range(n_frames):
|
||
frame = np.full((48, 64, 3), i % 256, dtype=np.uint8)
|
||
writer.write(frame)
|
||
finally:
|
||
writer.release()
|
||
return path
|
||
|
||
|
||
@pytest.fixture
|
||
def synthetic_video(tmp_path: Path) -> Path:
|
||
return _make_synthetic_video(tmp_path / "az405-video.mp4", n_frames=60, fps=30)
|
||
|
||
|
||
@pytest.fixture
|
||
def synthetic_tlog_path(tmp_path: Path) -> Path:
|
||
p = tmp_path / "az405.tlog"
|
||
p.write_bytes(b"fake-tlog")
|
||
return p
|
||
|
||
|
||
# ---------------------------------------------------------------------
|
||
# Fake pymavlink source
|
||
|
||
|
||
def _ns(seconds: float) -> int:
|
||
return int(seconds * 1_000_000_000)
|
||
|
||
|
||
def _fake_msg(msg_type: str, *, ts_s: float, **fields: Any) -> SimpleNamespace:
|
||
ns = SimpleNamespace(_timestamp=ts_s, **fields)
|
||
ns.get_type = lambda: msg_type
|
||
return ns
|
||
|
||
|
||
def _build_takeoff_messages(
|
||
*,
|
||
accel_pre_total_g: float = 1.0,
|
||
accel_post_total_g: float = 2.2,
|
||
accel_hz: int = 200,
|
||
pre_seconds: float = 2.0,
|
||
post_seconds: float = 1.5,
|
||
) -> list[SimpleNamespace]:
|
||
"""A short tlog stream with a clear take-off pattern + GPS + heartbeat."""
|
||
out: list[SimpleNamespace] = []
|
||
accel_period = 1.0 / accel_hz
|
||
# Pre-takeoff: 1 g hover (z-acc = -1g in body, after sign).
|
||
t = 0.0
|
||
while t < pre_seconds:
|
||
out.append(
|
||
_fake_msg(
|
||
"RAW_IMU",
|
||
ts_s=t,
|
||
xacc=0,
|
||
yacc=0,
|
||
zacc=-int(accel_pre_total_g * 1000),
|
||
xgyro=0,
|
||
ygyro=0,
|
||
zgyro=0,
|
||
)
|
||
)
|
||
t += accel_period
|
||
# Post-takeoff: 2.2 g sustained climb thrust.
|
||
while t < pre_seconds + post_seconds:
|
||
out.append(
|
||
_fake_msg(
|
||
"RAW_IMU",
|
||
ts_s=t,
|
||
xacc=0,
|
||
yacc=0,
|
||
zacc=-int(accel_post_total_g * 1000),
|
||
xgyro=0,
|
||
ygyro=0,
|
||
zgyro=0,
|
||
)
|
||
)
|
||
t += accel_period
|
||
|
||
# Attitude: flat hover then 1.5 rad/s pitch ramp.
|
||
t = 0.0
|
||
attitude_period = 1.0 / 100.0
|
||
while t < pre_seconds:
|
||
out.append(
|
||
_fake_msg("ATTITUDE", ts_s=t, roll=0.0, pitch=0.0, yaw=0.0)
|
||
)
|
||
t += attitude_period
|
||
pitch_rate = 1.5
|
||
while t < pre_seconds + post_seconds:
|
||
dt = t - pre_seconds
|
||
out.append(
|
||
_fake_msg(
|
||
"ATTITUDE",
|
||
ts_s=t,
|
||
roll=0.0,
|
||
pitch=pitch_rate * dt,
|
||
yaw=0.0,
|
||
)
|
||
)
|
||
t += attitude_period
|
||
|
||
# GPS_RAW_INT + HEARTBEAT (required by AZ-399 pre-scan).
|
||
out.append(
|
||
_fake_msg(
|
||
"GPS_RAW_INT",
|
||
ts_s=0.0,
|
||
fix_type=3,
|
||
lat=499910000,
|
||
lon=362210000,
|
||
alt=153_400,
|
||
)
|
||
)
|
||
out.append(_fake_msg("HEARTBEAT", ts_s=0.0, system_status=4, base_mode=0))
|
||
out.sort(key=lambda m: m._timestamp)
|
||
return out
|
||
|
||
|
||
class _FakeTlog:
|
||
"""Minimal pymavlink ``mavlink_connection`` stand-in.
|
||
|
||
Returns each stored message once on ``recv_match``; ignores the
|
||
``type=`` filter (the AZ-399 decode loop receives unfiltered
|
||
HEARTBEAT/IMU/ATTITUDE/GPS streams).
|
||
"""
|
||
|
||
def __init__(self, messages: list[SimpleNamespace]) -> None:
|
||
self._iter = iter(messages)
|
||
self.closed = False
|
||
|
||
def recv_match(self, **_kwargs: Any) -> SimpleNamespace | None:
|
||
return next(self._iter, None)
|
||
|
||
def close(self) -> None:
|
||
self.closed = True
|
||
|
||
|
||
def _factory_for(messages: list[SimpleNamespace]) -> Any:
|
||
"""Return a source factory that yields a fresh ``_FakeTlog`` per call.
|
||
|
||
The coordinator opens the tlog twice (once for ``_load_tlog_samples``
|
||
in the auto-sync path, once via the FC adapter's pre-scan + decode
|
||
handles), so the messages have to be re-emittable.
|
||
"""
|
||
|
||
def _factory(_path: str) -> _FakeTlog:
|
||
return _FakeTlog(list(messages))
|
||
|
||
return _factory
|
||
|
||
|
||
def _frames_factory_with_motion(
|
||
*,
|
||
n_stationary: int = 10,
|
||
n_moving: int = 50,
|
||
fps: int = 30,
|
||
) -> Any:
|
||
"""Return a frames_factory yielding the AC-4 motion-onset shape."""
|
||
period_ns = int(1_000_000_000 / fps)
|
||
rng = np.random.default_rng(seed=0)
|
||
|
||
def _factory(_path: Path, _scan_seconds: float) -> Any:
|
||
out: list[tuple[int, np.ndarray]] = []
|
||
# Stationary: identical frames so optical flow ≈ 0.
|
||
base = np.full((48, 64, 3), 128, dtype=np.uint8)
|
||
for i in range(n_stationary):
|
||
out.append((i * period_ns, base.copy()))
|
||
# Moving: each frame replaces a 16×16 patch at a random offset
|
||
# so Farneback returns a clear non-zero magnitude. Determinism
|
||
# is preserved by the seeded RNG.
|
||
for j in range(n_moving):
|
||
frame = base.copy()
|
||
r = rng.integers(0, 32)
|
||
c = rng.integers(0, 48)
|
||
frame[r : r + 16, c : c + 16, :] = 240
|
||
out.append(((n_stationary + j) * period_ns, frame))
|
||
return out
|
||
|
||
return _factory
|
||
|
||
|
||
def _video_timestamps_factory(
|
||
*,
|
||
n_frames: int = 60,
|
||
fps: int = 30,
|
||
) -> Any:
|
||
"""Return a timestamps_factory with deterministic per-frame ts (ns)."""
|
||
period_ns = int(1_000_000_000 / fps)
|
||
|
||
def _factory(_path: Path) -> list[int]:
|
||
return [i * period_ns for i in range(n_frames)]
|
||
|
||
return _factory
|
||
|
||
|
||
# ---------------------------------------------------------------------
|
||
# AC-11 — open() returns a complete bundle
|
||
|
||
|
||
def test_ac11_open_returns_complete_bundle_with_correct_strategies(
|
||
synthetic_video: Path,
|
||
synthetic_tlog_path: Path,
|
||
camera_calibration: CameraCalibration,
|
||
fake_wgs_converter: mock.MagicMock,
|
||
fake_fdr_client: mock.MagicMock,
|
||
) -> None:
|
||
# Arrange
|
||
messages = _build_takeoff_messages()
|
||
adapter = ReplayInputAdapter(
|
||
video_path=synthetic_video,
|
||
tlog_path=synthetic_tlog_path,
|
||
camera_calibration=camera_calibration,
|
||
target_fc_dialect=FcKind.ARDUPILOT_PLANE,
|
||
wgs_converter=fake_wgs_converter,
|
||
fdr_client=fake_fdr_client,
|
||
pace=ReplayPace.ASAP,
|
||
manual_time_offset_ms=0,
|
||
auto_sync_config=AutoSyncConfig(),
|
||
tlog_source_factory=_factory_for(messages),
|
||
video_timestamps_factory=_video_timestamps_factory(),
|
||
)
|
||
|
||
# Act
|
||
try:
|
||
bundle = adapter.open()
|
||
|
||
# Assert
|
||
assert isinstance(bundle, ReplayInputBundle)
|
||
assert isinstance(bundle.frame_source, VideoFileFrameSource)
|
||
assert isinstance(bundle.fc_adapter, TlogReplayFcAdapter)
|
||
assert isinstance(bundle.clock, TlogDerivedClock)
|
||
assert bundle.resolved_time_offset_ms == 0
|
||
# Manual path → no auto-sync result.
|
||
assert bundle.auto_sync_result is None
|
||
finally:
|
||
adapter.close()
|
||
|
||
|
||
def test_ac11_pace_realtime_yields_wall_clock(
|
||
synthetic_video: Path,
|
||
synthetic_tlog_path: Path,
|
||
camera_calibration: CameraCalibration,
|
||
fake_wgs_converter: mock.MagicMock,
|
||
fake_fdr_client: mock.MagicMock,
|
||
) -> None:
|
||
# Arrange
|
||
messages = _build_takeoff_messages()
|
||
adapter = ReplayInputAdapter(
|
||
video_path=synthetic_video,
|
||
tlog_path=synthetic_tlog_path,
|
||
camera_calibration=camera_calibration,
|
||
target_fc_dialect=FcKind.ARDUPILOT_PLANE,
|
||
wgs_converter=fake_wgs_converter,
|
||
fdr_client=fake_fdr_client,
|
||
pace=ReplayPace.REALTIME,
|
||
manual_time_offset_ms=0,
|
||
auto_sync_config=AutoSyncConfig(),
|
||
tlog_source_factory=_factory_for(messages),
|
||
video_timestamps_factory=_video_timestamps_factory(),
|
||
)
|
||
|
||
# Act
|
||
try:
|
||
bundle = adapter.open()
|
||
|
||
# Assert
|
||
assert isinstance(bundle.clock, WallClock)
|
||
finally:
|
||
adapter.close()
|
||
|
||
|
||
def test_ac11_pace_asap_yields_tlog_derived_clock(
|
||
synthetic_video: Path,
|
||
synthetic_tlog_path: Path,
|
||
camera_calibration: CameraCalibration,
|
||
fake_wgs_converter: mock.MagicMock,
|
||
fake_fdr_client: mock.MagicMock,
|
||
) -> None:
|
||
# Arrange
|
||
messages = _build_takeoff_messages()
|
||
adapter = ReplayInputAdapter(
|
||
video_path=synthetic_video,
|
||
tlog_path=synthetic_tlog_path,
|
||
camera_calibration=camera_calibration,
|
||
target_fc_dialect=FcKind.ARDUPILOT_PLANE,
|
||
wgs_converter=fake_wgs_converter,
|
||
fdr_client=fake_fdr_client,
|
||
pace=ReplayPace.ASAP,
|
||
manual_time_offset_ms=0,
|
||
auto_sync_config=AutoSyncConfig(),
|
||
tlog_source_factory=_factory_for(messages),
|
||
video_timestamps_factory=_video_timestamps_factory(),
|
||
)
|
||
|
||
# Act
|
||
try:
|
||
bundle = adapter.open()
|
||
|
||
# Assert
|
||
assert isinstance(bundle.clock, TlogDerivedClock)
|
||
finally:
|
||
adapter.close()
|
||
|
||
|
||
# ---------------------------------------------------------------------
|
||
# AC-12 — idempotent close
|
||
|
||
|
||
def test_ac12_close_is_idempotent(
|
||
synthetic_video: Path,
|
||
synthetic_tlog_path: Path,
|
||
camera_calibration: CameraCalibration,
|
||
fake_wgs_converter: mock.MagicMock,
|
||
fake_fdr_client: mock.MagicMock,
|
||
) -> None:
|
||
# Arrange
|
||
messages = _build_takeoff_messages()
|
||
adapter = ReplayInputAdapter(
|
||
video_path=synthetic_video,
|
||
tlog_path=synthetic_tlog_path,
|
||
camera_calibration=camera_calibration,
|
||
target_fc_dialect=FcKind.ARDUPILOT_PLANE,
|
||
wgs_converter=fake_wgs_converter,
|
||
fdr_client=fake_fdr_client,
|
||
pace=ReplayPace.ASAP,
|
||
manual_time_offset_ms=0,
|
||
auto_sync_config=AutoSyncConfig(),
|
||
tlog_source_factory=_factory_for(messages),
|
||
video_timestamps_factory=_video_timestamps_factory(),
|
||
)
|
||
adapter.open()
|
||
|
||
# Act / Assert — both calls must complete without raising.
|
||
adapter.close()
|
||
adapter.close()
|
||
|
||
|
||
def test_close_without_open_does_not_raise(
|
||
synthetic_video: Path,
|
||
synthetic_tlog_path: Path,
|
||
camera_calibration: CameraCalibration,
|
||
fake_wgs_converter: mock.MagicMock,
|
||
fake_fdr_client: mock.MagicMock,
|
||
) -> None:
|
||
# Arrange
|
||
adapter = ReplayInputAdapter(
|
||
video_path=synthetic_video,
|
||
tlog_path=synthetic_tlog_path,
|
||
camera_calibration=camera_calibration,
|
||
target_fc_dialect=FcKind.ARDUPILOT_PLANE,
|
||
wgs_converter=fake_wgs_converter,
|
||
fdr_client=fake_fdr_client,
|
||
pace=ReplayPace.ASAP,
|
||
manual_time_offset_ms=0,
|
||
auto_sync_config=AutoSyncConfig(),
|
||
)
|
||
|
||
# Act / Assert
|
||
adapter.close()
|
||
|
||
|
||
# ---------------------------------------------------------------------
|
||
# AC-13 — missing tlog messages fail fast
|
||
|
||
|
||
def test_ac13_missing_imu_messages_fails_fast_before_video_read(
|
||
synthetic_video: Path,
|
||
synthetic_tlog_path: Path,
|
||
camera_calibration: CameraCalibration,
|
||
fake_wgs_converter: mock.MagicMock,
|
||
fake_fdr_client: mock.MagicMock,
|
||
) -> None:
|
||
# Arrange — tlog has only ATTITUDE; no RAW_IMU / SCALED_IMU2.
|
||
attitude_only = [
|
||
_fake_msg("ATTITUDE", ts_s=t * 0.01, roll=0.0, pitch=0.0, yaw=0.0)
|
||
for t in range(100)
|
||
]
|
||
adapter = ReplayInputAdapter(
|
||
video_path=synthetic_video,
|
||
tlog_path=synthetic_tlog_path,
|
||
camera_calibration=camera_calibration,
|
||
target_fc_dialect=FcKind.ARDUPILOT_PLANE,
|
||
wgs_converter=fake_wgs_converter,
|
||
fdr_client=fake_fdr_client,
|
||
pace=ReplayPace.ASAP,
|
||
manual_time_offset_ms=0,
|
||
auto_sync_config=AutoSyncConfig(),
|
||
tlog_source_factory=_factory_for(attitude_only),
|
||
)
|
||
|
||
# Act / Assert
|
||
with pytest.raises(
|
||
ReplayInputAdapterError, match="tlog missing required message types"
|
||
):
|
||
adapter.open()
|
||
|
||
|
||
def test_ac13_missing_attitude_messages_fails_fast(
|
||
synthetic_video: Path,
|
||
synthetic_tlog_path: Path,
|
||
camera_calibration: CameraCalibration,
|
||
fake_wgs_converter: mock.MagicMock,
|
||
fake_fdr_client: mock.MagicMock,
|
||
) -> None:
|
||
# Arrange — tlog has only RAW_IMU; no ATTITUDE.
|
||
imu_only = [
|
||
_fake_msg(
|
||
"RAW_IMU",
|
||
ts_s=t * 0.005,
|
||
xacc=0,
|
||
yacc=0,
|
||
zacc=-1000,
|
||
xgyro=0,
|
||
ygyro=0,
|
||
zgyro=0,
|
||
)
|
||
for t in range(100)
|
||
]
|
||
adapter = ReplayInputAdapter(
|
||
video_path=synthetic_video,
|
||
tlog_path=synthetic_tlog_path,
|
||
camera_calibration=camera_calibration,
|
||
target_fc_dialect=FcKind.ARDUPILOT_PLANE,
|
||
wgs_converter=fake_wgs_converter,
|
||
fdr_client=fake_fdr_client,
|
||
pace=ReplayPace.ASAP,
|
||
manual_time_offset_ms=0,
|
||
auto_sync_config=AutoSyncConfig(),
|
||
tlog_source_factory=_factory_for(imu_only),
|
||
)
|
||
|
||
# Act / Assert
|
||
with pytest.raises(
|
||
ReplayInputAdapterError, match=r"tlog missing required message types.*ATTITUDE"
|
||
):
|
||
adapter.open()
|
||
|
||
|
||
# ---------------------------------------------------------------------
|
||
# AC-8 — manual override bypasses auto-detect
|
||
|
||
|
||
def test_ac8_manual_override_bypasses_auto_detect(
|
||
synthetic_video: Path,
|
||
synthetic_tlog_path: Path,
|
||
camera_calibration: CameraCalibration,
|
||
fake_wgs_converter: mock.MagicMock,
|
||
fake_fdr_client: mock.MagicMock,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
# Arrange
|
||
detect_calls: list[Any] = []
|
||
|
||
def _explode_if_called(*args: Any, **kwargs: Any) -> Any:
|
||
detect_calls.append((args, kwargs))
|
||
raise AssertionError(
|
||
"auto-sync detector called even though manual_time_offset_ms was set"
|
||
)
|
||
|
||
monkeypatch.setattr(
|
||
"gps_denied_onboard.replay_input.tlog_video_adapter.detect_video_motion_onset",
|
||
_explode_if_called,
|
||
)
|
||
|
||
# Patch the take-off compute kernel referenced via the helper; the
|
||
# coordinator's manual path must skip it entirely.
|
||
monkeypatch.setattr(
|
||
"gps_denied_onboard.replay_input.auto_sync._compute_tlog_takeoff_from_samples",
|
||
_explode_if_called,
|
||
)
|
||
|
||
messages = _build_takeoff_messages()
|
||
adapter = ReplayInputAdapter(
|
||
video_path=synthetic_video,
|
||
tlog_path=synthetic_tlog_path,
|
||
camera_calibration=camera_calibration,
|
||
target_fc_dialect=FcKind.ARDUPILOT_PLANE,
|
||
wgs_converter=fake_wgs_converter,
|
||
fdr_client=fake_fdr_client,
|
||
pace=ReplayPace.ASAP,
|
||
manual_time_offset_ms=500,
|
||
auto_sync_config=AutoSyncConfig(),
|
||
tlog_source_factory=_factory_for(messages),
|
||
video_timestamps_factory=_video_timestamps_factory(),
|
||
)
|
||
|
||
# Act
|
||
try:
|
||
bundle = adapter.open()
|
||
|
||
# Assert — detector helpers were NOT invoked.
|
||
assert detect_calls == []
|
||
assert bundle.resolved_time_offset_ms == 500
|
||
assert bundle.auto_sync_result is None
|
||
finally:
|
||
adapter.close()
|
||
|
||
|
||
# ---------------------------------------------------------------------
|
||
# AC-7 — AC-8 hard-fail raises
|
||
|
||
|
||
def test_ac7_ac8_validator_hard_fail_raises_on_open(
|
||
synthetic_video: Path,
|
||
synthetic_tlog_path: Path,
|
||
camera_calibration: CameraCalibration,
|
||
fake_wgs_converter: mock.MagicMock,
|
||
fake_fdr_client: mock.MagicMock,
|
||
) -> None:
|
||
# Arrange — manual offset of 60 s will push every video frame
|
||
# outside the IMU coverage window (the fake tlog only carries
|
||
# ~3.5 s of samples).
|
||
messages = _build_takeoff_messages()
|
||
adapter = ReplayInputAdapter(
|
||
video_path=synthetic_video,
|
||
tlog_path=synthetic_tlog_path,
|
||
camera_calibration=camera_calibration,
|
||
target_fc_dialect=FcKind.ARDUPILOT_PLANE,
|
||
wgs_converter=fake_wgs_converter,
|
||
fdr_client=fake_fdr_client,
|
||
pace=ReplayPace.ASAP,
|
||
manual_time_offset_ms=60_000,
|
||
auto_sync_config=AutoSyncConfig(),
|
||
tlog_source_factory=_factory_for(messages),
|
||
video_timestamps_factory=_video_timestamps_factory(),
|
||
)
|
||
|
||
# Act / Assert
|
||
with pytest.raises(ReplayInputAdapterError, match="auto-sync hard-fail"):
|
||
adapter.open()
|
||
|
||
|
||
# ---------------------------------------------------------------------
|
||
# AZ-611 — skip_auto_sync_validation bypasses the AC-9 validator
|
||
|
||
|
||
def test_az611_skip_auto_sync_validation_bypasses_ac9(
|
||
synthetic_video: Path,
|
||
synthetic_tlog_path: Path,
|
||
camera_calibration: CameraCalibration,
|
||
fake_wgs_converter: mock.MagicMock,
|
||
fake_fdr_client: mock.MagicMock,
|
||
) -> None:
|
||
"""A manual offset that WOULD hard-fail AC-9 succeeds when the
|
||
operator explicitly opts out via ``skip_auto_sync_validation=True``.
|
||
Mirrors the AC-7 hard-fail scenario above so the bypass is the
|
||
only variable.
|
||
"""
|
||
# Arrange — same manual offset (60 s) that AC-7 above proves
|
||
# pushes every frame outside the IMU window.
|
||
messages = _build_takeoff_messages()
|
||
adapter = ReplayInputAdapter(
|
||
video_path=synthetic_video,
|
||
tlog_path=synthetic_tlog_path,
|
||
camera_calibration=camera_calibration,
|
||
target_fc_dialect=FcKind.ARDUPILOT_PLANE,
|
||
wgs_converter=fake_wgs_converter,
|
||
fdr_client=fake_fdr_client,
|
||
pace=ReplayPace.ASAP,
|
||
manual_time_offset_ms=60_000,
|
||
skip_auto_sync_validation=True,
|
||
auto_sync_config=AutoSyncConfig(),
|
||
tlog_source_factory=_factory_for(messages),
|
||
video_timestamps_factory=_video_timestamps_factory(),
|
||
)
|
||
|
||
# Act
|
||
try:
|
||
bundle = adapter.open()
|
||
|
||
# Assert — the bypass let the open() complete with the manual
|
||
# offset intact, even though the validator would have rejected it.
|
||
assert bundle.resolved_time_offset_ms == 60_000
|
||
assert bundle.auto_sync_result is None
|
||
finally:
|
||
adapter.close()
|
||
|
||
|
||
def test_az611_skip_auto_sync_validation_requires_manual_offset(
|
||
synthetic_video: Path,
|
||
synthetic_tlog_path: Path,
|
||
camera_calibration: CameraCalibration,
|
||
fake_wgs_converter: mock.MagicMock,
|
||
fake_fdr_client: mock.MagicMock,
|
||
) -> None:
|
||
"""Constructor refuses ``skip_auto_sync_validation=True`` paired
|
||
with ``manual_time_offset_ms=None`` (silent-zero guard).
|
||
"""
|
||
# Act / Assert
|
||
with pytest.raises(
|
||
ReplayInputAdapterError,
|
||
match=r"skip_auto_sync_validation=True requires.*manual_time_offset_ms",
|
||
):
|
||
ReplayInputAdapter(
|
||
video_path=synthetic_video,
|
||
tlog_path=synthetic_tlog_path,
|
||
camera_calibration=camera_calibration,
|
||
target_fc_dialect=FcKind.ARDUPILOT_PLANE,
|
||
wgs_converter=fake_wgs_converter,
|
||
fdr_client=fake_fdr_client,
|
||
pace=ReplayPace.ASAP,
|
||
manual_time_offset_ms=None,
|
||
skip_auto_sync_validation=True,
|
||
auto_sync_config=AutoSyncConfig(),
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------
|
||
# AC-6 — low combined confidence WARN-and-proceed
|
||
|
||
|
||
def test_ac6_low_confidence_warn_and_proceed_does_not_raise(
|
||
synthetic_video: Path,
|
||
synthetic_tlog_path: Path,
|
||
camera_calibration: CameraCalibration,
|
||
fake_wgs_converter: mock.MagicMock,
|
||
fake_fdr_client: mock.MagicMock,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
caplog: pytest.LogCaptureFixture,
|
||
) -> None:
|
||
# Arrange — stub the detectors to return low-confidence results.
|
||
from gps_denied_onboard.replay_input.auto_sync import _DetectorResult
|
||
|
||
low_conf = _DetectorResult(onset_ns=_ns(2.0), confidence=0.40)
|
||
|
||
def _stub_take_off(*args: Any, **kwargs: Any) -> _DetectorResult:
|
||
return low_conf
|
||
|
||
def _stub_motion_onset(*args: Any, **kwargs: Any) -> _DetectorResult:
|
||
return _DetectorResult(onset_ns=_ns(2.0), confidence=0.40)
|
||
|
||
monkeypatch.setattr(
|
||
"gps_denied_onboard.replay_input.auto_sync._compute_tlog_takeoff_from_samples",
|
||
_stub_take_off,
|
||
)
|
||
monkeypatch.setattr(
|
||
"gps_denied_onboard.replay_input.tlog_video_adapter.detect_video_motion_onset",
|
||
_stub_motion_onset,
|
||
)
|
||
|
||
messages = _build_takeoff_messages()
|
||
adapter = ReplayInputAdapter(
|
||
video_path=synthetic_video,
|
||
tlog_path=synthetic_tlog_path,
|
||
camera_calibration=camera_calibration,
|
||
target_fc_dialect=FcKind.ARDUPILOT_PLANE,
|
||
wgs_converter=fake_wgs_converter,
|
||
fdr_client=fake_fdr_client,
|
||
pace=ReplayPace.ASAP,
|
||
manual_time_offset_ms=None,
|
||
auto_sync_config=AutoSyncConfig(),
|
||
tlog_source_factory=_factory_for(messages),
|
||
video_timestamps_factory=_video_timestamps_factory(),
|
||
)
|
||
|
||
# Act
|
||
caplog.set_level("WARNING", logger="replay_input.tlog_video_adapter")
|
||
try:
|
||
bundle = adapter.open()
|
||
|
||
# Assert — open() returned the bundle (didn't raise) and the
|
||
# WARN log fired.
|
||
assert bundle.auto_sync_result is not None
|
||
assert bundle.auto_sync_result.combined_confidence == pytest.approx(0.40)
|
||
warn_kinds = [
|
||
r.kind for r in caplog.records if hasattr(r, "kind")
|
||
]
|
||
assert "replay.auto_sync.low_confidence" in warn_kinds
|
||
finally:
|
||
adapter.close()
|
||
|
||
|
||
def test_ac11_resolved_offset_matches_auto_sync_result(
|
||
synthetic_video: Path,
|
||
synthetic_tlog_path: Path,
|
||
camera_calibration: CameraCalibration,
|
||
fake_wgs_converter: mock.MagicMock,
|
||
fake_fdr_client: mock.MagicMock,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
# Arrange — high-confidence stubs so AC-6 WARN does not fire.
|
||
from gps_denied_onboard.replay_input.auto_sync import _DetectorResult
|
||
|
||
def _stub_take_off(*args: Any, **kwargs: Any) -> _DetectorResult:
|
||
return _DetectorResult(onset_ns=_ns(2.0), confidence=0.95)
|
||
|
||
def _stub_motion_onset(*args: Any, **kwargs: Any) -> _DetectorResult:
|
||
return _DetectorResult(onset_ns=_ns(0.333), confidence=0.95)
|
||
|
||
monkeypatch.setattr(
|
||
"gps_denied_onboard.replay_input.auto_sync._compute_tlog_takeoff_from_samples",
|
||
_stub_take_off,
|
||
)
|
||
monkeypatch.setattr(
|
||
"gps_denied_onboard.replay_input.tlog_video_adapter.detect_video_motion_onset",
|
||
_stub_motion_onset,
|
||
)
|
||
|
||
messages = _build_takeoff_messages()
|
||
adapter = ReplayInputAdapter(
|
||
video_path=synthetic_video,
|
||
tlog_path=synthetic_tlog_path,
|
||
camera_calibration=camera_calibration,
|
||
target_fc_dialect=FcKind.ARDUPILOT_PLANE,
|
||
wgs_converter=fake_wgs_converter,
|
||
fdr_client=fake_fdr_client,
|
||
pace=ReplayPace.ASAP,
|
||
manual_time_offset_ms=None,
|
||
auto_sync_config=AutoSyncConfig(),
|
||
tlog_source_factory=_factory_for(messages),
|
||
video_timestamps_factory=_video_timestamps_factory(),
|
||
)
|
||
|
||
# Act
|
||
try:
|
||
bundle = adapter.open()
|
||
|
||
# Assert
|
||
expected_offset_ms = (_ns(2.0) - _ns(0.333)) // 1_000_000
|
||
assert bundle.resolved_time_offset_ms == expected_offset_ms
|
||
assert bundle.auto_sync_result is not None
|
||
assert bundle.auto_sync_result.offset_ms == expected_offset_ms
|
||
finally:
|
||
adapter.close()
|