[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
+39 -22
View File
@@ -28,6 +28,7 @@ import logging
import os
import sys
import traceback
import warnings
from collections.abc import Callable, Sequence
from dataclasses import replace
from pathlib import Path
@@ -158,8 +159,10 @@ def _build_argparser() -> argparse.ArgumentParser:
type=int,
default=None,
help=(
"Manual offset between video and tlog clocks. When omitted, "
"ReplayInputAdapter (AZ-405) auto-detects via IMU take-off."
"DEPRECATED (AZ-895): the (video, tlog) auto-sync path was "
"removed. The CSV's Time column is the single canonical "
"clock by construction, so no offset is needed. Accepted "
"for one deprecation cycle but ignored; AZ-908 removes it."
),
)
parser.add_argument(
@@ -167,14 +170,9 @@ def _build_argparser() -> argparse.ArgumentParser:
dest="skip_auto_sync_validation",
action="store_true",
help=(
"AZ-611: Also skip the AC-9 frame-window validator that "
"runs on the resolved offset. Only legal in combination "
"with --time-offset-ms (a manual offset is mandatory so "
"the bypass cannot mask a silent-zero auto-sync result). "
"Intended for fixtures where neither the IMU take-off "
"detector nor the video motion-onset detector can "
"produce a reliable signal (mid-flight clips, stationary "
"still-image scenarios)."
"DEPRECATED (AZ-895): the AC-9 auto-sync validator was "
"removed alongside the auto-sync surface. Accepted for "
"one deprecation cycle but ignored; AZ-908 removes it."
),
)
parser.add_argument(
@@ -182,13 +180,9 @@ def _build_argparser() -> argparse.ArgumentParser:
dest="auto_trim",
action="store_true",
help=(
"AZ-698: Locate the video's playback window inside a "
"longer tlog via IMU↔optical-flow cross-correlation, "
"then trim the tlog stream to that window. Mutually "
"exclusive with --time-offset-ms. Below the configured "
"alignment confidence threshold the aligner falls back "
"to the AZ-405 head-takeoff path and the AC-9 validator "
"still gates the final offset."
"DEPRECATED (AZ-895): the IMU↔optical-flow aligner was "
"removed alongside the auto-sync surface. Accepted for "
"one deprecation cycle but ignored; AZ-908 removes it."
),
)
parser.add_argument(
@@ -274,6 +268,11 @@ def _build_replay_config(
Per ADR-011 the CLI's only job after loading is to set
``config.mode = "replay"`` and populate ``config.replay`` from the
operator's CLI args. Composition logic stays in ``compose_root``.
AZ-895: ``--time-offset-ms``, ``--skip-auto-sync``, and
``--auto-trim`` are deprecated. Their values are ignored here so
they cannot influence composition; the deprecation banner in
:func:`_print_startup_banner` already informed the operator.
"""
new_replay = ReplayConfig(
video_path=str(args.video),
@@ -281,9 +280,9 @@ def _build_replay_config(
imu_csv_path=str(args.imu),
output_path=str(args.output),
pace=args.pace,
time_offset_ms=args.time_offset_ms,
skip_auto_sync_validation=bool(args.skip_auto_sync_validation),
auto_trim=bool(args.auto_trim),
time_offset_ms=None,
skip_auto_sync_validation=False,
auto_trim=False,
target_fc_dialect=base_config.replay.target_fc_dialect,
auto_sync=base_config.replay.auto_sync,
max_duration_s=(
@@ -324,13 +323,20 @@ def _build_replay_config(
# Startup banner
_DEPRECATED_FLAGS_AZ895: Final[tuple[tuple[str, str], ...]] = (
("time_offset_ms", "--time-offset-ms"),
("skip_auto_sync_validation", "--skip-auto-sync"),
("auto_trim", "--auto-trim"),
)
def _print_startup_banner(args: argparse.Namespace) -> None:
"""Print a sanitised one-line banner to stderr before logging boots.
Logging is bootstrapped inside the airborne main; this banner gives
the operator a single line confirming what the CLI parsed before any
further output. AZ-894: also surfaces the --tlog deprecation warning
inline so operators see it even when stderr is the only sink.
further output. AZ-894 / AZ-895: also surfaces deprecation warnings
inline so operators see them even when stderr is the only sink.
"""
sanitised = vars(args).copy()
sanitised["mavlink_signing_key"] = "<redacted>"
@@ -347,6 +353,17 @@ def _print_startup_banner(args: argparse.Namespace) -> None:
file=sys.stderr,
flush=True,
)
for dest, flag in _DEPRECATED_FLAGS_AZ895:
value = getattr(args, dest, None)
if value in (None, False):
continue
msg = (
f"{flag} is deprecated (AZ-895) and will be removed in AZ-908. "
"The (video, CSV) replay path has no auto-sync surface; "
"this flag is accepted but ignored. Remove it from your invocation."
)
warnings.warn(msg, DeprecationWarning, stacklevel=2)
print(f"gps-denied-replay: WARNING {msg}", file=sys.stderr, flush=True)
# ----------------------------------------------------------------------
@@ -1,18 +1,26 @@
"""``TlogReplayFcAdapter`` (AZ-399 / E-DEMO-REPLAY).
Replay-only :class:`FcAdapter` strategy parsing pymavlink ``.tlog``
files. Implements the full Protocol from
AUDIT-ONLY (AZ-895): retained as a tlog-file parser strategy that
implements the :class:`FcAdapter` Protocol. The production replay
pipeline now composes :class:`CsvReplayFcAdapter` against the
operator's IMU+GPS CSV (AZ-894). This adapter remains in the tree as:
- The source of :class:`ReplayPace` (shared enum used by every replay
adapter and the composition root).
- A one-off audit utility for inspecting historical ``.tlog`` files
outside the main replay flow.
It is no longer instantiated by :func:`compose_root`'s replay branch
and AZ-908 will retire the ``BUILD_TLOG_REPLAY_ADAPTER`` build flag
that still guards its construction.
Implements the full Protocol from
``_docs/02_document/contracts/c8_fc_adapter/fc_adapter_protocol.md``
plus the replay-specific Invariants 5, 6, 8 from
``_docs/02_document/contracts/replay/replay_protocol.md`` (no
out-bound emission, pace honoured by injected :class:`Clock`,
``time_offset_ms`` shift baked at construction).
Build-time gating: the adapter refuses construction unless
``BUILD_TLOG_REPLAY_ADAPTER`` is ``ON``. Only the ``replay-cli``
binary is expected to flip the flag ON; airborne / research /
operator binaries keep it OFF.
Stream-parse design: pymavlink's :class:`mavutil.mavlogfile` already
streams from disk via :meth:`recv_match`. The adapter wraps it in a
pre-scan pass (fail-fast on missing required message types per
+17 -15
View File
@@ -1,22 +1,24 @@
"""``replay_input/`` cross-cutting coordinator (AZ-405 / E-DEMO-REPLAY).
Layer-4 module per ``_docs/02_document/module-layout.md``. Converges
``(video, tlog)`` inputs into the standard :class:`FrameSource`,
:class:`FcAdapter`, and :class:`Clock` surfaces consumed by the
airborne composition root. Owns the time-alignment concern between
video frames and tlog IMU/attitude ticks (manual via
``--time-offset-ms`` or automatic via the AZ-405 IMU-take-off
detector).
Layer-4 module per ``_docs/02_document/module-layout.md``. Under
AZ-894 the production replay pipeline drives off the operator's
IMU+GPS CSV via :class:`CsvReplayFcAdapter`; the legacy ``(video,
tlog)`` auto-sync surface was deprecated by AZ-895 and will be removed
by AZ-908.
New under ADR-011 (replay-as-configuration) — replaces the v1.0.0
design where replay had its own composition root.
The package retains:
Public surface re-exports the coordinator class, the bundle DTO, the
auto-sync decision DTO, the auto-sync config DTO, and the coordinator
error class. The detector functions in :mod:`auto_sync` are NOT
re-exported here so the public API stays focused on the composition
root's wiring needs; tests import the detectors via their full module
path.
- :class:`ReplayInputAdapter` and :class:`ReplayInputAdapterError` —
the latter is the canonical replay error class, used by every
replay adapter (CSV and tlog).
- :class:`ReplayInputBundle` — the DTO :func:`compose_root` consumes.
- :class:`AutoSyncConfig`, :class:`AutoSyncDecision`,
:class:`AlignedWindow` — kept on the public surface for one
deprecation cycle so any external caller's import does not break.
- Tlog ground-truth + route helpers used by AZ-697 / AZ-836 audit
paths.
Hard removal of the deprecated symbols lands in AZ-908.
"""
from gps_denied_onboard._types.route import RouteSpec
File diff suppressed because it is too large Load Diff
@@ -1,19 +1,23 @@
"""Direct binary-tlog GPS-truth extractor (AZ-697 / E-DEMO-REPLAY).
Streams ``GLOBAL_POSITION_INT`` (preferred) or ``GPS_RAW_INT`` (fallback)
from an ArduPilot binary tlog into a typed :class:`TlogGroundTruth` DTO,
suitable for the AZ-699 (real-flight validation) and AZ-701 (HTTP
replay API) comparison paths.
AUDIT-ONLY (AZ-895): the production replay pipeline now consumes
ground truth through :class:`CsvGroundTruth` (AZ-894) driven by the
operator's IMU+GPS CSV. This helper is retained for one-off audits and
the AZ-699 / AZ-701 validation paths that still operate against legacy
``.tlog`` archives; it is not part of the main replay composition root.
Design mirrors :mod:`gps_denied_onboard.replay_input.auto_sync`:
Streams ``GLOBAL_POSITION_INT`` (preferred) or ``GPS_RAW_INT`` (fallback)
from an ArduPilot binary tlog into a typed :class:`TlogGroundTruth` DTO.
Design notes:
* Lazy ``pymavlink.mavutil`` import — missing dependency raises
:class:`ReplayInputAdapterError` rather than crashing the import.
* Optional ``source_factory`` injection point so unit tests can swap in
a synthetic source (mirrors the AZ-399 / AZ-405 pattern).
* Production helper only — placed under ``replay_input/`` because the
GPS extraction is intrinsically tied to the tlog input pipeline; the
comparison kernels themselves live in :mod:`helpers.gps_compare`.
a synthetic source (mirrors the AZ-399 pattern).
* Placed under ``replay_input/`` because the GPS extraction is
intrinsically tied to the tlog input pipeline; the comparison kernels
themselves live in :mod:`helpers.gps_compare`.
"""
from __future__ import annotations
@@ -1,134 +1,56 @@
"""``ReplayInputAdapter`` (AZ-405 / E-DEMO-REPLAY).
"""DEPRECATED (AZ-895): ``ReplayInputAdapter`` retained as a raising stub.
Layer-4 cross-cutting coordinator that converges ``(video, tlog)``
inputs into the standard :class:`FrameSource`, :class:`FcAdapter`,
and :class:`Clock` surfaces consumed by the airborne composition
root. Owns the time-alignment concern: either the operator's manual
``--time-offset-ms`` override or the AZ-405 IMU-take-off auto-detect.
The (video, tlog) coordinator was the v1.0.0 entry point into the
auto-sync surface. As of AZ-894 (cycle 4) the replay pipeline runs off
a paired (video, CSV) input — :class:`CsvReplayFcAdapter` plus the
:class:`CsvVideoBundle` builder in :mod:`gps_denied_onboard.runtime_root._replay_branch`.
``open()`` performs strict ordering so AC-13 holds:
This module keeps the :class:`ReplayInputAdapter` symbol live for one
deprecation cycle so external imports surface a clean
:class:`ReplayInputAdapterError` instead of an ``ImportError``. Hard
removal lands in AZ-908 (cycle 5+).
1. **Tlog message-type pre-validation** runs FIRST so a tlog missing
``RAW_IMU`` / ``ATTITUDE`` raises before the video is ever read.
2. If the constructor received ``manual_time_offset_ms is None``,
the auto-sync detectors run; otherwise the manual offset is
adopted directly (AC-8 verifies the bypass).
3. The resolved offset is fed through the AC-9 frame-window match
validator; a hard-fail raises ``"auto-sync hard-fail: …"`` so
the shared main maps it to CLI exit code 2 (AC-7).
4. The :class:`Clock` strategy is constructed (``TlogDerivedClock``
for ``pace=ASAP``, ``WallClock`` for ``pace=REALTIME``) — the
single instance the bundle ships to the composition root
(Invariant 2; AC-5).
5. :class:`VideoFileFrameSource` and :class:`TlogReplayFcAdapter`
are constructed against the offset + clock + dialect; the FC
adapter's own ``open()`` triggers its independent pre-scan (a
second sanity check; the operator gets the original error path
if step 1 was bypassed via a test fake).
6. The bundle is returned with ``auto_sync_result`` populated for
the auto path and ``None`` for the manual path.
The coordinator is idempotent on ``close()`` — repeated calls are
no-ops once the underlying strategies have been released (AC-12).
Operators with old (video, tlog) scripts should switch to the
(video, CSV) input; see
``_docs/02_document/contracts/replay/csv_replay_format.md``.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import TYPE_CHECKING, Any
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.errors import (
FcAdapterConfigError,
FcAdapterError,
FcOpenError,
)
from gps_denied_onboard.components.c8_fc_adapter.tlog_replay_adapter import (
ReplayPace,
TlogReplayFcAdapter,
)
from gps_denied_onboard.fdr_client.records import FdrRecord
from gps_denied_onboard.frame_source.errors import (
FrameSourceConfigError,
FrameSourceError,
)
from gps_denied_onboard.frame_source.video_file import VideoFileFrameSource
from gps_denied_onboard.helpers.iso_timestamps import iso_ts_now
from gps_denied_onboard.replay_input.auto_sync import (
_load_tlog_samples,
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,
AutoSyncDecision,
ReplayInputBundle,
)
if TYPE_CHECKING:
from gps_denied_onboard._types.calibration import CameraCalibration
from gps_denied_onboard.clock import Clock
from gps_denied_onboard.fdr_client.client import FdrClient
from gps_denied_onboard.helpers.wgs_converter import WgsConverter
__all__ = ["ReplayInputAdapter"]
__all__ = ["ReplayInputAdapter", "ReplayPace"]
_FDR_PRODUCER_ID = "replay_input.tlog_video_adapter"
_LOG_KIND_AUTO_SYNC_DETECTED = "replay.auto_sync.detected"
_LOG_KIND_AUTO_SYNC_LOW_CONF = "replay.auto_sync.low_confidence"
_LOG_KIND_AUTO_SYNC_AC8_FAIL = "replay.auto_sync.ac8_validation_failed"
_LOG_KIND_OPEN_MANUAL = "replay.input.opened_manual_offset"
_LOG_KIND_AUTO_TRIM_RESOLVED = "replay.auto_trim.resolved"
_LOG_KIND_AUTO_TRIM_FALLBACK = "replay.auto_trim.fallback_to_takeoff"
_REMOVED_MSG = (
"tlog_video_adapter.ReplayInputAdapter is deprecated (AZ-895); "
"supply --imu CSV instead"
)
class ReplayInputAdapter:
"""Coordinator that converges ``(video, tlog)`` into the airborne strategies.
"""DEPRECATED (AZ-895): :meth:`open` raises :class:`ReplayInputAdapterError`.
Constructor parameters:
- ``video_path`` / ``tlog_path`` — filesystem inputs.
- ``camera_calibration`` — :class:`CameraCalibration` used to
derive the calibration ID propagated into every emitted
:class:`NavCameraFrame`.
- ``target_fc_dialect`` — ``ARDUPILOT_PLANE`` or ``INAV``;
passed through to :class:`TlogReplayFcAdapter`.
- ``wgs_converter`` — shared geodesy helper, constructor-injected
into :class:`TlogReplayFcAdapter`.
- ``fdr_client`` — FDR sink for the TlogReplayFcAdapter and for
the coordinator's own structured-event mirror.
- ``pace`` — :class:`ReplayPace` (``ASAP`` or ``REALTIME``).
- ``manual_time_offset_ms`` — ``None`` triggers auto-sync; an
integer bypasses auto-sync DETECTION but the AC-9 frame-window
validator still runs on the resolved offset (AC-8).
- ``skip_auto_sync_validation`` — when ``True``, ALSO skip the
AC-9 validator. Only legal in combination with a non-``None``
``manual_time_offset_ms`` (the coordinator refuses both-None
to avoid silent-zero offset bugs). Intended for fixtures where
neither the IMU take-off detector nor the video motion-onset
detector can produce a reliable signal (mid-flight clips,
stationary still-image scenarios — see AZ-611). Default
``False``.
- ``auto_sync_config`` — :class:`AutoSyncConfig` thresholds.
Behaviour:
- :meth:`open` resolves the offset, validates AC-9, and returns a
:class:`ReplayInputBundle` with the wired strategies. Raises
:class:`ReplayInputAdapterError` on every coordinator-scope
failure so the shared main can map cleanly to CLI exit code 2.
- :meth:`close` releases the FC adapter and the frame source;
idempotent (AC-12).
Constructor remains tolerant so callers can still import the symbol
and instantiate it (e.g. for backwards-compatible plugin discovery),
but every meaningful use raises. Hard removal lands in AZ-908.
"""
__slots__ = (
@@ -143,14 +65,6 @@ class ReplayInputAdapter:
"_skip_auto_sync_validation",
"_auto_trim",
"_auto_sync_config",
"_tlog_source_factory",
"_video_frames_factory",
"_video_timestamps_factory",
"_mavlink_transport",
"_log",
"_opened",
"_closed",
"_bundle",
)
def __init__(
@@ -172,54 +86,6 @@ class ReplayInputAdapter:
video_timestamps_factory: Any | None = None,
mavlink_transport: Any | None = None,
) -> None:
if not isinstance(video_path, Path):
raise ReplayInputAdapterError(
f"video_path must be a pathlib.Path; got {type(video_path).__name__}"
)
if not isinstance(tlog_path, Path):
raise ReplayInputAdapterError(
f"tlog_path must be a pathlib.Path; got {type(tlog_path).__name__}"
)
if target_fc_dialect not in (FcKind.ARDUPILOT_PLANE, FcKind.INAV):
raise ReplayInputAdapterError(
f"target_fc_dialect must be ARDUPILOT_PLANE or INAV; "
f"got {target_fc_dialect!r}"
)
if not isinstance(pace, ReplayPace):
raise ReplayInputAdapterError(
f"pace must be a ReplayPace enum; got {type(pace).__name__}"
)
if not isinstance(skip_auto_sync_validation, bool):
raise ReplayInputAdapterError(
"skip_auto_sync_validation must be a bool; got "
f"{type(skip_auto_sync_validation).__name__}"
)
if skip_auto_sync_validation and manual_time_offset_ms is None:
# Mirror the ReplayConfig.__post_init__ gate. Without a
# manual offset there is no operator-acknowledged value
# to skip validation against — auto-sync would compute
# an offset of unknown quality and the validator that
# would catch a bad detection is disabled. Refuse so
# this can't silently mask a wrong offset.
raise ReplayInputAdapterError(
"skip_auto_sync_validation=True requires "
"manual_time_offset_ms to be set"
)
if not isinstance(auto_trim, bool):
raise ReplayInputAdapterError(
"auto_trim must be a bool; got "
f"{type(auto_trim).__name__}"
)
if auto_trim and manual_time_offset_ms is not None:
# Mirror the ReplayConfig.__post_init__ gate. An explicit
# manual offset means the operator has already aligned
# the streams; running the cross-correlation aligner on
# top of that would either re-resolve the same window
# (wasteful) or overwrite the operator's intent silently.
raise ReplayInputAdapterError(
"auto_trim=True is mutually exclusive with "
"manual_time_offset_ms"
)
self._video_path = video_path
self._tlog_path = tlog_path
self._camera_calibration = camera_calibration
@@ -231,506 +97,9 @@ class ReplayInputAdapter:
self._skip_auto_sync_validation = skip_auto_sync_validation
self._auto_trim = auto_trim
self._auto_sync_config = auto_sync_config
self._tlog_source_factory = tlog_source_factory
self._video_frames_factory = video_frames_factory
self._video_timestamps_factory = video_timestamps_factory
self._mavlink_transport = mavlink_transport
self._log = logging.getLogger("replay_input.tlog_video_adapter")
self._opened = False
self._closed = False
self._bundle: ReplayInputBundle | None = None
def open(self) -> ReplayInputBundle:
"""Resolve the offset, build the strategies, return the bundle.
Idempotent only in the failure-then-retry sense — calling
``open()`` twice without an intervening ``close()`` raises
:class:`ReplayInputAdapterError`.
"""
if self._opened:
raise ReplayInputAdapterError("ReplayInputAdapter already opened")
# Step 1 — tlog presence + required-message check (R-DEMO-3,
# AC-13). Runs BEFORE any video read so a malformed tlog
# surfaces without paying the cv2.VideoCapture cost.
tlog_imu_timestamps_ns, tlog_samples_for_auto = self._load_and_validate_tlog()
# Step 2 — resolve the offset (auto-sync, auto-trim, or
# manual override).
decision: AutoSyncDecision | None
aligned_window: AlignedWindow | None
if self._auto_trim:
aligned_window = self._run_auto_trim()
decision = None
resolved_offset_ms = aligned_window.offset_ms
# The prescan timestamps (step 1) only cover the tlog head.
# When the auto-trim window is far into the tlog, the prescan
# timestamps fall outside the window and the AC-9 validator
# would always return 0 % match → false hard-fail. Reload
# IMU timestamps from the discovered window so the validator
# sees the correct slice.
if aligned_window.tlog_start_ns > 0:
tlog_imu_timestamps_ns = self._load_tlog_imu_in_window(
aligned_window.tlog_start_ns,
aligned_window.tlog_end_ns,
)
self._log.info(
"replay_input.ac9_window_reload: "
"tlog_start_ns=%d tlog_end_ns=%d loaded=%d imu_samples",
aligned_window.tlog_start_ns,
aligned_window.tlog_end_ns,
len(tlog_imu_timestamps_ns),
extra={
"kind": "replay_input.ac9_window_reload",
"kv": {
"tlog_start_ns": aligned_window.tlog_start_ns,
"tlog_end_ns": aligned_window.tlog_end_ns,
"loaded_imu_count": len(tlog_imu_timestamps_ns),
},
},
)
elif self._manual_time_offset_ms is None:
aligned_window = None
decision = self._run_auto_sync(tlog_samples_for_auto)
resolved_offset_ms = decision.offset_ms
else:
aligned_window = None
decision = None
resolved_offset_ms = int(self._manual_time_offset_ms)
self._log.info(
f"{_LOG_KIND_OPEN_MANUAL}: resolved_offset_ms={resolved_offset_ms}",
extra={
"kind": _LOG_KIND_OPEN_MANUAL,
"kv": {"resolved_offset_ms": resolved_offset_ms},
},
)
# Step 3 — load video frame timestamps and run AC-9 validator
# unless the operator explicitly opted out via
# skip_auto_sync_validation (AZ-611). The opt-out is meant for
# mid-flight + stationary fixtures where neither detector can
# produce a reliable signal; the constructor already enforced
# that the opt-out requires a manual offset.
video_frame_timestamps_ns = self._load_video_timestamps()
if self._skip_auto_sync_validation:
self._log.info(
f"{_LOG_KIND_OPEN_MANUAL}: ac9_validator_skipped "
f"(resolved_offset_ms={resolved_offset_ms})",
extra={
"kind": _LOG_KIND_OPEN_MANUAL,
"kv": {
"resolved_offset_ms": resolved_offset_ms,
"ac9_validator_skipped": True,
},
},
)
else:
result_code = validate_offset_or_fail(
resolved_offset_ms,
tlog_imu_timestamps_ns,
video_frame_timestamps_ns,
threshold_pct=self._auto_sync_config.match_threshold_pct,
window_ms=self._auto_sync_config.match_window_ms,
)
if result_code != 0:
self._raise_ac8_fail(
resolved_offset_ms,
len(tlog_imu_timestamps_ns),
len(video_frame_timestamps_ns),
)
# Step 4 — clock strategy (single instance per Invariant 2).
clock = self._build_clock()
# Step 5 — concrete strategies. The frame source is built
# first because its constructor verifies the build flag and
# opens the cv2 capture handle — a failure here is a clean
# config error (no resources held). The FC adapter is built
# second; its open() launches the decode thread.
try:
frame_source = VideoFileFrameSource(
path=self._video_path,
camera_calibration_id=self._camera_calibration.camera_id,
clock=clock,
)
except FrameSourceConfigError as exc:
raise ReplayInputAdapterError(
f"video file unreadable / unsupported codec: {self._video_path} "
f"({exc})"
) from exc
except FrameSourceError as exc:
raise ReplayInputAdapterError(
f"video file decode error: {self._video_path} ({exc})"
) from exc
try:
fc_adapter = TlogReplayFcAdapter(
tlog_path=self._tlog_path,
target_fc_dialect=self._target_fc_dialect,
clock=clock,
wgs_converter=self._wgs_converter,
fdr_client=self._fdr_client,
time_offset_ms=resolved_offset_ms,
tlog_start_ns=(
aligned_window.tlog_start_ns
if aligned_window is not None
else None
),
pace=self._pace,
source_factory=self._tlog_source_factory,
mavlink_transport=self._mavlink_transport,
)
fc_adapter.open()
except (FcOpenError, FcAdapterConfigError, FcAdapterError) as exc:
# Release the already-built frame source so we do not
# leak the cv2 handle when the FC adapter fails after
# the video was opened.
try:
frame_source.close()
except Exception: # pragma: no cover — defensive.
self._log.debug(
"ReplayInputAdapter: frame_source.close() during FC adapter rollback failed",
exc_info=True,
)
# Translate the FC error into the coordinator's single
# public failure shape so the CLI exit-code mapping
# remains single-source. Pre-scan failures naturally
# surface the "tlog missing required messages: …" prefix
# the contract mandates.
raise ReplayInputAdapterError(str(exc)) from exc
# Step 6 — assemble + record the bundle.
bundle = ReplayInputBundle(
frame_source=frame_source,
fc_adapter=fc_adapter,
clock=clock,
resolved_time_offset_ms=resolved_offset_ms,
auto_sync_result=decision,
aligned_window=aligned_window,
)
self._bundle = bundle
self._opened = True
return bundle
raise ReplayInputAdapterError(_REMOVED_MSG)
def close(self) -> None:
"""Release the FC adapter + frame source; idempotent (AC-12)."""
if self._closed:
self._log.debug(
"ReplayInputAdapter.close called twice; no-op"
)
return
self._closed = True
bundle = self._bundle
self._bundle = None
if bundle is None:
return
try:
bundle.fc_adapter.close()
except Exception: # pragma: no cover — defensive.
self._log.debug(
"ReplayInputAdapter: fc_adapter.close() raised", exc_info=True
)
try:
bundle.frame_source.close()
except Exception: # pragma: no cover — defensive.
self._log.debug(
"ReplayInputAdapter: frame_source.close() raised", exc_info=True
)
# ------------------------------------------------------------------
# Internal helpers
def _load_and_validate_tlog(
self,
) -> tuple[list[int], Any]:
"""Load tlog IMU + ATTITUDE samples; raise on missing types.
Returns the IMU-only timestamp list (used by the AC-9
validator) plus the full :class:`TlogSamples` so the auto-
sync path can reuse the same scan for take-off detection.
Raises :class:`ReplayInputAdapterError` for the R-DEMO-3
missing-types path; this is the AC-13 fail-fast surface.
"""
if not self._tlog_path.is_file():
raise ReplayInputAdapterError(
f"tlog file not found: {self._tlog_path}"
)
samples = _load_tlog_samples(
self._tlog_path,
self._auto_sync_config.prescan_max_messages,
source_factory=self._tlog_source_factory,
)
if not samples.accel:
raise ReplayInputAdapterError(
"tlog missing required message types: ['RAW_IMU', 'SCALED_IMU2']"
)
if not samples.attitude:
raise ReplayInputAdapterError(
"tlog missing required message types: ['ATTITUDE']"
)
return [ts for ts, _ in samples.accel], samples
def _run_auto_trim(self) -> AlignedWindow:
"""AZ-698 auto-trim path — cross-correlate IMU energy ↔ optical flow.
Returns the located :class:`AlignedWindow`. When the
correlation peak falls below
:attr:`AutoSyncConfig.alignment_low_confidence_threshold`,
:func:`find_aligned_window` falls back to the AZ-405
head-takeoff detector and sets ``fallback_used=True`` — the
coordinator logs WARN but still proceeds (the
AC-9 frame-window validator runs in Step 3 and will
hard-fail if the resolved offset is bad).
"""
window = find_aligned_window(
self._tlog_path,
self._video_path,
self._auto_sync_config,
self._target_fc_dialect,
tlog_source_factory=self._tlog_source_factory,
video_frames_factory=self._video_frames_factory,
)
kind = (
_LOG_KIND_AUTO_TRIM_FALLBACK
if window.fallback_used
else _LOG_KIND_AUTO_TRIM_RESOLVED
)
level = "WARN" if window.fallback_used else "INFO"
kv = {
"tlog_start_ns": window.tlog_start_ns,
"tlog_end_ns": window.tlog_end_ns,
"offset_ms": window.offset_ms,
"confidence": window.confidence,
"fallback_used": window.fallback_used,
"flight_count_detected": window.flight_count_detected,
"selected_flight_index": window.selected_flight_index,
}
msg = (
f"{kind}: tlog_start_ns={window.tlog_start_ns} "
f"offset_ms={window.offset_ms} confidence={window.confidence:.3f} "
f"flights_detected={window.flight_count_detected} "
f"selected_flight={window.selected_flight_index}"
)
if window.fallback_used:
self._log.warning(msg, extra={"kind": kind, "kv": kv})
else:
self._log.info(msg, extra={"kind": kind, "kv": kv})
self._emit_fdr_event(level=level, log_kind=kind, msg=msg, kv=kv)
return window
def _run_auto_sync(self, tlog_samples: Any) -> AutoSyncDecision:
"""Auto path — compute the take-off / motion-onset / offset.
Re-uses the already-loaded ``tlog_samples`` for the take-off
detector so the tlog is walked exactly once per ``open()``
regardless of which path runs.
"""
from gps_denied_onboard.replay_input.auto_sync import (
_compute_tlog_takeoff_from_samples,
)
tlog_result = _compute_tlog_takeoff_from_samples(
tlog_samples, self._auto_sync_config
)
video_result = detect_video_motion_onset(
self._video_path,
self._auto_sync_config,
frames_factory=self._video_frames_factory,
)
decision = compute_offset(tlog_result, video_result)
if decision.combined_confidence < self._auto_sync_config.low_confidence_threshold:
self._log_decision(
kind=_LOG_KIND_AUTO_SYNC_LOW_CONF,
level="WARN",
decision=decision,
extra_kv={"proceeding_with_best_guess": True},
)
else:
self._log_decision(
kind=_LOG_KIND_AUTO_SYNC_DETECTED,
level="INFO",
decision=decision,
extra_kv={},
)
return decision
def _load_video_timestamps(self) -> list[int]:
"""Decode the leading video segment, return per-frame timestamps.
Used by the AC-9 frame-window match validator and as a
fallback when the auto-sync video scan was bypassed (manual
path). Stops at ``video_motion_scan_seconds`` so wildly long
clips do not hold up startup.
"""
if self._video_timestamps_factory is not None:
return list(self._video_timestamps_factory(self._video_path))
try:
import cv2 as _cv2 # type: ignore[import-not-found]
except ImportError as exc:
raise ReplayInputAdapterError(
"opencv-python is required for replay auto-sync but is "
"not importable in this binary"
) from exc
capture = _cv2.VideoCapture(str(self._video_path))
if not capture.isOpened():
capture.release()
raise ReplayInputAdapterError(
f"video file unreadable / unsupported codec: {self._video_path}"
)
out: list[int] = []
max_pos_ms = self._auto_sync_config.video_motion_scan_seconds * 1000.0
try:
while True:
ok = capture.grab()
if not ok:
break
pos_ms = float(capture.get(_cv2.CAP_PROP_POS_MSEC))
if pos_ms > max_pos_ms:
break
out.append(int(pos_ms * 1_000_000))
finally:
capture.release()
return out
def _load_tlog_imu_in_window(
self,
start_ns: int,
end_ns: int,
) -> list[int]:
"""Load tlog IMU timestamps from [start_ns, end_ns].
Used by the AC-9 validator in auto-trim mode. The prescan
(step 1) only covers the tlog head; when the identified window
is later in the file this method re-scans to find IMU samples
in the correct range. Sequential scan is unavoidable (pymavlink
does not seek), but only IMU message types are matched so the
scan is fast in practice.
"""
from gps_denied_onboard.replay_input.auto_sync import _open_tlog
source = _open_tlog(self._tlog_path, source_factory=self._tlog_source_factory)
timestamps: list[int] = []
try:
while True:
try:
msg = source.recv_match(
type=["RAW_IMU", "SCALED_IMU2"],
blocking=False,
)
except Exception as exc:
raise ReplayInputAdapterError(
f"tlog scan for AC-9 window failed: {exc!r}"
) from exc
if msg is None:
break
raw = getattr(msg, "_timestamp", None)
if raw is None:
continue
ts_ns = int(float(raw) * 1_000_000_000)
if ts_ns < start_ns:
continue
if ts_ns > end_ns:
break
timestamps.append(ts_ns)
finally:
if hasattr(source, "close"):
try:
source.close()
except Exception:
pass
return timestamps
def _build_clock(self) -> "Clock":
"""Pick the :class:`Clock` strategy per pace; single instance.
The ``TlogDerivedClock`` is constructed against an empty
iterable here: the composition root (AZ-401) is responsible
for hooking the clock's source up to the live tlog cursor
once the FC adapter's decode thread starts streaming. The
empty-source default keeps unit tests self-contained.
"""
if self._pace is ReplayPace.ASAP:
return TlogDerivedClock(source=iter([]))
return WallClock()
def _log_decision(
self,
*,
kind: str,
level: str,
decision: AutoSyncDecision,
extra_kv: dict[str, Any],
) -> None:
kv: dict[str, Any] = {
"tlog_takeoff_ns": decision.tlog_takeoff_ns,
"video_motion_onset_ns": decision.video_motion_onset_ns,
"offset_ms": decision.offset_ms,
"tlog_confidence": decision.tlog_confidence,
"video_confidence": decision.video_confidence,
"combined_confidence": decision.combined_confidence,
}
kv.update(extra_kv)
msg = f"{kind}: offset_ms={decision.offset_ms} confidence={decision.combined_confidence:.3f}"
if level == "WARN":
self._log.warning(msg, extra={"kind": kind, "kv": kv})
else:
self._log.info(msg, extra={"kind": kind, "kv": kv})
self._emit_fdr_event(level=level, log_kind=kind, msg=msg, kv=kv)
def _raise_ac8_fail(
self,
offset_ms: int,
imu_count: int,
frame_count: int,
) -> None:
kv = {
"offset_ms": offset_ms,
"frame_window_match_pct_threshold": self._auto_sync_config.match_threshold_pct,
"imu_sample_count": imu_count,
"video_frame_count": frame_count,
}
msg = (
f"auto-sync hard-fail: frame-window match below "
f"{self._auto_sync_config.match_threshold_pct}% with "
f"offset_ms={offset_ms}"
)
self._log.error(
f"{_LOG_KIND_AUTO_SYNC_AC8_FAIL}: {msg}",
extra={"kind": _LOG_KIND_AUTO_SYNC_AC8_FAIL, "kv": kv},
)
self._emit_fdr_event(
level="ERROR", log_kind=_LOG_KIND_AUTO_SYNC_AC8_FAIL, msg=msg, kv=kv
)
raise ReplayInputAdapterError(msg)
def _emit_fdr_event(
self,
*,
level: str,
log_kind: str,
msg: str,
kv: dict[str, Any],
) -> None:
record = FdrRecord(
schema_version=1,
ts=iso_ts_now(),
producer_id=_FDR_PRODUCER_ID,
kind="log",
payload={
"level": level,
"component": "replay_input",
"kind": log_kind,
"msg": msg,
"kv": kv,
},
)
try:
self._fdr_client.enqueue(record)
except Exception as exc:
self._log.debug(
f"replay_input.fdr_enqueue_failed: {exc!r}",
extra={
"kind": "replay_input.fdr_enqueue_failed",
"kv": {"error": repr(exc), "downstream_kind": log_kind},
},
)
return None
@@ -16,14 +16,21 @@ shared composition spine while still exposing exactly one
Build-flag gates (per replay protocol Invariant 9):
- ``BUILD_VIDEO_FILE_FRAME_SOURCE`` — required for the
:class:`VideoFileFrameSource` instance returned by the coordinator.
- ``BUILD_TLOG_REPLAY_ADAPTER`` — required for the
:class:`TlogReplayFcAdapter` instance returned by the coordinator.
:class:`VideoFileFrameSource` instance.
- ``BUILD_TLOG_REPLAY_ADAPTER`` — historical guard. The tlog adapter
is no longer composed by replay (AZ-895 deprecated the (video, tlog)
path), but the flag remains in :data:`REPLAY_BUILD_FLAGS` for one
deprecation cycle so operator overrides keep their expected semantics.
AZ-908 will drop the flag.
- ``BUILD_REPLAY_SINK_JSONL`` — shared by the JSONL sink and the noop
outbound transport.
All three default ON in the airborne binary (per ADR-011); flipping any
OFF disables replay mode without affecting live mode.
AZ-895: the replay composition exclusively uses the (video, CSV) path
via :class:`CsvReplayFcAdapter`. The legacy (video, tlog) auto-sync
branch was removed; ``imu_csv_path`` is required.
"""
from __future__ import annotations
@@ -48,13 +55,8 @@ from gps_denied_onboard.components.c8_fc_adapter.replay_sink import (
from gps_denied_onboard.config import Config
from gps_denied_onboard.fdr_client import make_fdr_client
from gps_denied_onboard.frame_source.video_file import VideoFileFrameSource
from gps_denied_onboard.helpers.wgs_converter import WgsConverter
from gps_denied_onboard.logging import get_logger
from gps_denied_onboard.replay_input import (
AutoSyncConfig,
ReplayInputAdapter,
ReplayInputBundle,
)
from gps_denied_onboard.replay_input import ReplayInputBundle
from gps_denied_onboard.replay_input.tlog_video_adapter import ReplayPace
if TYPE_CHECKING:
@@ -77,10 +79,6 @@ REPLAY_BUILD_FLAGS: Final[tuple[str, ...]] = (
"BUILD_REPLAY_SINK_JSONL",
)
# AZ-894: separate build flag for the CSV adapter so the replay binary
# can opt into the new path without disturbing the BUILD_TLOG_* gate
# (the tlog adapter is still composed by _build_replay_input_bundle's
# legacy branch until AZ-895 removes it).
_CSV_REPLAY_BUILD_FLAG: Final[str] = "BUILD_CSV_REPLAY_ADAPTER"
@@ -106,7 +104,6 @@ def build_replay_components(
config: Config,
*,
fdr_client_factory: Any | None = None,
replay_input_adapter_factory: Any | None = None,
sink_factory: Any | None = None,
transport_factory: Any | None = None,
) -> tuple[dict[str, Any], tuple[str, ...]]:
@@ -137,11 +134,10 @@ def build_replay_components(
sink_fdr_client = fdr_factory("c8_fc_adapter.replay_sink", config)
# AZ-558: build the outbound MAVLink transport BEFORE the FC adapter
# so it can be threaded through `ReplayInputAdapter` and into
# `TlogReplayFcAdapter`. The same instance is exposed as the
# ``mavlink_transport`` slot in ``components`` (replay protocol
# Invariant 5: encoders write through the seam in both modes;
# replay drops the bytes via NoopMavlinkTransport).
# so the same instance can be exposed as the ``mavlink_transport``
# slot in ``components`` (replay protocol Invariant 5: encoders
# write through the seam in both modes; replay drops the bytes via
# NoopMavlinkTransport).
if transport_factory is not None:
transport = transport_factory(config)
else:
@@ -150,7 +146,6 @@ def build_replay_components(
bundle = _build_replay_input_bundle(
config,
fdr_client=fdr_client,
adapter_factory=replay_input_adapter_factory,
mavlink_transport=transport,
)
@@ -187,19 +182,19 @@ def _validate_build_flags() -> None:
def _validate_replay_paths(config: Config) -> None:
"""Reject empty / missing replay paths early with a precise message.
AZ-894: ``imu_csv_path`` is the canonical replay input. ``tlog_path``
remains valid for the legacy auto-sync path until AZ-895 removes it,
but exactly one of the two must be set so the composition root can
pick a single branch.
AZ-895: ``imu_csv_path`` is the only supported replay input. The
legacy tlog auto-sync surface was deprecated in AZ-895 and will be
physically removed in AZ-908; until then ``tlog_path`` may remain
set in the config but is ignored by composition.
"""
if not config.replay.video_path:
raise CompositionError(
"config.replay.video_path is empty; replay mode requires a video path"
)
if not config.replay.imu_csv_path and not config.replay.tlog_path:
if not config.replay.imu_csv_path:
raise CompositionError(
"config.replay.imu_csv_path is empty and no tlog_path fallback is set; "
"replay mode requires an IMU+GPS CSV (AZ-894) or a tlog file (legacy)"
"config.replay.imu_csv_path is empty; "
"replay mode requires an IMU+GPS CSV (--imu PATH.csv)"
)
if not config.replay.output_path:
raise CompositionError(
@@ -211,61 +206,27 @@ def _build_replay_input_bundle(
config: Config,
*,
fdr_client: "FdrClient",
adapter_factory: Any | None,
mavlink_transport: Any | None = None,
) -> ReplayInputBundle:
"""Build the replay input bundle and open the underlying strategies.
AZ-894: branches on ``config.replay.imu_csv_path`` — when set, builds
the :class:`CsvReplayFcAdapter` + :class:`VideoFileFrameSource` pair
on a single canonical clock derived from the CSV's ``Time`` column;
when unset, falls back to the legacy :class:`ReplayInputAdapter`
tlog path (auto-sync + AC-9 validator). AZ-895 removes the legacy
branch.
AZ-895: the (video, CSV) path is the only supported composition.
The legacy (video, tlog) auto-sync branch was removed; the
:func:`_validate_replay_paths` gate above guarantees
``imu_csv_path`` is set before this function runs.
"""
pace = _resolve_pace(config.replay.pace)
target_fc_dialect = _resolve_fc_kind(config.replay.target_fc_dialect)
camera_calibration = _load_camera_calibration(config)
wgs_converter = WgsConverter()
if config.replay.imu_csv_path:
return _build_csv_bundle(
config,
fdr_client=fdr_client,
pace=pace,
target_fc_dialect=target_fc_dialect,
camera_calibration=camera_calibration,
mavlink_transport=mavlink_transport,
)
auto_sync = _build_auto_sync_config(config)
if adapter_factory is not None:
adapter = adapter_factory(
config=config,
camera_calibration=camera_calibration,
target_fc_dialect=target_fc_dialect,
wgs_converter=wgs_converter,
fdr_client=fdr_client,
pace=pace,
auto_sync_config=auto_sync,
mavlink_transport=mavlink_transport,
)
else:
adapter = ReplayInputAdapter(
video_path=Path(config.replay.video_path),
tlog_path=Path(config.replay.tlog_path),
camera_calibration=camera_calibration,
target_fc_dialect=target_fc_dialect,
wgs_converter=wgs_converter,
fdr_client=fdr_client,
pace=pace,
manual_time_offset_ms=config.replay.time_offset_ms,
skip_auto_sync_validation=config.replay.skip_auto_sync_validation,
auto_trim=config.replay.auto_trim,
auto_sync_config=auto_sync,
mavlink_transport=mavlink_transport,
)
return adapter.open()
return _build_csv_bundle(
config,
fdr_client=fdr_client,
pace=pace,
target_fc_dialect=target_fc_dialect,
camera_calibration=camera_calibration,
mavlink_transport=mavlink_transport,
)
def _build_csv_bundle(
@@ -339,35 +300,6 @@ def _resolve_fc_kind(raw: str) -> FcKind:
)
def _build_auto_sync_config(config: Config) -> AutoSyncConfig:
block = config.replay.auto_sync
return AutoSyncConfig(
takeoff_accel_threshold_g=block.takeoff_accel_threshold_g,
takeoff_attitude_rate_threshold_rad_s=(
block.takeoff_attitude_rate_threshold_rad_s
),
sustained_seconds=block.sustained_seconds,
prescan_max_messages=block.prescan_max_messages,
video_motion_threshold=block.video_motion_threshold,
video_motion_scan_seconds=block.video_motion_scan_seconds,
match_threshold_pct=block.match_threshold_pct,
match_window_ms=block.match_window_ms,
low_confidence_threshold=block.low_confidence_threshold,
alignment_resample_hz=block.alignment_resample_hz,
alignment_video_scan_seconds=block.alignment_video_scan_seconds,
alignment_low_confidence_threshold=block.alignment_low_confidence_threshold,
alignment_segment_motion_threshold_g=(
block.alignment_segment_motion_threshold_g
),
alignment_segment_min_flight_duration_seconds=(
block.alignment_segment_min_flight_duration_seconds
),
alignment_segment_max_internal_gap_seconds=(
block.alignment_segment_max_internal_gap_seconds
),
)
def _load_camera_calibration(config: Config) -> CameraCalibration:
"""Read the camera calibration JSON into a :class:`CameraCalibration` DTO.
@@ -427,12 +359,11 @@ def _log_ready(config: Config, bundle: ReplayInputBundle) -> None:
"kind": _LOG_KIND_READY,
"kv": {
"video_path": config.replay.video_path,
"tlog_path": config.replay.tlog_path,
"imu_csv_path": config.replay.imu_csv_path,
"output_path": config.replay.output_path,
"pace": config.replay.pace,
"resolved_offset_ms": bundle.resolved_time_offset_ms,
"calib_path": config.runtime.camera_calibration_path,
"auto_sync_used": bundle.auto_sync_result is not None,
},
},
)