[AZ-405] Replay — replay_input/ coordinator + IMU take-off auto-sync

Adds the Layer-4 cross-cutting `replay_input/` module per ADR-011:
ReplayInputAdapter converges (video, tlog) into the standard
FrameSource + FcAdapter + Clock surfaces the airborne composition
root consumes. Owns time-alignment between video frames and tlog
IMU/attitude ticks (manual via --time-offset-ms or auto via the
AZ-405 IMU-take-off detector + Farneback motion-onset detector).

Auto-sync algorithm (auto_sync.py):
- Tlog take-off detector: sustained vertical-accel excess > 0.5 g for
  >= 0.5 s + sustained attitude-rate magnitude > 1 rad/s.
- Video motion-onset detector: dense Farneback flow magnitude > 1.5 px
  sustained >= 0.5 s (deterministic per AC-10).
- compute_offset combines the two; confidence = min(tlog, video).
- validate_offset_or_fail implements the AC-9 95 % frame-window match
  validator with configurable threshold + window.

ReplayInputAdapter.open() ordering (AC-13):
1. Load tlog samples + fail-fast on missing RAW_IMU/SCALED_IMU2 or
   ATTITUDE BEFORE any video read.
2. Resolve offset (auto-sync OR manual override; manual bypasses the
   detectors entirely per AC-8).
3. Run AC-9 validator on resolved offset; raise auto-sync hard-fail
   for AC-7 (CLI exit 2 mapping).
4. Build single Clock instance per pace (TlogDerived/ASAP, Wall/REAL).
5. Construct VideoFileFrameSource and TlogReplayFcAdapter with the
   resolved offset baked in (replay protocol Invariant 8).

Structured log + FDR records on auto-sync detected / low-confidence /
AC-8 hard-fail kinds. Idempotent close (AC-12).

Tests: 25 unit tests across tests/unit/replay_input/ covering all 13
ACs (kernel-level synthetic fixtures for AC-1..AC-10; coordinator-
level OpenCV synthetic videos + faked pymavlink for AC-6..AC-13).

