[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
@@ -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,
},
},
)