[AZ-401] [AZ-400] Replay — compose_root replay-mode branch + transport seam

Wires the airborne composition root for replay-as-configuration (ADR-011):

- compose_root(config) branches on config.mode in {"live", "replay"}.
  Live behaviour is unchanged; replay builds ReplayInputAdapter,
  attaches JsonlReplaySink, and injects NoopMavlinkTransport.
- New private module runtime_root/_replay_branch.py holds the
  replay-only strategy graph + build-flag gate + calibration loader.
- Config gains Config.mode (Literal["live","replay"]) plus
  Config.replay sub-block with nested ReplayAutoSyncConfig that mirrors
  the AZ-405 AutoSyncConfig DTO; YAML loader + ENV map updated.

Absorbs the AZ-400 transport-seam retrofit that AZ-401 strictly
required but AZ-400 had not delivered:

- New MavlinkTransport Protocol (write/bytes_written/close).
- NoopMavlinkTransport (replay; build-flag gated, idempotent close,
  thread-safe byte counter).
- SerialMavlinkTransport (live, no-op restructure of existing pymavlink
  byte path; encoder retrofit to actually USE it is the AZ-558
  follow-up).

AZ-401 AC-9 (NoopMavlinkTransport.bytes_written > 0 after C8 encoders
run) is BLOCKED on AZ-558 — the encoder routing retrofit is out of
the AZ-401 task envelope (FORBIDDEN files: pymavlink_ardupilot_adapter,
msp2_inav_adapter). AZ-558 spec, batch_61_review.md, and the test's
@pytest.mark.skip rationale all carry the deferral reason.

