Adds the Layer-4 cross-cutting `replay_input/` module per ADR-011: ReplayInputAdapter converges (video, tlog) into the standard FrameSource + FcAdapter + Clock surfaces the airborne composition root consumes. Owns time-alignment between video frames and tlog IMU/attitude ticks (manual via --time-offset-ms or auto via the AZ-405 IMU-take-off detector + Farneback motion-onset detector). Auto-sync algorithm (auto_sync.py): - Tlog take-off detector: sustained vertical-accel excess > 0.5 g for >= 0.5 s + sustained attitude-rate magnitude > 1 rad/s. - Video motion-onset detector: dense Farneback flow magnitude > 1.5 px sustained >= 0.5 s (deterministic per AC-10). - compute_offset combines the two; confidence = min(tlog, video). - validate_offset_or_fail implements the AC-9 95 % frame-window match validator with configurable threshold + window. ReplayInputAdapter.open() ordering (AC-13): 1. Load tlog samples + fail-fast on missing RAW_IMU/SCALED_IMU2 or ATTITUDE BEFORE any video read. 2. Resolve offset (auto-sync OR manual override; manual bypasses the detectors entirely per AC-8). 3. Run AC-9 validator on resolved offset; raise auto-sync hard-fail for AC-7 (CLI exit 2 mapping). 4. Build single Clock instance per pace (TlogDerived/ASAP, Wall/REAL). 5. Construct VideoFileFrameSource and TlogReplayFcAdapter with the resolved offset baked in (replay protocol Invariant 8). Structured log + FDR records on auto-sync detected / low-confidence / AC-8 hard-fail kinds. Idempotent close (AC-12). Tests: 25 unit tests across tests/unit/replay_input/ covering all 13 ACs (kernel-level synthetic fixtures for AC-1..AC-10; coordinator- level OpenCV synthetic videos + faked pymavlink for AC-6..AC-13). Contract update: replay_protocol.md v2.0.0 added fdr_client to the ReplayInputAdapter __init__ signature (was missing in the prose; the task spec already listed it in the allowed-imports section). Co-authored-by: Cursor <cursoragent@cursor.com>
26 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.
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.
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.