[AZ-895] Deprecate replay auto-sync surface; file AZ-908 follow-up

Option A (minimum-deprecation, 2 SP) per user complexity-budget
decision. Auto-sync stays importable as a raising stub for one cycle
so external callers see a clean ReplayInputAdapterError instead of an
ImportError. Full physical removal is filed as AZ-908 (cycle-5+ backlog).

Production:
- auto_sync.py: 700+ LOC -> 56-line no-op stub raising
  "auto-sync removed; supply --imu CSV instead"
- tlog_video_adapter.py: 700+ LOC -> 105-line deprecated stub;
  ReplayInputAdapter.open() raises immediately, close() is a no-op
- _replay_branch.py: dropped legacy auto-sync branch +
  _build_auto_sync_config; _validate_replay_paths now requires
  imu_csv_path; replay_input_adapter_factory parameter removed
- cli/replay.py: --time-offset-ms / --skip-auto-sync / --auto-trim
  emit DeprecationWarning + stderr line; values ignored
- tlog_replay_adapter.py + tlog_ground_truth.py docstrings: AUDIT-ONLY

Tests:
- DELETED test_az405_auto_sync, test_az405_replay_input_adapter,
  test_az698_window_alignment (covered code no longer runs)
- ADDED test_az895_auto_sync_deprecated_stub (5 parametrised, pins AC-1)
- test_az402_replay_cli: deprecation warnings + ignored-value asserts
- test_az401_compose_root_replay: new imu_csv_path-required gate;
  deleted the calibration-loading test that relied on the removed
  replay_input_adapter_factory injection point
- test_derkachi_real_tlog: xfail reason refreshed to AZ-848 + AZ-883
  (AC-4 "AZ-848-scoped reason")

Docs:
- module-layout.md: replay_input file list flags deprecated modules,
  adds csv_ground_truth.py
- _dependencies_table.md: +AZ-908 row, preamble + totals updated
  (179 -> 180 tasks, 567 -> 570 SP)
- AZ-908 backlog spec added; AZ-895 spec moved todo -> done
- batch_03_cycle4_report.md written