Tests: 22 compose_root replay-branch tests + 17 transport tests.
Full regression: 2063 passed, 86 environment-skips, 1 documented
skip (AC-9 / AZ-558), 1 pre-existing flaky perf test deselected.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-14 11:55:33 +03:00
parent 8149083cac
commit 17a0d074af
19 changed files with 2156 additions and 45 deletions
+4 -3
View File
@@ -1,8 +1,8 @@
# Dependencies Table
**Date**: 2026-05-14 (refreshed after cumulative review batches 52-54: AZ-528 hygiene PBI added for c1_vio strategy facade orchestration-spine 3-way duplication (Medium); earlier same-day after Batch 53: AZ-333 VINS-Mono landed — first c1_vio strategy after the AZ-332 OKVIS2 production-default; consolidation hygiene for the strategy-facade duplication deferred to a post-AZ-334 PBI; earlier same-day after Batch 51: AZ-527 hygiene PBI added from cumulative review batches 49-51 F1; 2026-05-13: AZ-526 hygiene PBI added from cumulative review batches 46-48 F1+F3; same-day refresh after Batch 44 SRP refactor: AZ-317 superseded; AZ-329 + AZ-330 specs rewritten; AZ-523 + AZ-524 audit-trail tickets added; E-C12 epic renamed `Operator Pre-flight Tooling``Operator Pre-flight Orchestrator`; earlier same-day refresh: AZ-507 + AZ-508 hygiene PBIs from cumulative review batches 31-33; 2026-05-11: AZ-489 + AZ-490 ADR-010 operator-origin path)
**Total Tasks**: 149 (108 product + 41 blackbox-test) — AZ-317 retained in the table marked SUPERSEDED for audit; AZ-523 (C11 gate removal) + AZ-524 (C12 rename) added as 2 closed audit-trail tasks; AZ-526 = 2pt clock-helper hygiene; AZ-527 = 2pt c2 engine-dim helper hygiene; AZ-528 = 3pt c1_vio facade-spine hygiene
**Total Complexity Points**: 494 (361 product + 133 blackbox-test) — AZ-523 = 3pt, AZ-524 = 2pt, AZ-526 = 2pt, AZ-527 = 2pt, AZ-528 = 3pt
**Date**: 2026-05-14 (refreshed after Batch 61: AZ-558 follow-up added — routes C8 outbound encoder bytes through `MavlinkTransport` seam; closes AZ-401 AC-9 deferred during batch 61 due to encoder-side routing not being in the AZ-401 task envelope; earlier same-day after cumulative review batches 52-54: AZ-528 hygiene PBI added for c1_vio strategy facade orchestration-spine 3-way duplication (Medium); earlier same-day after Batch 53: AZ-333 VINS-Mono landed — first c1_vio strategy after the AZ-332 OKVIS2 production-default; consolidation hygiene for the strategy-facade duplication deferred to a post-AZ-334 PBI; earlier same-day after Batch 51: AZ-527 hygiene PBI added from cumulative review batches 49-51 F1; 2026-05-13: AZ-526 hygiene PBI added from cumulative review batches 46-48 F1+F3; same-day refresh after Batch 44 SRP refactor: AZ-317 superseded; AZ-329 + AZ-330 specs rewritten; AZ-523 + AZ-524 audit-trail tickets added; E-C12 epic renamed `Operator Pre-flight Tooling``Operator Pre-flight Orchestrator`; earlier same-day refresh: AZ-507 + AZ-508 hygiene PBIs from cumulative review batches 31-33; 2026-05-11: AZ-489 + AZ-490 ADR-010 operator-origin path)
**Total Tasks**: 150 (109 product + 41 blackbox-test) — AZ-317 retained in the table marked SUPERSEDED for audit; AZ-523 (C11 gate removal) + AZ-524 (C12 rename) added as 2 closed audit-trail tasks; AZ-526 = 2pt clock-helper hygiene; AZ-527 = 2pt c2 engine-dim helper hygiene; AZ-528 = 3pt c1_vio facade-spine hygiene; AZ-558 = 3pt MavlinkTransport routing follow-up
**Total Complexity Points**: 497 (364 product + 133 blackbox-test) — AZ-523 = 3pt, AZ-524 = 2pt, AZ-526 = 2pt, AZ-527 = 2pt, AZ-528 = 3pt, AZ-558 = 3pt
Dependencies columns list only the tracker-ID portion (descriptive tail
text in each task spec is omitted here for table-readability). The
@@ -113,6 +113,7 @@ are all declared and documented below under **Cycle Check**.
| AZ-403 | (CANCELLED per ADR-011 — replay is a configuration of the airborne binary; no fourth image) | — | — | AZ-265 |
| AZ-404 | E2E replay fixture test — Derkachi 12 min clip + mode-agnosticism + operator workflow | 5 | AZ-402, AZ-401, AZ-405, AZ-263, AZ-269, AZ-266, AZ-272, AZ-273 | AZ-265 |
| AZ-405 | replay_input/ coordinator + auto-sync of video ↔ tlog via IMU take-off detection | 5 | AZ-399, AZ-398, AZ-263, AZ-269, AZ-266, AZ-272, AZ-279 | AZ-265 |
| AZ-558 | Route C8 outbound encoder bytes through MavlinkTransport seam (closes AZ-401 AC-9) | 3 | AZ-401, AZ-273, AZ-294, AZ-399 | AZ-265 |
| AZ-406 | Blackbox Test Infrastructure Bootstrap (Tier-1 + Tier-2 harness scaffold) | 5 | AZ-263 | AZ-262 |
| AZ-407 | Static fixture builders — tile-cache, age-injector, cold-boot, MAVLink passkey, CVE JPEG | 3 | AZ-406 | AZ-262 |
| AZ-408 | Runtime synthetic-injection fixture builders — outlier, blackout-spoof, multi-segment | 3 | AZ-406, AZ-407 | AZ-262 |
@@ -0,0 +1,73 @@
# Replay — route C8 outbound encoder bytes through MavlinkTransport seam (closes AZ-401 AC-9)
**Task**: AZ-558_mavlink_transport_routing
**Name**: Retrofit `PymavlinkArdupilotAdapter`, `Msp2InavAdapter`, and the replay FC adapter to write through `MavlinkTransport.write(bytes)` instead of calling pymavlink's `mav.*_send` helpers directly
**Description**: AZ-401 added the `MavlinkTransport` Protocol seam plus `NoopMavlinkTransport` (replay) and `SerialMavlinkTransport` (live). Both implementations are unit-tested and import-clean, but currently dormant — the live encoders bypass the seam by calling `pymavlink`'s `mavutil.mavlink_connection.mav.gps_input_send(...)` directly, and `TlogReplayFcAdapter` raises on every `emit_external_position()`. This task closes that gap: every outbound MAVLink byte from C8 flows through `MavlinkTransport.write()`, and AZ-401 AC-9 (`NoopMavlinkTransport.bytes_written() > 0`) becomes assertable.
**Complexity**: 3 points
**Dependencies**: AZ-401 (`MavlinkTransport` Protocol + impls + replay branch — already landed); AZ-273 (`PymavlinkArdupilotAdapter`); AZ-294 (`Msp2InavAdapter`); AZ-399 (`TlogReplayFcAdapter`).
**Component**: c8_fc_adapter (epic AZ-265 / E-DEMO-REPLAY)
**Tracker**: AZ-558
**Epic**: AZ-265 (E-DEMO-REPLAY)
### Document Dependencies
- `_docs/02_document/contracts/replay/replay_protocol.md` (v2.0.0) — Invariant 5 (encoders produce identical byte streams in both modes; only the transport differs).
- `_docs/02_document/contracts/c8_fc_adapter/fc_adapter_protocol.md``MavlinkTransport` Protocol shape.
- `_docs/03_implementation/reviews/batch_61_review.md` — F1 / F2 (the open spec gap this task closes).
## Problem
AZ-401 wired `NoopMavlinkTransport` into `compose_root`'s replay-mode branch as a `mavlink_transport` slot. The slot is constructed; the value is plumbed into the `RuntimeRoot.components` dict. But no encoder consumes it. `PymavlinkArdupilotAdapter.__init__` accepts a pymavlink `mavutil.mavlink_connection` (not a `MavlinkTransport`) and calls `connection.mav.gps_input_send(...)` directly inside `emit_external_position()`. The same shape holds for `Msp2InavAdapter`. The result: AZ-401 AC-9 (`NoopMavlinkTransport.bytes_written() > 0` after the C8 encoders run in replay mode) is unsatisfiable as wired.
## Outcome
- `PymavlinkArdupilotAdapter.__init__` accepts a `mavlink_transport: MavlinkTransport` keyword argument (Protocol-typed). Every place inside the class that produces MAVLink bytes routes them through `mavlink_transport.write(payload)` instead of calling `connection.mav.gps_input_send(...)` (or any other `mav.*_send` helper).
- `Msp2InavAdapter` adopts the same retrofit.
- `TlogReplayFcAdapter` either: (a) is retrofitted to produce real bytes (preferred, mirrors live encoder shape so AC-9 holds), or (b) gains a thin "replay encoder" sibling that the replay branch instantiates instead. Decide during implementation; the AC-9 outcome is the same.
- `compose_root` (live mode) builds `SerialMavlinkTransport(connection)` from the existing pymavlink connection and injects it into the AP / iNav adapter constructors. Live mode wire-output bytes MUST be byte-identical before/after the retrofit (the new path is a no-op pass-through).
- AZ-401 AC-9 unskips and passes: drive a known `EstimatorOutput` sequence through replay-mode runtime → assert `NoopMavlinkTransport.bytes_written() > 0` AND no serial-port descriptor activity.
- INFO log on every `MavlinkTransport.write()` is **NOT** emitted (would flood the FDR at 10 Hz). The `MavlinkTransport.bytes_written()` counter is the diagnostic surface.
## Acceptance Criteria
**AC-1: AP / iNav adapter constructors accept `mavlink_transport`**`PymavlinkArdupilotAdapter.__init__` and `Msp2InavAdapter.__init__` accept a `mavlink_transport: MavlinkTransport` kwarg. Every outbound `mav.*_send(...)` call in those classes is replaced by `mavlink_transport.write(payload)` where `payload` is the bytes the helper would have sent. (Implementation hint: use `mav.gps_input_encode(...)` to get the message object, then `msg.pack(...)` → bytes, then `mavlink_transport.write(bytes)`. Verify the produced bytes are byte-identical to the prior `gps_input_send` wire output via a recorded fixture.)
**AC-2: Wire-byte equivalence (live mode)** — record the wire-output bytes from `PymavlinkArdupilotAdapter.emit_external_position(known_estimator_output)` before the retrofit (one-time fixture capture). After the retrofit, drive the same input through the retrofitted adapter wired to a `BytesCapturingTransport` (test-only `MavlinkTransport` impl that stores writes); assert the captured bytes are byte-identical to the recorded fixture. Same for `Msp2InavAdapter`.
**AC-3: Replay FC adapter produces bytes**`TlogReplayFcAdapter` (or its replay-encoder sibling) calls `mavlink_transport.write(payload)` from `emit_external_position()`. The exact byte content is whatever `pymavlink.mavutil.gps_input_encode(...).pack()` produces for the input — same as live, per replay protocol Invariant 5.
**AC-4: AZ-401 AC-9 unskips**`tests/unit/test_az401_compose_root_replay.py::test_ac9_noop_transport_bytes_written_after_runtime_drive` is no longer `@pytest.mark.skip`. The test drives 10 `EstimatorOutput` ticks through a replay-mode runtime; assertions: `NoopMavlinkTransport.bytes_written() > 0`, no serial descriptor opened, no `mav.gps_input_send` calls (mock-spec assertion).
**AC-5: `mav.*_send` is no longer called from C8 outbound code paths** — AST scan in a unit test asserts that no source file under `src/gps_denied_onboard/components/c8_fc_adapter/` contains the substring `.gps_input_send(` or `.mav.` (the latter scoped to method-call AST nodes, not type annotations). The Protocol seam is the only egress.
**AC-6: `compose_root` injects the transport** — live mode constructs `SerialMavlinkTransport(connection)` and passes it into the AP / iNav adapter constructors. Replay mode reuses the existing `NoopMavlinkTransport` slot. Unit test asserts the constructor kwargs match.
## Non-Functional Requirements
- Live mode wire-output bytes MUST be byte-identical before and after this retrofit (`SerialMavlinkTransport` is a no-op pass-through). AC-2 is the gate.
- The retrofit MUST NOT change the `MavlinkTransport` Protocol shape (locked by AZ-400 retrofit / AZ-401).
- `compose_root` startup time MUST stay within the AZ-401 NFR (`compose_root` p99 ≤ 1 s in either mode; the new transport construction is constant-time).
## Constraints
- `mavlink_transport` is a constructor kwarg, not a setter. The transport's lifecycle is owned by the composition root (per ADR-001 — composition root owns construction; ADR-009 — explicit ownership).
- `SerialMavlinkTransport` does NOT open the pymavlink connection; the AP / iNav adapters continue to own the connection lifecycle (open / signing handshake / reconnect on disconnect). The transport just wraps `connection.write` — it's a thin adapter.
- Keep the `connection` parameter on the AP / iNav adapter constructors: the connection is still needed for inbound parsing (`connection.recv_msg()`) and signing handshake; only the **outbound** path moves to the transport seam.
- `PymavlinkArdupilotAdapter` and `Msp2InavAdapter` MUST NOT import `SerialMavlinkTransport` or `NoopMavlinkTransport` directly — they accept the transport via the Protocol type. The composition root is the only place that names concrete transport classes (replay protocol Invariant 5 + AZ-401 AC-7).
## Risks & Mitigation
- **Risk: live MAVLink wire output drifts** — *Mitigation*: AC-2 (byte-equivalence fixture). Recorded once before the retrofit; checked once after.
- **Risk: pymavlink `gps_input_encode` API differs from `gps_input_send` in subtle ways (CRC, sequence numbers)** — *Mitigation*: capture both before and after; the byte-equivalence fixture is the spec, not the pymavlink source.
- **Risk: signing handshake is performed by `mavutil.mavlink_connection`, not by `connection.write`; bypassing `mav.*_send` could miss the signing wrap** — *Mitigation*: investigate during implementation; if signing happens at the `mav.*_send` level, the transport seam needs to either run signing itself or invoke a pymavlink helper that wraps `pack()` with signing. Scoped here as a known-unknown.
## Runtime Completeness
- **Named capability**: byte-routing seam for outbound MAVLink — every C8 outbound byte goes through `MavlinkTransport.write()`.
- **Production code**: real adapter retrofits, real wire-byte fixture, real composition-root injection.
- **Allowed external stubs**: `BytesCapturingTransport` (test-only) for AC-2 / AC-4; otherwise none.
- **Unacceptable substitutes**: leaving `mav.*_send` in place "for compatibility" — defeats the seam and re-opens AZ-401 AC-9.
## Contract
Implements `_docs/02_document/contracts/replay/replay_protocol.md` (v2.0.0) Invariant 5 and `_docs/02_document/contracts/c8_fc_adapter/fc_adapter_protocol.md` `MavlinkTransport` shape. Closes the AZ-401 AC-9 deferral documented in `_docs/03_implementation/reviews/batch_61_review.md`.
@@ -0,0 +1,124 @@
# Code Review Report
**Batch**: 61 (AZ-401, with absorbed AZ-400 transport-seam retrofit)
**Date**: 2026-05-14
**Verdict**: PASS_WITH_WARNINGS
## Findings
| # | Severity | Category | File:Line | Title |
|---|----------|----------|-----------|-------|
| 1 | High | Spec-Gap | tests/unit/test_az401_compose_root_replay.py:526 | AC-9 (`NoopMavlinkTransport.bytes_written() > 0` after C8 encoders) is BLOCKED by AZ-399 design choice — test is `pytest.skip` with documented rationale |
| 2 | Medium | Scope | src/gps_denied_onboard/components/c8_fc_adapter/serial_mavlink_transport.py | Live transport seam introduced as no-op restructure; existing `PymavlinkArdupilotAdapter` / `Msp2InavAdapter` encoders do NOT yet route bytes through `MavlinkTransport.write()` |
| 3 | Low | Maintainability | src/gps_denied_onboard/runtime_root/__init__.py | New optional `replay_components_factory` kwarg (test-injection seam) added to `compose_root` |
| 4 | Low | Style | src/gps_denied_onboard/runtime_root/_replay_branch.py | Inline `_load_camera_calibration` helper duplicates the live-mode loader pattern (one-time tax acceptable; share if a third call site appears) |
### Finding Details
**F1: AC-9 is BLOCKED — `NoopMavlinkTransport.bytes_written() > 0`** (High / Spec-Gap)
- Location: `tests/unit/test_az401_compose_root_replay.py:526` (`test_ac9_noop_transport_bytes_written_after_runtime_drive`)
- Description: AC-9 demands that after the C8 outbound encoders run in replay mode, `NoopMavlinkTransport.bytes_written() > 0`. The intended path is: C5 emits an `EstimatorOutput` → C8 outbound encoder produces a MAVLink `GPS_INPUT` byte stream → `MavlinkTransport.write(bytes)` records the count. The blocker is at the C8 encoder layer: `TlogReplayFcAdapter` (AZ-399) raises `FcEmitError` on every `emit_external_position()` call rather than routing bytes through a transport seam, and the live-mode `PymavlinkArdupilotAdapter` / `Msp2InavAdapter` adapters call `pymavlink`'s `mavutil.mavlink_connection.mav.gps_input_send(...)` directly — they bypass `MavlinkTransport` entirely. Closing AC-9 requires retrofitting the AP / iNav / QGC encoder code paths to consume `MavlinkTransport`, which AZ-400's original spec scoped but did not deliver. The Protocol seam + both implementations (`NoopMavlinkTransport`, `SerialMavlinkTransport`) are present and unit-tested in `test_az400_mavlink_transport.py` (17 tests passing), so the architectural seam is in place.
- Suggestion: file a follow-up task `AZ-401-followup-mavlink-transport-routing` that retrofits `PymavlinkArdupilotAdapter` and `Msp2InavAdapter` (and the Replay FC adapter) to write through `MavlinkTransport.write()` instead of calling pymavlink's `mav.*_send` helpers directly. Keep the AC-9 skip in place with the same blocker reference until that task lands. The skip's `reason` text is the spec for the follow-up.
- Task: AZ-401 (deferred)
**F2: Live transport seam is a no-op restructure** (Medium / Scope)
- Location: `src/gps_denied_onboard/components/c8_fc_adapter/serial_mavlink_transport.py`
- Description: `SerialMavlinkTransport` wraps a pymavlink `mavlink_connection` and forwards bytes via `connection.write(bytes)`. The class is fully implemented and unit-tested (cumulative byte counting, error wrapping for `OSError`, idempotent close, write-after-close rejection). However, the existing live encoders (`PymavlinkArdupilotAdapter`, `Msp2InavAdapter`) still call `connection.mav.gps_input_send(...)` directly — they don't construct or use a `SerialMavlinkTransport`. So the class exists, conforms to the Protocol, and is import-clean — but it is **dormant** in the live path. This is an explicit, deliberate scope reduction: AZ-401's primary goal was the replay-mode branch in `compose_root`, and the AZ-400 retrofit was absorbed only to the minimum extent the replay branch needed. The full live-side retrofit is the same follow-up task as F1.
- Suggestion: same as F1 — track via `AZ-401-followup-mavlink-transport-routing`. The follow-up should also flip `SerialMavlinkTransport` from "constructed but never wired" to "the only path live bytes flow through".
- Task: AZ-401 (deferred)
**F3: New `replay_components_factory` kwarg on `compose_root`** (Low / Maintainability)
- Location: `src/gps_denied_onboard/runtime_root/__init__.py``compose_root(config, *, replay_components_factory=None)`
- Description: Adds an optional kwarg defaulting to `None`. When `None` (production), `compose_root` calls `_replay_branch.build_replay_components(config)`. When provided (tests), the factory is used instead. This mirrors the established pattern from `replay_input.tlog_video_adapter.ReplayInputAdapter.__init__` (`tlog_source_factory`, `video_frames_factory`, `video_timestamps_factory`) noted in batch 60 review as F3, and from `TlogReplayFcAdapter.__init__` (`source_factory`, AZ-399). Production callers (CLI entrypoint AZ-402, runtime root operator AZ-326) pass none of them.
- Suggestion: keep — the precedent is now present in three coordinators. If a fourth adopts the same shape, migrate to a shared `_TestFactories` Protocol.
- Task: AZ-401
**F4: `_load_camera_calibration` duplicates live-mode loader pattern** (Low / Style)
- Location: `src/gps_denied_onboard/runtime_root/_replay_branch.py``_load_camera_calibration(path: Path) -> CameraCalibration`
- Description: Reads a JSON calibration file, validates the required keys, and returns a `CameraCalibration`. The live-mode binary will need the same logic when AZ-263 / AZ-326 wire it. There are currently zero other callers of this exact loader (live mode reads its calib via the operator entry point, AZ-326), so the duplication is hypothetical until a second loader is written.
- Suggestion: keep inline. When AZ-326 / AZ-326-operator-orchestrator implements its calibration loader, factor into `gps_denied_onboard/_helpers/camera_calibration_loader.py` (Layer-2) and have both call sites reuse it.
- Task: AZ-401
## Phase Summary
### Phase 1 — Context Loading
Read inputs:
- `_docs/02_tasks/todo/AZ-401_replay_compose.md`
- `_docs/02_document/contracts/replay/replay_protocol.md` (v2.0.0)
- `_docs/02_document/architecture.md` (ADR-011 — replay-as-configuration)
- `_docs/02_document/module-layout.md` (Layer 4 + Build-Time Exclusion Map)
- `_docs/02_document/epics.md` (E-DEMO-REPLAY ACs 1 / 5 / 9 / 11 / 12)
- `_docs/02_document/contracts/c8_fc_adapter/fc_adapter_protocol.md` (the new `MavlinkTransport` Protocol seam)
### Phase 2 — Spec Compliance
| AC | Verdict | Test |
|----|---------|------|
| AC-1 (single composition root, `compose_replay` deleted) | PASS | `test_ac1_compose_replay_no_longer_exported` |
| AC-2 (live mode unchanged) | PASS | `test_ac2_live_default_mode_returns_runtime_root_with_no_replay_keys` + `test_ac2_live_explicit_mode_unchanged` |
| AC-3 (replay wires VideoFile / TlogReplay / Noop / JsonlReplaySink) | PASS | `test_ac3_replay_mode_wires_five_replay_strategies` |
| AC-4 (replay rejects each `BUILD_*` flag OFF; live unaffected) | PASS | `test_ac4_replay_rejects_each_build_flag_off` (parameterized × 3) + `test_ac4_live_with_replay_flag_off_succeeds` |
| AC-5 (pace → clock kind; same Clock instance across consumers) | PASS | `test_ac5_replay_pace_asap_uses_tlog_derived_clock` + `test_ac5_replay_pace_realtime_uses_wall_clock` + `test_ac5_clock_single_instance_id_equality` |
| AC-6 (JSONL sink emits per tick, 10 frames → 10 lines) | PASS | `test_ac6_jsonl_sink_emits_per_tick_when_runtime_drives_outputs` |
| AC-7 (no mode-aware imports outside runtime_root) | PASS | `test_ac7_no_component_imports_video_file_frame_source` + `test_ac7_only_runtime_root_imports_replay_strategies` |
| AC-8 (replay branch imports only public APIs / documented deep submodules) | PASS | `test_ac8_replay_branch_imports_only_public_apis` |
| AC-9 (NoopMavlinkTransport.bytes_written > 0) | **BLOCKED** | `test_ac9_noop_transport_bytes_written_after_runtime_drive` (skipped with documented reason — see F1) |
| AC-10 (replay does not alter C6 cache shape) | PASS (smoke) | `test_ac10_replay_does_not_alter_c6_cache_shape` (full E2E owned by AZ-404) |
AZ-400 retrofit ACs (Transport Protocol + impls) covered by 17 tests in `tests/unit/c8_fc_adapter/test_az400_mavlink_transport.py`.
Contract verification: `_docs/02_document/contracts/replay/replay_protocol.md` v2.0.0 §Composition Root + Invariants 1, 5, 9, 11, 12 all match the implementation. `MavlinkTransport` Protocol shape (`write(bytes) -> int`, `bytes_written() -> int`, `close()`) matches the contract documentation in `c8_fc_adapter/fc_adapter_protocol.md`.
### Phase 3 — Code Quality
- **SOLID**: `MavlinkTransport` Protocol cleanly separates the "byte sink" responsibility from the encoder; `NoopMavlinkTransport` and `SerialMavlinkTransport` are LSP-substitutable. `compose_root` delegates the replay-specific composition to `_replay_branch.build_replay_components` (single responsibility — `compose_root` just routes by mode).
- **Error handling**: every transport write goes through a lock; closed-state is checked; `OSError` from the underlying serial connection is wrapped in `MavlinkTransportError` with the original cause chained via `from exc`. `CompositionError` carries enough context (which flag is OFF, which path is empty) for an operator to diagnose without grepping source.
- **Naming**: `_replay_branch.py` is the established convention (private module under `runtime_root/`); `build_replay_components` is a verb-form factory; `REPLAY_BUILD_FLAGS` and `REPLAY_COMPONENT_KEYS` are uppercase constants.
- **Complexity**: longest function is `build_replay_components` at ~85 lines, all linear flow with explicit guard clauses. No cyclomatic-complexity-> 10 functions.
- **Test quality**: every AC test asserts a meaningful behavior (not just "no error thrown"). The two AST-scan tests (AC-7, AC-8) survive across files via `ast.parse` rather than substring-grep.
- **Dead code**: none introduced. The legacy `compose_replay` export was already deleted in a prior batch (greenfield iteration); this batch confirmed that via AC-1.
### Phase 4 — Security Quick-Scan
- No SQL strings, no shell-escapes, no `eval` / `exec` / `pickle.loads`.
- `_load_camera_calibration` reads operator-controlled JSON and validates keys; treats missing keys as a hard error rather than silently substituting.
- Replay paths come from operator-controlled config; no taint surface from external input.
- No hardcoded secrets, API keys, or credentials.
- No sensitive data logged: the `replay.compose_root.ready` log emits paths and pace, not auth keys.
### Phase 5 — Performance Scan
- `compose_root` live-mode path adds **one** `if config.mode == "replay"` check per startup — well under the 50 ms budget the task spec requires.
- `NoopMavlinkTransport.write` acquires a `threading.Lock` per call. This matches `SerialMavlinkTransport`'s contract (the live encoders write from a single thread today, but the seam is thread-safe by construction). At an expected emit rate of ≤ 10 Hz, lock contention is irrelevant.
- The `_validate_build_flags` helper iterates `REPLAY_BUILD_FLAGS` (length 3) and reads `os.environ` — constant-time at startup; not in any hot path.
- `_replay_branch` does no I/O on the live-mode path (it's never imported when `config.mode == "live"` triggers the early return in `compose_root`).
### Phase 6 — Cross-Task Consistency
- The AZ-400 retrofit (transport seam) is consistent with AZ-401's replay branch: `_replay_branch.build_replay_components` returns a `mavlink_transport: NoopMavlinkTransport` slot that the (future) C8 encoder retrofit will consume.
- `Config.replay.auto_sync` (added here) is a structural mirror of `replay_input.interface.AutoSyncConfig` (added in AZ-405 / batch 60). The two dataclasses have the same field names + defaults; `_replay_branch._build_auto_sync_config` translates between them. If they ever drift, the failure surface is `replay_input.tlog_video_adapter.ReplayInputAdapter.__init__` rejecting an unrecognised kwarg — caught at startup.
- No conflicting patterns introduced. The build-flag-gating convention (`os.environ[FLAG] == "ON"`) matches what `JsonlReplaySink.__init__` and `NoopMavlinkTransport.__init__` already do.
### Phase 7 — Architecture Compliance
- **Layer direction**: `runtime_root` (Layer-5 cross-cutting composition) imports from `components/*` (Layer-3) and `replay_input/` (Layer-4 cross-cutting coordinator). All Layer-5 → Layer-3/4 — correct direction.
- **Public API respect**: `_replay_branch.py` imports the noop transport + JSONL sink via deep paths (`gps_denied_onboard.components.c8_fc_adapter.noop_mavlink_transport`, `...replay_sink`). These are documented exceptions in `module-layout.md` (the strategy modules are owned by C8 but instantiated only in the composition root). The AC-8 test enforces this allowlist mechanically.
- **No new cyclic deps**: `_replay_branch.py` is leaf-imported only by `runtime_root/__init__.py`. The import graph stays a DAG.
- **Duplicate symbols**: `ReplayConfig` (in `config/schema.py`) and `AutoSyncConfig` (in `replay_input/interface.py`) are distinct DTOs with different responsibilities (config-schema vs. coordinator DTO); the duplication is intentional per the contracts and is mediated by `_build_auto_sync_config` in the replay branch.
- **Cross-cutting concerns**: build-flag check is local to `_replay_branch._validate_build_flags` and to each strategy's constructor. Could be factored into a `_helpers/build_flags.py` utility once a fourth call site appears.
## Verdict Reasoning
One **High** finding (AC-9 BLOCKED) — would normally drive FAIL. Downgrade to PASS_WITH_WARNINGS reasoning:
- The blocker is **architectural / scope-shape**, not a regression. The Protocol seam + both implementations are present and tested. The wiring gap (encoders → transport) is a separate retrofit that AZ-400 was supposed to deliver but did not — that gap is now visible (the AC-9 skip reason) instead of hidden.
- Closing AC-9 inside this batch would require modifying `pymavlink_ardupilot_adapter.py` and `msp2_inav_adapter.py` (FORBIDDEN per the AZ-401 task envelope — those files are owned by AZ-273 / AZ-294, not by AZ-401 or AZ-400's retrofit allowance).
- The recommended path is the follow-up task `AZ-401-followup-mavlink-transport-routing` (see F1 / F2). The AC stays open, the skip carries the spec for the followup, and the batch ships with the seam in place.
The other findings are all Medium / Low and reflect deliberate scope reductions that are documented in the spec's Excluded section.
+4 -4
View File
@@ -6,13 +6,13 @@ step: 7
name: Implement
status: in_progress
sub_step:
phase: 7
name: batch-loop
phase: 1
name: parse
detail: ""
retry_count: 0
cycle: 1
tracker: jira
last_completed_batch: 60
last_completed_batch: 61
last_cumulative_review: batches_58-60
current_batch: 61
current_batch: 62
current_batch_tasks: ""
@@ -10,7 +10,14 @@ from gps_denied_onboard._types.emitted import EmittedExternalPosition
from gps_denied_onboard.components.c8_fc_adapter.interface import (
FcAdapter,
GcsAdapter,
MavlinkTransport,
)
from gps_denied_onboard.components.c8_fc_adapter.replay_sink import ReplaySink
__all__ = ["EmittedExternalPosition", "FcAdapter", "GcsAdapter", "ReplaySink"]
__all__ = [
"EmittedExternalPosition",
"FcAdapter",
"GcsAdapter",
"MavlinkTransport",
"ReplaySink",
]
@@ -18,6 +18,8 @@ __all__ = [
"GcsAdapterConfigError",
"GcsAdapterError",
"GcsEmitError",
"MavlinkTransportConfigError",
"MavlinkTransportError",
"SigningHandshakeError",
"SigningKeyExpiredError",
"SourceSetSwitchError",
@@ -96,3 +98,15 @@ class GcsAdapterConfigError(GcsAdapterError):
Raised at config-load for unknown strategy names and at factory
build for build-flag-OFF strategies.
"""
# ---------------------------------------------------------------------
# MavlinkTransport tree (AZ-400 Protocol seam)
class MavlinkTransportError(Exception):
"""Base class for every `MavlinkTransport` failure."""
class MavlinkTransportConfigError(MavlinkTransportError):
"""Construction-time / build-flag failure for a transport strategy."""
@@ -31,7 +31,56 @@ from gps_denied_onboard._types.fc import (
)
from gps_denied_onboard._types.state import EstimatorOutput
__all__ = ["FcAdapter", "GcsAdapter"]
__all__ = ["FcAdapter", "GcsAdapter", "MavlinkTransport"]
@runtime_checkable
class MavlinkTransport(Protocol):
"""Outbound MAVLink byte-stream destination (AZ-400 Protocol seam).
The contract (replay_protocol.md v2.0.0 Invariant 5) splits the C8
outbound code path into two halves: an *encoder* half (per-message
`gps_input_send` / `statustext_send` / `command_long_send` calls
that produce MAVLink 2.0 byte streams) and a *transport* half that
decides where those bytes go (a real serial UART in live mode, a
drop-on-the-floor sink in replay).
Concrete strategies:
* :class:`SerialMavlinkTransport` — wraps a ``pymavlink``
``mavutil.mavlink_connection`` open on the FC's UART (live mode).
* :class:`NoopMavlinkTransport` — counts the bytes the encoders
try to send and discards them (replay mode + Invariant 5
verification + AC-9 byte-count check).
Only :func:`gps_denied_onboard.runtime_root.compose_root` may
instantiate transports; component code consumes them via
constructor injection so the strategy is mode-agnostic from the
encoder's point of view.
"""
def write(self, payload: bytes) -> int:
"""Write ``payload`` to the transport; return the byte count consumed.
Must accept any byte length (encoders may issue zero-length
flushes during the MAVLink 2.0 signing handshake). Implementors
that fail mid-write must raise (do NOT return a short count) so
the caller can decide whether the link is dead.
"""
...
def bytes_written(self) -> int:
"""Cumulative byte count the transport has accepted since open.
Used by AC-9 of AZ-401 to verify the encoder code path actually
ran in replay mode (and by live-side health checks to detect a
completely silent UART).
"""
...
def close(self) -> None:
"""Close the underlying transport; idempotent."""
...
@runtime_checkable
@@ -0,0 +1,106 @@
"""``NoopMavlinkTransport`` — replay-mode outbound byte sink (AZ-400).
Replay-mode strategy for the :class:`MavlinkTransport` Protocol. Counts
every byte the C8 outbound encoders try to send and discards the
payload. Used by ``compose_root`` in ``config.mode == "replay"`` so the
encoders' code path can be exercised in replay tests without opening a
real serial UART.
Build-time gating: the transport refuses construction unless
``BUILD_REPLAY_SINK_JSONL`` is ON. The flag is shared with the
``JsonlReplaySink`` because both answer the same question — "where do
the airborne binary's outbound side-effects go in replay?" — and the
replay binary always wants both ON together.
Thread-safety: ``write`` and ``bytes_written`` are guarded by a lock so
concurrent encoder threads (the live binary's outbound thread + a
diagnostic emit thread) do not race the counter. Replay's runtime loop
is single-threaded, but the lock costs ~100 ns and prevents test-side
surprises (mirrors :class:`JsonlReplaySink`).
"""
from __future__ import annotations
import os
import threading
from typing import Final
from gps_denied_onboard.components.c8_fc_adapter.errors import (
MavlinkTransportConfigError,
MavlinkTransportError,
)
from gps_denied_onboard.logging import get_logger
__all__ = ["NoopMavlinkTransport"]
_BUILD_FLAG: Final[str] = "BUILD_REPLAY_SINK_JSONL"
_LOG_KIND_OPENED: Final[str] = "replay.transport.noop_opened"
_LOG_KIND_CLOSED: Final[str] = "replay.transport.noop_closed"
_LOG_KIND_DOUBLE_CLOSE: Final[str] = "replay.transport.noop_double_close"
def _build_flag_on() -> bool:
raw = os.environ.get(_BUILD_FLAG, "")
return raw.strip().lower() in {"on", "1", "true", "yes"}
class NoopMavlinkTransport:
"""Drop-on-the-floor :class:`MavlinkTransport` for replay mode.
Counts the bytes the C8 outbound encoders attempt to write; never
raises on the write path. Idempotent close.
"""
__slots__ = ("_log", "_lock", "_bytes_written", "_closed")
def __init__(self) -> None:
if not _build_flag_on():
raise MavlinkTransportConfigError(
f"{_BUILD_FLAG} is OFF in this binary; NoopMavlinkTransport is "
"unavailable. Rebuild with the flag set to ON in the airborne "
"Dockerfile."
)
self._log = get_logger("c8_fc_adapter.noop_mavlink_transport")
self._lock = threading.Lock()
self._bytes_written = 0
self._closed = False
self._log.info(
_LOG_KIND_OPENED,
extra={"kind": _LOG_KIND_OPENED, "kv": {}},
)
def write(self, payload: bytes) -> int:
if not isinstance(payload, (bytes, bytearray, memoryview)):
raise MavlinkTransportError(
"NoopMavlinkTransport.write expects bytes-like; got "
f"{type(payload).__name__}"
)
with self._lock:
if self._closed:
raise MavlinkTransportError("write on closed NoopMavlinkTransport")
n = len(payload)
self._bytes_written += n
return n
def bytes_written(self) -> int:
with self._lock:
return self._bytes_written
def close(self) -> None:
with self._lock:
if self._closed:
self._log.debug(
_LOG_KIND_DOUBLE_CLOSE,
extra={"kind": _LOG_KIND_DOUBLE_CLOSE, "kv": {}},
)
return
self._closed = True
total = self._bytes_written
self._log.info(
_LOG_KIND_CLOSED,
extra={
"kind": _LOG_KIND_CLOSED,
"kv": {"bytes_written": total},
},
)
@@ -360,8 +360,8 @@ def create(*, output_path: Path, fdr_client: "FdrClient") -> JsonlReplaySink:
"""Module-level factory entrypoint per project convention.
Mirrors the ``create`` factories used by the C2/C3 strategies so
the AZ-401 ``compose_replay`` wiring resolves the sink through a
single named-symbol contract instead of poking at the class
constructor directly.
the AZ-401 replay-mode branch of ``compose_root`` resolves the
sink through a single named-symbol contract instead of poking at
the class constructor directly.
"""
return JsonlReplaySink(output_path=output_path, fdr_client=fdr_client)
@@ -0,0 +1,124 @@
"""``SerialMavlinkTransport`` — live-mode outbound byte sink (AZ-400).
Live-mode strategy for the :class:`MavlinkTransport` Protocol. Wraps a
``pymavlink`` ``mavutil.mavlink_connection`` so the C8 outbound
encoders can write through a typed transport seam instead of poking the
connection directly.
The existing :class:`PymavlinkArdupilotAdapter` / :class:`Msp2InavAdapter`
encoders still call ``self._connection.mav.gps_input_send(...)`` etc.
directly; the full retrofit that routes those calls through this
transport is tracked separately (see the AZ-401 batch report — the
encoder rewrite is deferred to keep this commit's blast radius
bounded). This module ships the typed surface so
* :func:`gps_denied_onboard.runtime_root.compose_root` in live mode can
construct it under the same registry slot the replay branch uses for
:class:`NoopMavlinkTransport` (replay protocol Invariant 5 surface
parity); and
* future AP/iNav/QGC encoder edits route their per-message ``write``
calls through here without touching the composition root.
The class is intentionally minimal — it forwards ``write(payload)`` to
the underlying pymavlink connection's ``write`` method (every
``mavlink_connection`` returned by ``mavutil.mavlink_connection`` is a
file-like object exposing ``.write(bytes) -> int``) and tracks a
running byte count for parity with :class:`NoopMavlinkTransport`.
"""
from __future__ import annotations
import threading
from typing import TYPE_CHECKING, Any, Final
from gps_denied_onboard.components.c8_fc_adapter.errors import (
MavlinkTransportError,
)
from gps_denied_onboard.logging import get_logger
if TYPE_CHECKING:
pass
__all__ = ["SerialMavlinkTransport"]
_LOG_KIND_OPENED: Final[str] = "live.transport.serial_opened"
_LOG_KIND_CLOSED: Final[str] = "live.transport.serial_closed"
_LOG_KIND_DOUBLE_CLOSE: Final[str] = "live.transport.serial_double_close"
class SerialMavlinkTransport:
""":class:`MavlinkTransport` over a pymavlink serial connection.
Constructor injects an already-open ``mavutil.mavlink_connection``
object so the connection lifecycle (port open, signing handshake,
reconnection on disconnect) stays owned by the existing
:class:`PymavlinkArdupilotAdapter` / :class:`Msp2InavAdapter`. The
transport itself does not open the connection — that ownership
boundary keeps this commit a no-op restructure for live wiring.
"""
__slots__ = ("_connection", "_log", "_lock", "_bytes_written", "_closed")
def __init__(self, connection: Any) -> None:
if connection is None:
raise MavlinkTransportError(
"SerialMavlinkTransport requires an open pymavlink connection"
)
write = getattr(connection, "write", None)
if not callable(write):
raise MavlinkTransportError(
"SerialMavlinkTransport.connection must expose a callable "
".write(bytes) -> int; got "
f"{type(connection).__name__}"
)
self._connection = connection
self._log = get_logger("c8_fc_adapter.serial_mavlink_transport")
self._lock = threading.Lock()
self._bytes_written = 0
self._closed = False
self._log.info(
_LOG_KIND_OPENED,
extra={"kind": _LOG_KIND_OPENED, "kv": {}},
)
def write(self, payload: bytes) -> int:
if not isinstance(payload, (bytes, bytearray, memoryview)):
raise MavlinkTransportError(
"SerialMavlinkTransport.write expects bytes-like; got "
f"{type(payload).__name__}"
)
with self._lock:
if self._closed:
raise MavlinkTransportError("write on closed SerialMavlinkTransport")
try:
returned = self._connection.write(bytes(payload))
except OSError as exc:
raise MavlinkTransportError(
f"SerialMavlinkTransport underlying write failed: {exc!r}"
) from exc
n = int(returned) if returned is not None else len(payload)
self._bytes_written += n
return n
def bytes_written(self) -> int:
with self._lock:
return self._bytes_written
def close(self) -> None:
with self._lock:
if self._closed:
self._log.debug(
_LOG_KIND_DOUBLE_CLOSE,
extra={"kind": _LOG_KIND_DOUBLE_CLOSE, "kv": {}},
)
return
self._closed = True
total = self._bytes_written
self._log.info(
_LOG_KIND_CLOSED,
extra={
"kind": _LOG_KIND_CLOSED,
"kv": {"bytes_written": total},
},
)
@@ -3,8 +3,10 @@
from gps_denied_onboard.config.loader import ENV_KEY_MAP, load_config
from gps_denied_onboard.config.schema import (
DEFAULT_FORBIDDEN_RECORD_KINDS,
KNOWN_FC_DIALECTS,
KNOWN_FC_STRATEGIES,
KNOWN_GCS_STRATEGIES,
KNOWN_REPLAY_PACES,
Config,
ConfigError,
FcConfig,
@@ -13,6 +15,8 @@ from gps_denied_onboard.config.schema import (
GcsConfig,
LogConfig,
RecordKindPolicyConfig,
ReplayAutoSyncConfig,
ReplayConfig,
RequiredFieldMissingError,
RuntimeConfig,
TileSnapshotConfig,
@@ -22,8 +26,10 @@ from gps_denied_onboard.config.schema import (
__all__ = [
"DEFAULT_FORBIDDEN_RECORD_KINDS",
"ENV_KEY_MAP",
"KNOWN_FC_DIALECTS",
"KNOWN_FC_STRATEGIES",
"KNOWN_GCS_STRATEGIES",
"KNOWN_REPLAY_PACES",
"Config",
"ConfigError",
"FcConfig",
@@ -32,6 +38,8 @@ __all__ = [
"GcsConfig",
"LogConfig",
"RecordKindPolicyConfig",
"ReplayAutoSyncConfig",
"ReplayConfig",
"RequiredFieldMissingError",
"RuntimeConfig",
"TileSnapshotConfig",
+110 -1
View File
@@ -23,10 +23,13 @@ import yaml
from gps_denied_onboard.config.schema import (
_COMPONENT_REGISTRY,
Config,
ConfigError,
FcConfig,
FdrConfig,
GcsConfig,
LogConfig,
ReplayAutoSyncConfig,
ReplayConfig,
RequiredFieldMissingError,
RuntimeConfig,
_replace_block,
@@ -64,6 +67,13 @@ ENV_KEY_MAP: Final[dict[str, tuple[str, str]]] = {
"GCS_PORT_DEVICE": ("gcs", "port_device"),
"GCS_PORT_BAUD": ("gcs", "port_baud"),
"GCS_SUMMARY_RATE_HZ": ("gcs", "summary_rate_hz"),
# Replay block (AZ-401)
"REPLAY_VIDEO_PATH": ("replay", "video_path"),
"REPLAY_TLOG_PATH": ("replay", "tlog_path"),
"REPLAY_OUTPUT_PATH": ("replay", "output_path"),
"REPLAY_PACE": ("replay", "pace"),
"REPLAY_TIME_OFFSET_MS": ("replay", "time_offset_ms"),
"REPLAY_TARGET_FC_DIALECT": ("replay", "target_fc_dialect"),
}
# Env vars that MUST resolve to a non-empty value before `load_config`
@@ -106,6 +116,12 @@ _FIELD_COERCIONS: Final[dict[str, type]] = {
"spoof_recovery_source_set": int,
"source_set_switch_timeout_ms": int,
"summary_rate_hz": float,
# Replay block coercions (AZ-401)
"video_path": str,
"tlog_path": str,
"output_path": str,
"pace": str,
"target_fc_dialect": str,
}
@@ -121,8 +137,91 @@ def _coerce_value(field_name: str, raw: Any) -> Any:
) from exc
def _coerce_optional_int(field_name: str, raw: Any) -> int | None:
"""Coerce ``raw`` to ``int`` or ``None`` (empty / null sentinels become ``None``)."""
if raw is None:
return None
if isinstance(raw, str) and raw.strip() == "":
return None
if isinstance(raw, int) and not isinstance(raw, bool):
return raw
try:
return int(raw)
except (TypeError, ValueError) as exc:
raise RequiredFieldMissingError(
f"config field {field_name!r}: cannot coerce {raw!r} to int ({exc})"
) from exc
def _build_replay_block(overrides: Mapping[str, Any]) -> ReplayConfig:
"""Build a :class:`ReplayConfig` from YAML/env overrides.
Handles two non-trivial coercions the generic path cannot:
* ``time_offset_ms`` — ``int | None`` (empty string / None → None).
* ``auto_sync`` — nested mapping → :class:`ReplayAutoSyncConfig`.
"""
flat: dict[str, Any] = {}
auto_sync_overrides: Mapping[str, Any] = {}
for key, value in overrides.items():
if key == "auto_sync":
if value is None:
continue
if not isinstance(value, Mapping):
raise ConfigError(
f"replay.auto_sync must be a mapping; got {type(value).__name__}"
)
auto_sync_overrides = value
continue
if key == "time_offset_ms":
flat[key] = _coerce_optional_int(key, value)
continue
flat[key] = _coerce_value(key, value)
auto_sync_block = _replace_block(
ReplayAutoSyncConfig(),
{k: _coerce_replay_auto_sync_field(k, v) for k, v in auto_sync_overrides.items()},
)
flat["auto_sync"] = auto_sync_block
return _replace_block(ReplayConfig(), flat)
_REPLAY_AUTO_SYNC_TYPES: Final[dict[str, type]] = {
"takeoff_accel_threshold_g": float,
"takeoff_attitude_rate_threshold_rad_s": float,
"sustained_seconds": float,
"prescan_max_messages": int,
"video_motion_threshold": float,
"video_motion_scan_seconds": float,
"match_threshold_pct": float,
"match_window_ms": int,
"low_confidence_threshold": float,
}
def _coerce_replay_auto_sync_field(field_name: str, raw: Any) -> Any:
target_type = _REPLAY_AUTO_SYNC_TYPES.get(field_name)
if target_type is None or isinstance(raw, target_type):
return raw
try:
return target_type(raw)
except (TypeError, ValueError) as exc:
raise RequiredFieldMissingError(
f"config field replay.auto_sync.{field_name}: cannot coerce {raw!r} "
f"to {target_type.__name__} ({exc})"
) from exc
_TOP_LEVEL_SCALAR_FIELDS: Final[frozenset[str]] = frozenset({"mode"})
def _load_yaml_files(paths: Sequence[Path]) -> dict[str, dict[str, Any]]:
"""Merge YAML files in order: later paths win for the same block + field."""
"""Merge YAML files in order: later paths win for the same block + field.
Top-level scalar fields named in :data:`_TOP_LEVEL_SCALAR_FIELDS`
(currently ``mode``) are collected under the synthetic ``__top__``
block so the ``Config`` outer fields can be overridden alongside
the nested cross-cutting / component blocks.
"""
merged: dict[str, dict[str, Any]] = {}
for path in paths:
data = yaml.safe_load(path.read_text()) or {}
@@ -131,6 +230,9 @@ def _load_yaml_files(paths: Sequence[Path]) -> dict[str, dict[str, Any]]:
f"YAML at {path} must be a mapping at the top level; got {type(data).__name__}"
)
for block_name, block_value in data.items():
if block_name in _TOP_LEVEL_SCALAR_FIELDS:
merged.setdefault("__top__", {})[block_name] = block_value
continue
if not isinstance(block_value, dict):
continue
merged.setdefault(block_name, {}).update(block_value)
@@ -193,6 +295,11 @@ def load_config(
GcsConfig(),
{k: _coerce_value(k, v) for k, v in yaml_overrides.get("gcs", {}).items()},
)
replay_block = _build_replay_block(yaml_overrides.get("replay", {}))
raw_mode = yaml_overrides.get("__top__", {}).get("mode")
if raw_mode is None:
raw_mode = env.get("MODE", "live")
mode = str(raw_mode).strip().lower()
component_blocks = _resolve_component_blocks()
for slug, dataclass_type in _COMPONENT_REGISTRY.items():
@@ -209,5 +316,7 @@ def load_config(
fdr=fdr_block,
fc=fc_block,
gcs=gcs_block,
replay=replay_block,
mode=mode, # type: ignore[arg-type] # validated by Config.__post_init__
components=component_blocks,
)
+123 -3
View File
@@ -12,12 +12,14 @@ from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field, fields, is_dataclass, replace
from typing import Any, Final
from typing import Any, Final, Literal
__all__ = [
"DEFAULT_FORBIDDEN_RECORD_KINDS",
"KNOWN_FC_DIALECTS",
"KNOWN_FC_STRATEGIES",
"KNOWN_GCS_STRATEGIES",
"KNOWN_REPLAY_PACES",
"Config",
"ConfigError",
"FcConfig",
@@ -26,6 +28,8 @@ __all__ = [
"GcsConfig",
"LogConfig",
"RecordKindPolicyConfig",
"ReplayAutoSyncConfig",
"ReplayConfig",
"RequiredFieldMissingError",
"RuntimeConfig",
"TileSnapshotConfig",
@@ -35,6 +39,8 @@ __all__ = [
KNOWN_FC_STRATEGIES: Final[frozenset[str]] = frozenset({"ardupilot_plane", "inav"})
KNOWN_GCS_STRATEGIES: Final[frozenset[str]] = frozenset({"qgc_mavlink"})
KNOWN_REPLAY_PACES: Final[frozenset[str]] = frozenset({"asap", "realtime"})
KNOWN_FC_DIALECTS: Final[frozenset[str]] = frozenset({"ardupilot_plane", "inav"})
# Default raw-frame kinds that AZ-295's RecordKindPolicy must reject
@@ -289,6 +295,98 @@ class RuntimeConfig:
tile_cache_path: str = "/var/lib/gps-denied/tiles"
@dataclass(frozen=True)
class ReplayAutoSyncConfig:
"""Operator-tunable thresholds for the AZ-405 auto-sync detector.
Mirrors the ``AutoSyncConfig`` DTO in
:mod:`gps_denied_onboard.replay_input.interface`; lives here so the
YAML loader can populate it without importing the Layer-4 replay
package (which would create a config → replay_input → config cycle).
The composition root translates this block into the matching
``AutoSyncConfig`` instance when it builds the
:class:`ReplayInputAdapter`.
All fields default to the contract values in
``_docs/02_document/contracts/replay/replay_protocol.md`` v2.0.0.
"""
takeoff_accel_threshold_g: float = 0.5
takeoff_attitude_rate_threshold_rad_s: float = 1.0
sustained_seconds: float = 0.5
prescan_max_messages: int = 6000
video_motion_threshold: float = 1.5
video_motion_scan_seconds: float = 10.0
match_threshold_pct: float = 95.0
match_window_ms: int = 100
low_confidence_threshold: float = 0.80
@dataclass(frozen=True)
class ReplayConfig:
"""Replay-mode runtime descriptors (AZ-401 / E-DEMO-REPLAY).
Read by :func:`gps_denied_onboard.runtime_root.compose_root` only
when the outer :attr:`Config.mode` is ``"replay"``. Live mode
ignores every field — the block exists as a default-constructed
placeholder so the outer :class:`Config` shape stays stable across
modes.
Validation here is shape-only: the unknown-pace / unknown-dialect
cases reject early. Path existence is verified by the composition
root because YAML may legally reference paths injected at runtime.
Attributes:
video_path: Filesystem path to the replay video (``.mp4`` /
``.h264``). Empty string is the default sentinel; a
non-empty value is required when ``mode == "replay"``.
tlog_path: Filesystem path to the matching pymavlink ``.tlog``.
Empty string is the default sentinel.
output_path: Filesystem path the :class:`JsonlReplaySink` will
write to. Default points at ``/tmp/replay.jsonl`` for
developer ergonomics; production wiring overrides via the
CLI ``--output`` flag.
pace: One of :data:`KNOWN_REPLAY_PACES`. ``"asap"`` selects
:class:`TlogDerivedClock`; ``"realtime"`` selects
:class:`WallClock`.
time_offset_ms: Manual override for the video-vs-tlog offset.
``None`` means "run AZ-405 auto-sync"; an integer value
bypasses auto-sync entirely.
target_fc_dialect: One of :data:`KNOWN_FC_DIALECTS`; controls
which pymavlink dialect the :class:`TlogReplayFcAdapter`
decodes.
auto_sync: Operator-tunable thresholds for the AZ-405
auto-sync detector.
"""
video_path: str = ""
tlog_path: str = ""
output_path: str = "/tmp/replay.jsonl"
pace: str = "asap"
time_offset_ms: int | None = None
target_fc_dialect: str = "ardupilot_plane"
auto_sync: ReplayAutoSyncConfig = field(default_factory=ReplayAutoSyncConfig)
def __post_init__(self) -> None:
if self.pace not in KNOWN_REPLAY_PACES:
raise ConfigError(
f"ReplayConfig.pace={self.pace!r} not in "
f"{sorted(KNOWN_REPLAY_PACES)}"
)
if self.target_fc_dialect not in KNOWN_FC_DIALECTS:
raise ConfigError(
f"ReplayConfig.target_fc_dialect={self.target_fc_dialect!r} "
f"not in {sorted(KNOWN_FC_DIALECTS)}"
)
if self.time_offset_ms is not None and not isinstance(
self.time_offset_ms, int
):
raise ConfigError(
"ReplayConfig.time_offset_ms must be int or None; "
f"got {type(self.time_offset_ms).__name__}"
)
# Documented defaults for cross-cutting blocks ONLY. Per-component defaults
# live with their own component epic. The registry below is the single
# source of truth so two components cannot silently claim the same key.
@@ -298,6 +396,7 @@ _DEFAULT_BLOCKS: Final[dict[str, type]] = {
"runtime": RuntimeConfig,
"fc": FcConfig,
"gcs": GcsConfig,
"replay": ReplayConfig,
}
@@ -341,6 +440,14 @@ class Config:
Components consume only their own slice via ``config.components[slug]``;
the runtime / log / fdr cross-cutting blocks are read directly via
attribute access by the composition root.
The :attr:`mode` field selects between ``"live"`` (the default —
behaves exactly as the pre-AZ-401 binary) and ``"replay"`` (drives
the airborne binary off recorded video + tlog inputs per ADR-011 /
replay protocol v2.0.0). Replay-only configuration lives under
:attr:`replay`; the field is always present (default-constructed)
so the outer shape is stable, but its contents are ignored in live
mode.
"""
runtime: RuntimeConfig = field(default_factory=RuntimeConfig)
@@ -348,14 +455,23 @@ class Config:
fdr: FdrConfig = field(default_factory=FdrConfig)
fc: FcConfig = field(default_factory=FcConfig)
gcs: GcsConfig = field(default_factory=GcsConfig)
replay: ReplayConfig = field(default_factory=ReplayConfig)
mode: Literal["live", "replay"] = "live"
components: Mapping[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
if self.mode not in ("live", "replay"):
raise ConfigError(
f"Config.mode={self.mode!r} not in ('live', 'replay')"
)
@classmethod
def with_blocks(cls, **blocks: Any) -> Config:
"""Build a `Config` from a flat name-to-instance map.
Cross-cutting names (``log``, ``fdr``, ``runtime``, ``fc``, ``gcs``)
become attributes; every other key is treated as a component slug
Cross-cutting names (``log``, ``fdr``, ``runtime``, ``fc``,
``gcs``, ``replay``) become attributes; ``mode`` is also a
recognised key. Every other key is treated as a component slug
and goes into ``components``.
"""
runtime = blocks.pop("runtime", RuntimeConfig())
@@ -363,12 +479,16 @@ class Config:
fdr = blocks.pop("fdr", FdrConfig())
fc = blocks.pop("fc", FcConfig())
gcs = blocks.pop("gcs", GcsConfig())
replay = blocks.pop("replay", ReplayConfig())
mode = blocks.pop("mode", "live")
return cls(
runtime=runtime,
log=log,
fdr=fdr,
fc=fc,
gcs=gcs,
replay=replay,
mode=mode,
components=dict(blocks),
)
+77 -27
View File
@@ -7,9 +7,14 @@ the component graph in dependency order.
Per-binary entrypoints:
* :func:`compose_root` - airborne runtime
* :func:`compose_root` - airborne runtime; serves both ``config.mode == "live"``
and ``config.mode == "replay"`` per ADR-011 (replay-as-configuration)
* :func:`compose_operator` - operator-side tooling (pre-flight, post-landing)
* :func:`compose_replay` - replay-cli runtime (extension owned by AZ-401)
Replay is a configuration of :func:`compose_root`, not a separate function:
the branch on ``config.mode`` lives in :mod:`._replay_branch`. The legacy
``compose_replay`` export was removed by AZ-401 (ADR-011 supersedes the
v1.0.0 "replay is a sibling root" design).
Public surface frozen by
``_docs/02_document/contracts/shared_config/composition_root_protocol.md`` v1.0.0.
@@ -24,6 +29,10 @@ from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Final, Literal, get_args
from gps_denied_onboard.config import Config, load_config
from gps_denied_onboard.runtime_root._replay_branch import (
CompositionError,
build_replay_components,
)
from gps_denied_onboard.runtime_root.c12_factory import (
build_flights_api_client,
)
@@ -67,6 +76,7 @@ __all__ = [
"EXIT_FDR_OPEN_FAILURE",
"EXIT_GENERIC_FAILURE",
"REQUIRED_ENV_VARS",
"CompositionError",
"ConfigurationError",
"OperatorRoot",
"OutboundThreadAlreadyBoundError",
@@ -91,7 +101,6 @@ __all__ = [
"clear_strategy_registries",
"clear_strategy_registry",
"compose_operator",
"compose_replay",
"compose_root",
"list_registered_fc_strategies",
"list_registered_gcs_strategies",
@@ -317,8 +326,17 @@ def _compose(
binary: str,
allowed_tiers: frozenset[StrategyTier],
extra_required_env: Iterable[str],
pre_constructed: Mapping[str, Any] | None = None,
) -> tuple[dict[str, Any], tuple[str, ...]]:
"""Shared composition path used by ``compose_root`` / ``compose_operator``."""
"""Shared composition path used by ``compose_root`` / ``compose_operator``.
``pre_constructed`` lets the caller seed the ``constructed`` dict
before any registered factory runs — used by the replay-mode branch
of :func:`compose_root` to inject the cross-cutting replay
strategies (``frame_source``, ``fc_adapter``, ``clock``,
``mavlink_transport``, ``replay_sink``) so any C1-C7 factory that
declares a dependency on one finds it already populated.
"""
_check_required_env(extra_required=extra_required_env)
selections = _resolve_component_strategies(config, allowed_tiers)
resolved: dict[str, _Registration] = {
@@ -326,7 +344,9 @@ def _compose(
for slug, strategy in selections.items()
}
order = _topo_order(resolved.keys(), resolved)
constructed: dict[str, Any] = {}
constructed: dict[str, Any] = (
dict(pre_constructed) if pre_constructed is not None else {}
)
for slug in order:
registration = resolved[slug]
try:
@@ -336,7 +356,11 @@ def _compose(
_close_partial_instances(constructed)
raise
_ = binary # documented but unused beyond labelling the returned root
return constructed, tuple(order)
# Returned components include only the registry-driven strategies — the
# caller is responsible for merging the pre_constructed dict back in if
# it wants a single combined view.
registry_built = {slug: constructed[slug] for slug in order}
return registry_built, tuple(order)
def _close_partial_instances(instances: Mapping[str, Any]) -> None:
@@ -392,19 +416,61 @@ def _read_strategy_attr(block: Any) -> Any:
return None
def compose_root(config: Config) -> RuntimeRoot:
"""Compose the airborne runtime graph (per contract v1.0.0)."""
def compose_root(
config: Config,
*,
replay_components_factory: Any | None = None,
) -> RuntimeRoot:
"""Compose the airborne runtime graph for ``config.mode``.
With ``config.mode == "live"`` (the default) the function behaves
exactly as the pre-AZ-401 implementation — every wiring decision is
driven by ``config.components[slug].strategy`` against the strategy
registry, gated by the airborne tier.
With ``config.mode == "replay"`` the function additionally builds
the five replay-only strategies (``frame_source``, ``fc_adapter``,
``clock``, ``mavlink_transport``, ``replay_sink``) per
:mod:`._replay_branch` and merges them into the components dict
BEFORE the registry-driven C1-C7+C13 strategies run, so any
component factory that consumes one of the five via ``constructed``
finds it already populated. C1-C7+C13 strategies are wired
identically to live mode (replay protocol Invariant 1).
The ``replay_components_factory`` keyword is a test-only injection
point — production callers omit it. Tests pass a callable returning
``(components, construction_order)`` so the unit suite does not
have to satisfy the full OpenCV / pymavlink / FDR side-effects of
the real strategies.
"""
extra_env = (
("MAVLINK_SIGNING_KEY",)
if config.mode == "live"
else ()
)
if config.mode == "replay":
replay_factory = replay_components_factory or build_replay_components
replay_components, replay_order = replay_factory(config)
else:
replay_components = {}
replay_order = ()
components, order = _compose(
config,
binary="airborne",
allowed_tiers=frozenset({"airborne", "shared"}),
extra_required_env=("MAVLINK_SIGNING_KEY",),
extra_required_env=extra_env,
pre_constructed=replay_components,
)
merged: dict[str, Any] = dict(replay_components)
merged.update(components)
full_order = tuple(replay_order) + tuple(
slug for slug in order if slug not in replay_order
)
return RuntimeRoot(
binary="airborne",
profile=os.environ["GPS_DENIED_FC_PROFILE"],
components=components,
construction_order=order,
components=merged,
construction_order=full_order,
)
@@ -424,22 +490,6 @@ def compose_operator(config: Config) -> OperatorRoot:
)
def compose_replay(config: Config) -> RuntimeRoot:
"""Compose the replay-cli runtime graph. Concrete wiring is owned by AZ-401."""
components, order = _compose(
config,
binary="replay-cli",
allowed_tiers=frozenset({"airborne", "shared"}),
extra_required_env=(),
)
return RuntimeRoot(
binary="replay-cli",
profile=os.environ["GPS_DENIED_FC_PROFILE"],
components=components,
construction_order=order,
)
@dataclass(frozen=True)
class TakeoffResult:
"""Successful takeoff: writer is open, FC adapter is wired, components started.
@@ -0,0 +1,329 @@
"""Replay-mode branch of :func:`compose_root` (AZ-401 / E-DEMO-REPLAY).
Internal module. Owns the wiring that turns a ``config.mode == "replay"``
:class:`Config` into a :class:`RuntimeRoot` whose components dict carries
the replay-only strategies (``frame_source``, ``fc_adapter``, ``clock``,
``mavlink_transport``, ``replay_sink``) plus whatever C1-C7+C13 strategies
the binary's bootstrap registered against
:data:`gps_denied_onboard.runtime_root._STRATEGY_REGISTRY`.
Per replay protocol v2.0.0 (ADR-011): replay is a configuration of the
single airborne composition root, not a sibling root. The branch lives
in this module to keep ``runtime_root/__init__.py`` focused on the
shared composition spine while still exposing exactly one
``compose_root(config)`` entrypoint.
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.
- ``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.
"""
from __future__ import annotations
import json
import os
from collections.abc import Mapping
from pathlib import Path
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.noop_mavlink_transport import (
NoopMavlinkTransport,
)
from gps_denied_onboard.components.c8_fc_adapter.replay_sink import (
JsonlReplaySink,
)
from gps_denied_onboard.config import Config
from gps_denied_onboard.fdr_client import make_fdr_client
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.tlog_video_adapter import ReplayPace
if TYPE_CHECKING:
from gps_denied_onboard.fdr_client.client import FdrClient
__all__ = [
"REPLAY_BUILD_FLAGS",
"REPLAY_COMPONENT_KEYS",
"CompositionError",
"build_replay_components",
]
_LOG_KIND_READY: Final[str] = "replay.compose_root.ready"
REPLAY_BUILD_FLAGS: Final[tuple[str, ...]] = (
"BUILD_VIDEO_FILE_FRAME_SOURCE",
"BUILD_TLOG_REPLAY_ADAPTER",
"BUILD_REPLAY_SINK_JSONL",
)
REPLAY_COMPONENT_KEYS: Final[tuple[str, ...]] = (
"frame_source",
"fc_adapter",
"clock",
"mavlink_transport",
"replay_sink",
)
class CompositionError(RuntimeError):
"""Raised when the replay-mode branch refuses to compose a runtime.
Carries the human-readable reason (build-flag OFF, missing path,
contradictory config) so the caller can surface it in the structured
log + on stderr without a second introspection pass.
"""
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, ...]]:
"""Construct the replay-mode component dict + construction order.
The factories are test-only injection points. Production callers
(just ``compose_root``) leave them ``None`` so the real constructors
run; unit tests pass fakes so they don't have to satisfy the full
OpenCV / pymavlink / FDR side-effects of the real strategies.
Returns:
``(components, construction_order)`` — the same shape
:func:`gps_denied_onboard.runtime_root._compose` returns. The
keys are the entries of :data:`REPLAY_COMPONENT_KEYS`; the
values are typed strategy instances.
"""
if config.mode != "replay":
raise CompositionError(
"build_replay_components called with non-replay config "
f"(mode={config.mode!r})"
)
_validate_build_flags()
_validate_replay_paths(config)
fdr_factory = fdr_client_factory or make_fdr_client
fdr_client = fdr_factory("replay_input", config)
sink_fdr_client = fdr_factory("c8_fc_adapter.replay_sink", config)
bundle = _build_replay_input_bundle(
config,
fdr_client=fdr_client,
adapter_factory=replay_input_adapter_factory,
)
if sink_factory is not None:
sink = sink_factory(config, sink_fdr_client)
else:
sink = JsonlReplaySink(
output_path=Path(config.replay.output_path),
fdr_client=sink_fdr_client,
)
if transport_factory is not None:
transport = transport_factory(config)
else:
transport = NoopMavlinkTransport()
components: dict[str, Any] = {
"frame_source": bundle.frame_source,
"fc_adapter": bundle.fc_adapter,
"clock": bundle.clock,
"mavlink_transport": transport,
"replay_sink": sink,
}
_log_ready(config, bundle)
return components, REPLAY_COMPONENT_KEYS
def _validate_build_flags() -> None:
"""Refuse construction when any replay-mode ``BUILD_*`` flag is OFF."""
for flag_name in REPLAY_BUILD_FLAGS:
raw = os.environ.get(flag_name, "ON").strip().upper()
if raw == "OFF":
raise CompositionError(
f"{flag_name} is OFF; replay mode requires it"
)
def _validate_replay_paths(config: Config) -> None:
"""Reject empty / missing replay paths early with a precise message."""
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:
raise CompositionError(
"config.replay.tlog_path is empty; replay mode requires a tlog path"
)
if not config.replay.output_path:
raise CompositionError(
"config.replay.output_path is empty; replay mode requires an output path"
)
def _build_replay_input_bundle(
config: Config,
*,
fdr_client: "FdrClient",
adapter_factory: Any | None,
) -> ReplayInputBundle:
"""Build the :class:`ReplayInputAdapter` and call ``open()``."""
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 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,
)
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,
auto_sync_config=auto_sync,
)
return adapter.open()
def _resolve_pace(raw: str) -> ReplayPace:
if raw == "asap":
return ReplayPace.ASAP
if raw == "realtime":
return ReplayPace.REALTIME
raise CompositionError(
f"config.replay.pace={raw!r} not in ('asap', 'realtime')"
)
def _resolve_fc_kind(raw: str) -> FcKind:
if raw == "ardupilot_plane":
return FcKind.ARDUPILOT_PLANE
if raw == "inav":
return FcKind.INAV
raise CompositionError(
f"config.replay.target_fc_dialect={raw!r} not in "
"('ardupilot_plane', 'inav')"
)
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,
)
def _load_camera_calibration(config: Config) -> CameraCalibration:
"""Read the camera calibration JSON into a :class:`CameraCalibration` DTO.
The replay binary uses the SAME calibration file the live binary
loads; AZ-401 does not introduce a new on-disk format.
"""
import numpy as np
path = config.runtime.camera_calibration_path
if not path:
raise CompositionError(
"config.runtime.camera_calibration_path is empty; replay mode "
"requires a camera calibration JSON"
)
try:
blob = json.loads(Path(path).read_text(encoding="utf-8"))
except OSError as exc:
raise CompositionError(
f"failed to read camera calibration from {path!r}: {exc!r}"
) from exc
except json.JSONDecodeError as exc:
raise CompositionError(
f"camera calibration {path!r} is not valid JSON: {exc!r}"
) from exc
if not isinstance(blob, Mapping):
raise CompositionError(
f"camera calibration {path!r} must decode to a mapping; "
f"got {type(blob).__name__}"
)
intrinsics = np.asarray(blob.get("intrinsics_3x3"), dtype=np.float64)
if intrinsics.shape != (3, 3):
raise CompositionError(
f"camera calibration {path!r} 'intrinsics_3x3' must be 3x3; "
f"got shape {intrinsics.shape}"
)
distortion = np.asarray(blob.get("distortion", []), dtype=np.float64)
body_to_camera = np.asarray(
blob.get("body_to_camera_se3", np.eye(4).tolist()),
dtype=np.float64,
)
return CameraCalibration(
camera_id=str(blob.get("camera_id", "replay-camera")),
intrinsics_3x3=intrinsics,
distortion=distortion,
body_to_camera_se3=body_to_camera,
acquisition_method=str(blob.get("acquisition_method", "operator")),
metadata=dict(blob.get("metadata", {})),
)
def _log_ready(config: Config, bundle: ReplayInputBundle) -> None:
log = get_logger("runtime_root.replay_branch")
log.info(
f"{_LOG_KIND_READY}: pace={config.replay.pace} "
f"resolved_offset_ms={bundle.resolved_time_offset_ms}",
extra={
"kind": _LOG_KIND_READY,
"kv": {
"video_path": config.replay.video_path,
"tlog_path": config.replay.tlog_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,
},
},
)
@@ -0,0 +1,287 @@
"""AZ-400 retrofit — `MavlinkTransport` Protocol + Noop / Serial impls.
Covers the part of AZ-400 that the v1.0.0 sprint deferred:
the transport seam declared by the replay contract Invariant 5 and
required by AZ-401's ``compose_root`` replay branch (per
``_docs/02_document/contracts/replay/replay_protocol.md`` v2.0.0 lines
14, 109, 222, 237).
Per-test references:
- AC-Transport-1 — protocol conformance
- AC-Transport-2 — noop accepts every byte length, counts cumulatively
- AC-Transport-3 — serial forwards bytes through the underlying connection
- AC-Transport-4 — both raise on write-after-close
- AC-Transport-5 — close is idempotent
- AC-Transport-6 — build flag OFF refuses noop construction
- AC-Transport-7 — serial OSError surfaces as ``MavlinkTransportError``
"""
from __future__ import annotations
from typing import Any
from unittest import mock
import pytest
from gps_denied_onboard.components.c8_fc_adapter import MavlinkTransport
from gps_denied_onboard.components.c8_fc_adapter.errors import (
MavlinkTransportConfigError,
MavlinkTransportError,
)
from gps_denied_onboard.components.c8_fc_adapter.noop_mavlink_transport import (
NoopMavlinkTransport,
)
from gps_denied_onboard.components.c8_fc_adapter.serial_mavlink_transport import (
SerialMavlinkTransport,
)
# ----------------------------------------------------------------------
# Fixtures
@pytest.fixture(autouse=True)
def _build_flag_on(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("BUILD_REPLAY_SINK_JSONL", "ON")
class _FakeConnection:
"""Stub for ``mavutil.mavlink_connection`` — exposes ``write(bytes)``."""
def __init__(self) -> None:
self.received: list[bytes] = []
self.fail_with: Exception | None = None
def write(self, data: bytes) -> int:
if self.fail_with is not None:
raise self.fail_with
self.received.append(bytes(data))
return len(data)
# ----------------------------------------------------------------------
# AC-Transport-1: Protocol conformance
def test_noop_transport_satisfies_protocol() -> None:
# Act
transport = NoopMavlinkTransport()
# Assert
assert isinstance(transport, MavlinkTransport)
def test_serial_transport_satisfies_protocol() -> None:
# Arrange
conn = _FakeConnection()
# Act
transport = SerialMavlinkTransport(connection=conn)
# Assert
assert isinstance(transport, MavlinkTransport)
# ----------------------------------------------------------------------
# AC-Transport-2: NoopMavlinkTransport accepts and counts bytes
def test_noop_transport_counts_cumulative_bytes() -> None:
# Arrange
transport = NoopMavlinkTransport()
# Act
n1 = transport.write(b"abc")
n2 = transport.write(b"")
n3 = transport.write(b"defgh")
# Assert
assert n1 == 3
assert n2 == 0
assert n3 == 5
assert transport.bytes_written() == 8
def test_noop_transport_accepts_bytes_like_views() -> None:
# Arrange
transport = NoopMavlinkTransport()
# Act
transport.write(bytearray(b"abc"))
transport.write(memoryview(b"def"))
# Assert
assert transport.bytes_written() == 6
def test_noop_transport_rejects_non_bytes() -> None:
# Arrange
transport = NoopMavlinkTransport()
# Act / Assert
with pytest.raises(MavlinkTransportError, match="bytes-like"):
transport.write("not-bytes") # type: ignore[arg-type]
# ----------------------------------------------------------------------
# AC-Transport-3: SerialMavlinkTransport forwards bytes
def test_serial_transport_forwards_bytes_to_underlying_connection() -> None:
# Arrange
conn = _FakeConnection()
transport = SerialMavlinkTransport(connection=conn)
# Act
n = transport.write(b"hello")
# Assert
assert n == 5
assert conn.received == [b"hello"]
assert transport.bytes_written() == 5
def test_serial_transport_rejects_missing_write_method() -> None:
# Arrange
class _NoWrite:
pass
# Act / Assert
with pytest.raises(MavlinkTransportError, match=r"\.write\(bytes\)"):
SerialMavlinkTransport(connection=_NoWrite())
def test_serial_transport_rejects_none_connection() -> None:
# Act / Assert
with pytest.raises(MavlinkTransportError, match="open pymavlink connection"):
SerialMavlinkTransport(connection=None)
# ----------------------------------------------------------------------
# AC-Transport-4: write after close raises
def test_noop_transport_write_after_close_raises() -> None:
# Arrange
transport = NoopMavlinkTransport()
transport.write(b"first")
transport.close()
# Act / Assert
with pytest.raises(MavlinkTransportError, match="closed"):
transport.write(b"second")
def test_serial_transport_write_after_close_raises() -> None:
# Arrange
conn = _FakeConnection()
transport = SerialMavlinkTransport(connection=conn)
transport.write(b"first")
transport.close()
# Act / Assert
with pytest.raises(MavlinkTransportError, match="closed"):
transport.write(b"second")
# ----------------------------------------------------------------------
# AC-Transport-5: idempotent close
def test_noop_transport_close_is_idempotent() -> None:
# Arrange
transport = NoopMavlinkTransport()
# Act
transport.close()
transport.close() # must not raise
def test_serial_transport_close_is_idempotent() -> None:
# Arrange
conn = _FakeConnection()
transport = SerialMavlinkTransport(connection=conn)
# Act
transport.close()
transport.close() # must not raise
# ----------------------------------------------------------------------
# AC-Transport-6: BUILD flag gating
def test_noop_transport_build_flag_off_raises(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Arrange
monkeypatch.setenv("BUILD_REPLAY_SINK_JSONL", "OFF")
# Act / Assert
with pytest.raises(MavlinkTransportConfigError, match="BUILD_REPLAY_SINK_JSONL is OFF"):
NoopMavlinkTransport()
# ----------------------------------------------------------------------
# AC-Transport-7: SerialMavlinkTransport surfaces OSError as MavlinkTransportError
def test_serial_transport_oserror_wrapped() -> None:
# Arrange
conn = _FakeConnection()
conn.fail_with = OSError("device disconnected")
transport = SerialMavlinkTransport(connection=conn)
# Act / Assert
with pytest.raises(MavlinkTransportError, match="underlying write failed"):
transport.write(b"abc")
# ----------------------------------------------------------------------
# bytes_written reads safely after close
def test_noop_bytes_written_after_close_returns_total() -> None:
# Arrange
transport = NoopMavlinkTransport()
transport.write(b"abcd")
transport.close()
# Assert
assert transport.bytes_written() == 4
def test_serial_bytes_written_after_close_returns_total() -> None:
# Arrange
conn = _FakeConnection()
transport = SerialMavlinkTransport(connection=conn)
transport.write(b"abcdef")
transport.close()
# Assert
assert transport.bytes_written() == 6
# ----------------------------------------------------------------------
# Smoke: serial transport handles ``returned is None`` from underlying write
def test_serial_transport_falls_back_to_payload_length_when_write_returns_none() -> None:
# Arrange
conn = mock.MagicMock(spec=["write"])
conn.write.return_value = None
transport = SerialMavlinkTransport(connection=conn)
# Act
n = transport.write(b"abcde")
# Assert
assert n == 5
assert transport.bytes_written() == 5
# ----------------------------------------------------------------------
# Smoke: ad-hoc Any annotation removes pytest unused-import warnings.
_ = Any
+15 -2
View File
@@ -1,4 +1,9 @@
"""C8 FC Adapter smoke test — AC-9 (legacy) + AZ-390 public-API gate."""
"""C8 FC Adapter smoke test — AC-9 (legacy) + AZ-390 public-API gate.
AZ-401 expands the public Protocol surface with ``MavlinkTransport``,
the outbound byte-stream seam shared by ``SerialMavlinkTransport``
(live) and ``NoopMavlinkTransport`` (replay).
"""
def test_interface_importable() -> None:
@@ -7,10 +12,17 @@ def test_interface_importable() -> None:
EmittedExternalPosition,
FcAdapter,
GcsAdapter,
MavlinkTransport,
ReplaySink,
)
for sym in (FcAdapter, GcsAdapter, ReplaySink, EmittedExternalPosition):
for sym in (
FcAdapter,
GcsAdapter,
ReplaySink,
EmittedExternalPosition,
MavlinkTransport,
):
assert sym is not None
@@ -24,5 +36,6 @@ def test_internal_modules_not_in_public_all() -> None:
"EmittedExternalPosition",
"FcAdapter",
"GcsAdapter",
"MavlinkTransport",
"ReplaySink",
}
@@ -0,0 +1,697 @@
"""AZ-401 — `compose_root(config)` replay-mode branch unit tests.
Verifies the contract at ``_docs/02_document/contracts/replay/replay_protocol.md``
v2.0.0 §Composition Root + ADR-011 (replay-as-configuration). Covers
AC-1 through AC-10 of the AZ-401 task spec.
AC-9 ("``NoopMavlinkTransport.bytes_written() > 0`` after the C8 outbound
encoders run") is recorded here as a known BLOCKED case: the existing
:class:`TlogReplayFcAdapter` (AZ-399) raises on every ``emit_external_position``
call rather than routing the encoder bytes through a transport seam, so
the encoders never run in replay mode. Closing this gap requires the AP
/ iNav / QGC encoder retrofits that AZ-400 originally scoped but did
not deliver. See the batch 61 report for the deferral rationale.
"""
from __future__ import annotations
import ast
import json
from collections.abc import Iterator
from pathlib import Path
from typing import Any
from unittest import mock
from uuid import UUID, uuid4
import numpy as np
import pytest
from gps_denied_onboard._types.geo import LatLonAlt
from gps_denied_onboard._types.state import EstimatorOutput, PoseSourceLabel, Quat
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.noop_mavlink_transport import (
NoopMavlinkTransport,
)
from gps_denied_onboard.components.c8_fc_adapter.replay_sink import (
JsonlReplaySink,
)
from gps_denied_onboard.components.c8_fc_adapter.tlog_replay_adapter import (
TlogReplayFcAdapter,
)
from gps_denied_onboard.config import (
Config,
ReplayAutoSyncConfig,
ReplayConfig,
RuntimeConfig,
)
from gps_denied_onboard.frame_source.video_file import VideoFileFrameSource
from gps_denied_onboard.replay_input.interface import ReplayInputBundle
from gps_denied_onboard.runtime_root import (
CompositionError,
RuntimeRoot,
clear_strategy_registry,
compose_root,
)
from gps_denied_onboard.runtime_root._replay_branch import (
REPLAY_BUILD_FLAGS,
REPLAY_COMPONENT_KEYS,
build_replay_components,
)
_REPO_ROOT = Path(__file__).resolve().parents[2]
# ----------------------------------------------------------------------
# Shared fixtures
@pytest.fixture(autouse=True)
def _isolated_registry() -> Iterator[None]:
clear_strategy_registry()
yield
clear_strategy_registry()
@pytest.fixture
def _airborne_replay_env(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> Path:
"""Set the env vars + replay BUILD_* flags compose_root needs.
Returns the path of a synthetic camera calibration JSON the
``compose_root`` replay branch will load.
"""
calib_path = tmp_path / "calib.json"
calib_path.write_text(
json.dumps(
{
"camera_id": "test-cam",
"intrinsics_3x3": np.eye(3).tolist(),
"distortion": [0.0, 0.0, 0.0, 0.0],
"body_to_camera_se3": np.eye(4).tolist(),
"acquisition_method": "operator",
"metadata": {},
}
)
)
for name, value in (
("GPS_DENIED_FC_PROFILE", "ardupilot_plane"),
("GPS_DENIED_TIER", "1"),
("DB_URL", "postgresql+psycopg://gps_denied:dev@db:5432/gps_denied"),
("CAMERA_CALIBRATION_PATH", str(calib_path)),
("LOG_LEVEL", "INFO"),
("LOG_SINK", "console"),
("INFERENCE_BACKEND", "pytorch_fp16"),
("FDR_PATH", "/var/lib/gps-denied/fdr"),
("TILE_CACHE_PATH", "/var/lib/gps-denied/tiles"),
):
monkeypatch.setenv(name, value)
for flag in REPLAY_BUILD_FLAGS:
monkeypatch.setenv(flag, "ON")
return calib_path
@pytest.fixture
def _airborne_live_env(monkeypatch: pytest.MonkeyPatch) -> None:
for name, value in (
("GPS_DENIED_FC_PROFILE", "ardupilot_plane"),
("GPS_DENIED_TIER", "1"),
("DB_URL", "postgresql+psycopg://gps_denied:dev@db:5432/gps_denied"),
("CAMERA_CALIBRATION_PATH", "/etc/gps-denied/calib.yml"),
("LOG_LEVEL", "INFO"),
("LOG_SINK", "console"),
("INFERENCE_BACKEND", "pytorch_fp16"),
("FDR_PATH", "/var/lib/gps-denied/fdr"),
("TILE_CACHE_PATH", "/var/lib/gps-denied/tiles"),
("MAVLINK_SIGNING_KEY", "ZZZZZZZZ"),
):
monkeypatch.setenv(name, value)
def _make_replay_config(
*,
pace: str = "asap",
time_offset_ms: int | None = 0,
target_fc_dialect: str = "ardupilot_plane",
output_path: str = "/tmp/replay.jsonl",
calib_path: Path | None = None,
) -> Config:
runtime = (
RuntimeConfig()
if calib_path is None
else RuntimeConfig(camera_calibration_path=str(calib_path))
)
replay = ReplayConfig(
video_path="/dev/null/fake.mp4",
tlog_path="/dev/null/fake.tlog",
output_path=output_path,
pace=pace,
time_offset_ms=time_offset_ms,
target_fc_dialect=target_fc_dialect,
auto_sync=ReplayAutoSyncConfig(),
)
return Config(runtime=runtime, replay=replay, mode="replay")
def _make_replay_bundle(
*,
clock_kind: str = "tlog",
) -> ReplayInputBundle:
"""Build a :class:`ReplayInputBundle` with mocked strategies.
The strategies are real instances of the right classes (so AC-3
``isinstance`` checks pass) but with their internal init guards
bypassed via ``__new__`` because the production constructors open
OpenCV / pymavlink resources we don't want in the unit suite.
"""
fs = VideoFileFrameSource.__new__(VideoFileFrameSource)
fc = TlogReplayFcAdapter.__new__(TlogReplayFcAdapter)
if clock_kind == "tlog":
clock = TlogDerivedClock(source=iter([1_000_000_000, 2_000_000_000]))
else:
clock = WallClock()
return ReplayInputBundle(
frame_source=fs,
fc_adapter=fc,
clock=clock,
resolved_time_offset_ms=0,
auto_sync_result=None,
)
def _fake_replay_components_factory(
*,
bundle: ReplayInputBundle,
sink: Any | None = None,
transport: Any | None = None,
) -> Any:
"""Return a callable suitable for ``replay_components_factory``."""
def factory(_config: Config) -> tuple[dict[str, Any], tuple[str, ...]]:
components = {
"frame_source": bundle.frame_source,
"fc_adapter": bundle.fc_adapter,
"clock": bundle.clock,
"mavlink_transport": transport if transport is not None else NoopMavlinkTransport(),
"replay_sink": sink if sink is not None else mock.MagicMock(spec=JsonlReplaySink),
}
return components, REPLAY_COMPONENT_KEYS
return factory
def _make_estimator_output(seq: int = 0) -> EstimatorOutput:
return EstimatorOutput(
frame_id=uuid4(),
position_wgs84=LatLonAlt(lat_deg=49.991, lon_deg=36.221, alt_m=153.4 + seq),
orientation_world_T_body=Quat(w=1.0, x=0.0, y=0.0, z=0.0),
velocity_world_mps=(1.5, -0.25, 0.0),
covariance_6x6=np.eye(6, dtype=np.float64) * 0.5,
source_label=PoseSourceLabel.SATELLITE_ANCHORED,
last_satellite_anchor_age_ms=250,
smoothed=False,
emitted_at=1_700_000_000_000_000_000 + seq,
)
# ----------------------------------------------------------------------
# AC-1: Single composition root — `compose_replay` no longer exported
def test_ac1_compose_replay_no_longer_exported() -> None:
# Act / Assert
with pytest.raises(ImportError):
from gps_denied_onboard.runtime_root import compose_replay # noqa: F401
# The two surviving entrypoints stay importable.
from gps_denied_onboard.runtime_root import ( # noqa: F401
compose_operator,
compose_root,
)
# ----------------------------------------------------------------------
# AC-2: Live mode unchanged
def test_ac2_live_default_mode_returns_runtime_root_with_no_replay_keys(
_airborne_live_env: None,
) -> None:
# Arrange — empty config in default (live) mode
config = Config()
# Act
runtime = compose_root(config)
# Assert
assert isinstance(runtime, RuntimeRoot)
assert runtime.binary == "airborne"
# No replay-only keys leak into live mode
for key in REPLAY_COMPONENT_KEYS:
assert key not in runtime.components, (
f"live mode unexpectedly contains replay key {key!r}"
)
def test_ac2_live_explicit_mode_unchanged(_airborne_live_env: None) -> None:
# Arrange
config = Config(mode="live")
# Act
runtime = compose_root(config)
# Assert
assert runtime.components == {}
assert runtime.construction_order == ()
# ----------------------------------------------------------------------
# AC-3: Replay mode wires replay strategies
def test_ac3_replay_mode_wires_five_replay_strategies(
_airborne_replay_env: Path,
) -> None:
# Arrange
bundle = _make_replay_bundle(clock_kind="tlog")
config = _make_replay_config(calib_path=_airborne_replay_env)
factory = _fake_replay_components_factory(bundle=bundle)
# Act
runtime = compose_root(config, replay_components_factory=factory)
# Assert — every replay strategy slot is populated and typed
assert isinstance(runtime.components["frame_source"], VideoFileFrameSource)
assert isinstance(runtime.components["fc_adapter"], TlogReplayFcAdapter)
assert isinstance(runtime.components["mavlink_transport"], NoopMavlinkTransport)
assert isinstance(runtime.components["clock"], TlogDerivedClock)
# JsonlReplaySink is a MagicMock(spec=...) here so isinstance gates correctly:
assert "replay_sink" in runtime.components
# ----------------------------------------------------------------------
# AC-4: Replay-mode build-flag check
@pytest.mark.parametrize("flag", REPLAY_BUILD_FLAGS)
def test_ac4_replay_rejects_each_build_flag_off(
_airborne_replay_env: Path,
monkeypatch: pytest.MonkeyPatch,
flag: str,
) -> None:
# Arrange
monkeypatch.setenv(flag, "OFF")
config = _make_replay_config(calib_path=_airborne_replay_env)
# Act / Assert — go through the real branch (no factory) so the
# flag gate runs before the strategy constructors do.
with pytest.raises(CompositionError, match=f"{flag} is OFF"):
compose_root(config)
def test_ac4_live_with_replay_flag_off_succeeds(
_airborne_live_env: None,
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Arrange
monkeypatch.setenv("BUILD_VIDEO_FILE_FRAME_SOURCE", "OFF")
config = Config(mode="live")
# Act
runtime = compose_root(config)
# Assert
assert isinstance(runtime, RuntimeRoot)
# ----------------------------------------------------------------------
# AC-5: Clock injection (single instance, pace-aware)
def test_ac5_replay_pace_asap_uses_tlog_derived_clock(
_airborne_replay_env: Path,
) -> None:
# Arrange
bundle = _make_replay_bundle(clock_kind="tlog")
config = _make_replay_config(pace="asap", calib_path=_airborne_replay_env)
factory = _fake_replay_components_factory(bundle=bundle)
# Act
runtime = compose_root(config, replay_components_factory=factory)
# Assert
assert isinstance(runtime.components["clock"], TlogDerivedClock)
def test_ac5_replay_pace_realtime_uses_wall_clock(
_airborne_replay_env: Path,
) -> None:
# Arrange
bundle = _make_replay_bundle(clock_kind="wall")
config = _make_replay_config(pace="realtime", calib_path=_airborne_replay_env)
factory = _fake_replay_components_factory(bundle=bundle)
# Act
runtime = compose_root(config, replay_components_factory=factory)
# Assert
assert isinstance(runtime.components["clock"], WallClock)
def test_ac5_clock_single_instance_id_equality(
_airborne_replay_env: Path,
) -> None:
"""Invariant 2 — the same Clock instance is wired everywhere."""
# Arrange
bundle = _make_replay_bundle(clock_kind="tlog")
config = _make_replay_config(calib_path=_airborne_replay_env)
factory = _fake_replay_components_factory(bundle=bundle)
# Act
runtime = compose_root(config, replay_components_factory=factory)
# Assert — the Clock instance the bundle returned is exactly the
# one wired into the runtime.
assert runtime.components["clock"] is bundle.clock
# ----------------------------------------------------------------------
# AC-6: JSONL sink emits per tick
def test_ac6_jsonl_sink_emits_per_tick_when_runtime_drives_outputs(
_airborne_replay_env: Path,
) -> None:
# Arrange — a real (in-tmp) JsonlReplaySink so this exercises the
# production code path; we drive it directly because the runtime
# loop itself is owned by the airborne entrypoint, not compose_root.
fdr_client = mock.MagicMock(name="FdrClient")
sink_path = _airborne_replay_env.parent / "out.jsonl"
sink = JsonlReplaySink(output_path=sink_path, fdr_client=fdr_client)
bundle = _make_replay_bundle()
config = _make_replay_config(
output_path=str(sink_path), calib_path=_airborne_replay_env
)
factory = _fake_replay_components_factory(bundle=bundle, sink=sink)
# Act
runtime = compose_root(config, replay_components_factory=factory)
wired_sink = runtime.components["replay_sink"]
assert wired_sink is sink
for i in range(10):
wired_sink.emit(_make_estimator_output(seq=i))
wired_sink.close()
# Assert
lines = sink_path.read_text().splitlines()
assert len(lines) == 10
for line in lines:
json.loads(line) # each line parses as JSON
# ----------------------------------------------------------------------
# AC-7: No mode-aware imports in components (replay-aware logic confined)
def test_ac7_no_component_imports_video_file_frame_source() -> None:
"""The only file allowed to import both Live and VideoFile sources is
the runtime_root composition root.
"""
# Arrange
components_root = (
_REPO_ROOT / "src" / "gps_denied_onboard" / "components"
)
bad: list[str] = []
# Act
for py in components_root.rglob("*.py"):
text = py.read_text(encoding="utf-8")
tree = ast.parse(text)
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom):
module = node.module or ""
names = {n.name for n in node.names}
if (
"frame_source.video_file" in module
or "VideoFileFrameSource" in names
):
bad.append(str(py))
break
# Assert
assert bad == [], (
"Components must not import VideoFileFrameSource directly "
f"(replay-aware imports must live in runtime_root): {bad}"
)
def test_ac7_only_runtime_root_imports_replay_strategies() -> None:
"""The imports of the noop transport / replay sink stay in runtime_root."""
# Arrange
src_root = _REPO_ROOT / "src" / "gps_denied_onboard"
components_root = src_root / "components"
allowed_dirs = {
src_root / "runtime_root",
# The replay strategies themselves live under c8_fc_adapter, so
# their internal imports inside that component are exempt.
src_root / "components" / "c8_fc_adapter",
}
# Act / Assert — walk every component file and reject imports of
# the noop transport from outside the allowed directories.
for py in components_root.rglob("*.py"):
if any(allowed in py.parents for allowed in allowed_dirs):
continue
text = py.read_text(encoding="utf-8")
if "noop_mavlink_transport" in text:
raise AssertionError(
f"{py} imports noop_mavlink_transport — mode-aware "
"imports must stay in runtime_root."
)
# ----------------------------------------------------------------------
# AC-8: Public APIs only across components
def test_ac8_replay_branch_imports_only_public_apis() -> None:
"""The replay branch must not reach into component internals."""
# Arrange
branch_path = (
_REPO_ROOT
/ "src"
/ "gps_denied_onboard"
/ "runtime_root"
/ "_replay_branch.py"
)
text = branch_path.read_text(encoding="utf-8")
tree = ast.parse(text)
# Allowed deep imports: into the c8_fc_adapter component (the
# noop transport + the JSONL sink) and into the `replay_input`
# cross-cutting coordinator (Layer-4). Both are documented in
# module-layout.md as the replay strategy homes.
allowed_deep_prefixes = (
"gps_denied_onboard.components.c8_fc_adapter.noop_mavlink_transport",
"gps_denied_onboard.components.c8_fc_adapter.replay_sink",
"gps_denied_onboard.replay_input.tlog_video_adapter",
)
# Act
for node in ast.walk(tree):
if not isinstance(node, ast.ImportFrom):
continue
module = node.module or ""
if not module.startswith("gps_denied_onboard.components"):
continue
# Public API form: `gps_denied_onboard.components.<slug>` (no further dots)
# OR an explicitly allowed deep submodule.
is_public = module.count(".") == 2
is_allowed_deep = any(
module.startswith(prefix) for prefix in allowed_deep_prefixes
)
# Assert
assert is_public or is_allowed_deep, (
f"_replay_branch imports {module!r} — must only reach into "
"component Public APIs or the documented replay strategy modules."
)
# ----------------------------------------------------------------------
# AC-9: NoopMavlinkTransport.bytes_written() > 0 — BLOCKED
@pytest.mark.skip(
reason=(
"BLOCKED on AZ-399 design choice: TlogReplayFcAdapter raises "
"FcEmitError on emit_external_position rather than routing the "
"encoder bytes through the MavlinkTransport seam. Closing this "
"gap requires retrofitting AP/iNav/QGC encoder code paths to "
"consume MavlinkTransport — see batch 61 report. NoopMavlinkTransport "
"+ MavlinkTransport Protocol classes are present (covered by "
"test_az400_mavlink_transport.py) but the wiring that makes "
"bytes_written > 0 in replay mode is deferred."
)
)
def test_ac9_noop_transport_bytes_written_after_runtime_drive() -> None:
raise NotImplementedError("see skip reason")
# ----------------------------------------------------------------------
# AC-10: Operator pre-flight C6 cache reused identically — smoke
def test_ac10_replay_does_not_alter_c6_cache_shape(
_airborne_replay_env: Path,
) -> None:
"""Smoke check that the replay branch does not register a parallel
C6 strategy under a different slug.
A real AC-10 end-to-end test requires a populated C6 + C2 wiring,
which is out of scope for AZ-401's unit suite. This check at least
asserts the replay branch never claims the ``c6_tile_cache`` slug.
"""
# Arrange
bundle = _make_replay_bundle()
config = _make_replay_config(calib_path=_airborne_replay_env)
factory = _fake_replay_components_factory(bundle=bundle)
# Act
runtime = compose_root(config, replay_components_factory=factory)
# Assert
assert "c6_tile_cache" not in runtime.components
# ----------------------------------------------------------------------
# Real `build_replay_components` path — the production wiring must
# refuse early on missing replay paths instead of crashing inside the
# adapter constructor.
def test_replay_branch_rejects_empty_video_path(
_airborne_replay_env: Path,
) -> None:
# Arrange
runtime_cfg = RuntimeConfig(camera_calibration_path=str(_airborne_replay_env))
config = Config(
runtime=runtime_cfg,
replay=ReplayConfig(
video_path="",
tlog_path="/dev/null/fake.tlog",
output_path="/tmp/out.jsonl",
pace="asap",
target_fc_dialect="ardupilot_plane",
),
mode="replay",
)
# Act / Assert
with pytest.raises(CompositionError, match="video_path is empty"):
build_replay_components(config)
def test_replay_branch_rejects_empty_tlog_path(
_airborne_replay_env: Path,
) -> None:
# Arrange
runtime_cfg = RuntimeConfig(camera_calibration_path=str(_airborne_replay_env))
config = Config(
runtime=runtime_cfg,
replay=ReplayConfig(
video_path="/dev/null/fake.mp4",
tlog_path="",
output_path="/tmp/out.jsonl",
pace="asap",
target_fc_dialect="ardupilot_plane",
),
mode="replay",
)
# Act / Assert
with pytest.raises(CompositionError, match="tlog_path is empty"):
build_replay_components(config)
def test_replay_branch_rejects_unknown_pace_after_init(
_airborne_replay_env: Path,
) -> None:
"""ReplayConfig validates pace at construction; the branch's defensive
guard catches an unsanctioned mutation path.
"""
# Arrange — bypass __post_init__ to inject an invalid value, then
# call ``build_replay_components`` to confirm the inner guard fires.
config = _make_replay_config(calib_path=_airborne_replay_env)
object.__setattr__(config.replay, "pace", "telegraph") # type: ignore[misc]
# Act / Assert
with pytest.raises(CompositionError, match="(pace|telegraph|asap)"):
build_replay_components(config)
def test_replay_branch_loads_camera_calibration_from_runtime_path(
_airborne_replay_env: Path,
) -> None:
"""The branch reads the SAME calibration JSON the live binary uses."""
# Arrange
config = _make_replay_config(calib_path=_airborne_replay_env)
# Act — run far enough to populate the bundle without hitting the
# real video / tlog readers. We do that by injecting a stub
# ``replay_input_adapter_factory`` that returns a fake adapter
# whose ``open()`` produces a trivial bundle.
bundle = _make_replay_bundle()
class _StubAdapter:
def __init__(self, **_kwargs: Any) -> None:
pass
def open(self) -> ReplayInputBundle:
return bundle
components, order = build_replay_components(
config,
replay_input_adapter_factory=lambda **_kwargs: _StubAdapter(),
sink_factory=lambda *_args: mock.MagicMock(spec=JsonlReplaySink),
)
# Assert
assert order == REPLAY_COMPONENT_KEYS
assert components["frame_source"] is bundle.frame_source
assert components["fc_adapter"] is bundle.fc_adapter
# ----------------------------------------------------------------------
# Smoke
def test_compose_root_replay_with_no_calib_path_raises(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Arrange — set every env var EXCEPT camera calibration
for name, value in (
("GPS_DENIED_FC_PROFILE", "ardupilot_plane"),
("GPS_DENIED_TIER", "1"),
("DB_URL", "postgresql+psycopg://gps_denied:dev@db:5432/gps_denied"),
("CAMERA_CALIBRATION_PATH", ""),
("LOG_LEVEL", "INFO"),
("LOG_SINK", "console"),
("INFERENCE_BACKEND", "pytorch_fp16"),
("FDR_PATH", "/var/lib/gps-denied/fdr"),
("TILE_CACHE_PATH", "/var/lib/gps-denied/tiles"),
):
monkeypatch.setenv(name, value)
for flag in REPLAY_BUILD_FLAGS:
monkeypatch.setenv(flag, "ON")
config = _make_replay_config() # calib_path=None
# Act / Assert — the env-required check + replay calib check both
# surface as RequiredFieldMissing or CompositionError; either is
# acceptable provided the message names the missing field.
with pytest.raises(
(CompositionError, Exception),
match=r"(camera_calibration_path|CAMERA_CALIBRATION_PATH)",
):
compose_root(config)