mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 17:01:14 +00:00
[AZ-894] [AZ-896] Add CSV-driven replay adapter + format docs
Replaces the tlog two-clock replay surface with a single-clock path driven by the Derkachi-schema CSV. --imu is the new required CLI arg; --tlog stays as a deprecated alias (warned + ignored when --imu set) until AZ-895 deletes it. * csv_ground_truth.py parses the 15-column schema, fails fast at startup on every documented schema fault (AC-5). * CsvReplayFcAdapter slots into ReplayInputBundle.fc_adapter alongside the tlog sibling; mirrors Invariant-5 outbound wiring; inbound bus is intentionally a no-op since the loop reads CSV directly. * _run_replay_loop branches on imu_csv_path, stamps VioOutput.emitted_at_ns from the CSV-derived frame_end_ns (AC-4), closing the AZ-848 two-clock surface for the new path. * AZ-896 ships the operator-facing format spec at _docs/02_document/contracts/replay/csv_replay_format.md plus a 20-row example CSV (AC-3 regression-locked). Tests: 11 + 12 new unit tests, plus updates to AZ-401 import-boundary and AZ-402 CLI suites. Full unit suite 2,327 passed / 86 skipped. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -789,6 +789,7 @@ def _run_replay_loop(config: Config, runtime: RuntimeRoot) -> int:
|
||||
fixes (cold-start impossible), or when the estimator raises a
|
||||
fatal error. ``EXIT_SUCCESS`` (0) on clean completion.
|
||||
"""
|
||||
import dataclasses
|
||||
import time
|
||||
|
||||
from gps_denied_onboard._types.geo import LatLonAlt
|
||||
@@ -802,6 +803,9 @@ def _run_replay_loop(config: Config, runtime: RuntimeRoot) -> int:
|
||||
EstimatorFatalError,
|
||||
)
|
||||
from gps_denied_onboard.logging import get_logger
|
||||
from gps_denied_onboard.replay_input.csv_ground_truth import (
|
||||
load_csv_ground_truth,
|
||||
)
|
||||
from gps_denied_onboard.replay_input.errors import ReplayInputAdapterError
|
||||
from gps_denied_onboard.replay_input.tlog_ground_truth import (
|
||||
load_tlog_ground_truth,
|
||||
@@ -856,38 +860,66 @@ def _run_replay_loop(config: Config, runtime: RuntimeRoot) -> int:
|
||||
# explicitly.
|
||||
calibration = _load_camera_calibration(config)
|
||||
|
||||
# Cold-start origin from tlog's first GPS fix. This is the
|
||||
# ADR-010 / Principle #11 documented fallback when no operator
|
||||
# Manifest is available. ESKF/GTSAM both require an origin
|
||||
# before the first add_fc_imu (else EstimatorAlreadyStartedError).
|
||||
# Cold-start origin from the first GPS fix in the configured input.
|
||||
# AZ-894: prefer the CSV ground-truth (single canonical clock); fall
|
||||
# back to the legacy tlog path when ``imu_csv_path`` is unset (the
|
||||
# AZ-895 deprecation completes the removal).
|
||||
csv_path_str = config.replay.imu_csv_path
|
||||
tlog_path_str = config.replay.tlog_path
|
||||
_log.info(
|
||||
"replay_loop.loading_gps_for_cold_start: tlog_path=%s",
|
||||
tlog_path_str,
|
||||
extra={
|
||||
"kind": "replay_loop.loading_gps_for_cold_start",
|
||||
"kv": {"tlog_path": tlog_path_str},
|
||||
},
|
||||
)
|
||||
try:
|
||||
gt = load_tlog_ground_truth(Path(tlog_path_str))
|
||||
except ReplayInputAdapterError as exc:
|
||||
_log.error(
|
||||
"replay_loop.tlog_load_failed: %r",
|
||||
exc,
|
||||
using_csv = bool(csv_path_str)
|
||||
if using_csv:
|
||||
_log.info(
|
||||
"replay_loop.loading_gps_for_cold_start: imu_csv_path=%s",
|
||||
csv_path_str,
|
||||
extra={
|
||||
"kind": "replay_loop.tlog_load_failed",
|
||||
"kv": {"error": repr(exc)},
|
||||
"kind": "replay_loop.loading_gps_for_cold_start",
|
||||
"kv": {"imu_csv_path": csv_path_str},
|
||||
},
|
||||
)
|
||||
return EXIT_GENERIC_FAILURE
|
||||
try:
|
||||
csv_gt = load_csv_ground_truth(Path(csv_path_str))
|
||||
except ReplayInputAdapterError as exc:
|
||||
_log.error(
|
||||
"replay_loop.csv_load_failed: %r",
|
||||
exc,
|
||||
extra={
|
||||
"kind": "replay_loop.csv_load_failed",
|
||||
"kv": {"error": repr(exc)},
|
||||
},
|
||||
)
|
||||
return EXIT_GENERIC_FAILURE
|
||||
gt = csv_gt
|
||||
else:
|
||||
_log.info(
|
||||
"replay_loop.loading_gps_for_cold_start: tlog_path=%s",
|
||||
tlog_path_str,
|
||||
extra={
|
||||
"kind": "replay_loop.loading_gps_for_cold_start",
|
||||
"kv": {"tlog_path": tlog_path_str},
|
||||
},
|
||||
)
|
||||
try:
|
||||
gt = load_tlog_ground_truth(Path(tlog_path_str))
|
||||
except ReplayInputAdapterError as exc:
|
||||
_log.error(
|
||||
"replay_loop.tlog_load_failed: %r",
|
||||
exc,
|
||||
extra={
|
||||
"kind": "replay_loop.tlog_load_failed",
|
||||
"kv": {"error": repr(exc)},
|
||||
},
|
||||
)
|
||||
return EXIT_GENERIC_FAILURE
|
||||
if not gt.records:
|
||||
_log.error(
|
||||
"replay_loop.cold_start_impossible: tlog has no GPS messages, "
|
||||
"replay_loop.cold_start_impossible: input has no GPS fixes, "
|
||||
"cannot seed C5 set_takeoff_origin",
|
||||
extra={
|
||||
"kind": "replay_loop.cold_start_impossible",
|
||||
"kv": {"tlog_path": tlog_path_str},
|
||||
"kv": {
|
||||
"input_path": csv_path_str if using_csv else tlog_path_str,
|
||||
"input_kind": "csv" if using_csv else "tlog",
|
||||
},
|
||||
},
|
||||
)
|
||||
return EXIT_GENERIC_FAILURE
|
||||
@@ -924,22 +956,29 @@ def _run_replay_loop(config: Config, runtime: RuntimeRoot) -> int:
|
||||
},
|
||||
)
|
||||
|
||||
# Open the tlog directly for synchronous IMU read. Bypasses the
|
||||
# decode-thread race in TlogReplayFcAdapter (see docstring).
|
||||
try:
|
||||
from pymavlink import mavutil # type: ignore[import-untyped]
|
||||
except ImportError as exc:
|
||||
_log.error(
|
||||
"replay_loop.pymavlink_unavailable: %r",
|
||||
exc,
|
||||
extra={
|
||||
"kind": "replay_loop.pymavlink_unavailable",
|
||||
"kv": {"error": repr(exc)},
|
||||
},
|
||||
)
|
||||
return EXIT_GENERIC_FAILURE
|
||||
|
||||
tlog_reader = mavutil.mavlink_connection(str(tlog_path_str))
|
||||
# IMU source. CSV path: walk the pre-loaded list (gt.imu_samples)
|
||||
# so the loop's IMU draining stays on the single canonical clock.
|
||||
# Tlog path (legacy): open the tlog directly for synchronous IMU
|
||||
# read; bypasses the decode-thread race in TlogReplayFcAdapter.
|
||||
tlog_reader: Any = None
|
||||
csv_imu_samples: tuple[ImuSample, ...] = ()
|
||||
csv_imu_idx = 0
|
||||
if using_csv:
|
||||
csv_imu_samples = csv_gt.imu_samples
|
||||
else:
|
||||
try:
|
||||
from pymavlink import mavutil # type: ignore[import-untyped]
|
||||
except ImportError as exc:
|
||||
_log.error(
|
||||
"replay_loop.pymavlink_unavailable: %r",
|
||||
exc,
|
||||
extra={
|
||||
"kind": "replay_loop.pymavlink_unavailable",
|
||||
"kv": {"error": repr(exc)},
|
||||
},
|
||||
)
|
||||
return EXIT_GENERIC_FAILURE
|
||||
tlog_reader = mavutil.mavlink_connection(str(tlog_path_str))
|
||||
|
||||
# IMU sample buffer used to build per-frame ImuWindows. We
|
||||
# accumulate every RAW_IMU/SCALED_IMU2 sample whose FC-clock
|
||||
@@ -949,32 +988,41 @@ def _run_replay_loop(config: Config, runtime: RuntimeRoot) -> int:
|
||||
imu_eof = False
|
||||
|
||||
def _drain_imu_until(target_ns: int) -> None:
|
||||
"""Advance the tlog reader, appending IMU samples up to ``target_ns``.
|
||||
"""Advance the IMU source, appending samples up to ``target_ns``.
|
||||
|
||||
Stops at end-of-stream (``recv_match`` returns ``None``).
|
||||
Mirrors :meth:`TlogReplayFcAdapter._handle_imu` for sample
|
||||
construction so the bytes-on-wire and the synchronous-read
|
||||
paths produce identical IMU samples.
|
||||
Branches on ``using_csv`` so the closure stays a single
|
||||
definition. Both branches respect the same ``pending_imu``
|
||||
buffer + ``imu_anchor_ns`` / ``imu_eof`` state and produce
|
||||
:class:`ImuSample` instances with identical numeric semantics
|
||||
— the tlog branch matches :meth:`TlogReplayFcAdapter._handle_imu`
|
||||
for byte-for-byte compatibility with the legacy path.
|
||||
"""
|
||||
nonlocal imu_anchor_ns, imu_eof
|
||||
nonlocal imu_anchor_ns, imu_eof, csv_imu_idx
|
||||
while not imu_eof:
|
||||
if pending_imu and pending_imu[-1].ts_ns >= target_ns:
|
||||
return
|
||||
msg = tlog_reader.recv_match(
|
||||
type=["RAW_IMU", "SCALED_IMU2"],
|
||||
blocking=False,
|
||||
)
|
||||
if msg is None:
|
||||
imu_eof = True
|
||||
return
|
||||
ts_ns = int(getattr(msg, "time_usec", 0)) * 1000
|
||||
if ts_ns == 0:
|
||||
continue
|
||||
sample = ImuSample(
|
||||
ts_ns=ts_ns,
|
||||
accel_xyz=(float(msg.xacc), float(msg.yacc), float(msg.zacc)),
|
||||
gyro_xyz=(float(msg.xgyro), float(msg.ygyro), float(msg.zgyro)),
|
||||
)
|
||||
if using_csv:
|
||||
if csv_imu_idx >= len(csv_imu_samples):
|
||||
imu_eof = True
|
||||
return
|
||||
sample = csv_imu_samples[csv_imu_idx]
|
||||
csv_imu_idx += 1
|
||||
else:
|
||||
msg = tlog_reader.recv_match(
|
||||
type=["RAW_IMU", "SCALED_IMU2"],
|
||||
blocking=False,
|
||||
)
|
||||
if msg is None:
|
||||
imu_eof = True
|
||||
return
|
||||
ts_ns = int(getattr(msg, "time_usec", 0)) * 1000
|
||||
if ts_ns == 0:
|
||||
continue
|
||||
sample = ImuSample(
|
||||
ts_ns=ts_ns,
|
||||
accel_xyz=(float(msg.xacc), float(msg.yacc), float(msg.zacc)),
|
||||
gyro_xyz=(float(msg.xgyro), float(msg.ygyro), float(msg.zgyro)),
|
||||
)
|
||||
if imu_anchor_ns is None:
|
||||
imu_anchor_ns = sample.ts_ns
|
||||
pending_imu.append(sample)
|
||||
@@ -1141,6 +1189,17 @@ def _run_replay_loop(config: Config, runtime: RuntimeRoot) -> int:
|
||||
return EXIT_GENERIC_FAILURE
|
||||
|
||||
if vio_out is not None:
|
||||
# AZ-894 AC-4: when the CSV adapter drives the loop,
|
||||
# stamp VioOutput.emitted_at_ns with the CSV-derived
|
||||
# frame timestamp (single-clock invariant). Without
|
||||
# this, C1 produces a Jetson-monotonic timestamp that
|
||||
# disagrees with the CSV-anchored ImuWindow.ts_end_ns
|
||||
# the estimator consumed the line before — exactly
|
||||
# the AZ-848 two-clock surface this ticket eliminates.
|
||||
if using_csv:
|
||||
vio_out = dataclasses.replace(
|
||||
vio_out, emitted_at_ns=frame_end_ns
|
||||
)
|
||||
try:
|
||||
state_estimator.add_vio(vio_out)
|
||||
except EstimatorDegradedError as exc:
|
||||
@@ -1203,17 +1262,18 @@ def _run_replay_loop(config: Config, runtime: RuntimeRoot) -> int:
|
||||
if slack_ns > 0:
|
||||
time.sleep(slack_ns / 1_000_000_000.0)
|
||||
finally:
|
||||
try:
|
||||
tlog_reader.close()
|
||||
except Exception as exc: # pragma: no cover — defensive.
|
||||
_log.debug(
|
||||
"replay_loop.tlog_reader_close_error: %r",
|
||||
exc,
|
||||
extra={
|
||||
"kind": "replay_loop.tlog_reader_close_error",
|
||||
"kv": {"error": repr(exc)},
|
||||
},
|
||||
)
|
||||
if tlog_reader is not None:
|
||||
try:
|
||||
tlog_reader.close()
|
||||
except Exception as exc: # pragma: no cover — defensive.
|
||||
_log.debug(
|
||||
"replay_loop.tlog_reader_close_error: %r",
|
||||
exc,
|
||||
extra={
|
||||
"kind": "replay_loop.tlog_reader_close_error",
|
||||
"kv": {"error": repr(exc)},
|
||||
},
|
||||
)
|
||||
|
||||
_log.info(
|
||||
"replay_loop.complete: frames=%d emitted=%d vio_init_skipped=%d "
|
||||
|
||||
@@ -36,6 +36,9 @@ from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
from gps_denied_onboard._types.calibration import CameraCalibration
|
||||
from gps_denied_onboard._types.fc import FcKind
|
||||
from gps_denied_onboard.components.c8_fc_adapter.csv_replay_adapter import (
|
||||
CsvReplayFcAdapter,
|
||||
)
|
||||
from gps_denied_onboard.components.c8_fc_adapter.noop_mavlink_transport import (
|
||||
NoopMavlinkTransport,
|
||||
)
|
||||
@@ -44,6 +47,7 @@ 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 (
|
||||
@@ -73,6 +77,12 @@ 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"
|
||||
|
||||
|
||||
REPLAY_COMPONENT_KEYS: Final[tuple[str, ...]] = (
|
||||
"frame_source",
|
||||
@@ -175,14 +185,21 @@ def _validate_build_flags() -> None:
|
||||
|
||||
|
||||
def _validate_replay_paths(config: Config) -> None:
|
||||
"""Reject empty / missing replay paths early with a precise message."""
|
||||
"""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.
|
||||
"""
|
||||
if not config.replay.video_path:
|
||||
raise CompositionError(
|
||||
"config.replay.video_path is empty; replay mode requires a video path"
|
||||
)
|
||||
if not config.replay.tlog_path:
|
||||
if not config.replay.imu_csv_path and not config.replay.tlog_path:
|
||||
raise CompositionError(
|
||||
"config.replay.tlog_path is empty; replay mode requires a tlog path"
|
||||
"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)"
|
||||
)
|
||||
if not config.replay.output_path:
|
||||
raise CompositionError(
|
||||
@@ -197,13 +214,31 @@ def _build_replay_input_bundle(
|
||||
adapter_factory: Any | None,
|
||||
mavlink_transport: Any | None = None,
|
||||
) -> ReplayInputBundle:
|
||||
"""Build the :class:`ReplayInputAdapter` and call ``open()``."""
|
||||
"""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.
|
||||
"""
|
||||
pace = _resolve_pace(config.replay.pace)
|
||||
target_fc_dialect = _resolve_fc_kind(config.replay.target_fc_dialect)
|
||||
auto_sync = _build_auto_sync_config(config)
|
||||
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,
|
||||
@@ -233,6 +268,56 @@ def _build_replay_input_bundle(
|
||||
return adapter.open()
|
||||
|
||||
|
||||
def _build_csv_bundle(
|
||||
config: Config,
|
||||
*,
|
||||
fdr_client: "FdrClient",
|
||||
pace: ReplayPace,
|
||||
target_fc_dialect: FcKind,
|
||||
camera_calibration: CameraCalibration,
|
||||
mavlink_transport: Any | None,
|
||||
) -> ReplayInputBundle:
|
||||
"""Compose the AZ-894 CSV bundle (frame source + CSV FC adapter + clock).
|
||||
|
||||
No auto-sync / auto-trim is run — the CSV's ``Time`` column is the
|
||||
single canonical clock by construction, so ``resolved_time_offset_ms``
|
||||
is fixed at 0 and ``auto_sync_result`` / ``aligned_window`` are
|
||||
``None``.
|
||||
"""
|
||||
from gps_denied_onboard.clock.tlog_derived import TlogDerivedClock
|
||||
from gps_denied_onboard.clock.wall_clock import WallClock
|
||||
|
||||
csv_path = Path(config.replay.imu_csv_path)
|
||||
if not csv_path.is_file():
|
||||
raise CompositionError(
|
||||
f"config.replay.imu_csv_path points at a missing file: {csv_path}"
|
||||
)
|
||||
|
||||
clock = TlogDerivedClock(source=iter([])) if pace is ReplayPace.ASAP else WallClock()
|
||||
frame_source = VideoFileFrameSource(
|
||||
path=Path(config.replay.video_path),
|
||||
camera_calibration_id=camera_calibration.camera_id,
|
||||
clock=clock,
|
||||
)
|
||||
fc_adapter = CsvReplayFcAdapter(
|
||||
csv_path=csv_path,
|
||||
target_fc_dialect=target_fc_dialect,
|
||||
clock=clock,
|
||||
fdr_client=fdr_client,
|
||||
pace=pace,
|
||||
mavlink_transport=mavlink_transport,
|
||||
)
|
||||
fc_adapter.open()
|
||||
return ReplayInputBundle(
|
||||
frame_source=frame_source,
|
||||
fc_adapter=fc_adapter,
|
||||
clock=clock,
|
||||
resolved_time_offset_ms=0,
|
||||
auto_sync_result=None,
|
||||
aligned_window=None,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_pace(raw: str) -> ReplayPace:
|
||||
if raw == "asap":
|
||||
return ReplayPace.ASAP
|
||||
|
||||
Reference in New Issue
Block a user