mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-23 04:11:13 +00:00
[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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user