- Modified the autodev state to reflect the current testing phase and details of the new `jetson-e2e` tests. - Enhanced the "How to Test" documentation to provide clearer instructions on the demo replay validation process, including video and tlog alignment steps. - Updated architectural documentation to include the new demo replay operator flow and its dependencies. - Documented the removal of deprecated auto-sync features and clarified the operator-facing UI for replay validation. - Added new entries in the dependencies table for upcoming tasks related to the demo replay flow. These changes improve clarity and usability for operators and developers working with the demo replay system.
39 KiB
Contract: Replay Mode (replay_input module + FrameSource + Clock + ReplaySink + NoopMavlinkTransport)
Owner: replay (epic AZ-265 / E-DEMO-REPLAY) — strategies live inside existing components (frame_source/, clock/, c8_fc_adapter/); a small new replay_input/ cross-cutting module converges (video, tlog) inputs into the standard FrameSource + FcAdapter boundaries the rest of the system already consumes.
Producer task: AZ-398 (FrameSource Protocol + VideoFileFrameSource + LiveCameraFrameSource retrofit + Clock Protocol)
Consumer tasks: AZ-399 (TlogReplayFcAdapter), AZ-400 (ReplaySink + JsonlReplaySink + NoopMavlinkTransport), AZ-401 (replay-mode branch in compose_root), AZ-402 (gps-denied-replay CLI wrapper), AZ-404 (E2E replay fixture test), AZ-405 (Auto-sync IMU take-off detection inside replay_input/).
Version: 2.0.0 (replaces v1.0.0 — "replay is a fourth Docker image" design replaced by "replay is a configuration of the airborne binary"; see ADR-011)
Status: draft
Last Updated: 2026-05-14
Module-layout home:
src/gps_denied_onboard/frame_source/interface.py,__init__.py—FrameSourceProtocol (Layer 1 cross-cutting permodule-layout.md).src/gps_denied_onboard/clock/interface.py,__init__.py—ClockProtocol (Layer 1 cross-cutting).src/gps_denied_onboard/components/c8_fc_adapter/tlog_replay_adapter.py—TlogReplayFcAdapterstrategy (gatedBUILD_TLOG_REPLAY_ADAPTER; ON in the airborne binary).src/gps_denied_onboard/components/c8_fc_adapter/replay_sink.py—ReplaySinkProtocol +JsonlReplaySinkstrategy (gatedBUILD_REPLAY_SINK_JSONL; ON in the airborne binary).src/gps_denied_onboard/components/c8_fc_adapter/noop_mavlink_transport.py—NoopMavlinkTransportstrategy (gatedBUILD_REPLAY_SINK_JSONL; ON in the airborne binary; wraps the live MAVLink transport layer so C8 encoders are unchanged).src/gps_denied_onboard/replay_input/— new Layer-4 cross-cutting coordinator that owns(video, tlog)→(FrameSource, FcAdapter, Clock)convergence + auto-sync + time-offset application.src/gps_denied_onboard/runtime_root/__init__.py—compose_root(config)extended with aconfig.mode = "live" | "replay"branch (no separatecompose_replaycomposition root; replay is a configuration of the single airborne composition root).src/gps_denied_onboard/cli/replay.py—gps-denied-replayconsole-script: builds a replay-modeConfigand dispatches into the same companion entry point as live.
Purpose
Defines the public interfaces enabling offline replay mode per epic AZ-265: run the production C1–C5 pipeline (with the full C6 tile cache + the same C7 inference runtime + the same C13 FDR) against historical inputs (1–2 min Derkachi-style clip + matching pymavlink .tlog) so the parent-suite UI demo has end-to-end fidelity equal to a live flight.
Design (v2.0.0 — replaces v1.0.0): replay is a configuration of the airborne binary, not a separate Docker image. See ADR-011 for the full rationale. The same image, same components, same composition root, same pre-flight workflow as a live flight; only three strategies differ at runtime:
| Concern | Live strategy | Replay strategy |
|---|---|---|
FrameSource |
LiveCameraFrameSource |
VideoFileFrameSource |
FcAdapter (inbound IMU/attitude/GPS/flight-state) |
PymavlinkArdupilotAdapter / Msp2InavAdapter |
TlogReplayFcAdapter |
FcAdapter outbound transport (the bytes that go onto the wire) |
Real serial/UART link to ArduPilot Plane / iNav | NoopMavlinkTransport (sink; C8 encoders unchanged) |
Clock |
WallClock |
TlogDerivedClock (pace=ASAP) or WallClock (pace=REALTIME) |
| Per-tick position observable to the UI | C8 outbound + GCS telemetry summary | Additional JsonlReplaySink tap on C5's EstimatorOutput stream |
Everything else is identical: C6 reads the same pre-built tile cache the operator built via the normal C10/C11/C12 pre-flight flow; C7 deserializes the same TensorRT engines; C13 writes a real FDR for the replay run (a real flight record, just driven by historical inputs). Production C1–C5 components remain mode-agnostic — replay-aware logic lives ONLY in the composition root branch, the strategies named above, the replay_input/ coordinator, and the CLI.
The user-visible result: a UI consumer tails the JSONL file and sees per-tick (lat, lon, alt, horiz_accuracy) exactly as the airborne binary would emit them in a real flight. Other MAVLink emits (FC GPS_INPUT, GCS STATUSTEXT, EKF source-set commands) are swallowed by NoopMavlinkTransport — the operator confirmed they don't need to be observable in replay (the contract above is the single source of truth for that decision).
This contract defines four Protocols, one coordinator class, and the replay-mode composition branch:
FrameSource— formalised cross-cutting interface for camera-frame ingestion. Two strategies:LiveCameraFrameSource(live) andVideoFileFrameSource(replay; gatedBUILD_VIDEO_FILE_FRAME_SOURCE).Clock— wall-clock vs. tlog-derived time abstraction (R-DEMO-4 mitigation). Two strategies:WallClock(live/research/operator/replay-realtime) andTlogDerivedClock(replay-asap).ReplaySink— offlineEstimatorOutputconsumer interface tapping C5's output stream. One strategy:JsonlReplaySink(oneEstimatorOutputper JSONL line; gatedBUILD_REPLAY_SINK_JSONL).TlogReplayFcAdapter— replay-onlyFcAdapterstrategy (per AZ-261FcAdapterProtocol from_docs/02_document/contracts/c8_fc_adapter/fc_adapter_protocol.md); parses pymavlink.tlogand emitsImuWindow/AttitudeWindow/GpsHealth/FlightStateSignalat tlog-timestamp cadence (or wall-clock-paced per--pace). GatedBUILD_TLOG_REPLAY_ADAPTER.NoopMavlinkTransport— replay-only outbound transport that swallows every byte the C8 encoders try to write. The C8 outbound encoder code path is unchanged between live and replay (Invariant 1); the transport layer is the only place the destination differs. GatedBUILD_REPLAY_SINK_JSONL(shares the build flag withJsonlReplaySink— both are "where does this binary send its outputs in replay" concerns).ReplayInputAdapter— Layer-4 coordinator class inreplay_input/that owns(video, tlog)lifecycle, applies the time-offset (manual via--time-offset-msor auto via AZ-405 IMU-take-off detection), instantiatesVideoFileFrameSource+TlogReplayFcAdapter+ chosenClock, and hands the trio to the composition root. The composition root sees only standardFrameSource+FcAdapter+Clockafter the coordinator is opened.
The shared WgsConverter (AZ-279) is constructor-injected into the tlog adapter for tlog-GPS → local-tangent-plane conversion (unchanged from v1.0.0).
Public API
Protocol: FrameSource (unchanged from v1.0.0)
@runtime_checkable
class FrameSource(Protocol):
def next_frame(self) -> NavCameraFrame | None: ... # None on end-of-stream
def close(self) -> None: ...
Protocol: Clock (unchanged from v1.0.0)
@runtime_checkable
class Clock(Protocol):
def monotonic_ns(self) -> int: ...
def time_ns(self) -> int: ... # wall-clock (UTC) for log timestamps
def sleep_until_ns(self, target_ns: int) -> None: ... # honoured in --pace realtime; no-op in --pace asap
Protocol: ReplaySink (unchanged from v1.0.0)
@runtime_checkable
class ReplaySink(Protocol):
def emit(self, output: EstimatorOutput) -> None: ...
def close(self) -> None: ...
Concrete: TlogReplayFcAdapter (unchanged from v1.0.0)
class TlogReplayFcAdapter(FcAdapter):
def __init__(
self,
tlog_path: Path,
target_fc_dialect: FcKind, # ARDUPILOT_PLANE | INAV
clock: Clock,
wgs_converter: WgsConverter,
time_offset_ms: int = 0, # set by ReplayInputAdapter (auto-sync or --time-offset-ms)
pace: ReplayPace = ReplayPace.ASAP, # REALTIME | ASAP
): ...
The TlogReplayFcAdapter implements the full FcAdapter Protocol from AZ-261. subscribe_telemetry fans out IMU/attitude/GPS-health/flight-state from the tlog at the configured pace. emit_external_position, emit_status_text, and request_source_set_switch are implemented as no-ops that delegate to the underlying transport — in replay mode the transport is NoopMavlinkTransport (see below), so the bytes go nowhere; in live mode the same encoders shape the same bytes for a real wire. The encoder code path is identical; only the transport differs.
Concrete: NoopMavlinkTransport
class NoopMavlinkTransport(MavlinkTransport):
"""Outbound transport sink for replay mode.
Accepts every `write(payload: bytes)` and `close()` call without I/O.
Counts bytes written for observability (FDR + INFO log at close).
"""
def write(self, payload: bytes) -> None: ... # silent drop
def close(self) -> None: ...
def bytes_written(self) -> int: ... # observability
The C8 outbound encoders (per the v1.0.0 FcAdapter protocol — emit_external_position, emit_status_text, request_source_set_switch, and the QgcTelemetryAdapter 1–2 Hz GCS summary) operate over a constructor-injected MavlinkTransport interface (a new tiny Protocol introduced by AZ-401 to make this swap clean). In live mode the transport is SerialMavlinkTransport writing to the UART; in replay mode it is NoopMavlinkTransport. The encoders themselves are unchanged — they produce the same byte streams, including the MAVLink 2.0 signing handshake and per-flight key rotation. The signing key is mandatory in both modes (the operator supplies a dummy key for replay; the contract does not constrain the key's provenance).
This is the single architectural point that lets us say "replay is exactly like live, only the destination differs" without baking if replay_mode: branches into C8.
Concrete: ReplayInputAdapter
@dataclass(frozen=True, slots=True)
class ReplayInputBundle:
frame_source: FrameSource
fc_adapter: FcAdapter
clock: Clock
resolved_time_offset_ms: int
auto_sync_result: AutoSyncDecision | None # None when --time-offset-ms is provided
class ReplayInputAdapter:
"""Converges (video, tlog) into the standard FrameSource + FcAdapter + Clock surfaces.
Owns the time-alignment between video frames and tlog IMU/attitude ticks
(manual via --time-offset-ms or automatic via AZ-405 IMU-take-off detection).
Instantiates VideoFileFrameSource, TlogReplayFcAdapter, and the chosen Clock.
The composition root, after calling .open(), sees no replay-specific types.
"""
def __init__(
self,
*,
video_path: Path,
tlog_path: Path,
camera_calibration: CameraCalibration,
target_fc_dialect: FcKind,
wgs_converter: WgsConverter,
fdr_client: FdrClient, # forwarded to TlogReplayFcAdapter + used for replay_input's own FDR records (auto-sync detected / low-confidence / AC-8 hard-fail)
pace: ReplayPace,
manual_time_offset_ms: int | None, # None → auto-sync runs (AZ-405)
auto_sync_config: AutoSyncConfig,
) -> None: ...
def open(self) -> ReplayInputBundle:
"""Resolve time-offset (auto-sync or manual), build the strategies, return the bundle.
Raises:
ReplayInputAdapterError("tlog missing required message types: ...")
— R-DEMO-3 fail-fast at startup.
ReplayInputAdapterError("auto-sync hard-fail: ...")
— AC-8 of the epic (≤ 95 % frame-window match).
ReplayInputAdapterError("video file unreadable / unsupported codec / ...")
— VideoFileFrameSource opening failure surfaced at coordinator scope.
"""
...
def close(self) -> None: ... # closes both inputs; idempotent
CLI surface
gps-denied-replay
--video PATH
--tlog PATH
--output results.jsonl
--camera-calibration calib.json
--config config.yaml # same config schema as the airborne binary
--mavlink-signing-key PATH # mandatory; operator provides a dummy key for replay
[--pace {realtime,asap}] # default asap
[--time-offset-ms N] # overrides AZ-405 auto-sync
The CLI is a thin mode-config wrapper: it loads config.yaml, sets config.mode = "replay" and the replay-specific paths/flags, and calls the same entry point the live binary uses. The shared entry point calls compose_root(config) which returns a wired runtime; the runtime's per-frame loop is unchanged between live and replay.
Composition root extension
runtime_root/__init__.py exposes a single compose_root(config) -> Runtime (no separate compose_replay). When config.mode == "replay":
- Build a
ReplayInputAdapterfromconfig.replay.{video_path, tlog_path, pace, time_offset_ms, …}+ the sameCameraCalibrationandWgsConverterthe live path already uses. - Call
replay_input.open()→ReplayInputBundle(frame_source, fc_adapter, clock, …). - Pick the
MavlinkTransportstrategy:NoopMavlinkTransport(replay) vs.SerialMavlinkTransport(live), based onconfig.mode. - Add a
JsonlReplaySinksubscriber to C5'sEstimatorOutputstream (replay only). The live binary already emits to C8 outbound + QGC telemetry adapter; the JSONL sink is an additional listener, not a replacement. - Wire C1–C5 + C6 + C7 + C13 exactly as in the live composition (Invariant 1 — components see the same interfaces).
- Return the wired
Runtimewhose per-frame loop is the existing one (single source of truth — no per-mode loop).
loop:
frame = frame_source.next_frame() # VideoFileFrameSource in replay
if frame is None: break
c1 = vio.process(frame) # C1
candidates = vpr.lookup(c1) # C2 (uses real C6 DescriptorIndex)
reranked = rerank.rerank(candidates) # C2.5
matched = matcher.match(reranked) # C3
refined = refiner.refine_if_needed(matched) # C3.5
pose = pose_estimator.estimate(refined) # C4
state.add_pose_anchor(pose) # C5
state.add_vio(c1.vio_output) # C5
output = state.current_estimate()
# multiple listeners, all wired by the composition root:
fc_adapter.emit_external_position(output) # → NoopMavlinkTransport in replay; SerialMavlinkTransport live
fdr.write(output) # C13: ALWAYS, both modes
if replay_sink is not None: # replay only
replay_sink.emit(output) # JsonlReplaySink → JSONL file → UI tails it
Side notes:
- The tlog adapter's
subscribe_telemetrycallbacks are wired to C5'sadd_fc_imuand to C1's IMU prior on the same threads as in the live binary (Invariant 1 — same threads, same callbacks, different source). set_takeoff_origin(AZ-490 / ADR-010) is invoked identically in replay: the operator's pre-flight C10 Manifest is the source of truth in both modes. The tlog's first GPS fix is the fallback, gated through the same Principle #11 bounded-delta check.BUILD_FAISS_INDEXis ON in the airborne binary (live and replay alike). C2 in replay queries the real C6FaissDescriptorIndex, populated by the pre-flight C10 build. This is the architectural change vs. v1.0.0 of this contract.
Composition profile: open-loop ESKF (AZ-776 / ADR-012)
The replay binary supports a second composition profile alongside the production GTSAM-iSAM2 path: open-loop ESKF. It is the Tier-2 smoke baseline for the AZ-265 replay flow and the only profile that can run end-to-end against the Derkachi clip today (the satellite-anchored profile waits on AZ-777's C6 reference tile cache).
The user-facing switch is one YAML field on the C4 block:
c4_pose:
enabled: false
c5_state:
strategy: eskf
When c4_pose.enabled = false:
compose_rootremovesc4_posefrom the component selection map before topological ordering; the C4 wrapper never runs, theOpenCVGtsamPoseEstimatoris never instantiated, andpose_estimator.estimate(refined)in the per-frame loop above is a no-op (C5 receives IMU + VIO inputs only, no PnP anchor).build_pre_constructedomitsc5_isam2_graph_handlefrom thepre_constructeddict (no consumer requires it). The ESKF estimator is still pre-built and cached in the internal_c5_prebuilt_estimatorslot exactly as in the gtsam_isam2 path; the C5 wrapper short-circuits onto the prebuilt instance per AZ-625.- The replay per-frame loop is otherwise unchanged — C1 VIO still runs, C5 still ingests, the JSONL sink still emits per video frame. Position drifts open-loop without satellite re-anchoring; AZ-777 closes that half of the loop by adding C2/C3/C4 against the C6 tile cache.
The 2×2 pairing matrix between c4_pose.enabled and c5_state.strategy is enforced at compose time (Invariant 13 below). The two valid cells are:
c4_pose.enabled |
c5_state.strategy |
Profile | Status |
|---|---|---|---|
true |
gtsam_isam2 |
Steady-state airborne (ADR-003 / ADR-009) | Production |
false |
eskf |
Open-loop ESKF (this section) | Replay Tier-2 smoke baseline |
The two invalid cells (true + eskf and false + gtsam_isam2) raise CompositionError from compose_root with explicit error text naming both blocks. The Tier-2 replay fixture (tests/e2e/replay/conftest.py) writes the second valid cell. The first cell is the live + airborne default.
Invariants
-
Mode-agnostic C1–C7, C13: production components MUST NOT contain
if config.mode == "replay":branches. Mode-specific behaviour lives in the strategies (FrameSource / FcAdapter / MavlinkTransport / ReplaySink / Clock). Verified by an explicit grep guard in CI (the AZ-404 E2E test owns this assertion). -
Single
Clockper process:compose_rootresolvesClockexactly once at startup. All time-driven logic (AC-5.2 fallback timer, STATUSTEXT rate-limits, key rotation logging) consumes the injectedClockvia constructor — nevertime.monotonic_ns()directly. Verified by an AST scan in CI for directtime.monotonic_ns/time.time_nsreferences incomponents/**/*.py. -
Frame source ordering:
next_frame()returns frames in monotonically non-decreasingmonotonic_nsorder. Out-of-order frames raiseFrameSourceError(NOT silently dropped — replay must be deterministic). -
End-of-stream is None:
next_frame()returnsNoneONLY when the stream is permanently exhausted. Transient I/O failures raiseFrameSourceError. -
Outbound MAVLink encoders are mode-agnostic: the C8 outbound encoders for
GPS_INPUT/MSP2_SENSOR_GPS/STATUSTEXT/NAMED_VALUE_FLOAT/MAV_CMD_SET_EKF_SOURCE_SETproduce identical byte streams in both modes. Only theMavlinkTransportstrategy differs (Serial vs. Noop). The MAVLink 2.0 signing handshake runs in replay too (the operator provides a dummy signing key); the signing bytes are produced and then dropped byNoopMavlinkTransport. Verified by a unit test that captures the encoder output in both modes and diffs the byte streams. -
Pace mode honoured by Clock:
pace=REALTIME→Clock.sleep_until_ns(target_ns)blocks until wall-clock catches up;pace=ASAP→ no-op. The pace flag is consumed ONLY by theClockand the tlog adapter — components see only theClockProtocol. -
JsonlReplaySink one-line-per-emit: each
emit(output)writes exactly one JSON object + newline; the file is fsync'd onclose(). Schema matchesEstimatorOutput(frozen dataclass serialised viadataclasses.asdict+orjson.dumps). -
Time-offset resolved before composition: the
ReplayInputAdapterresolvestime_offset_ms(auto-sync or manual) and locks it into theTlogReplayFcAdapterconstructor beforecompose_rootreturns the wired runtime. No live re-tuning. -
Build-flag gating:
VideoFileFrameSource,TlogReplayFcAdapter,JsonlReplaySink,NoopMavlinkTransportMUST refuse construction when their respectiveBUILD_*flag is OFF (per ADR-002). In the airborne binary all four flags are ON by default; setting any of them OFF in airborne disables replay mode (the binary still runs live mode normally). -
Determinism: same
(video, tlog, config, time_offset_ms, pace=ASAP)input → same JSONL output within ≤ 1e-6 float drift in position fields (AC-5). -
MAVLink signing key required in replay: the airborne binary refuses to run without
--mavlink-signing-key PATHin both modes. In replay the operator supplies a dummy file (well-formed key bytes; no real channel to verify against). This preserves Invariant 5 — the encoders' signing code path runs identically in both modes. -
Real C6 cache in replay: the airborne binary in replay mode reads the same pre-built C6 tile cache the operator built via the normal pre-flight C10/C11/C12 flow. There is no replay-specific cache shape. Verified by the AZ-404 E2E fixture, which runs the operator's pre-flight flow before invoking the replay CLI.
Sub-invariant 12.a (cycle 3 — AZ-839 / Epic AZ-835 C3): the e2e
operator_pre_flight_setupfixture replaces the cycle-1mkdirplaceholder with a real driver that wires C1 (replay_input.tlog_route.extract_route_from_tlog— AZ-836) + C2 (c11_tile_manager.route_client.SatelliteProviderRouteClient.seed_route— AZ-838) + C11 (tile_downloader.HttpTileDownloader.download_for_bbox) + C10 (DescriptorBatcher) to populate C6 from a tlog-derived corridor. The fixture yields aPopulatedC6Cachedataclass (cache_root,tile_store_path,faiss_index_path,faiss_sidecar_sha256_path,faiss_sidecar_meta_path,route_spec,tile_count,elapsed_seconds). The cache is mounted into a named docker volume that survives across pytest sessions (cold first invocation populates; subsequent invocations within the same compose session reuse — warm cache). Cold-start budget: ≤ 5 min on Tier-2 Jetson; warm: ≤ 30 s. Sidecar triple-consistency (.index+.sha256+.meta.json) per AZ-306 is verified at every fixture yield; mismatch raisesIndexUnavailableError. The C12 production binding for the route-driven path is a future-cycle integration; production pre-flight still uses the bbox-drivendownload_tiles_for_areapath today.Sub-invariant 12.c (cycle 3 — Epic AZ-835: route-driven supersedes bbox): route-driven seeding (operator's tlog-derived
RouteSpec→POST /api/satellite/route→ corridor materialised bysatellite-provider) supersedes the legacy AZ-777 bbox-driven approach (POST /api/satellite/requestover a fixed lat/lon box) for the real-flight validation path. The supersedure rationale is twofold:- Tile efficiency (~100×): the AZ-777 bbox for a typical Derkachi-style flight produces ~11,400 z15-z18 tiles (~140 MB, 48 % over the C6 cache budget). A 10-point coarsened route with
regionSizeMeters=500per point produces ~50-100 unique tiles (~1.5 MB) for the same VPR descriptor lock area. The route-driven path is the only one that fits the AZ-696 reference-fixture budget on Jetson. - Pre-commitment honesty: a bbox pre-commits to where the operator might fly. A route pre-commits to where they did fly. For real-flight validation against ground-truth GPS, the latter is the right primitive — it ensures the FAISS index is populated with descriptors of the tiles the airborne pipeline will actually query, not a superset whose VPR misses are statistically indistinguishable from the AZ-696 AC-3 ≤ 100 m threshold violations.
AZ-777 Phase 1 (e2e-runner wiring + C11 read-contract adaptation) is retained and reused by Epic AZ-835. AZ-777 Phases 3 and 5 are superseded by Epic AZ-835 children (AZ-839 for the operator-fixture rewrite, AZ-842 for the docs work). Phase 4 (un-xfail of AC-4/AC-5) was deferred to backlog after cycle-4 AZ-895 took the un-xfail target along a different path; it is not on the active epic.
Sub-invariant 12.d (cycle 3 — AZ-839 / Epic AZ-835 C3: fixture failure-handling contract): the
operator_pre_flight_setupfixture must distinguish three failure classes fromSatelliteProviderRouteClient.seed_route/HttpTileDownloader.download_for_bboxand surface them honestly:Class Source Fixture response Validation RouteValidationError(pre-emptive AZ-809 bound violation) orIndexUnavailableError(sidecar triple mismatch at yield-time)Re-raise — operator/test author error, no remediation in the fixture Terminal RouteTerminalFailureError(satellite-provider rejected the route id or status polling returnedmapsReady=falsepastpoll_max_attempts)Re-raise — service-side state cannot be recovered by retry Transient RouteTransientErrororTileDownloadErrorwith HTTP 5xx / network resetRetry up to 3 attempts using C11's existing exponential backoff schedule ( HttpTileDownloader.RETRY_*constants); re-raise on exhaustionThe fixture does NOT swallow transient failures silently — the third attempt's exception surfaces with the full retry history in the message so the test report can distinguish "fixture genuinely tried 3×" from "fixture short-circuited". Cold-start budget of ≤ 5 min on Tier-2 Jetson is measured wall-clock around the entire retry loop, not per-attempt.
Sub-invariant 12.b (cycle 3 — AZ-840 / Epic AZ-835 C4): the E2E orchestrator test
tests/e2e/replay/test_az835_e2e_real_flight.pytakes only(tlog, video, calibration)and runs the full 7-step pipeline end-to-end on Tier-2 Jetson — no operator hand-curation between steps. The 7 steps are: (1) active flight cut + tlog/video sync via AZ-405; (2) on-fly frame + IMU extraction; (3) auto-create route via AZ-836; (4) POST route to satellite-provider via the C3 fixture'soperator_pre_flight_setup(delegates to AZ-838); (5) build FAISS index (driven by C3); (6) run gps-denied airborne pipeline against the populated cache + tlog/video/calibration (reuses the airborne composition root path AZ-699 exercises); (7) compute horizontal-error distribution and emit the AZ-699 verdict report at_docs/06_metrics/real_flight_validation_<YYYY-MM-DD>.md. The verdict report is emitted ALWAYS, regardless of PASS / FAIL on the AZ-696 ≥ 80 % within 100 m gate — the success criterion is that the report exists with the honest distribution, not that the verdict is PASS. Gated byRUN_REPLAY_E2E=1+@pytest.mark.tier2. - Tile efficiency (~100×): the AZ-777 bbox for a typical Derkachi-style flight produces ~11,400 z15-z18 tiles (~140 MB, 48 % over the C6 cache budget). A 10-point coarsened route with
-
C4↔C5 pairing matrix is enforced at compose time (AZ-776 / ADR-012):
compose_rootrejects the two off-diagonal cells of the (c4_pose.enabled,c5_state.strategy) matrix with aCompositionErrornaming both blocks.enabled=False+gtsam_isam2andenabled=True+eskfare forbidden. The two valid cells areenabled=True+gtsam_isam2(production steady-state per ADR-003 / ADR-009) andenabled=False+eskf(open-loop ESKF — replay Tier-2 smoke baseline; satellite anchoring deferred to AZ-777). Verified bytests/unit/runtime_root/test_az776_open_loop_eskf_composition.pyAC-3a and AC-3b. -
Single canonical clock & CSV-driven replay path (cycle 4 — AZ-894 / AZ-895 / AZ-896): production runs as a single edge process on a single device. There is exactly one wall/monotonic clock authoritative for timestamps that cross component boundaries — the clock at the C8 inbound boundary (
FcAdapter) where IMU windows enter the system. Two-clock surfaces — for example a C1VioOutput.emitted_at_nsderived from the Jetsonmonotonic_ns()paired against a C8ImuWindow.ts_end_nsderived from FC-boot — produced the AZ-848 ESKF out-of-order regression observed in cycle 3 (Jetson clock advanced between IMU window arrival and VIO emission, so the VIO emission timestamp routinely landed before the IMU window'sts_end_nswhen the two were compared as if on the same axis, and ESKF rejected its own VIO updates). All downstream timestamps (EstimatorOutput.ts_ns,JsonlReplaySinkper-rowt, FDRflight_event.ts_ns) MUST derive from a single canonical clock that produces deterministic per-record values for a given input. In live mode the canonical clock is the C8 inbound IMU window's FC-boot-relative timestamp; in replay mode it is the CSV row'sTimecolumn.Sub-invariant 14.a (CSV-driven replay path — AZ-894): the replay-mode operator input is
(video, CSV). The CSV row'sTimecolumn is the canonical clock for the entire replay run: every IMU window emitted by the newcsv_replay_input.CsvReplayInputAdapter(gatedBUILD_CSV_REPLAY_ADAPTER=ONin the airborne and research binaries) carriests_end_nsderived from the CSVTimecolumn; theClockstrategy injected into the composition root isCsvDerivedClockwhich uses the same column. There is no auto-sync (see 14.c below). The CSV must satisfy the format spec at_docs/02_document/contracts/replay/csv_replay_format.md(AZ-896) — including the requirement that row 0'sTimeequals video frame 0 (t=0) so the airborne pipeline does not need to apply any per-stream offset.Sub-invariant 14.b (tlog adapter audit-only role — AZ-895):
TlogReplayFcAdapter(Sub-invariant 14 of the prior cycles' design) is retained in source for two audit / migration paths and removed from the replay test/demo critical path:- FDR analysis: one-shot tlog parsing for incident review (e.g. AZ-848 timestamp investigation) — invoked from offline analysis scripts under
tools/, not from the airborne composition root. - One-shot tlog → CSV export: a CLI utility (
gps-denied-tlog-to-csv) that reads a pymavlink tlog and writes the canonical CSV per AZ-896. This is the migration ramp for users who only have legacy tlog inputs.
The previous
compose_root(config={"mode": "replay", "replay_input.adapter": "tlog"})code path is preserved with a one-cycle deprecation warning on startup; removal is tracked in AZ-908 (cycle-5+ backlog). The CSV adapter (BUILD_CSV_REPLAY_ADAPTER=ON) is the default and the only path the e2e fixture suite exercises after cycle 4.Sub-invariant 14.c (auto-sync deprecation — AZ-895): the
replay_input.auto_syncmodule (AZ-405) is reduced to a deprecated no-op stub that raisesReplayInputAdapterError("auto-sync removed; supply --imu CSV instead")from every public entry point. The CLI flags--time-offset-ms,--skip-auto-sync, and--auto-trimare accepted with a deprecation warning and ignored. The justification: with a single canonical clock at the CSV row level (14.a), there is no second clock to align against — the operator authors the CSV with the correct row-0 alignment, and the fixture verifies row 0'sTime == 0. Hard removal of the deprecated surface is tracked in AZ-908; this cycle ships only the stub + warnings to preserve source-compat for any downstream caller built against AZ-405's pre-deprecation shape.Sub-invariant 14.d (operator-facing UI — AZ-897, superseded by Invariant 15): retained for historical cycle-4 CSV-only upload spec. Default demo entry is now F11 / AZ-969.
- FDR analysis: one-shot tlog parsing for incident review (e.g. AZ-848 timestamp investigation) — invoked from offline analysis scripts under
-
Operator demo replay path (cycle 5 — AZ-969 / F11): the default product demo accepts raw
(video, tlog, calibration)from the suite UI. Alignment is operator-visible (dual timeline bars + explicit refine); the backend exports an AZ-896 CSV whoseTimecolumn is the single canonical replay clock (Invariant 14.a). Steps: preview timelines (AZ-970) → coarse align + refine (AZ-897, AZ-971) → export CSV (AZ-972) → seed corridor cache from tlog GPS (AZ-974) → rungps-denied-replay(AZ-973) → map + verdict. The(video, pre-authored CSV)bypass (AZ-959) is optional, not default. E2E tests MUST use the same orchestration modules as production — no parallel test-only graph. AZ-908 (hard removal of alignment stubs) is deferred until AZ-971 ships.
Producer / Consumer Split
| Task ID | Scope |
|---|---|
| AZ-398 (Producer) | FrameSource Protocol; Clock Protocol; VideoFileFrameSource (gated BUILD_VIDEO_FILE_FRAME_SOURCE); LiveCameraFrameSource retrofit (rename existing camera-ingest plumbing into the Protocol shape — no behaviour change); WallClock + TlogDerivedClock strategies; composition wiring in compose_root (Clock = WallClock in live, picked per-pace in replay). NO tlog parsing, NO sink, NO replay coordinator. |
| AZ-399 (Consumer 1) | TlogReplayFcAdapter: pymavlink stream-parser (DO NOT materialise; R-DEMO-2 throughput floor); maps tlog message types → FcTelemetryFrame; supports both AP and iNav dialects; subscribe_telemetry fan-out at the configured pace; respects time_offset_ms; honours Clock for pacing; outbound emit_* methods delegate to constructor-injected MavlinkTransport (Invariant 5); fail-fast at startup if required message types absent (R-DEMO-3). |
| AZ-400 (Consumer 2) | ReplaySink Protocol + JsonlReplaySink (one JSON object per line; orjson serialiser; close() fsyncs). Also: MavlinkTransport Protocol cut-out + NoopMavlinkTransport strategy + SerialMavlinkTransport retrofit (rename the existing C8 transport code into the Protocol shape — no behaviour change). |
| AZ-401 (Consumer 3) | Extend compose_root(config) with a config.mode = "live" | "replay" branch: in replay mode, builds the ReplayInputAdapter, picks NoopMavlinkTransport, adds the JsonlReplaySink listener on C5's EstimatorOutput stream, and otherwise wires C1–C7 + C13 identically to live. Build-flag check at startup. NO separate compose_replay function (replay is a configuration of the single composition root). |
| AZ-402 (Consumer 4) | gps-denied-replay CLI: argparse, config + calibration loader, sets config.mode = "replay", dispatches into the same companion entry point as live; structured-error exit codes (0=success, 2=AC-8 sync-impossible from ReplayInputAdapter.open(), 1=any other error). |
| AZ-404 (Consumer 6) | E2E replay fixture test: tests/e2e/replay/test_derkachi_1min.py — runs the CLI against a 1–2 min Derkachi clip + matching tlog; asserts AC-3 (≤ 100 m for ≥ 80 % of ticks); gated by RUN_REPLAY_E2E=1 in CI. Asserts Invariant 1 (no if config.mode == "replay" branches in components) via an AST scan. |
| AZ-405 (Consumer 7) | Auto-sync of video ↔ tlog via IMU take-off detection. Lives inside replay_input/ (this task creates the module): take-off pattern (sustained vertical accel > 0.5 g + change in attitude rate > 1 rad/s lasting ≥ 0.5 s) + video motion-onset; confidence-scored; falls back to WARN + best-guess if < 80 %; --time-offset-ms always overrides; AC-8 hard-fail (exit 2) if neither auto-detect nor manual offset produces > 95 % frame-window match. The ReplayInputAdapter coordinator is also defined and implemented by this task (it is the natural home for the auto-sync logic — the coordinator owns the time-alignment concern, and auto-sync is one of the two ways the offset is resolved). |
AZ-403 (formerly: replay-cli Dockerfile + SBOM diff CI step) is CANCELLED: the replay-cli Docker image no longer exists under v2.0.0. The airborne Docker image IS the replay image; no SBOM diff is needed because there are no components to assert as absent. See _docs/02_tasks/done/AZ-403_replay_dockerfile_ci.md (cancellation banner) and the ADR-011 amendment in architecture.md.
Constraints
@runtime_checkableon all Protocols; DTOsfrozen=True, slots=True.- Lazy-import per ADR-002 with the new
BUILD_VIDEO_FILE_FRAME_SOURCE,BUILD_TLOG_REPLAY_ADAPTER,BUILD_REPLAY_SINK_JSONLflags. All three flags are ON in the airborne binary (production-default); OFF in the operator-orchestrator binary; the research binary mirrors airborne (ON). - C1–C7 + C13 components MUST remain mode-agnostic (Invariant 1).
- All time-driven logic in components MUST consume the injected
Clock(Invariant 2). - No HTTP server in the airborne binary regardless of mode (parent-suite UI shells out to the CLI and tails the JSONL file; defer until the subprocess shape is proven insufficient).
- pymavlink bundled unmodified per D-C8-3.
- The tlog parser MUST stream-parse — never materialise the entire tlog into memory (R-DEMO-2; multi-GB tlogs).
- MAVLink 2.0 signing key is mandatory in both modes (Invariant 11). The replay run reuses the live binary's per-flight key-load code path; the operator supplies a dummy key file.
Risks / Mitigations
- R-DEMO-1 (tlog ↔ video timestamp drift / unsynchronised recordings): auto-sync via IMU take-off detection (AC-7) +
--time-offset-msmanual override. Fixed-wing hand-launch fallback documented. Owned byreplay_input/per AZ-405. - R-DEMO-2 (pymavlink slow on multi-GB tlogs): stream-parse, never materialise. Throughput floor benchmarked + documented in CI.
- R-DEMO-3 (demo footage missing required FC messages):
ReplayInputAdapter.open(...)fails fast at startup, listing missing message types and the components that need them. - R-DEMO-4 (production C1–C5 paths bake real-time-cadence assumptions):
Clockinjection (Invariants 1, 2). Captured in ADR-011 (architecture.md). - R-DEMO-5 (new in v2.0.0) (live and replay diverge silently because the modes share a composition root): mitigated by Invariant 1 (no mode-aware branches in components) + Invariant 5 (encoders are byte-identical) + the AZ-404 E2E test asserting both invariants on every PR. The single composition root is the single point of mode awareness.
Notes for the Implementer
- The
LiveCameraFrameSourceretrofit is a no-op restructure: the existing camera-ingest thread becomes a class implementingFrameSource. Its behaviour is unchanged. This is what allows C1 to consumeFrameSourcevia constructor without becoming replay-aware. - The
SerialMavlinkTransportretrofit (introduced by AZ-400) is a no-op restructure: the existing pymavlink transport code becomes a class implementing the new tinyMavlinkTransportProtocol. Its behaviour is unchanged. This is what allows C8 outbound encoders to remain identical between live and replay. - The
TlogReplayFcAdapter'ssubscribe_telemetryfan-out runs on a dedicated thread (mirroring the livePymavlinkArdupilotAdapterdecode-thread semantics). C1 and C5 see identical thread boundaries in live and replay (Invariant 1). - The
ClockProtocol is the SAME interface in live and replay — only the strategy differs. This is the single Liskov-clean line that lets components consumeClockwithout knowing the mode. - The
ReplayInputAdapterlives atsrc/gps_denied_onboard/replay_input/__init__.py(public) +tlog_video_adapter.py(concrete) +auto_sync.py(AZ-405 logic). It is a Layer-4 module permodule-layout.md(it imports from Layer 1frame_source/andclock/interfaces, and instantiates Layer-4 strategies fromc8_fc_adapter/). The composition root imports the public API ofreplay_input/only; it does not reach into the coordinator's internals. - The parent-suite UI demo flow: operator plans a route in the suite UI → C12 builds the cache → operator runs
gps-denied-replay --video ... --tlog ... --output results.jsonl→ UI tailsresults.jsonland renders per-tick(lat, lon, alt, horiz_accuracy). The operator's pre-flight workflow is identical to a live flight up until the final "fly" step. This is the user-confirmed design intent.