Touched-module tests green (111 passed, 1 skipped). Full unit suite
green: 2287 passed, 85 skipped, 1 deselected (pre-existing flaky perf
test, unrelated).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-26 22:09:59 +03:00
parent fdb593a775
commit 007aa36fbf
19 changed files with 600 additions and 4213 deletions
@@ -1,483 +0,0 @@
"""AZ-405 — auto-sync detector + offset-compute + AC-9 validator.
Covers AC-1..AC-10 of ``_docs/02_tasks/todo/AZ-405_replay_auto_sync.md``.
Tests run against the pure compute kernels in
:mod:`gps_denied_onboard.replay_input.auto_sync` (no disk IO, no real
pymavlink, no real OpenCV) so the suite is fast + deterministic.
Style: every test follows the Arrange / Act / Assert pattern.
"""
from __future__ import annotations
import math
from typing import Any
import pytest
from gps_denied_onboard.replay_input.auto_sync import (
TlogSamples,
_compute_tlog_takeoff_from_samples,
_compute_video_onset_from_samples,
compute_offset,
validate_offset_or_fail,
)
from gps_denied_onboard.replay_input.errors import ReplayInputAdapterError
from gps_denied_onboard.replay_input.interface import AutoSyncConfig
# ---------------------------------------------------------------------
# Synthetic-fixture helpers
def _ns(seconds: float) -> int:
return int(seconds * 1_000_000_000)
def _flat_accel_samples(
*, start_s: float, end_s: float, hz: int, total_g: float
) -> list[tuple[int, float]]:
out: list[tuple[int, float]] = []
period = 1.0 / hz
t = start_s
while t < end_s:
out.append((_ns(t), total_g))
t += period
return out
def _flat_attitude_samples(
*, start_s: float, end_s: float, hz: int, roll: float, pitch: float, yaw: float
) -> list[tuple[int, float, float, float]]:
out: list[tuple[int, float, float, float]] = []
period = 1.0 / hz
t = start_s
while t < end_s:
out.append((_ns(t), roll, pitch, yaw))
t += period
return out
def _ramp_attitude_samples(
*,
start_s: float,
end_s: float,
hz: int,
base_roll: float,
base_pitch: float,
base_yaw: float,
rate_rad_s: float,
) -> list[tuple[int, float, float, float]]:
"""Attitude that ramps in pitch at ``rate_rad_s`` rad/s."""
out: list[tuple[int, float, float, float]] = []
period = 1.0 / hz
t = start_s
while t < end_s:
dt = t - start_s
pitch = base_pitch + rate_rad_s * dt
out.append((_ns(t), base_roll, pitch, base_yaw))
t += period
return out
def _build_takeoff_samples() -> TlogSamples:
"""AC-1 fixture: 2 s flat hover, then 1.5 s sustained 2.2 g + 1.5 rad/s.
Take-off onset is at t = 2.0 s (the first sample with the
elevated acceleration). Body-frame accelerometer convention: at
hover the proper-acceleration magnitude is 1 g (gravity reaction);
during a 1.2 g thrust climb it is 2.2 g, so the take-off excess
above 1 g rest is 1.2 g — well above the 0.5 g threshold.
"""
accel_pre = _flat_accel_samples(start_s=0.0, end_s=2.0, hz=200, total_g=1.0)
accel_post = _flat_accel_samples(start_s=2.0, end_s=3.5, hz=200, total_g=2.2)
accel = accel_pre + accel_post
attitude_pre = _flat_attitude_samples(
start_s=0.0, end_s=2.0, hz=100, roll=0.0, pitch=0.0, yaw=0.0
)
attitude_post = _ramp_attitude_samples(
start_s=2.0,
end_s=3.5,
hz=100,
base_roll=0.0,
base_pitch=0.0,
base_yaw=0.0,
rate_rad_s=1.5,
)
attitude = attitude_pre + attitude_post
return TlogSamples(
accel=tuple(accel),
attitude=tuple(attitude),
imu_count_by_type={
"RAW_IMU": len(accel),
"ATTITUDE": len(attitude),
},
)
def _build_low_amplitude_vibration_samples() -> TlogSamples:
"""AC-2 fixture: 5 s of 0.3 g body-frame vibration (no take-off).
Total proper-acceleration during vibration = 1.3 g (0.3 g excess
above the 1 g rest baseline) — strictly below the 0.5 g detector
threshold so the sustained-event search rejects every window.
"""
accel = _flat_accel_samples(start_s=0.0, end_s=5.0, hz=200, total_g=1.3)
attitude = _flat_attitude_samples(
start_s=0.0, end_s=5.0, hz=100, roll=0.0, pitch=0.0, yaw=0.0
)
return TlogSamples(
accel=tuple(accel),
attitude=tuple(attitude),
imu_count_by_type={
"RAW_IMU": len(accel),
"ATTITUDE": len(attitude),
},
)
def _build_hand_launch_samples() -> TlogSamples:
"""AC-3 fixture: 0.8 g impulse for 100 ms; not sustained for 0.5 s.
Body-frame accelerometer convention (see ``_build_takeoff_samples``):
a 0.8 g impulse becomes 1.8 g total proper-acceleration during the
impulse window.
"""
accel_pre = _flat_accel_samples(start_s=0.0, end_s=2.0, hz=200, total_g=1.0)
accel_impulse = _flat_accel_samples(
start_s=2.0, end_s=2.1, hz=200, total_g=1.8
)
accel_post = _flat_accel_samples(start_s=2.1, end_s=3.0, hz=200, total_g=1.0)
accel = accel_pre + accel_impulse + accel_post
attitude = _flat_attitude_samples(
start_s=0.0, end_s=3.0, hz=100, roll=0.0, pitch=0.0, yaw=0.0
)
return TlogSamples(
accel=tuple(accel),
attitude=tuple(attitude),
imu_count_by_type={
"RAW_IMU": len(accel),
"ATTITUDE": len(attitude),
},
)
def _flow_samples_from_frames(
*, n_stationary: int, n_moving: int, fps: int = 30, motion_px: float = 4.0
) -> tuple[tuple[int, float], ...]:
"""Synthesise the flow-magnitude series the video detector consumes.
The detector emits a ``(ts_ns, mean_magnitude_px)`` tuple for each
consecutive frame pair (skipping the first frame's pair). For
AC-4 we pretend frames 1..10 are stationary (mag ≈ 0) and frames
11..60 are moving (mag = motion_px).
"""
out: list[tuple[int, float]] = []
period_ns = int(1_000_000_000 / fps)
for i in range(1, n_stationary):
out.append((i * period_ns, 0.05))
for j in range(n_stationary, n_stationary + n_moving):
out.append((j * period_ns, motion_px))
return tuple(out)
# ---------------------------------------------------------------------
# AC-1 — tlog take-off detector (positive)
def test_ac1_tlog_takeoff_detector_positive_within_50ms_and_high_confidence() -> None:
# Arrange
config = AutoSyncConfig()
samples = _build_takeoff_samples()
# Act
result = _compute_tlog_takeoff_from_samples(samples, config)
# Assert
expected_onset_ns = _ns(2.0)
assert abs(result.onset_ns - expected_onset_ns) <= _ns(0.05), (
f"detected onset {result.onset_ns / 1e9}s deviates >50ms from expected 2.0s"
)
assert result.confidence >= 0.85, (
f"confidence {result.confidence} below AC-1 minimum of 0.85"
)
# ---------------------------------------------------------------------
# AC-2 — tlog take-off detector (ambiguous)
def test_ac2_tlog_takeoff_detector_low_amplitude_vibration_low_confidence() -> None:
# Arrange
config = AutoSyncConfig()
samples = _build_low_amplitude_vibration_samples()
# Act
result = _compute_tlog_takeoff_from_samples(samples, config)
# Assert
assert result.confidence < 0.50, (
f"confidence {result.confidence} should be < 0.50 for ambiguous vibration"
)
# ---------------------------------------------------------------------
# AC-3 — tlog take-off detector (hand launch)
def test_ac3_tlog_takeoff_detector_hand_launch_warn_regime() -> None:
# Arrange
config = AutoSyncConfig()
samples = _build_hand_launch_samples()
# Act
result = _compute_tlog_takeoff_from_samples(samples, config)
# Assert
assert result.confidence < 0.80, (
f"confidence {result.confidence} should be < 0.80 for unsustained hand-launch"
)
# ---------------------------------------------------------------------
# AC-4 — video motion-onset detector
def test_ac4_video_motion_onset_detected_within_one_frame() -> None:
# Arrange
config = AutoSyncConfig()
flow_samples = _flow_samples_from_frames(n_stationary=10, n_moving=50, fps=30)
period_ns = int(1_000_000_000 / 30)
expected_onset_ns = 10 * period_ns
# Act
result = _compute_video_onset_from_samples(flow_samples, config)
# Assert
assert abs(result.onset_ns - expected_onset_ns) <= period_ns, (
f"detected motion onset {result.onset_ns} ns deviates >1 frame "
f"from expected {expected_onset_ns} ns"
)
assert result.confidence > 0.80, (
f"confidence {result.confidence} too low for clear motion onset"
)
# ---------------------------------------------------------------------
# AC-5 — combined offset within ± 200 ms
def test_ac5_combined_offset_within_200ms_of_ground_truth() -> None:
# Arrange
config = AutoSyncConfig()
tlog_samples = _build_takeoff_samples()
tlog_result = _compute_tlog_takeoff_from_samples(tlog_samples, config)
flow_samples = _flow_samples_from_frames(n_stationary=10, n_moving=50, fps=30)
video_result = _compute_video_onset_from_samples(flow_samples, config)
# Ground-truth offset = tlog take-off (2.0 s) video onset (10/30 s)
period_ns = int(1_000_000_000 / 30)
ground_truth_offset_ms = (_ns(2.0) - 10 * period_ns) // 1_000_000
# Act
decision = compute_offset(tlog_result, video_result)
# Assert
assert abs(decision.offset_ms - ground_truth_offset_ms) <= 200, (
f"offset {decision.offset_ms} ms deviates >200 ms from ground truth "
f"{ground_truth_offset_ms} ms"
)
# ---------------------------------------------------------------------
# AC-6 — low combined confidence (verified via the coordinator test
# in test_az405_replay_input_adapter.py; here we only verify the
# combined-confidence aggregator picks min())
def test_ac6_combined_confidence_takes_minimum_of_inputs() -> None:
# Arrange
from gps_denied_onboard.replay_input.auto_sync import _DetectorResult
high = _DetectorResult(onset_ns=_ns(1.0), confidence=0.95)
low = _DetectorResult(onset_ns=_ns(2.0), confidence=0.50)
# Act
decision = compute_offset(high, low)
# Assert
assert decision.combined_confidence == pytest.approx(0.50)
assert decision.offset_ms == (_ns(1.0) - _ns(2.0)) // 1_000_000
# ---------------------------------------------------------------------
# AC-7 — AC-9 validator hard-fail (the coordinator-level raise is
# covered in test_az405_replay_input_adapter.py)
def test_ac7_validator_hard_fail_returns_2_for_offset_outside_window() -> None:
# Arrange
fps = 30
period_ns = int(1_000_000_000 / fps)
video_ts = [i * period_ns for i in range(60)]
# IMU sampled at 200 Hz from t=0 to t=2 (mismatch deliberate; the
# bad offset shifts everything outside the window).
imu_ts = [int(i / 200 * 1_000_000_000) for i in range(400)]
bad_offset_ms = 60_000
# Act
code = validate_offset_or_fail(
bad_offset_ms,
imu_ts,
video_ts,
threshold_pct=95.0,
window_ms=100,
)
# Assert
assert code == 2
# ---------------------------------------------------------------------
# AC-9 — frame-window match-percentage validator (positive)
def test_ac9_validator_passes_for_well_matched_offset() -> None:
# Arrange
fps = 30
period_ns = int(1_000_000_000 / fps)
video_ts = [i * period_ns for i in range(60)]
# IMU samples densely spanning the same time range — every video
# frame has an IMU sample within ± 100 ms.
imu_ts = [int(i / 200 * 1_000_000_000) for i in range(60 * 200 // 30)]
# Act
code = validate_offset_or_fail(
0, imu_ts, video_ts, threshold_pct=95.0, window_ms=100
)
# Assert
assert code == 0
def test_ac9_threshold_configurable() -> None:
# Arrange — set up a series where exactly 80% of frames match.
fps = 30
period_ns = int(1_000_000_000 / fps)
video_ts = [i * period_ns for i in range(50)]
# IMU only covers the first 80% of the video timeline; the last
# 10 frames will be far outside the window.
imu_ts = [
int(i / 200 * 1_000_000_000) for i in range(int(40 / 30 * 200))
]
# Act / Assert
# Default 95% threshold → fail (80% < 95%).
assert validate_offset_or_fail(
0, imu_ts, video_ts, threshold_pct=95.0, window_ms=100
) == 2
# Lowered to 75% → pass.
assert validate_offset_or_fail(
0, imu_ts, video_ts, threshold_pct=75.0, window_ms=100
) == 0
# ---------------------------------------------------------------------
# AC-10 — confidence determinism
def test_ac10_confidence_score_deterministic_across_two_runs() -> None:
# Arrange
config = AutoSyncConfig()
samples = _build_takeoff_samples()
# Act
first = _compute_tlog_takeoff_from_samples(samples, config)
second = _compute_tlog_takeoff_from_samples(samples, config)
# Assert
assert first.onset_ns == second.onset_ns
assert math.isclose(first.confidence, second.confidence, abs_tol=1e-9)
def test_ac10_video_onset_deterministic_across_two_runs() -> None:
# Arrange
config = AutoSyncConfig()
flow_samples = _flow_samples_from_frames(n_stationary=5, n_moving=20, fps=30)
# Act
first = _compute_video_onset_from_samples(flow_samples, config)
second = _compute_video_onset_from_samples(flow_samples, config)
# Assert
assert first.onset_ns == second.onset_ns
assert math.isclose(first.confidence, second.confidence, abs_tol=1e-9)
# ---------------------------------------------------------------------
# R-DEMO-3 fail-fast on the pure compute path
def test_pure_takeoff_kernel_raises_on_no_imu_samples() -> None:
# Arrange
config = AutoSyncConfig()
samples = TlogSamples(
accel=(),
attitude=(),
imu_count_by_type={"ATTITUDE": 100},
)
# Act / Assert
with pytest.raises(ReplayInputAdapterError, match="tlog missing required"):
_compute_takeoff_or_propagate(samples, config)
def test_pure_takeoff_kernel_raises_on_no_attitude_samples() -> None:
# Arrange
config = AutoSyncConfig()
accel = _flat_accel_samples(start_s=0.0, end_s=1.0, hz=200, total_g=1.0)
samples = TlogSamples(
accel=tuple(accel),
attitude=(),
imu_count_by_type={"RAW_IMU": len(accel)},
)
# Act / Assert
with pytest.raises(ReplayInputAdapterError, match="tlog missing required"):
_compute_takeoff_or_propagate(samples, config)
def _compute_takeoff_or_propagate(samples: TlogSamples, config: AutoSyncConfig) -> Any:
"""Local trampoline so the assertions are explicit even if the
underscore-named helper migrates."""
return _compute_tlog_takeoff_from_samples(samples, config)
# ---------------------------------------------------------------------
# AC-9 edge cases
def test_validator_returns_2_on_empty_video_or_tlog() -> None:
# Arrange
imu_ts = [0, 1_000_000, 2_000_000]
video_ts: list[int] = []
# Act / Assert — empty video
assert (
validate_offset_or_fail(
0, imu_ts, video_ts, threshold_pct=95.0, window_ms=100
)
== 2
)
# Empty tlog
assert (
validate_offset_or_fail(
0, [], [0, 1_000_000], threshold_pct=95.0, window_ms=100
)
== 2
)
@@ -1,804 +0,0 @@
"""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()
@@ -1,935 +0,0 @@
"""AZ-698 — tlog trim + mid-flight cross-correlation alignment tests.
Covers AC-1..AC-4 of ``_docs/02_tasks/todo/AZ-698_tlog_trim_midflight_alignment.md``.
AC-5 (end-to-end CLI smoke) is exercised by the existing replay e2e
suite in ``tests/e2e/replay/`` and skipped here when its prerequisites
(ffmpeg-capable cv2 build + real ``derkachi.tlog``) are absent.
Style: every test follows the Arrange / Act / Assert pattern.
"""
from __future__ import annotations
import math
from pathlib import Path
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock
import pytest
from gps_denied_onboard._types.fc import (
AttitudeSample,
FcKind,
FcTelemetryFrame,
FlightStateSignal,
GpsHealth,
ImuTelemetrySample,
TelemetryKind,
)
from gps_denied_onboard.clock import Clock
from gps_denied_onboard.components.c8_fc_adapter.tlog_replay_adapter import (
ReplayPace,
TlogReplayFcAdapter,
)
from gps_denied_onboard.replay_input.auto_sync import (
_align_via_cross_correlation,
_resample_uniform,
_segment_flights_from_imu_energy,
compute_offset,
detect_video_motion_onset,
find_aligned_window,
validate_offset_or_fail,
)
from gps_denied_onboard.replay_input.errors import ReplayInputAdapterError
from gps_denied_onboard.replay_input.interface import (
AlignedWindow,
AutoSyncConfig,
)
# ---------------------------------------------------------------------
# Synthetic-stream helpers
def _ns(seconds: float) -> int:
return int(seconds * 1_000_000_000)
def _build_motion_burst_stream(
*,
start_s: float,
end_s: float,
hz: float,
burst_at_s: float,
burst_amplitude: float,
burst_duration_s: float = 1.0,
baseline_amplitude: float = 0.0,
) -> tuple[tuple[int, float], ...]:
"""Build a synthetic ``(ts_ns, magnitude)`` stream.
Constant at ``baseline_amplitude`` outside a single rectangular
burst (``burst_amplitude`` for ``burst_duration_s`` starting at
``burst_at_s``). Used so cross-correlation has a clear peak that
tests can assert exact-index for.
"""
out: list[tuple[int, float]] = []
period_s = 1.0 / hz
t = start_s
burst_end_s = burst_at_s + burst_duration_s
while t < end_s:
if burst_at_s <= t < burst_end_s:
out.append((_ns(t), burst_amplitude))
else:
out.append((_ns(t), baseline_amplitude))
t += period_s
return tuple(out)
def _build_double_burst_stream(
*,
start_s: float,
end_s: float,
hz: float,
burst_a_at_s: float,
burst_b_at_s: float,
burst_amplitude: float,
burst_duration_s: float = 1.0,
baseline_amplitude: float = 0.0,
) -> tuple[tuple[int, float], ...]:
"""Two-burst variant to constrain cross-correlation more tightly."""
out: list[tuple[int, float]] = []
period_s = 1.0 / hz
t = start_s
while t < end_s:
if burst_a_at_s <= t < burst_a_at_s + burst_duration_s:
out.append((_ns(t), burst_amplitude))
elif burst_b_at_s <= t < burst_b_at_s + burst_duration_s:
out.append((_ns(t), burst_amplitude))
else:
out.append((_ns(t), baseline_amplitude))
t += period_s
return tuple(out)
def _build_multi_flight_stream(
*,
flights: tuple[tuple[float, float], ...],
end_s: float,
hz: float,
in_flight_amplitude: float = 0.3,
ground_amplitude: float = 0.02,
) -> tuple[tuple[int, float], ...]:
"""Build a multi-flight IMU energy stream.
``flights`` is a tuple of ``(start_s, end_s)`` per flight. Between
flights the energy is ``ground_amplitude``; inside each flight it
is ``in_flight_amplitude``. Used by the multi-flight segmentation
tests to mimic a real "3 takeoffs at the same field" tlog.
"""
out: list[tuple[int, float]] = []
period_s = 1.0 / hz
t = 0.0
while t < end_s:
in_flight = any(s <= t < e for s, e in flights)
out.append((_ns(t), in_flight_amplitude if in_flight else ground_amplitude))
t += period_s
return tuple(out)
# ---------------------------------------------------------------------
# AC-1: takeoff-aligned regression — find_aligned_window must produce
# the same offset (within ± 50 ms) as the AZ-405 compute_offset path
# when the video covers the take-off.
def test_ac1_takeoff_aligned_offset_matches_az405_within_50ms() -> None:
# Arrange: 30 s tlog with a take-off-shaped IMU energy burst at
# t = 2 s; 5 s video with the same-shaped optical-flow burst at
# video_t = 0.5 s (motion onset half a second into the clip).
# AZ-405 would resolve offset_ms = (tlog_takeoff_ns -
# video_motion_onset_ns) // 1e6 ≈ 1.5 s. The AZ-698 aligner
# must agree within 50 ms.
tlog_energy = _build_motion_burst_stream(
start_s=0.0,
end_s=30.0,
hz=10.0,
burst_at_s=2.0,
burst_amplitude=1.2,
burst_duration_s=1.5,
baseline_amplitude=0.0,
)
flow_samples = _build_motion_burst_stream(
start_s=0.0,
end_s=5.0,
hz=10.0,
burst_at_s=0.5,
burst_amplitude=2.0,
burst_duration_s=1.5,
baseline_amplitude=0.0,
)
config = AutoSyncConfig()
expected_offset_ms = _ns(2.0 - 0.5) // 1_000_000
# Act
window = _align_via_cross_correlation(
tlog_energy=tlog_energy,
flow_samples=flow_samples,
config=config,
target_fc_dialect=FcKind.ARDUPILOT_PLANE,
tlog_path=Path("/nonexistent.tlog"),
tlog_source_factory=None,
)
# Assert
assert window.fallback_used is False, "expected primary cross-corr path, not fallback"
assert abs(window.offset_ms - expected_offset_ms) <= 50, (
f"AZ-698 offset {window.offset_ms} ms outside ±50 ms of AZ-405-equivalent "
f"{expected_offset_ms} ms"
)
# ---------------------------------------------------------------------
# AC-2: mid-flight alignment — tlog 030 s with motion burst at t=15 s,
# video 05 s with motion burst at video_t=1 s. Expected:
# tlog_start_ns ≈ (15 - 1) s = 14 s (where video t=0 lands)
# offset_ms ≈ 14 000
def test_ac2_mid_flight_alignment_locates_correct_window() -> None:
# Arrange: distinctive double-burst pattern in both streams so
# cross-correlation lock is unambiguous (single-burst patterns
# can lock on the wrong baseline at edge bins).
tlog_energy = _build_double_burst_stream(
start_s=0.0,
end_s=30.0,
hz=10.0,
burst_a_at_s=15.0,
burst_b_at_s=18.0,
burst_amplitude=1.5,
burst_duration_s=0.8,
baseline_amplitude=0.0,
)
flow_samples = _build_double_burst_stream(
start_s=0.0,
end_s=5.0,
hz=10.0,
burst_a_at_s=1.0,
burst_b_at_s=4.0,
burst_amplitude=2.5,
burst_duration_s=0.8,
baseline_amplitude=0.0,
)
config = AutoSyncConfig()
period_ns = _ns(1.0 / config.alignment_resample_hz)
# Act
window = _align_via_cross_correlation(
tlog_energy=tlog_energy,
flow_samples=flow_samples,
config=config,
target_fc_dialect=FcKind.ARDUPILOT_PLANE,
tlog_path=Path("/nonexistent.tlog"),
tlog_source_factory=None,
)
# Assert
assert window.fallback_used is False
# video burst A at t=1.0s aligns with tlog burst A at t=15.0s
# → video t=0 aligns with tlog t=14.0s within ±1 resample period.
assert abs(window.tlog_start_ns - _ns(14.0)) <= period_ns, (
f"tlog_start_ns={window.tlog_start_ns} not within one resample period "
f"({period_ns} ns) of the expected 14 s"
)
assert abs(window.offset_ms - 14_000) <= 100
assert window.tlog_end_ns > window.tlog_start_ns
# ---------------------------------------------------------------------
# AC-3: TlogReplayFcAdapter seek — messages whose raw _timestamp is
# below tlog_start_ns must NOT reach subscribers.
def _make_fake_msg(*, type_name: str, raw_ts_s: float, **fields: Any) -> SimpleNamespace:
"""Build a pymavlink-shaped fake message for replay-adapter tests."""
msg = SimpleNamespace(_timestamp=raw_ts_s, **fields)
def _get_type() -> str:
return type_name
msg.get_type = _get_type # type: ignore[attr-defined]
return msg
def _build_replay_adapter_with_seek(
*,
tlog_start_ns: int | None,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> tuple[TlogReplayFcAdapter, list[FcTelemetryFrame]]:
"""Construct a TlogReplayFcAdapter wired to deterministic fakes."""
monkeypatch.setenv("BUILD_TLOG_REPLAY_ADAPTER", "ON")
tlog_file = tmp_path / "fake.tlog"
tlog_file.write_bytes(b"\x00")
received: list[FcTelemetryFrame] = []
fake_clock = MagicMock(spec=Clock)
fake_clock.monotonic_ns.return_value = 0
fake_clock.sleep_until_ns.return_value = None
fake_wgs = MagicMock()
fake_fdr = MagicMock()
fake_fdr.enqueue.return_value = None
adapter = TlogReplayFcAdapter(
tlog_path=tlog_file,
target_fc_dialect=FcKind.ARDUPILOT_PLANE,
clock=fake_clock,
wgs_converter=fake_wgs,
fdr_client=fake_fdr,
time_offset_ms=0,
tlog_start_ns=tlog_start_ns,
pace=ReplayPace.ASAP,
)
adapter.subscribe_telemetry(received.append)
return adapter, received
def test_ac3_adapter_seek_skips_pre_window_messages(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# Arrange: adapter opened with tlog_start_ns = 100 s; feed 5
# IMU messages, two below 100 s (must be skipped) and three at
# or above 100 s (must reach the subscriber).
adapter, received = _build_replay_adapter_with_seek(
tlog_start_ns=_ns(100.0),
tmp_path=tmp_path,
monkeypatch=monkeypatch,
)
pre_window = [
_make_fake_msg(
type_name="RAW_IMU",
raw_ts_s=t,
time_usec=int(t * 1e6),
xacc=0,
yacc=0,
zacc=1000,
xgyro=0,
ygyro=0,
zgyro=0,
)
for t in (50.0, 99.999)
]
in_window = [
_make_fake_msg(
type_name="RAW_IMU",
raw_ts_s=t,
time_usec=int(t * 1e6),
xacc=0,
yacc=0,
zacc=1000,
xgyro=0,
ygyro=0,
zgyro=0,
)
for t in (100.0, 101.5, 110.0)
]
# Act
for msg in pre_window + in_window:
adapter.feed_one_message(msg)
# Assert
assert len(received) == 3, "expected three in-window IMU frames"
assert all(
frame.kind == TelemetryKind.IMU_SAMPLE for frame in received
), "non-IMU frame leaked through"
# ``received_at`` is the raw _timestamp (no offset). Every
# delivered frame's raw timestamp must be ≥ 100 s.
for frame in received:
assert frame.received_at >= _ns(100.0), (
f"frame with received_at={frame.received_at} ns leaked below the seek bound"
)
def test_ac3_adapter_default_no_seek_passes_every_message(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# Arrange: tlog_start_ns=None (default) → no seek; every message reaches subscribers.
adapter, received = _build_replay_adapter_with_seek(
tlog_start_ns=None,
tmp_path=tmp_path,
monkeypatch=monkeypatch,
)
messages = [
_make_fake_msg(
type_name="RAW_IMU",
raw_ts_s=t,
time_usec=int(t * 1e6),
xacc=0,
yacc=0,
zacc=1000,
xgyro=0,
ygyro=0,
zgyro=0,
)
for t in (10.0, 50.0, 100.0)
]
# Act
for msg in messages:
adapter.feed_one_message(msg)
# Assert
assert len(received) == 3, "default (no seek) must pass every IMU message"
# ---------------------------------------------------------------------
# AC-4: AC-9 frame-window validator passes for both scenarios.
def test_ac4_validator_passes_for_takeoff_aligned_offset() -> None:
# Arrange: video frames at 30 fps for 5 s; tlog IMU at 100 Hz
# for 30 s covering both pre-take-off and post; offset places
# video t=0 at tlog t=2 s.
video_ts = [int(t * 1_000_000_000) for t in (i / 30.0 for i in range(150))]
tlog_ts = [int(t * 1_000_000_000) for t in (i / 100.0 for i in range(3000))]
offset_ms = 2_000
# Act
result = validate_offset_or_fail(
offset_ms,
tlog_imu_timestamps_ns=tlog_ts,
video_frame_timestamps_ns=video_ts,
threshold_pct=95.0,
window_ms=100,
)
# Assert
assert result == 0
def test_ac4_validator_passes_for_mid_flight_offset() -> None:
# Arrange: video covers 05 s; tlog covers 060 s; mid-flight
# offset places video t=0 at tlog t=30 s. Every video frame
# still has an IMU sample within ±100 ms of (vts + 30s) because
# the tlog covers that range densely.
video_ts = [int(t * 1_000_000_000) for t in (i / 30.0 for i in range(150))]
tlog_ts = [int(t * 1_000_000_000) for t in (i / 100.0 for i in range(6000))]
offset_ms = 30_000
# Act
result = validate_offset_or_fail(
offset_ms,
tlog_imu_timestamps_ns=tlog_ts,
video_frame_timestamps_ns=video_ts,
threshold_pct=95.0,
window_ms=100,
)
# Assert
assert result == 0
# ---------------------------------------------------------------------
# Resampler unit tests — pin the binning semantics so future
# regressions are caught explicitly.
def test_resample_uniform_averages_within_bin() -> None:
# Arrange: 3 samples in the first 100 ms bin (values 1, 2, 3 →
# mean 2.0), 1 sample in the second bin (value 4 → 4.0).
samples = (
(_ns(0.00), 1.0),
(_ns(0.03), 2.0),
(_ns(0.06), 3.0),
(_ns(0.15), 4.0),
)
period_ns = _ns(0.10)
# Act
resampled = _resample_uniform(samples, period_ns, origin_ns=0)
# Assert
assert math.isclose(resampled[0], 2.0)
assert math.isclose(resampled[1], 4.0)
def test_resample_uniform_drops_trailing_empty_bins() -> None:
# Arrange: one sample in bin 0, then a 1 s gap before the next sample.
# The samples between get carry-forward of the previous bin's value;
# trailing zeros only appear AFTER the last sample.
samples = (
(_ns(0.0), 5.0),
(_ns(1.05), 7.0),
)
period_ns = _ns(0.1)
# Act
resampled = _resample_uniform(samples, period_ns, origin_ns=0)
# Assert
# The first bin is 5.0, bins 1..9 carry-forward to 5.0 (the previous
# bin's value), and bin 10 captures the t=1.05 s sample as 7.0.
assert resampled[0] == 5.0
assert resampled[-1] == 7.0
# No trailing-zero tail.
assert all(v != 0.0 for v in resampled)
# ---------------------------------------------------------------------
# Fallback path — when cross-correlation confidence is below the
# threshold, find_aligned_window must fall back to the head-takeoff
# detector and set fallback_used=True.
def test_low_confidence_triggers_takeoff_fallback(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# Arrange: flat-line tlog (no motion) → cross-correlation has no
# meaningful peak. The fallback path opens the real tlog via
# detect_tlog_takeoff which needs a working tlog file. We bypass
# the actual fallback work by raising the threshold to 1.1 (no
# peak can clear it) and stubbing the takeoff detector.
monkeypatch.setattr(
"gps_denied_onboard.replay_input.auto_sync.detect_tlog_takeoff",
lambda path, dialect, config, *, source_factory=None: SimpleNamespace(
onset_ns=_ns(7.0), confidence=0.9
),
)
flat_tlog = tuple(
(_ns(t / 10.0), 0.0) for t in range(0, 100)
)
flat_flow = tuple(
(_ns(t / 10.0), 0.0) for t in range(0, 20)
)
config = AutoSyncConfig(alignment_low_confidence_threshold=0.5)
tlog_path = tmp_path / "fake.tlog"
tlog_path.write_bytes(b"\x00")
# Act
window = _align_via_cross_correlation(
tlog_energy=flat_tlog,
flow_samples=flat_flow,
config=config,
target_fc_dialect=FcKind.ARDUPILOT_PLANE,
tlog_path=tlog_path,
tlog_source_factory=None,
)
# Assert
assert window.fallback_used is True
assert window.tlog_start_ns == _ns(7.0), "fallback did not pick up the stubbed takeoff onset"
# ---------------------------------------------------------------------
# Guard: video stream longer than tlog stream → reject (auto-trim
# requires the video to be a SLICE of a longer tlog).
def test_video_longer_than_tlog_raises() -> None:
# Arrange
tlog_energy = tuple((_ns(t / 10.0), 0.5) for t in range(10))
flow_samples = tuple((_ns(t / 10.0), 0.5) for t in range(50))
config = AutoSyncConfig()
# Act + Assert
with pytest.raises(ReplayInputAdapterError, match="video flow stream is longer"):
_align_via_cross_correlation(
tlog_energy=tlog_energy,
flow_samples=flow_samples,
config=config,
target_fc_dialect=FcKind.ARDUPILOT_PLANE,
tlog_path=Path("/nonexistent.tlog"),
tlog_source_factory=None,
)
# ---------------------------------------------------------------------
# AlignedWindow DTO is frozen + slotted.
def test_aligned_window_is_frozen() -> None:
# Arrange
w = AlignedWindow(
tlog_start_ns=1,
tlog_end_ns=2,
offset_ms=0,
confidence=0.9,
fallback_used=False,
)
# Act + Assert
with pytest.raises((AttributeError, TypeError)):
w.confidence = 0.5 # type: ignore[misc]
# ---------------------------------------------------------------------
# AC-5: end-to-end CLI smoke — skipped here because it requires
# ffmpeg-capable cv2 + the real ``derkachi.tlog``/``.mp4`` binaries.
# The actual CLI run is covered by ``tests/e2e/replay/`` when those
# prerequisites are available.
def _replay_inputs_present() -> bool:
fixtures = Path("_docs/00_problem/input_data/flight_derkachi")
return (fixtures / "derkachi.tlog").is_file() and (fixtures / "derkachi.mp4").is_file()
@pytest.mark.skipif(
not _replay_inputs_present(),
reason="AC-5 e2e smoke requires _docs/00_problem/input_data/flight_derkachi/derkachi.{tlog,mp4}",
)
def test_ac5_cli_auto_trim_smoke_uses_find_aligned_window(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Arrange: this test pins the wiring contract — the `--auto-trim`
# CLI flag must reach ReplayConfig.auto_trim. A full CLI run
# requires the runtime root which is exercised by the e2e suite.
from gps_denied_onboard.cli.replay import _build_replay_config
from gps_denied_onboard.config.schema import Config, ReplayConfig
args = SimpleNamespace(
video=Path("/tmp/v.mp4"),
tlog=Path("/tmp/t.tlog"),
output=Path("/tmp/o.jsonl"),
camera_calibration=Path("/tmp/c.json"),
config_path=Path("/tmp/c.yaml"),
mavlink_signing_key=Path("/tmp/k.bin"),
pace="asap",
time_offset_ms=None,
skip_auto_sync_validation=False,
auto_trim=True,
)
key_file = Path("/tmp/k.bin")
key_file.write_bytes(b"\x00" * 32)
base = Config()
base = type(base)(
mode=base.mode,
log=base.log,
fdr=base.fdr,
runtime=base.runtime,
fc=base.fc,
gcs=base.gcs,
replay=ReplayConfig(),
components=base.components,
)
# Act
new_config = _build_replay_config(args, base)
# Assert
assert new_config.replay.auto_trim is True
assert new_config.replay.time_offset_ms is None
# Cross-reference: the existing AZ-405 fixture still passes (no regression).
def test_autosync_decision_offset_is_within_ac9_window_for_baseline() -> None:
# Arrange: a takeoff-shaped tlog detector result + a video
# motion-onset detector result. compute_offset returns the
# AZ-405 offset_ms which is the AZ-698 baseline AC-1 references.
from gps_denied_onboard.replay_input.auto_sync import _DetectorResult
tlog_result = _DetectorResult(onset_ns=_ns(2.5), confidence=0.9)
video_result = _DetectorResult(onset_ns=_ns(0.5), confidence=0.85)
# Act
decision = compute_offset(tlog_result, video_result)
# Assert
assert decision.offset_ms == 2_000
assert decision.combined_confidence == pytest.approx(0.85, abs=1e-6)
# ---------------------------------------------------------------------
# Multi-flight tlog handling (user constraint: "if 1 flight take it, if
# multiple take the last"). The pre-NCC segmenter is the gatekeeper.
def test_segmenter_one_flight_returns_single_span() -> None:
# Arrange: 120 s tlog with a single flight from t=10..100 s.
samples = _build_multi_flight_stream(
flights=((10.0, 100.0),),
end_s=120.0,
hz=10.0,
)
# Act
segments = _segment_flights_from_imu_energy(
samples,
motion_threshold=0.1,
min_flight_duration_ns=_ns(30.0),
max_internal_gap_ns=_ns(5.0),
)
# Assert
assert len(segments) == 1
seg_start_ns, seg_end_ns = segments[0]
assert abs(seg_start_ns - _ns(10.0)) <= _ns(0.2)
assert abs(seg_end_ns - _ns(100.0)) <= _ns(0.2)
def test_segmenter_three_flights_returns_three_spans_in_order() -> None:
# Arrange: 360 s tlog with three takeoffs (60 s flights with 30 s
# ground gaps between them) — mimics the Derkachi scenario the
# user flagged: one tlog, three sorties, video covers only the
# last one.
flights_def = ((10.0, 70.0), (100.0, 160.0), (190.0, 250.0))
samples = _build_multi_flight_stream(
flights=flights_def,
end_s=300.0,
hz=10.0,
)
# Act
segments = _segment_flights_from_imu_energy(
samples,
motion_threshold=0.1,
min_flight_duration_ns=_ns(30.0),
max_internal_gap_ns=_ns(5.0),
)
# Assert
assert len(segments) == 3
for (actual_start, actual_end), (want_start, want_end) in zip(
segments, flights_def
):
assert abs(actual_start - _ns(want_start)) <= _ns(0.2)
assert abs(actual_end - _ns(want_end)) <= _ns(0.2)
def test_segmenter_drops_ground_blip_below_min_duration() -> None:
# Arrange: a 5 s ground manoeuvre (engine test) followed by a
# real 60 s flight. With min_flight_duration_ns=30 s the blip
# must be discarded, leaving only the real flight.
samples = _build_multi_flight_stream(
flights=((5.0, 10.0), (50.0, 110.0)),
end_s=120.0,
hz=10.0,
)
# Act
segments = _segment_flights_from_imu_energy(
samples,
motion_threshold=0.1,
min_flight_duration_ns=_ns(30.0),
max_internal_gap_ns=_ns(5.0),
)
# Assert
assert len(segments) == 1
seg_start_ns, _seg_end_ns = segments[0]
assert abs(seg_start_ns - _ns(50.0)) <= _ns(0.2)
def test_segmenter_keeps_brief_cruise_lull_inside_flight() -> None:
# Arrange: one flight with a 3 s cruise lull mid-way. The lull is
# below max_internal_gap_ns=5 s, so the segmenter must keep the
# whole flight as a single segment.
samples = _build_multi_flight_stream(
flights=((10.0, 45.0), (48.0, 100.0)),
end_s=120.0,
hz=10.0,
)
# Act
segments = _segment_flights_from_imu_energy(
samples,
motion_threshold=0.1,
min_flight_duration_ns=_ns(30.0),
max_internal_gap_ns=_ns(5.0),
)
# Assert
assert len(segments) == 1
seg_start_ns, seg_end_ns = segments[0]
assert abs(seg_start_ns - _ns(10.0)) <= _ns(0.2)
assert abs(seg_end_ns - _ns(100.0)) <= _ns(0.2)
def test_find_aligned_window_picks_last_flight_for_multi_flight_tlog(
tmp_path: Path,
) -> None:
# Arrange: a 300 s tlog with three sorties (10..70, 100..160,
# 190..250 s). The video covers only the LAST sortie — flow
# samples at video-clock 0..30 s with a motion burst at
# video t=5 s that, on the tlog timeline, corresponds to
# tlog t=200 s (5 s into flight 3 which starts at 190 s).
flights_def = ((10.0, 70.0), (100.0, 160.0), (190.0, 250.0))
tlog_energy = _build_multi_flight_stream(
flights=flights_def,
end_s=260.0,
hz=10.0,
)
flow_samples = _build_motion_burst_stream(
start_s=0.0,
end_s=30.0,
hz=10.0,
burst_at_s=5.0,
burst_amplitude=2.0,
burst_duration_s=3.0,
baseline_amplitude=0.05,
)
config = AutoSyncConfig(
alignment_segment_min_flight_duration_seconds=30.0,
alignment_segment_max_internal_gap_seconds=5.0,
)
# Inject the pre-loaded IMU energy by monkey-patching the loader
# used inside find_aligned_window; the function reads a tlog via
# pymavlink, but for the unit-level invariant we want to assert
# the segment selection — not the binary parser.
import gps_denied_onboard.replay_input.auto_sync as auto_sync_mod
fake_tlog = tmp_path / "multi_flight.tlog"
fake_tlog.write_bytes(b"\x00")
fake_video = tmp_path / "video.mp4"
fake_video.write_bytes(b"\x00")
def _fake_loader(
path: Path,
*,
max_messages: int,
source_factory: Any,
) -> tuple[tuple[int, float], ...]:
return tlog_energy
def _fake_frames(
path: Path, scan_seconds: float,
) -> "list[tuple[int, Any]]":
import numpy as np
rng = np.random.default_rng(42)
frames: list[tuple[int, Any]] = []
prev_offset = np.zeros((16, 16, 3), dtype=np.int16)
for ts_ns, mag in flow_samples:
# 3-channel BGR (cvtColor BGR→GRAY needs ≥ 3 channels).
# During a burst we shift pixels — that motion is what
# Farneback flow magnitudes pick up.
base = rng.integers(0, 30, size=(16, 16, 3), dtype=np.int16)
shift_px = int(mag * 4)
if shift_px > 0:
base = np.roll(base, shift=shift_px, axis=0)
frame = np.clip(base + prev_offset, 0, 255).astype(np.uint8)
frames.append((ts_ns, frame))
prev_offset = np.zeros_like(prev_offset)
return frames
monkeypatch = pytest.MonkeyPatch()
try:
monkeypatch.setattr(
auto_sync_mod, "_load_tlog_imu_energy_stream", _fake_loader
)
# Act
window = find_aligned_window(
fake_tlog,
fake_video,
config,
target_fc_dialect=FcKind.ARDUPILOT_PLANE,
video_frames_factory=_fake_frames,
)
finally:
monkeypatch.undo()
# Assert: the aligner MUST select FLIGHT 3 (190..250 s), NOT
# flight 1 (10..70 s). Whether NCC locks on or the fallback
# path fires, the resulting window must lie inside flight 3 —
# that's the user-visible contract ("take the last flight").
flight3_start_ns, flight3_end_ns = (_ns(190.0), _ns(250.0))
assert window.flight_count_detected == 3
assert window.selected_flight_index == 2
assert flight3_start_ns <= window.tlog_start_ns <= flight3_end_ns
# Sanity: did NOT lock onto flight 1 or 2.
assert window.tlog_start_ns > _ns(160.0)
def test_align_via_cross_correlation_locks_onto_burst_inside_last_segment() -> None:
# Arrange: pre-segmented tlog energy restricted to flight 3
# (mimicking what find_aligned_window passes after segmentation),
# plus a flow stream whose burst pattern matches a specific
# offset inside that segment. This directly exercises the NCC
# path with the inputs the post-segmentation aligner sees.
last_segment_tlog = _build_motion_burst_stream(
start_s=190.0,
end_s=250.0,
hz=10.0,
burst_at_s=210.0,
burst_amplitude=1.5,
burst_duration_s=5.0,
baseline_amplitude=0.05,
)
flow_samples = _build_motion_burst_stream(
start_s=0.0,
end_s=30.0,
hz=10.0,
burst_at_s=10.0,
burst_amplitude=2.0,
burst_duration_s=5.0,
baseline_amplitude=0.05,
)
config = AutoSyncConfig()
# Act
window = _align_via_cross_correlation(
tlog_energy=last_segment_tlog,
flow_samples=flow_samples,
config=config,
target_fc_dialect=FcKind.ARDUPILOT_PLANE,
tlog_path=Path("/nonexistent.tlog"),
tlog_source_factory=None,
flight_count_detected=3,
selected_flight_index=2,
)
# Assert: NCC must lock on (high confidence, no fallback). The
# tlog_start_ns must be the start of the matched 30 s window —
# video burst at video_t=10 s lines up with tlog_t=210 s ⇒
# tlog_start_ns ≈ 200 s (210 s 10 s).
assert not window.fallback_used
assert window.confidence > 0.6
assert window.flight_count_detected == 3
assert window.selected_flight_index == 2
assert abs(window.tlog_start_ns - _ns(200.0)) <= _ns(0.2)
def test_find_aligned_window_uses_only_segment_for_segmented_tlog_fallback(
tmp_path: Path,
) -> None:
# Arrange: a 3-flight tlog where the video flow is flat (no
# structure for NCC to lock onto). NCC must produce confidence
# ~ 0; the fallback path must use the LAST segment start, NOT
# the head-takeoff detector (which would lock onto flight 1).
flights_def = ((10.0, 70.0), (100.0, 160.0), (190.0, 250.0))
tlog_energy = _build_multi_flight_stream(
flights=flights_def,
end_s=260.0,
hz=10.0,
)
flat_flow = tuple((_ns(t / 10.0), 0.5) for t in range(0, 50))
config = AutoSyncConfig()
# Act
window = _align_via_cross_correlation(
tlog_energy=tuple(
(ts, e) for ts, e in tlog_energy
if _ns(190.0) <= ts <= _ns(250.0)
),
flow_samples=flat_flow,
config=config,
target_fc_dialect=FcKind.ARDUPILOT_PLANE,
tlog_path=tmp_path / "x.tlog",
tlog_source_factory=None,
flight_count_detected=3,
selected_flight_index=2,
)
# Assert
assert window.fallback_used is True
assert window.flight_count_detected == 3
assert window.selected_flight_index == 2
# The fallback must use flight 3's start, not flight 1's takeoff.
assert window.tlog_start_ns >= _ns(190.0)
assert window.tlog_start_ns <= _ns(250.0)
@@ -0,0 +1,38 @@
"""AZ-895: auto-sync surface deprecated; every public callable raises.
The full detector test suite was deleted alongside the detectors
themselves; this single test pins the deprecation contract: a clean
:class:`ReplayInputAdapterError` with the documented message, not a
silent import failure or vague RuntimeError. AZ-908 will remove
``auto_sync.py`` entirely and this test along with it.
"""
from __future__ import annotations
import pytest
from gps_denied_onboard.replay_input import ReplayInputAdapterError
from gps_denied_onboard.replay_input import auto_sync
_DEPRECATED_CALLABLES = (
"detect_tlog_takeoff",
"detect_video_motion_onset",
"compute_offset",
"validate_offset_or_fail",
"find_aligned_window",
)
@pytest.mark.parametrize("name", _DEPRECATED_CALLABLES)
def test_az895_public_callable_raises_with_documented_message(name: str) -> None:
"""AC-1: every public callable raises the documented deprecation error."""
# Arrange
fn = getattr(auto_sync, name)
# Act / Assert
with pytest.raises(
ReplayInputAdapterError,
match="auto-sync removed; supply --imu CSV instead",
):
fn()