mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 20:11:14 +00:00
[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:
@@ -1,8 +1,8 @@
|
|||||||
# Dependencies Table
|
# 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)
|
**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**: 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 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**: 494 (361 product + 133 blackbox-test) — AZ-523 = 3pt, AZ-524 = 2pt, AZ-526 = 2pt, AZ-527 = 2pt, AZ-528 = 3pt
|
**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
|
Dependencies columns list only the tracker-ID portion (descriptive tail
|
||||||
text in each task spec is omitted here for table-readability). The
|
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-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 1–2 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-404 | E2E replay fixture test — Derkachi 1–2 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-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-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-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 |
|
| 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.
|
||||||
@@ -6,13 +6,13 @@ step: 7
|
|||||||
name: Implement
|
name: Implement
|
||||||
status: in_progress
|
status: in_progress
|
||||||
sub_step:
|
sub_step:
|
||||||
phase: 7
|
phase: 1
|
||||||
name: batch-loop
|
name: parse
|
||||||
detail: ""
|
detail: ""
|
||||||
retry_count: 0
|
retry_count: 0
|
||||||
cycle: 1
|
cycle: 1
|
||||||
tracker: jira
|
tracker: jira
|
||||||
last_completed_batch: 60
|
last_completed_batch: 61
|
||||||
last_cumulative_review: batches_58-60
|
last_cumulative_review: batches_58-60
|
||||||
current_batch: 61
|
current_batch: 62
|
||||||
current_batch_tasks: ""
|
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 (
|
from gps_denied_onboard.components.c8_fc_adapter.interface import (
|
||||||
FcAdapter,
|
FcAdapter,
|
||||||
GcsAdapter,
|
GcsAdapter,
|
||||||
|
MavlinkTransport,
|
||||||
)
|
)
|
||||||
from gps_denied_onboard.components.c8_fc_adapter.replay_sink import ReplaySink
|
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",
|
"GcsAdapterConfigError",
|
||||||
"GcsAdapterError",
|
"GcsAdapterError",
|
||||||
"GcsEmitError",
|
"GcsEmitError",
|
||||||
|
"MavlinkTransportConfigError",
|
||||||
|
"MavlinkTransportError",
|
||||||
"SigningHandshakeError",
|
"SigningHandshakeError",
|
||||||
"SigningKeyExpiredError",
|
"SigningKeyExpiredError",
|
||||||
"SourceSetSwitchError",
|
"SourceSetSwitchError",
|
||||||
@@ -96,3 +98,15 @@ class GcsAdapterConfigError(GcsAdapterError):
|
|||||||
Raised at config-load for unknown strategy names and at factory
|
Raised at config-load for unknown strategy names and at factory
|
||||||
build for build-flag-OFF strategies.
|
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
|
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
|
@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.
|
"""Module-level factory entrypoint per project convention.
|
||||||
|
|
||||||
Mirrors the ``create`` factories used by the C2/C3 strategies so
|
Mirrors the ``create`` factories used by the C2/C3 strategies so
|
||||||
the AZ-401 ``compose_replay`` wiring resolves the sink through a
|
the AZ-401 replay-mode branch of ``compose_root`` resolves the
|
||||||
single named-symbol contract instead of poking at the class
|
sink through a single named-symbol contract instead of poking at
|
||||||
constructor directly.
|
the class constructor directly.
|
||||||
"""
|
"""
|
||||||
return JsonlReplaySink(output_path=output_path, fdr_client=fdr_client)
|
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.loader import ENV_KEY_MAP, load_config
|
||||||
from gps_denied_onboard.config.schema import (
|
from gps_denied_onboard.config.schema import (
|
||||||
DEFAULT_FORBIDDEN_RECORD_KINDS,
|
DEFAULT_FORBIDDEN_RECORD_KINDS,
|
||||||
|
KNOWN_FC_DIALECTS,
|
||||||
KNOWN_FC_STRATEGIES,
|
KNOWN_FC_STRATEGIES,
|
||||||
KNOWN_GCS_STRATEGIES,
|
KNOWN_GCS_STRATEGIES,
|
||||||
|
KNOWN_REPLAY_PACES,
|
||||||
Config,
|
Config,
|
||||||
ConfigError,
|
ConfigError,
|
||||||
FcConfig,
|
FcConfig,
|
||||||
@@ -13,6 +15,8 @@ from gps_denied_onboard.config.schema import (
|
|||||||
GcsConfig,
|
GcsConfig,
|
||||||
LogConfig,
|
LogConfig,
|
||||||
RecordKindPolicyConfig,
|
RecordKindPolicyConfig,
|
||||||
|
ReplayAutoSyncConfig,
|
||||||
|
ReplayConfig,
|
||||||
RequiredFieldMissingError,
|
RequiredFieldMissingError,
|
||||||
RuntimeConfig,
|
RuntimeConfig,
|
||||||
TileSnapshotConfig,
|
TileSnapshotConfig,
|
||||||
@@ -22,8 +26,10 @@ from gps_denied_onboard.config.schema import (
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"DEFAULT_FORBIDDEN_RECORD_KINDS",
|
"DEFAULT_FORBIDDEN_RECORD_KINDS",
|
||||||
"ENV_KEY_MAP",
|
"ENV_KEY_MAP",
|
||||||
|
"KNOWN_FC_DIALECTS",
|
||||||
"KNOWN_FC_STRATEGIES",
|
"KNOWN_FC_STRATEGIES",
|
||||||
"KNOWN_GCS_STRATEGIES",
|
"KNOWN_GCS_STRATEGIES",
|
||||||
|
"KNOWN_REPLAY_PACES",
|
||||||
"Config",
|
"Config",
|
||||||
"ConfigError",
|
"ConfigError",
|
||||||
"FcConfig",
|
"FcConfig",
|
||||||
@@ -32,6 +38,8 @@ __all__ = [
|
|||||||
"GcsConfig",
|
"GcsConfig",
|
||||||
"LogConfig",
|
"LogConfig",
|
||||||
"RecordKindPolicyConfig",
|
"RecordKindPolicyConfig",
|
||||||
|
"ReplayAutoSyncConfig",
|
||||||
|
"ReplayConfig",
|
||||||
"RequiredFieldMissingError",
|
"RequiredFieldMissingError",
|
||||||
"RuntimeConfig",
|
"RuntimeConfig",
|
||||||
"TileSnapshotConfig",
|
"TileSnapshotConfig",
|
||||||
|
|||||||
@@ -23,10 +23,13 @@ import yaml
|
|||||||
from gps_denied_onboard.config.schema import (
|
from gps_denied_onboard.config.schema import (
|
||||||
_COMPONENT_REGISTRY,
|
_COMPONENT_REGISTRY,
|
||||||
Config,
|
Config,
|
||||||
|
ConfigError,
|
||||||
FcConfig,
|
FcConfig,
|
||||||
FdrConfig,
|
FdrConfig,
|
||||||
GcsConfig,
|
GcsConfig,
|
||||||
LogConfig,
|
LogConfig,
|
||||||
|
ReplayAutoSyncConfig,
|
||||||
|
ReplayConfig,
|
||||||
RequiredFieldMissingError,
|
RequiredFieldMissingError,
|
||||||
RuntimeConfig,
|
RuntimeConfig,
|
||||||
_replace_block,
|
_replace_block,
|
||||||
@@ -64,6 +67,13 @@ ENV_KEY_MAP: Final[dict[str, tuple[str, str]]] = {
|
|||||||
"GCS_PORT_DEVICE": ("gcs", "port_device"),
|
"GCS_PORT_DEVICE": ("gcs", "port_device"),
|
||||||
"GCS_PORT_BAUD": ("gcs", "port_baud"),
|
"GCS_PORT_BAUD": ("gcs", "port_baud"),
|
||||||
"GCS_SUMMARY_RATE_HZ": ("gcs", "summary_rate_hz"),
|
"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`
|
# 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,
|
"spoof_recovery_source_set": int,
|
||||||
"source_set_switch_timeout_ms": int,
|
"source_set_switch_timeout_ms": int,
|
||||||
"summary_rate_hz": float,
|
"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
|
) 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]]:
|
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]] = {}
|
merged: dict[str, dict[str, Any]] = {}
|
||||||
for path in paths:
|
for path in paths:
|
||||||
data = yaml.safe_load(path.read_text()) or {}
|
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__}"
|
f"YAML at {path} must be a mapping at the top level; got {type(data).__name__}"
|
||||||
)
|
)
|
||||||
for block_name, block_value in data.items():
|
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):
|
if not isinstance(block_value, dict):
|
||||||
continue
|
continue
|
||||||
merged.setdefault(block_name, {}).update(block_value)
|
merged.setdefault(block_name, {}).update(block_value)
|
||||||
@@ -193,6 +295,11 @@ def load_config(
|
|||||||
GcsConfig(),
|
GcsConfig(),
|
||||||
{k: _coerce_value(k, v) for k, v in yaml_overrides.get("gcs", {}).items()},
|
{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()
|
component_blocks = _resolve_component_blocks()
|
||||||
for slug, dataclass_type in _COMPONENT_REGISTRY.items():
|
for slug, dataclass_type in _COMPONENT_REGISTRY.items():
|
||||||
@@ -209,5 +316,7 @@ def load_config(
|
|||||||
fdr=fdr_block,
|
fdr=fdr_block,
|
||||||
fc=fc_block,
|
fc=fc_block,
|
||||||
gcs=gcs_block,
|
gcs=gcs_block,
|
||||||
|
replay=replay_block,
|
||||||
|
mode=mode, # type: ignore[arg-type] # validated by Config.__post_init__
|
||||||
components=component_blocks,
|
components=component_blocks,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,12 +12,14 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from dataclasses import dataclass, field, fields, is_dataclass, replace
|
from dataclasses import dataclass, field, fields, is_dataclass, replace
|
||||||
from typing import Any, Final
|
from typing import Any, Final, Literal
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DEFAULT_FORBIDDEN_RECORD_KINDS",
|
"DEFAULT_FORBIDDEN_RECORD_KINDS",
|
||||||
|
"KNOWN_FC_DIALECTS",
|
||||||
"KNOWN_FC_STRATEGIES",
|
"KNOWN_FC_STRATEGIES",
|
||||||
"KNOWN_GCS_STRATEGIES",
|
"KNOWN_GCS_STRATEGIES",
|
||||||
|
"KNOWN_REPLAY_PACES",
|
||||||
"Config",
|
"Config",
|
||||||
"ConfigError",
|
"ConfigError",
|
||||||
"FcConfig",
|
"FcConfig",
|
||||||
@@ -26,6 +28,8 @@ __all__ = [
|
|||||||
"GcsConfig",
|
"GcsConfig",
|
||||||
"LogConfig",
|
"LogConfig",
|
||||||
"RecordKindPolicyConfig",
|
"RecordKindPolicyConfig",
|
||||||
|
"ReplayAutoSyncConfig",
|
||||||
|
"ReplayConfig",
|
||||||
"RequiredFieldMissingError",
|
"RequiredFieldMissingError",
|
||||||
"RuntimeConfig",
|
"RuntimeConfig",
|
||||||
"TileSnapshotConfig",
|
"TileSnapshotConfig",
|
||||||
@@ -35,6 +39,8 @@ __all__ = [
|
|||||||
|
|
||||||
KNOWN_FC_STRATEGIES: Final[frozenset[str]] = frozenset({"ardupilot_plane", "inav"})
|
KNOWN_FC_STRATEGIES: Final[frozenset[str]] = frozenset({"ardupilot_plane", "inav"})
|
||||||
KNOWN_GCS_STRATEGIES: Final[frozenset[str]] = frozenset({"qgc_mavlink"})
|
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
|
# 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"
|
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
|
# Documented defaults for cross-cutting blocks ONLY. Per-component defaults
|
||||||
# live with their own component epic. The registry below is the single
|
# live with their own component epic. The registry below is the single
|
||||||
# source of truth so two components cannot silently claim the same key.
|
# source of truth so two components cannot silently claim the same key.
|
||||||
@@ -298,6 +396,7 @@ _DEFAULT_BLOCKS: Final[dict[str, type]] = {
|
|||||||
"runtime": RuntimeConfig,
|
"runtime": RuntimeConfig,
|
||||||
"fc": FcConfig,
|
"fc": FcConfig,
|
||||||
"gcs": GcsConfig,
|
"gcs": GcsConfig,
|
||||||
|
"replay": ReplayConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -341,6 +440,14 @@ class Config:
|
|||||||
Components consume only their own slice via ``config.components[slug]``;
|
Components consume only their own slice via ``config.components[slug]``;
|
||||||
the runtime / log / fdr cross-cutting blocks are read directly via
|
the runtime / log / fdr cross-cutting blocks are read directly via
|
||||||
attribute access by the composition root.
|
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)
|
runtime: RuntimeConfig = field(default_factory=RuntimeConfig)
|
||||||
@@ -348,14 +455,23 @@ class Config:
|
|||||||
fdr: FdrConfig = field(default_factory=FdrConfig)
|
fdr: FdrConfig = field(default_factory=FdrConfig)
|
||||||
fc: FcConfig = field(default_factory=FcConfig)
|
fc: FcConfig = field(default_factory=FcConfig)
|
||||||
gcs: GcsConfig = field(default_factory=GcsConfig)
|
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)
|
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
|
@classmethod
|
||||||
def with_blocks(cls, **blocks: Any) -> Config:
|
def with_blocks(cls, **blocks: Any) -> Config:
|
||||||
"""Build a `Config` from a flat name-to-instance map.
|
"""Build a `Config` from a flat name-to-instance map.
|
||||||
|
|
||||||
Cross-cutting names (``log``, ``fdr``, ``runtime``, ``fc``, ``gcs``)
|
Cross-cutting names (``log``, ``fdr``, ``runtime``, ``fc``,
|
||||||
become attributes; every other key is treated as a component slug
|
``gcs``, ``replay``) become attributes; ``mode`` is also a
|
||||||
|
recognised key. Every other key is treated as a component slug
|
||||||
and goes into ``components``.
|
and goes into ``components``.
|
||||||
"""
|
"""
|
||||||
runtime = blocks.pop("runtime", RuntimeConfig())
|
runtime = blocks.pop("runtime", RuntimeConfig())
|
||||||
@@ -363,12 +479,16 @@ class Config:
|
|||||||
fdr = blocks.pop("fdr", FdrConfig())
|
fdr = blocks.pop("fdr", FdrConfig())
|
||||||
fc = blocks.pop("fc", FcConfig())
|
fc = blocks.pop("fc", FcConfig())
|
||||||
gcs = blocks.pop("gcs", GcsConfig())
|
gcs = blocks.pop("gcs", GcsConfig())
|
||||||
|
replay = blocks.pop("replay", ReplayConfig())
|
||||||
|
mode = blocks.pop("mode", "live")
|
||||||
return cls(
|
return cls(
|
||||||
runtime=runtime,
|
runtime=runtime,
|
||||||
log=log,
|
log=log,
|
||||||
fdr=fdr,
|
fdr=fdr,
|
||||||
fc=fc,
|
fc=fc,
|
||||||
gcs=gcs,
|
gcs=gcs,
|
||||||
|
replay=replay,
|
||||||
|
mode=mode,
|
||||||
components=dict(blocks),
|
components=dict(blocks),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,14 @@ the component graph in dependency order.
|
|||||||
|
|
||||||
Per-binary entrypoints:
|
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_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
|
Public surface frozen by
|
||||||
``_docs/02_document/contracts/shared_config/composition_root_protocol.md`` v1.0.0.
|
``_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 typing import TYPE_CHECKING, Any, Final, Literal, get_args
|
||||||
|
|
||||||
from gps_denied_onboard.config import Config, load_config
|
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 (
|
from gps_denied_onboard.runtime_root.c12_factory import (
|
||||||
build_flights_api_client,
|
build_flights_api_client,
|
||||||
)
|
)
|
||||||
@@ -67,6 +76,7 @@ __all__ = [
|
|||||||
"EXIT_FDR_OPEN_FAILURE",
|
"EXIT_FDR_OPEN_FAILURE",
|
||||||
"EXIT_GENERIC_FAILURE",
|
"EXIT_GENERIC_FAILURE",
|
||||||
"REQUIRED_ENV_VARS",
|
"REQUIRED_ENV_VARS",
|
||||||
|
"CompositionError",
|
||||||
"ConfigurationError",
|
"ConfigurationError",
|
||||||
"OperatorRoot",
|
"OperatorRoot",
|
||||||
"OutboundThreadAlreadyBoundError",
|
"OutboundThreadAlreadyBoundError",
|
||||||
@@ -91,7 +101,6 @@ __all__ = [
|
|||||||
"clear_strategy_registries",
|
"clear_strategy_registries",
|
||||||
"clear_strategy_registry",
|
"clear_strategy_registry",
|
||||||
"compose_operator",
|
"compose_operator",
|
||||||
"compose_replay",
|
|
||||||
"compose_root",
|
"compose_root",
|
||||||
"list_registered_fc_strategies",
|
"list_registered_fc_strategies",
|
||||||
"list_registered_gcs_strategies",
|
"list_registered_gcs_strategies",
|
||||||
@@ -317,8 +326,17 @@ def _compose(
|
|||||||
binary: str,
|
binary: str,
|
||||||
allowed_tiers: frozenset[StrategyTier],
|
allowed_tiers: frozenset[StrategyTier],
|
||||||
extra_required_env: Iterable[str],
|
extra_required_env: Iterable[str],
|
||||||
|
pre_constructed: Mapping[str, Any] | None = None,
|
||||||
) -> tuple[dict[str, Any], tuple[str, ...]]:
|
) -> 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)
|
_check_required_env(extra_required=extra_required_env)
|
||||||
selections = _resolve_component_strategies(config, allowed_tiers)
|
selections = _resolve_component_strategies(config, allowed_tiers)
|
||||||
resolved: dict[str, _Registration] = {
|
resolved: dict[str, _Registration] = {
|
||||||
@@ -326,7 +344,9 @@ def _compose(
|
|||||||
for slug, strategy in selections.items()
|
for slug, strategy in selections.items()
|
||||||
}
|
}
|
||||||
order = _topo_order(resolved.keys(), resolved)
|
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:
|
for slug in order:
|
||||||
registration = resolved[slug]
|
registration = resolved[slug]
|
||||||
try:
|
try:
|
||||||
@@ -336,7 +356,11 @@ def _compose(
|
|||||||
_close_partial_instances(constructed)
|
_close_partial_instances(constructed)
|
||||||
raise
|
raise
|
||||||
_ = binary # documented but unused beyond labelling the returned root
|
_ = 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:
|
def _close_partial_instances(instances: Mapping[str, Any]) -> None:
|
||||||
@@ -392,19 +416,61 @@ def _read_strategy_attr(block: Any) -> Any:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def compose_root(config: Config) -> RuntimeRoot:
|
def compose_root(
|
||||||
"""Compose the airborne runtime graph (per contract v1.0.0)."""
|
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(
|
components, order = _compose(
|
||||||
config,
|
config,
|
||||||
binary="airborne",
|
binary="airborne",
|
||||||
allowed_tiers=frozenset({"airborne", "shared"}),
|
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(
|
return RuntimeRoot(
|
||||||
binary="airborne",
|
binary="airborne",
|
||||||
profile=os.environ["GPS_DENIED_FC_PROFILE"],
|
profile=os.environ["GPS_DENIED_FC_PROFILE"],
|
||||||
components=components,
|
components=merged,
|
||||||
construction_order=order,
|
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)
|
@dataclass(frozen=True)
|
||||||
class TakeoffResult:
|
class TakeoffResult:
|
||||||
"""Successful takeoff: writer is open, FC adapter is wired, components started.
|
"""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
|
||||||
@@ -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:
|
def test_interface_importable() -> None:
|
||||||
@@ -7,10 +12,17 @@ def test_interface_importable() -> None:
|
|||||||
EmittedExternalPosition,
|
EmittedExternalPosition,
|
||||||
FcAdapter,
|
FcAdapter,
|
||||||
GcsAdapter,
|
GcsAdapter,
|
||||||
|
MavlinkTransport,
|
||||||
ReplaySink,
|
ReplaySink,
|
||||||
)
|
)
|
||||||
|
|
||||||
for sym in (FcAdapter, GcsAdapter, ReplaySink, EmittedExternalPosition):
|
for sym in (
|
||||||
|
FcAdapter,
|
||||||
|
GcsAdapter,
|
||||||
|
ReplaySink,
|
||||||
|
EmittedExternalPosition,
|
||||||
|
MavlinkTransport,
|
||||||
|
):
|
||||||
assert sym is not None
|
assert sym is not None
|
||||||
|
|
||||||
|
|
||||||
@@ -24,5 +36,6 @@ def test_internal_modules_not_in_public_all() -> None:
|
|||||||
"EmittedExternalPosition",
|
"EmittedExternalPosition",
|
||||||
"FcAdapter",
|
"FcAdapter",
|
||||||
"GcsAdapter",
|
"GcsAdapter",
|
||||||
|
"MavlinkTransport",
|
||||||
"ReplaySink",
|
"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)
|
||||||
Reference in New Issue
Block a user