Contract update: replay_protocol.md v2.0.0 added fdr_client to the
ReplayInputAdapter __init__ signature (was missing in the prose; the
task spec already listed it in the allowed-imports section).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-14 09:50:51 +03:00
parent f9b4241d3a
commit 8149083cac
14 changed files with 2979 additions and 4 deletions
@@ -0,0 +1,145 @@
"""``replay_input/`` DTOs (AZ-405 / E-DEMO-REPLAY).
Frozen + slotted dataclasses per ADR-002 / module-layout.md so the
composition root and the coordinator can pass these by value without
fear of mutation downstream.
The DTOs come in two flavours:
- :class:`AutoSyncConfig` — operator-tunable thresholds for the
auto-sync algorithm. The composition root builds an instance from
``config.replay.auto_sync`` (owned by AZ-269 / AZ-270) and passes
it to :class:`ReplayInputAdapter`. Defaults match the contract
in :mod:`auto_sync` and the AC-1 / AC-2 / AC-3 thresholds.
- :class:`AutoSyncDecision` — the outcome of one auto-sync run. The
composition root attaches this to the FDR record so an operator can
audit how the offset was resolved.
- :class:`ReplayInputBundle` — the trio of strategies the composition
root consumes after :meth:`ReplayInputAdapter.open` returns. The
bundle also carries the resolved offset so the FDR write at the
start of the replay run can record provenance.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from gps_denied_onboard._types.fc import FcKind # noqa: F401 # for docstrings.
from gps_denied_onboard.clock import Clock
from gps_denied_onboard.components.c8_fc_adapter.tlog_replay_adapter import (
TlogReplayFcAdapter,
)
from gps_denied_onboard.frame_source.video_file import VideoFileFrameSource
__all__ = [
"AutoSyncConfig",
"AutoSyncDecision",
"ReplayInputBundle",
]
@dataclass(frozen=True, slots=True)
class AutoSyncConfig:
"""Operator-tunable thresholds for the AZ-405 auto-sync algorithm.
Defaults match the contract in
``_docs/02_document/contracts/replay/replay_protocol.md`` v2.0.0
and the AC-1 / AC-2 / AC-3 thresholds in the AZ-405 spec.
Attributes:
takeoff_accel_threshold_g: Sustained vertical-acceleration
magnitude (in g) above which a tlog sample is considered
part of a take-off pattern. Default 0.5 (AC-1).
takeoff_attitude_rate_threshold_rad_s: Sustained attitude-rate
magnitude (rad/s) above which an ``ATTITUDE`` pair is
considered part of a take-off pattern. Default 1.0.
sustained_seconds: Minimum duration both signals must persist
above their thresholds for a candidate to be accepted.
Default 0.5.
prescan_max_messages: Upper bound on tlog messages walked by
the take-off detector. ~30 s of telemetry at 200 Hz =
6000 messages, matching the AZ-399 pre-scan budget.
video_motion_threshold: Mean optical-flow magnitude (pixels)
above which a video frame pair is considered ``moving``.
Default 1.5 (calibrated for 720p footage).
video_motion_scan_seconds: Length of the leading video segment
inspected for the motion onset. Default 10.0 (AC-4 covers
an onset at frame 11 of a 60-frame fixture).
match_threshold_pct: AC-9 frame-window match-percentage
threshold (default 95.0). Configurable per
``config.replay.auto_sync_match_threshold_pct``.
match_window_ms: AC-9 per-frame matching tolerance in
milliseconds (default 100).
low_confidence_threshold: Combined-confidence cut-off below
which :meth:`ReplayInputAdapter.open` logs WARN and uses
the best-guess offset (AC-6). Default 0.80.
"""
takeoff_accel_threshold_g: float = 0.5
takeoff_attitude_rate_threshold_rad_s: float = 1.0
sustained_seconds: float = 0.5
prescan_max_messages: int = 6000
video_motion_threshold: float = 1.5
video_motion_scan_seconds: float = 10.0
match_threshold_pct: float = 95.0
match_window_ms: int = 100
low_confidence_threshold: float = 0.80
@dataclass(frozen=True, slots=True)
class AutoSyncDecision:
"""Outcome of one auto-sync run (AZ-405).
Attributes:
offset_ms: Resolved offset to be applied to tlog timestamps.
``offset_ms = tlog_takeoff_ns - video_motion_onset_ns``
converted to milliseconds.
tlog_takeoff_ns: Detected tlog take-off timestamp.
video_motion_onset_ns: Detected video motion-onset timestamp.
tlog_confidence: Take-off detector confidence in [0, 1].
video_confidence: Motion-onset detector confidence in [0, 1].
combined_confidence: Aggregated confidence in [0, 1]. Below
:attr:`AutoSyncConfig.low_confidence_threshold` the
coordinator logs WARN and proceeds (AC-6).
"""
offset_ms: int
tlog_takeoff_ns: int
video_motion_onset_ns: int
tlog_confidence: float
video_confidence: float
combined_confidence: float
@dataclass(frozen=True, slots=True)
class ReplayInputBundle:
"""Trio of strategies returned by :meth:`ReplayInputAdapter.open`.
The composition root wires the bundle into the same C1C7 + C13
pipeline as live (replay protocol Invariant 1 — the components
see only the standard :class:`FrameSource` / :class:`FcAdapter` /
:class:`Clock` interfaces past this point).
Attributes:
frame_source: :class:`VideoFileFrameSource` instance ready
for ``next_frame()`` calls.
fc_adapter: :class:`TlogReplayFcAdapter` instance with its
decode thread already started by :meth:`open`.
clock: :class:`TlogDerivedClock` (pace=ASAP) or
:class:`WallClock` (pace=REALTIME).
resolved_time_offset_ms: Offset applied to tlog timestamps.
Equals either the ``manual_time_offset_ms`` constructor
argument or :attr:`AutoSyncDecision.offset_ms`.
auto_sync_result: Auto-sync outcome; ``None`` when the
constructor received an explicit
``manual_time_offset_ms``.
"""
frame_source: "VideoFileFrameSource"
fc_adapter: "TlogReplayFcAdapter"
clock: "Clock"
resolved_time_offset_ms: int
auto_sync_result: AutoSyncDecision | None