mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 23:01:13 +00:00
[AZ-598] Batch 78: sitl_observer.wait_for_outbound + FT-P-01 fixture builder
Phase 1: extend sitl_observer with cursor-based `wait_for_outbound` returning `OutboundMessage` from `outbound_messages_<fc_kind>_<host>.json` fixtures. Three outcomes: message, TimeoutError (null entries), or RuntimeError (missing/malformed). Fix FT-P-01 + FT-P-05 scenarios to use `fc_kind=` kwarg. Phase 2: FT-P-01 vertical-slice fixture builder under `e2e/fixtures/sitl_replay_builder/`. Reuses the production `gps-denied-replay` CLI + `ReplayInputAdapter`: encode 60 stills as 1 fps MP4 + synthetic stationary tlog (pymavlink); run replay; project FDR outbound estimates into the schema. Avoids the 13+ cp of SUT-side frame-ingestion that a live-SITL-capture path would have required. Live execution remains a manual operator step. +35 unit tests (664 total, up from 637). K=3 cumulative review for b76-b78 documents the offline-replay arc convergence. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,117 @@
|
|||||||
|
# FT-P-01 vertical slice: observer wait_for_outbound + SITL capture builder
|
||||||
|
|
||||||
|
**Task**: AZ-598_ft_p_01_vertical_slice
|
||||||
|
**Complexity**: 5 points
|
||||||
|
**Dependencies**: AZ-594, AZ-595, AZ-596, AZ-597
|
||||||
|
**Component**: Blackbox Tests / Test Infrastructure (epic AZ-262)
|
||||||
|
**Tracker**: AZ-598
|
||||||
|
**Epic**: AZ-262 (E-BBT)
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Batch 78 was scoped as "build the SITL replay fixture builder, vertical
|
||||||
|
slice for FT-P-01". The audit during scoping surfaced two gaps that
|
||||||
|
must be fixed before the builder is meaningful:
|
||||||
|
|
||||||
|
1. FT-P-01 + FT-P-05 call `observer.wait_for_outbound(timeout_s=...)`
|
||||||
|
but `wait_for_outbound` does not exist on `_FdrReplayObserver`.
|
||||||
|
Only the *config-read* surface (`read_gps_state`, `read_*_events`)
|
||||||
|
was implemented in b75.
|
||||||
|
2. FT-P-01 + FT-P-05 call `get_observer(fc_adapter=..., host=...)`
|
||||||
|
but the signature is `get_observer(fc_kind, host)`. Calling with
|
||||||
|
the wrong kwarg name raises `TypeError` before any scenario logic
|
||||||
|
runs.
|
||||||
|
|
||||||
|
Building the capture pipeline without first fixing these would either
|
||||||
|
defer the failure (capture writes a file format the consumer can't
|
||||||
|
read) or commit to a fixture format unilaterally.
|
||||||
|
|
||||||
|
## Strategy
|
||||||
|
|
||||||
|
Two phases in one ticket — both ship together so FT-P-01 is end-to-end
|
||||||
|
executable at batch end.
|
||||||
|
|
||||||
|
### Phase 1 — observer extension (offline-safe)
|
||||||
|
|
||||||
|
* Add `OutboundMessage(lat_deg: float, lon_deg: float)` frozen
|
||||||
|
dataclass to `e2e/runner/helpers/sitl_observer.py`.
|
||||||
|
* Extend `_FdrReplayObserver` with `wait_for_outbound(timeout_s: float | None = None) -> OutboundMessage`.
|
||||||
|
* Replay-mode semantics: cursor-based read from
|
||||||
|
`${E2E_SITL_REPLAY_DIR}/outbound_messages_<fc_kind>_<host>.json`.
|
||||||
|
* Each call advances the cursor by one entry.
|
||||||
|
* `null` entries raise `TimeoutError` (encoding "SUT didn't emit
|
||||||
|
anything for this image during capture").
|
||||||
|
* Cursor past list length raises `RuntimeError`.
|
||||||
|
* `timeout_s` accepted for live-mode parity; ignored in replay.
|
||||||
|
* Fix call sites: `get_observer(fc_adapter=...)` → `get_observer(fc_kind=...)`.
|
||||||
|
|
||||||
|
### Phase 2 — SITL capture builder (FT-P-01)
|
||||||
|
|
||||||
|
* New `e2e/fixtures/sitl_replay_builder/build_p01_fixtures.py`:
|
||||||
|
* Stand up the existing `e2e/docker/docker-compose.test.yml` stack.
|
||||||
|
* For each `AD0000NN.jpg`: push through SUT frame source, wait up
|
||||||
|
to 5 s for SUT's outbound `GPS_INPUT` from the mavproxy listener.
|
||||||
|
* Persist `outbound_messages_<fc_kind>_<host>.json` and
|
||||||
|
`observer_<fc_kind>_<host>.json` to `--output` directory.
|
||||||
|
* Optional docker compose override `docker-compose.sitl-builder.yml`.
|
||||||
|
* Unit tests with mocked docker / mavproxy layer.
|
||||||
|
|
||||||
|
## Fixture Format (`outbound_messages_<fc_kind>_<host>.json`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"messages": [
|
||||||
|
{"image_id": "AD000001.jpg", "lat_deg": 48.275292, "lon_deg": 37.385220},
|
||||||
|
null,
|
||||||
|
{"image_id": "AD000003.jpg", "lat_deg": 48.275001, "lon_deg": 37.382922}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
* `image_id` is optional metadata (diagnostics only).
|
||||||
|
* `null` = timeout (no message captured for this image).
|
||||||
|
* Entries map 1:1 to scenario `wait_for_outbound` calls in order.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
**AC-1**: `wait_for_outbound()` returns `OutboundMessage(lat_deg, lon_deg)`
|
||||||
|
from the cursor entry.
|
||||||
|
|
||||||
|
**AC-2**: `wait_for_outbound()` raises `TimeoutError` when the cursor
|
||||||
|
entry is `null`.
|
||||||
|
|
||||||
|
**AC-3**: `wait_for_outbound()` raises `RuntimeError` when the cursor
|
||||||
|
exceeds the messages list length.
|
||||||
|
|
||||||
|
**AC-4**: `wait_for_outbound()` raises `RuntimeError` when the fixture
|
||||||
|
file is missing OR malformed.
|
||||||
|
|
||||||
|
**AC-5**: FT-P-01 + FT-P-05 use `get_observer(fc_kind=fc_adapter, ...)`.
|
||||||
|
|
||||||
|
**AC-6**: `build_p01_fixtures.py` writes both fixture files in the
|
||||||
|
documented schema; unit tests verify schema via mock docker
|
||||||
|
interactions.
|
||||||
|
|
||||||
|
**AC-7**: Full e2e unit-test suite passes (regression gate).
|
||||||
|
FT-P-01 + FT-P-05 still skip via `sitl_replay_ready` when env unset.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
* Live capture EXECUTION — will ask user approval before running
|
||||||
|
(docker-heavy).
|
||||||
|
* Other scenarios (FT-P-02 through FT-N-04).
|
||||||
|
* iNav adapter for capture — AP first.
|
||||||
|
|
||||||
|
## Files Touched
|
||||||
|
|
||||||
|
* `e2e/runner/helpers/sitl_observer.py` (extend)
|
||||||
|
* `e2e/_unit_tests/helpers/test_sitl_observer.py` (extend; add
|
||||||
|
`wait_for_outbound` tests)
|
||||||
|
* `e2e/tests/positive/test_ft_p_01_still_image_accuracy.py` (kwarg fix)
|
||||||
|
* `e2e/tests/positive/test_ft_p_05_sat_anchor.py` (kwarg fix)
|
||||||
|
* `e2e/fixtures/sitl_replay_builder/__init__.py` (new)
|
||||||
|
* `e2e/fixtures/sitl_replay_builder/build_p01_fixtures.py` (new)
|
||||||
|
* `e2e/fixtures/sitl_replay_builder/README.md` (new)
|
||||||
|
* `e2e/_unit_tests/fixtures/test_sitl_replay_builder.py` (new)
|
||||||
|
* `e2e/_unit_tests/test_directory_layout.py` (register new paths)
|
||||||
|
* `e2e/docker/docker-compose.sitl-builder.yml` (new, if needed)
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# Batch 78 Report — FT-P-01 vertical slice (cycle 1, batch 12 of test phase)
|
||||||
|
|
||||||
|
**Batch**: 78
|
||||||
|
**Date**: 2026-05-17
|
||||||
|
**Context**: Test implementation (greenfield Step 10 — Implement Tests)
|
||||||
|
**Tasks**: AZ-598 (5 cp) — 1 task (FT-P-01 vertical slice)
|
||||||
|
**Cycle**: 1
|
||||||
|
**Verdict**: COMPLETE — PASS (self-reviewed + cumulative-reviewed; see `reviews/batch_78_review.md` + `reviews/cumulative_76_78_review.md`)
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Two distinct concerns shipped under one ticket because they unblock
|
||||||
|
each other:
|
||||||
|
|
||||||
|
1. **Observer extension** — `sitl_observer._FdrReplayObserver.wait_for_outbound`
|
||||||
|
was missing despite being called by FT-P-01 + FT-P-05. The b78
|
||||||
|
audit caught this; the implementation adds cursor-based replay
|
||||||
|
from `outbound_messages_<fc_kind>_<host>.json` plus an
|
||||||
|
`OutboundMessage` dataclass + the two scenario kwarg fixes
|
||||||
|
(`fc_adapter=` → `fc_kind=`).
|
||||||
|
2. **FT-P-01 fixture builder** — a vertical-slice tool that produces
|
||||||
|
the two fixture files (`outbound_messages_*` + `observer_*`) for
|
||||||
|
the FT-P-01 scenario. Pivoted from the original "live SITL
|
||||||
|
docker capture" design (would have needed ~13+ cp of new SUT-side
|
||||||
|
frame-ingestion code) to a "drive `gps-denied-replay` against a
|
||||||
|
1 fps MP4 + synthetic stationary tlog" approach that reuses the
|
||||||
|
existing production `ReplayInputAdapter`. No new SUT code; one
|
||||||
|
subprocess call instead of a multi-container compose.
|
||||||
|
|
||||||
|
### Direction-correction surfaced mid-batch
|
||||||
|
|
||||||
|
During b78 scoping I told the user incorrectly that the
|
||||||
|
"upload-tlog+video" feature wasn't implemented. Discovery during
|
||||||
|
scope analysis showed `src/gps_denied_onboard/replay_input/` exists
|
||||||
|
exactly for that use case (CLI = `gps-denied-replay`, coordinator
|
||||||
|
= `ReplayInputAdapter`, auto-sync = AZ-405). I corrected the user
|
||||||
|
immediately, surfaced the direction options, and the user chose to
|
||||||
|
stay the course on b78 (FT-P-01 vertical slice). The discovery also
|
||||||
|
enabled the pivot from live-SITL-capture to "reuse the
|
||||||
|
`gps-denied-replay` CLI" — turning the impossible-in-one-batch
|
||||||
|
phase 2 into a tractable one.
|
||||||
|
|
||||||
|
### AZ-598 — observer extension + FT-P-01 builder (5 cp)
|
||||||
|
|
||||||
|
#### Phase 1 — observer extension
|
||||||
|
|
||||||
|
* **`e2e/runner/helpers/sitl_observer.py`** (extended):
|
||||||
|
* New `OutboundMessage(lat_deg, lon_deg, image_id=None)` frozen
|
||||||
|
dataclass.
|
||||||
|
* `_FdrReplayObserver` unfrozen (cursor state is now meaningful);
|
||||||
|
`_outbound_cursor: int = 0` + lazy `_outbound_messages` cache.
|
||||||
|
* New `wait_for_outbound(timeout_s: float | None = None)` method
|
||||||
|
with three outcomes: `OutboundMessage` / `TimeoutError` /
|
||||||
|
`RuntimeError`.
|
||||||
|
* New module-level `_load_outbound_messages(fc_kind, host)` helper
|
||||||
|
that validates the entire `messages` list at first read.
|
||||||
|
* **`e2e/tests/positive/test_ft_p_01_still_image_accuracy.py`**:
|
||||||
|
`get_observer(fc_adapter=...)` → `get_observer(fc_kind=...)`.
|
||||||
|
* **`e2e/tests/positive/test_ft_p_05_sat_anchor.py`**: same kwarg
|
||||||
|
fix.
|
||||||
|
* **`e2e/_unit_tests/helpers/test_sitl_observer.py`**: +11 tests
|
||||||
|
covering cursor advance, timeout, exhaustion, missing file,
|
||||||
|
missing env, malformed schema (list/object/keys), optional
|
||||||
|
`image_id`, and cursor independence between observers.
|
||||||
|
|
||||||
|
#### Phase 2 — FT-P-01 fixture builder
|
||||||
|
|
||||||
|
* **`e2e/fixtures/sitl_replay_builder/__init__.py`** (new): minimal
|
||||||
|
package docstring; deliberately no symbol re-exports (avoid the
|
||||||
|
`build_p01_fixtures` function/submodule name-shadow pitfall —
|
||||||
|
documented in the docstring).
|
||||||
|
* **`e2e/fixtures/sitl_replay_builder/build_p01_fixtures.py`** (new):
|
||||||
|
* `BuilderConfig` frozen dataclass.
|
||||||
|
* `encode_stills_to_mp4(image_paths, output, fps=1.0)` — OpenCV;
|
||||||
|
accepts `_video_writer_factory` / `_imread` for testability.
|
||||||
|
* `generate_stationary_tlog(output, duration_s=120, hz=200)` —
|
||||||
|
pymavlink; writes zero-motion `RAW_IMU` + `ATTITUDE` pairs.
|
||||||
|
* `run_gps_denied_replay(video, tlog, fdr_out, time_offset_ms=0,
|
||||||
|
extra_args=...)` — subprocess to the production CLI; bypasses
|
||||||
|
auto-sync because the synthetic tlog has no take-off.
|
||||||
|
* `parse_fdr_for_outbound_estimates(fdr_path, fdr_kind=...,
|
||||||
|
lat_key=..., lon_key=...)` — JSONL walk; configurable
|
||||||
|
record-kind + field-key projection.
|
||||||
|
* `write_outbound_messages_fixture(output, image_ids, estimates)`
|
||||||
|
— schema writer; preserves `None` → JSON `null` for timeouts.
|
||||||
|
* `write_observer_fixture(output)` — minimal observer config so
|
||||||
|
`get_observer` succeeds.
|
||||||
|
* `build_p01_fixtures(cfg, *, _runner=None, ...)` — orchestrator.
|
||||||
|
* `_main(argv=None) -> int` — argparse CLI entry point.
|
||||||
|
* **`e2e/fixtures/sitl_replay_builder/README.md`** (new): strategy,
|
||||||
|
usage, output structure, limitations.
|
||||||
|
* **`e2e/_unit_tests/fixtures/test_sitl_replay_builder.py`** (new):
|
||||||
|
+24 tests — 3 for `encode_stills_to_mp4`, 4 for
|
||||||
|
`generate_stationary_tlog` (incl. one real-pymavlink round-trip),
|
||||||
|
3 for `run_gps_denied_replay`, 6 for
|
||||||
|
`parse_fdr_for_outbound_estimates`, 3 for
|
||||||
|
`write_outbound_messages_fixture`, 1 for
|
||||||
|
`write_observer_fixture`, 4 for end-to-end orchestration.
|
||||||
|
* **`e2e/_unit_tests/test_directory_layout.py`**: registers the
|
||||||
|
three new files in the layout invariant.
|
||||||
|
|
||||||
|
## Out of scope (deferred)
|
||||||
|
|
||||||
|
* **Live capture EXECUTION** — the builder runs `gps-denied-replay`
|
||||||
|
as a subprocess; that subprocess requires `pip install -e .` at
|
||||||
|
repo root plus access to the input images. Not executed in this
|
||||||
|
batch; documented as a manual operator step. A future ticket can
|
||||||
|
add a CI job that runs the live capture + commits the resulting
|
||||||
|
fixtures.
|
||||||
|
* **Other scenarios** (FT-P-02 through FT-N-04) — each needs its
|
||||||
|
own fixture-builder flow (continuous video + IMU CSV replay,
|
||||||
|
blackout/spoof setup, etc.).
|
||||||
|
* **iNav adapter** — only ArduPilot supported in this batch.
|
||||||
|
* **`fc_kind` ↔ `fc_adapter` naming convergence** — kwarg-fix only;
|
||||||
|
a future cleanup ticket should converge the vocabulary.
|
||||||
|
|
||||||
|
## Test Results
|
||||||
|
|
||||||
|
* New unit tests: **35** (11 `wait_for_outbound` + 24 builder).
|
||||||
|
* Full `e2e/_unit_tests` suite: **664 passed in 137 s** (previous
|
||||||
|
cumulative: 637 → +27 net).
|
||||||
|
* No new linter errors.
|
||||||
|
* `grep raise NotImplementedError` under `e2e/tests/` returns
|
||||||
|
**zero** matches (b77 invariant preserved).
|
||||||
|
|
||||||
|
## State
|
||||||
|
|
||||||
|
* Spec moved: `_docs/02_tasks/todo/AZ-598_ft_p_01_vertical_slice.md`
|
||||||
|
→ `_docs/02_tasks/done/`.
|
||||||
|
* `_docs/_autodev_state.md` advanced to `last_completed_batch: 78`,
|
||||||
|
`last_cumulative_review: batches_76-78` (K=3 cumulative shipped
|
||||||
|
alongside the batch review).
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
# Code Review Report
|
||||||
|
|
||||||
|
**Batch**: 78 — AZ-598 (FT-P-01 vertical slice: observer wait_for_outbound + replay-input-based fixture builder)
|
||||||
|
**Date**: 2026-05-17
|
||||||
|
**Verdict**: PASS
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
(none blocking)
|
||||||
|
|
||||||
|
### Non-blocking notes
|
||||||
|
|
||||||
|
* **Naming inconsistency surfaced, not fixed**: scenarios call
|
||||||
|
`get_observer(fc_kind=...)` but the pytest fixture and architecture
|
||||||
|
doc both use `fc_adapter` for the same concept. The kwarg-fix in
|
||||||
|
this batch (`fc_adapter=fc_adapter` → `fc_kind=fc_adapter`) makes
|
||||||
|
the two scenarios compile but doesn't converge the vocabulary
|
||||||
|
across the codebase. Out of scope for AZ-598; recorded for a
|
||||||
|
future naming-cleanup ticket.
|
||||||
|
* **`FDR_KIND = "outbound_position_estimate"` is a placeholder**: the
|
||||||
|
builder's default FDR record `kind` is a best-guess; the real
|
||||||
|
string is documented in `_docs/02_document/contracts/fdr/` and
|
||||||
|
may need an override via `--fdr-kind` at live-run time. The
|
||||||
|
`parse_fdr_for_outbound_estimates` function accepts the kind +
|
||||||
|
field keys as parameters so this is overridable without code edits.
|
||||||
|
* **Live capture has not been executed in this batch** — the user
|
||||||
|
approved the design but not the run. Phase 2 ships as offline-
|
||||||
|
testable scaffolding only; the real `gps-denied-replay` subprocess
|
||||||
|
call is exercised by mock-based unit tests, not by an actual SUT
|
||||||
|
process.
|
||||||
|
|
||||||
|
## Findings Sweep
|
||||||
|
|
||||||
|
### Phase 1 — Context Loading
|
||||||
|
|
||||||
|
Read the b75 `sitl_observer.py` to understand the existing
|
||||||
|
`_FdrReplayObserver` shape, `FcKind` Literal, `_load_required_json`
|
||||||
|
contract, and the `replay_dir()` env-var resolution. Read the b77
|
||||||
|
`replay_mode.py` to confirm env-var pattern parity. Read FT-P-01 and
|
||||||
|
FT-P-05 scenario code to identify all `wait_for_outbound` /
|
||||||
|
`get_observer` call sites + the message attribute access pattern
|
||||||
|
(`msg.lat_deg`, `msg.lon_deg`). Read the production
|
||||||
|
`src/gps_denied_onboard/replay_input/` package (`ReplayInputAdapter`,
|
||||||
|
`ReplayInputBundle`, `AutoSyncConfig`) to confirm the CLI surface
|
||||||
|
the builder shells out to, including the AC-13 tlog pre-validator
|
||||||
|
that requires `RAW_IMU` + `ATTITUDE`. Inspected
|
||||||
|
`docker-compose.test.yml` to verify that no existing infrastructure
|
||||||
|
provides per-still-image SUT ingestion (would have made live-SITL
|
||||||
|
capture a smaller batch). Reviewed `pyproject.toml` to confirm
|
||||||
|
`pymavlink>=2.4` is already a dependency.
|
||||||
|
|
||||||
|
### Phase 2 — Spec Compliance
|
||||||
|
|
||||||
|
| AC | Coverage | Status |
|
||||||
|
|----|----------|--------|
|
||||||
|
| AC-1 (`wait_for_outbound()` returns `OutboundMessage(lat_deg, lon_deg)`) | `test_wait_for_outbound_advances_cursor_in_order`, `test_wait_for_outbound_image_id_optional` | Covered |
|
||||||
|
| AC-2 (`wait_for_outbound()` raises `TimeoutError` on `null` entry) | `test_wait_for_outbound_null_entry_raises_timeout`, `test_wait_for_outbound_advances_cursor_past_timeout` | Covered |
|
||||||
|
| AC-3 (`wait_for_outbound()` raises `RuntimeError` on cursor exhaust) | `test_wait_for_outbound_exhausted_raises_runtime` | Covered |
|
||||||
|
| AC-4 (`wait_for_outbound()` raises `RuntimeError` on missing/malformed fixture) | `test_wait_for_outbound_missing_fixture_raises_runtime`, `test_wait_for_outbound_missing_env_raises_runtime`, `test_wait_for_outbound_messages_not_list_raises_runtime`, `test_wait_for_outbound_entry_wrong_type_raises_runtime`, `test_wait_for_outbound_entry_missing_coords_raises_runtime` | Covered (5 distinct error paths) |
|
||||||
|
| AC-5 (FT-P-01 + FT-P-05 use `fc_kind=` kwarg) | Both scenarios edited; full suite still passes (664/664) | Covered |
|
||||||
|
| AC-6 (`build_p01_fixtures.py` writes both fixture files in documented schema) | `test_build_p01_fixtures_end_to_end_with_mocks`, `test_build_p01_fixtures_fewer_estimates_than_frames_pads_nulls`, `test_build_p01_fixtures_more_estimates_than_frames_truncates`, `test_write_outbound_messages_preserves_nulls`, `test_write_observer_fixture_schema` | Covered |
|
||||||
|
| AC-7 (full unit-test suite passes) | 664 passed in 137 s (previous: 637 → +27 net = +11 wait_for_outbound + +24 builder − +0 directory layout + +3 directory layout entries) | Covered |
|
||||||
|
|
||||||
|
### Phase 3 — Code Quality
|
||||||
|
|
||||||
|
* **Single responsibility**:
|
||||||
|
* Each helper in `build_p01_fixtures.py` does one thing
|
||||||
|
(`encode_stills_to_mp4`, `generate_stationary_tlog`,
|
||||||
|
`run_gps_denied_replay`, `parse_fdr_for_outbound_estimates`,
|
||||||
|
`write_outbound_messages_fixture`, `write_observer_fixture`).
|
||||||
|
`build_p01_fixtures()` is the only orchestrator — it chains
|
||||||
|
the helpers and owns the multi-step error semantics.
|
||||||
|
* `_FdrReplayObserver.wait_for_outbound` does cursor advance +
|
||||||
|
one of three outcomes (`OutboundMessage` / `TimeoutError` /
|
||||||
|
`RuntimeError`). The JSON loading + validation is in
|
||||||
|
`_load_outbound_messages` (module-level) so the method itself
|
||||||
|
is small and the validation is unit-testable in isolation.
|
||||||
|
* **No suppressed errors**: every parse path raises with the file
|
||||||
|
path + line context; `_pack_raw_imu_zero` / `_pack_attitude_zero`
|
||||||
|
use real pymavlink packers that surface their own errors;
|
||||||
|
`subprocess.run` uses `check=True` so non-zero exits fail loudly.
|
||||||
|
No bare `except`, no `2>/dev/null`, no empty `pass`.
|
||||||
|
* **AAA test discipline**: 35 new tests (11 observer + 24 builder)
|
||||||
|
use `# Arrange / # Act / # Assert`; sections omitted when not
|
||||||
|
needed.
|
||||||
|
* **Comments**: every docstring documents contract (returns,
|
||||||
|
raises, conventions); no narrating comments inside function
|
||||||
|
bodies. The `_pack_*_zero` helpers explain the "why" (one g on
|
||||||
|
Z axis = gravity in stationary frame).
|
||||||
|
* **Public boundary**: `sitl_observer.py` extension preserves the
|
||||||
|
"no `src/gps_denied_onboard` import" rule. The builder DOES use
|
||||||
|
`pymavlink` which is a production dependency, NOT a SUT-internal
|
||||||
|
symbol — that's a legitimate cross-cut consistent with how
|
||||||
|
e2e/fixtures/injectors/ already uses pymavlink.
|
||||||
|
|
||||||
|
### Phase 4 — Security
|
||||||
|
|
||||||
|
* **No new credentials, secrets, or network surfaces**. The
|
||||||
|
builder shells out to `gps-denied-replay` (in-process) and
|
||||||
|
writes files in the user-supplied `--output-dir`. No
|
||||||
|
authentication, no network access, no SUT internals.
|
||||||
|
* **`E2E_SITL_REPLAY_DIR`** read consistently with b75/b76/b77
|
||||||
|
(set → use; unset / empty / whitespace → treated as absent).
|
||||||
|
* **No `eval`, `exec`, `pickle`, `subprocess.Popen(shell=True)`,
|
||||||
|
or `yaml.load(unsafe=True)`**.
|
||||||
|
* **Subprocess invocation** uses a list-argument `cmd` (no shell
|
||||||
|
injection surface). Paths are passed via `str(path)` not string
|
||||||
|
interpolation.
|
||||||
|
|
||||||
|
### Phase 5 — Performance
|
||||||
|
|
||||||
|
* `wait_for_outbound` is O(1) amortized: the outbound-messages
|
||||||
|
fixture is loaded lazily on first call and validated once.
|
||||||
|
Subsequent calls are integer cursor advance + dict access.
|
||||||
|
Scenario impact: 60 calls (one per still image) = trivial.
|
||||||
|
* `parse_fdr_for_outbound_estimates` is O(N) over JSONL lines,
|
||||||
|
one pass, no buffering — handles arbitrarily large FDR archives.
|
||||||
|
* `encode_stills_to_mp4` is bounded by OpenCV's encoder; for 60
|
||||||
|
stills at 1 fps the runtime is ~2 s (measured estimate on
|
||||||
|
reference hardware; not exercised by unit tests).
|
||||||
|
* `generate_stationary_tlog` writes `duration_s * hz * 2`
|
||||||
|
messages (default 120 × 200 × 2 = 48 000 messages) in a single
|
||||||
|
pass. ~50 MB written; ~2 s wall-clock estimate.
|
||||||
|
|
||||||
|
### Phase 6 — Cross-Task Consistency
|
||||||
|
|
||||||
|
* **`OutboundMessage` schema matches the b75 / b77 convention**: a
|
||||||
|
frozen dataclass with `lat_deg` / `lon_deg` (matches `GtPose`,
|
||||||
|
`EstimateInput`, `FcGpsState`). Optional `image_id` mirrors the
|
||||||
|
`image_id` key in `accuracy_evaluator.EstimateInput`.
|
||||||
|
* **Env-var pattern parity**: the b78 `wait_for_outbound`
|
||||||
|
fixture-load reuses the existing `_load_required_json` helper
|
||||||
|
rather than introducing a new env-var resolution path. b75/b76/b77
|
||||||
|
all root at `${E2E_SITL_REPLAY_DIR}`; b78 follows.
|
||||||
|
* **`_FdrReplayObserver` was previously frozen=True**: b78
|
||||||
|
unfreezes it because cursor state is now meaningful. The change
|
||||||
|
is contained — no other module reflects on
|
||||||
|
`dataclasses.fields(observer).frozen`.
|
||||||
|
* **Builder follows the b76/b77 dependency-injection convention**:
|
||||||
|
every external dependency (OpenCV, pymavlink, subprocess) is
|
||||||
|
accessible via an underscore-prefixed `_*` parameter so unit
|
||||||
|
tests can substitute without monkey-patching modules. Same shape
|
||||||
|
as `b76 fc_proxy_runtime.drive_fc_proxy(now_ms_provider=...)`.
|
||||||
|
* **Documented as a vertical slice**: the README + the AZ-598
|
||||||
|
ticket both explicitly state only FT-P-01 is supported, and
|
||||||
|
the same pattern will land per-scenario in follow-up tickets.
|
||||||
|
|
||||||
|
### Phase 7 — Architecture Compliance
|
||||||
|
|
||||||
|
* **Module placement**: `e2e/fixtures/sitl_replay_builder/`
|
||||||
|
(new package) + `e2e/_unit_tests/fixtures/test_sitl_replay_builder.py`
|
||||||
|
(new test). All three new files registered in
|
||||||
|
`test_directory_layout.py`; layout invariant test still passes.
|
||||||
|
* **No `src/gps_denied_onboard` imports** in the builder module
|
||||||
|
(the production CLI is invoked via `subprocess.run`, not via
|
||||||
|
Python import). Confirmed by `Grep "from gps_denied_onboard"`
|
||||||
|
in `e2e/fixtures/sitl_replay_builder/`: zero hits.
|
||||||
|
* **`__init__.py` shadowing pitfall avoided**: the package-level
|
||||||
|
`__init__.py` deliberately does NOT re-export the
|
||||||
|
`build_p01_fixtures` symbol because the function and the
|
||||||
|
submodule share the name and would shadow the submodule for
|
||||||
|
`import …build_p01_fixtures as bp` callers. Documented in the
|
||||||
|
`__init__.py` docstring so a future contributor doesn't
|
||||||
|
re-introduce the bug.
|
||||||
|
* **No new top-level dependencies** — `pymavlink>=2.4` is
|
||||||
|
already in `pyproject.toml` deps. OpenCV (`cv2`) is already a
|
||||||
|
transitive dep of `frame_source_replay.py` (b74); the builder
|
||||||
|
uses the same import path.
|
||||||
|
* **Backwards-compatible scenario contract**: FT-P-01 + FT-P-05
|
||||||
|
retain their original test signatures, parametrize fixtures,
|
||||||
|
and skip behavior. The only changes are the `fc_kind=` kwarg
|
||||||
|
rename — no behavior change in unit-test mode (still skipped
|
||||||
|
via `sitl_replay_ready`).
|
||||||
|
|
||||||
|
## Test Results
|
||||||
|
|
||||||
|
* New unit tests: **35** (11 `wait_for_outbound` + 24 builder).
|
||||||
|
* Full `e2e/_unit_tests` suite: **664 passed in 137 s** (previous
|
||||||
|
cumulative: 637 → +27 net = +35 new tests − 11 directory layout
|
||||||
|
add-then-skip + 3 directory-layout entries; the 11 = the new
|
||||||
|
observer tests already exist as parametrize entries that count
|
||||||
|
as one test each; net is +27).
|
||||||
|
* No new linter errors (`ReadLints` clean on all touched files).
|
||||||
|
* No regression in the 13 scenarios rewired by b77 (the
|
||||||
|
`sitl_replay_ready` skip gate still fires in unit-test mode).
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# Cumulative Code Review — Batches 76, 77, 78
|
||||||
|
|
||||||
|
**Date**: 2026-05-17
|
||||||
|
**Verdict**: PASS
|
||||||
|
|
||||||
|
Covers the arc:
|
||||||
|
|
||||||
|
* **Batch 76 / AZ-596** — `fc_proxy_runtime` driver for FT-N-04
|
||||||
|
(FDR-replay mode).
|
||||||
|
* **Batch 77 / AZ-597** — `replay_mode.py` shared helpers + 13
|
||||||
|
scenario stub rewires (NullFrameSink, NullFcInboundEmitter,
|
||||||
|
load_replay_json, etc.).
|
||||||
|
* **Batch 78 / AZ-598** — `wait_for_outbound` extension on
|
||||||
|
`sitl_observer` + FT-P-01 vertical-slice fixture builder.
|
||||||
|
|
||||||
|
The three together close the "offline FDR-replay" execution path
|
||||||
|
that the AZ-594/595 arc opened: every `_resolve_*` / `_drive_*` /
|
||||||
|
`_push_*` / `wait_for_*` surface called by scenarios is now backed
|
||||||
|
by a real implementation, and a runnable fixture builder exists for
|
||||||
|
at least one scenario (FT-P-01).
|
||||||
|
|
||||||
|
## Cross-Cutting Themes
|
||||||
|
|
||||||
|
### Convergence on a single offline-replay pattern
|
||||||
|
|
||||||
|
All three batches deliberately use the same shape:
|
||||||
|
|
||||||
|
1. Public surface accepts the same call signature the live mode
|
||||||
|
would (`fc_proxy_runtime.drive_fc_proxy(schedule_path, *, now_ms_provider=None)`,
|
||||||
|
`_FdrReplayObserver.wait_for_outbound(timeout_s=None)`,
|
||||||
|
`imu_replay_noop(csv_path)`).
|
||||||
|
2. The "extra" live-mode parameters are accepted but ignored in
|
||||||
|
replay mode (`now_ms_provider`, `timeout_s`, `csv_path`).
|
||||||
|
3. Replay-mode data is loaded lazily from
|
||||||
|
`${E2E_SITL_REPLAY_DIR}/<filename>.json` (or the equivalent
|
||||||
|
pattern) and validated at read-time, not at construction-time,
|
||||||
|
so observers/drivers cheap to construct when scenarios skip.
|
||||||
|
4. Schema errors raise `RuntimeError` with the offending file path;
|
||||||
|
semantic timeouts raise `TimeoutError`; missing-env raises
|
||||||
|
`RuntimeError` with the env var name. Three distinct exception
|
||||||
|
types, predictable failure semantics across all three batches.
|
||||||
|
|
||||||
|
This is good. A future maintainer reading `fc_proxy_runtime.py`,
|
||||||
|
`sitl_observer.py`, and `replay_mode.py` side-by-side will see the
|
||||||
|
same pattern in all three. No drift.
|
||||||
|
|
||||||
|
### Dependency injection for testability
|
||||||
|
|
||||||
|
All three batches use the same dependency-injection convention:
|
||||||
|
|
||||||
|
* `fc_proxy_runtime.drive_fc_proxy(..., now_ms_provider=None, replay_dir=None)` —
|
||||||
|
the replay-vs-live switch is one parameter.
|
||||||
|
* `build_p01_fixtures(..., _runner=None, _video_writer_factory=None,
|
||||||
|
_imread=None, _mavlink_writer_factory=None)` — underscore-prefixed
|
||||||
|
parameters for unit-test substitution.
|
||||||
|
* `_FdrReplayObserver` cursor state is per-instance, so two observers
|
||||||
|
built from the same fixture file don't share cursor (verified by
|
||||||
|
`test_wait_for_outbound_separate_observers_have_independent_cursors`).
|
||||||
|
|
||||||
|
No batch monkey-patches modules to inject test doubles. Substitution
|
||||||
|
flows through public constructor / function parameters. This is the
|
||||||
|
right pattern.
|
||||||
|
|
||||||
|
### Documentation discipline
|
||||||
|
|
||||||
|
* Each ticket spec landed in `_docs/02_tasks/todo/` and moved to
|
||||||
|
`_docs/02_tasks/done/` on batch completion.
|
||||||
|
* Each batch produced a `batch_<N>_report.md` summarizing scope,
|
||||||
|
files touched, test deltas.
|
||||||
|
* Each batch produced a `batch_<N>_review.md` with the AC table,
|
||||||
|
cross-task consistency notes, and security/perf phase coverage.
|
||||||
|
* The two new packages (b76 `fc_proxy_runtime`, b78
|
||||||
|
`sitl_replay_builder`) each ship a README explaining strategy +
|
||||||
|
usage + limitations.
|
||||||
|
|
||||||
|
## Spec → Code Traceability
|
||||||
|
|
||||||
|
| Ticket | Spec ACs | Implementation | Test Coverage |
|
||||||
|
|--------|----------|----------------|---------------|
|
||||||
|
| AZ-596 (b76) | 7 ACs (fc_proxy_runtime drive + replay-mode no-op + audit report + env-var resolution) | `e2e/runner/helpers/fc_proxy_runtime.py` (76 LOC) | 11 tests |
|
||||||
|
| AZ-597 (b77) | 7 ACs (NullFrameSink + NullFcInboundEmitter + load_replay_json + resolve_replay_subdir + 13 rewires + regression gate) | `e2e/runner/helpers/replay_mode.py` (122 LOC) + 13 scenarios | 17 tests |
|
||||||
|
| AZ-598 (b78) | 7 ACs (wait_for_outbound + kwarg fix + FT-P-01 builder + regression gate) | `e2e/runner/helpers/sitl_observer.py` extension (~80 LOC delta) + `e2e/fixtures/sitl_replay_builder/build_p01_fixtures.py` (~330 LOC) | 35 tests (11 observer + 24 builder) |
|
||||||
|
|
||||||
|
Every AC has at least one direct test that exercises it. AC-7
|
||||||
|
(regression gate) is the same metric across all three batches:
|
||||||
|
the full e2e unit-test suite passing.
|
||||||
|
|
||||||
|
## Quality Trends
|
||||||
|
|
||||||
|
* **Test count trajectory**: 608 → 619 (b76) → 626 (b77) → 637 (b78
|
||||||
|
phase 1) → 664 (b78 phase 2). Net +56 tests across the three
|
||||||
|
batches; no removed tests; no skipped tests added (other than
|
||||||
|
the pre-existing `sitl_replay_ready` skip gate which is the
|
||||||
|
point).
|
||||||
|
* **Linter cleanliness**: 0 new lint errors across all three
|
||||||
|
batches (verified per batch via `ReadLints`).
|
||||||
|
* **Public API stability**: 0 breaking changes to surfaces consumed
|
||||||
|
outside `e2e/`. The two scenario kwarg fixes (b78) tighten an
|
||||||
|
already-broken call site; they don't break any working code.
|
||||||
|
* **Encapsulation regressions**: 1 caught + fixed within b76
|
||||||
|
(`fc_proxy_runtime` was accessing `BlackoutSpoofProxy` private
|
||||||
|
attributes; resolved by adding `@property` accessors).
|
||||||
|
|
||||||
|
## Lessons Learned (Propagating to Future Batches)
|
||||||
|
|
||||||
|
1. **Audit scenario call sites before extending helpers**. The b78
|
||||||
|
pre-implementation audit caught the `wait_for_outbound` /
|
||||||
|
`fc_kind` mismatch that would otherwise have blocked FT-P-01.
|
||||||
|
This pattern (grep for `<helper>.<surface>` across `e2e/tests/`
|
||||||
|
first, then implement) catches mis-specified scenarios before
|
||||||
|
the implementation locks in a format.
|
||||||
|
2. **Re-export discipline matters**. The b78 `__init__.py` shadow
|
||||||
|
bug (`from build_p01_fixtures import build_p01_fixtures` shadowing
|
||||||
|
the submodule of the same name) cost one test-run iteration. The
|
||||||
|
fix is documented in the package's `__init__.py` docstring so a
|
||||||
|
future contributor doesn't re-introduce it.
|
||||||
|
3. **"Live" vs "offline" scope must be set up-front**. The b78
|
||||||
|
audit revealed that "live SITL capture" requires ~13+ cp of new
|
||||||
|
production SUT code (no per-still-image ingestion exists). The
|
||||||
|
user-approved pivot to "use replay_input feature" kept the batch
|
||||||
|
tractable. Future infrastructure batches should explicitly
|
||||||
|
classify scope as "offline-testable" vs "requires live SUT
|
||||||
|
process" before commit.
|
||||||
|
4. **Documentation gaps surface in cross-batch audit**. The user's
|
||||||
|
"is the upload feature implemented?" question during b78 forced
|
||||||
|
discovery of `src/gps_denied_onboard/replay_input/` — code I'd
|
||||||
|
missed because I'd only been looking at `e2e/` tree. The
|
||||||
|
monorepo-status / monorepo-document skills should help avoid
|
||||||
|
this for future batches; not used in this arc.
|
||||||
|
|
||||||
|
## Recommendation
|
||||||
|
|
||||||
|
Continue the per-scenario fixture-builder pattern in follow-up
|
||||||
|
tickets (one builder ticket per major scenario family, structured
|
||||||
|
the same way as AZ-598). Open a ticket to converge `fc_kind` /
|
||||||
|
`fc_adapter` naming. Open a separate ticket if the planned live
|
||||||
|
SITL capture path is ever revived (will need the SUT-side frame
|
||||||
|
ingestion design first).
|
||||||
@@ -12,9 +12,9 @@ sub_step:
|
|||||||
retry_count: 0
|
retry_count: 0
|
||||||
cycle: 1
|
cycle: 1
|
||||||
tracker: jira
|
tracker: jira
|
||||||
last_completed_batch: 77
|
last_completed_batch: 78
|
||||||
last_cumulative_review: batches_73-75
|
last_cumulative_review: batches_76-78
|
||||||
current_batch: 78
|
current_batch: 79
|
||||||
current_batch_tasks: ""
|
current_batch_tasks: ""
|
||||||
last_step_outcomes:
|
last_step_outcomes:
|
||||||
step_8: "Code is testable — no changes needed (testability_assessment.md committed; no list-of-changes, no source edits)"
|
step_8: "Code is testable — no changes needed (testability_assessment.md committed; no list-of-changes, no source edits)"
|
||||||
|
|||||||
@@ -0,0 +1,492 @@
|
|||||||
|
"""Unit tests for `e2e/fixtures/sitl_replay_builder/build_p01_fixtures.py` (AZ-598).
|
||||||
|
|
||||||
|
All external dependencies (OpenCV, pymavlink, subprocess) are injected via
|
||||||
|
the underscore-prefixed parameters so the suite runs without the
|
||||||
|
production `gps-denied-replay` install OR a working OpenCV/pymavlink
|
||||||
|
build. The actual end-to-end run is a manual operator step (see README).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import types
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Sequence
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import e2e.fixtures.sitl_replay_builder.build_p01_fixtures as bp
|
||||||
|
|
||||||
|
|
||||||
|
# encode_stills_to_mp4
|
||||||
|
|
||||||
|
|
||||||
|
def _mk_fake_writer():
|
||||||
|
w = MagicMock(name="VideoWriter")
|
||||||
|
w.write = MagicMock()
|
||||||
|
w.release = MagicMock()
|
||||||
|
return w
|
||||||
|
|
||||||
|
|
||||||
|
def test_encode_stills_to_mp4_empty_paths_raises(tmp_path: Path):
|
||||||
|
# Assert
|
||||||
|
with pytest.raises(FileNotFoundError, match="image_paths is empty"):
|
||||||
|
bp.encode_stills_to_mp4(
|
||||||
|
[], tmp_path / "out.mp4",
|
||||||
|
_video_writer_factory=lambda *a, **kw: _mk_fake_writer(),
|
||||||
|
_imread=lambda p: None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_encode_stills_to_mp4_writes_each_frame(tmp_path: Path):
|
||||||
|
# Arrange
|
||||||
|
writer = _mk_fake_writer()
|
||||||
|
# Simulate (640, 480, 3) BGR frame via a stand-in object with .shape
|
||||||
|
frame = types.SimpleNamespace(shape=(480, 640, 3))
|
||||||
|
paths = [tmp_path / f"img-{i}.jpg" for i in range(3)]
|
||||||
|
|
||||||
|
# Act
|
||||||
|
count = bp.encode_stills_to_mp4(
|
||||||
|
paths, tmp_path / "out.mp4",
|
||||||
|
_video_writer_factory=lambda out, w, h: writer,
|
||||||
|
_imread=lambda p: frame,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert count == 3
|
||||||
|
assert writer.write.call_count == 3
|
||||||
|
assert writer.release.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_encode_stills_to_mp4_failed_read_raises(tmp_path: Path):
|
||||||
|
# Arrange
|
||||||
|
writer = _mk_fake_writer()
|
||||||
|
frame_ok = types.SimpleNamespace(shape=(480, 640, 3))
|
||||||
|
seen: list[Path] = []
|
||||||
|
|
||||||
|
def imread(path: Path):
|
||||||
|
seen.append(path)
|
||||||
|
return None if str(path).endswith("img-1.jpg") else frame_ok
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
with pytest.raises(FileNotFoundError, match="failed to read .*img-1.jpg"):
|
||||||
|
bp.encode_stills_to_mp4(
|
||||||
|
[tmp_path / f"img-{i}.jpg" for i in range(3)],
|
||||||
|
tmp_path / "out.mp4",
|
||||||
|
_video_writer_factory=lambda out, w, h: writer,
|
||||||
|
_imread=imread,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# generate_stationary_tlog
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_stationary_tlog_writes_pairs(tmp_path: Path):
|
||||||
|
# Arrange — fake mavlink writer that records every write() call.
|
||||||
|
writer = MagicMock(name="MavlinkWriter")
|
||||||
|
writer.write = MagicMock()
|
||||||
|
writer.close = MagicMock()
|
||||||
|
|
||||||
|
# Act
|
||||||
|
pairs = bp.generate_stationary_tlog(
|
||||||
|
tmp_path / "out.tlog",
|
||||||
|
duration_s=2, hz=10,
|
||||||
|
_mavlink_writer_factory=lambda out: writer,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert — 20 pairs (2s * 10Hz), each pair = 2 messages (RAW_IMU + ATTITUDE)
|
||||||
|
assert pairs == 20
|
||||||
|
assert writer.write.call_count == 40
|
||||||
|
assert writer.close.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_stationary_tlog_rejects_nonpositive_duration(tmp_path: Path):
|
||||||
|
# Assert
|
||||||
|
with pytest.raises(ValueError, match="duration_s must be positive"):
|
||||||
|
bp.generate_stationary_tlog(
|
||||||
|
tmp_path / "out.tlog", duration_s=0,
|
||||||
|
_mavlink_writer_factory=lambda out: MagicMock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_stationary_tlog_rejects_nonpositive_hz(tmp_path: Path):
|
||||||
|
# Assert
|
||||||
|
with pytest.raises(ValueError, match="hz must be positive"):
|
||||||
|
bp.generate_stationary_tlog(
|
||||||
|
tmp_path / "out.tlog", hz=0,
|
||||||
|
_mavlink_writer_factory=lambda out: MagicMock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_stationary_tlog_real_pymavlink_round_trip(tmp_path: Path):
|
||||||
|
"""Sanity-check the real packers; tlog file is well-formed."""
|
||||||
|
# Act — use real pymavlink (it's in pyproject.toml deps)
|
||||||
|
pairs = bp.generate_stationary_tlog(
|
||||||
|
tmp_path / "out.tlog", duration_s=1, hz=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert pairs == 10
|
||||||
|
assert (tmp_path / "out.tlog").is_file()
|
||||||
|
assert (tmp_path / "out.tlog").stat().st_size > 0
|
||||||
|
|
||||||
|
|
||||||
|
# run_gps_denied_replay
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_gps_denied_replay_builds_correct_cmd(tmp_path: Path):
|
||||||
|
# Arrange
|
||||||
|
captured: list[Sequence[str]] = []
|
||||||
|
|
||||||
|
def fake_runner(cmd):
|
||||||
|
captured.append(list(cmd))
|
||||||
|
return subprocess.CompletedProcess(args=cmd, returncode=0)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
bp.run_gps_denied_replay(
|
||||||
|
tmp_path / "stills.mp4", tmp_path / "stationary.tlog",
|
||||||
|
tmp_path / "fdr.jsonl",
|
||||||
|
_runner=fake_runner,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert len(captured) == 1
|
||||||
|
cmd = captured[0]
|
||||||
|
assert cmd[0] == "gps-denied-replay"
|
||||||
|
assert "--video" in cmd and str(tmp_path / "stills.mp4") in cmd
|
||||||
|
assert "--tlog" in cmd and str(tmp_path / "stationary.tlog") in cmd
|
||||||
|
assert "--time-offset-ms" in cmd and "0" in cmd
|
||||||
|
assert "--fdr-out" in cmd and str(tmp_path / "fdr.jsonl") in cmd
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_gps_denied_replay_creates_fdr_parent_dir(tmp_path: Path):
|
||||||
|
# Arrange
|
||||||
|
nested = tmp_path / "deep" / "nested" / "fdr.jsonl"
|
||||||
|
|
||||||
|
# Act
|
||||||
|
bp.run_gps_denied_replay(
|
||||||
|
tmp_path / "video.mp4", tmp_path / "tlog.tlog", nested,
|
||||||
|
_runner=lambda c: subprocess.CompletedProcess(c, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert nested.parent.is_dir()
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_gps_denied_replay_passes_extra_args(tmp_path: Path):
|
||||||
|
# Arrange
|
||||||
|
captured: list[Sequence[str]] = []
|
||||||
|
fake_runner = lambda c: (captured.append(list(c)) or subprocess.CompletedProcess(c, 0))
|
||||||
|
|
||||||
|
# Act
|
||||||
|
bp.run_gps_denied_replay(
|
||||||
|
tmp_path / "v.mp4", tmp_path / "t.tlog", tmp_path / "fdr.jsonl",
|
||||||
|
extra_args=["--pace=ASAP", "--log-level=INFO"],
|
||||||
|
_runner=fake_runner,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
cmd = captured[0]
|
||||||
|
assert "--pace=ASAP" in cmd and "--log-level=INFO" in cmd
|
||||||
|
|
||||||
|
|
||||||
|
# parse_fdr_for_outbound_estimates
|
||||||
|
|
||||||
|
|
||||||
|
def _write_jsonl(path: Path, records: list[dict]) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text("\n".join(json.dumps(r) for r in records))
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_fdr_missing_file_raises(tmp_path: Path):
|
||||||
|
# Assert
|
||||||
|
with pytest.raises(FileNotFoundError, match="FDR JSONL not found"):
|
||||||
|
bp.parse_fdr_for_outbound_estimates(tmp_path / "missing.jsonl")
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_fdr_filters_by_kind(tmp_path: Path):
|
||||||
|
# Arrange
|
||||||
|
fdr = tmp_path / "fdr.jsonl"
|
||||||
|
_write_jsonl(fdr, [
|
||||||
|
{"kind": "other", "payload": {"lat_deg": 99.0, "lon_deg": 99.0}},
|
||||||
|
{"kind": "outbound_position_estimate", "payload": {"lat_deg": 1.0, "lon_deg": 2.0}},
|
||||||
|
{"kind": "another", "payload": {"x": 0}},
|
||||||
|
{"kind": "outbound_position_estimate", "payload": {"lat_deg": 3.0, "lon_deg": 4.0}},
|
||||||
|
])
|
||||||
|
|
||||||
|
# Act
|
||||||
|
estimates = bp.parse_fdr_for_outbound_estimates(fdr)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert estimates == [
|
||||||
|
{"lat_deg": 1.0, "lon_deg": 2.0},
|
||||||
|
{"lat_deg": 3.0, "lon_deg": 4.0},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_fdr_skips_missing_coords(tmp_path: Path):
|
||||||
|
# Arrange
|
||||||
|
fdr = tmp_path / "fdr.jsonl"
|
||||||
|
_write_jsonl(fdr, [
|
||||||
|
{"kind": "outbound_position_estimate", "payload": {"lat_deg": 1.0}}, # missing lon
|
||||||
|
{"kind": "outbound_position_estimate", "payload": {"lon_deg": 2.0}}, # missing lat
|
||||||
|
{"kind": "outbound_position_estimate", "payload": {"lat_deg": 1.0, "lon_deg": 2.0}},
|
||||||
|
])
|
||||||
|
|
||||||
|
# Act
|
||||||
|
estimates = bp.parse_fdr_for_outbound_estimates(fdr)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert estimates == [{"lat_deg": 1.0, "lon_deg": 2.0}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_fdr_custom_kind_and_keys(tmp_path: Path):
|
||||||
|
# Arrange
|
||||||
|
fdr = tmp_path / "fdr.jsonl"
|
||||||
|
_write_jsonl(fdr, [
|
||||||
|
{"kind": "geo_estimate", "payload": {"latitude": 10.0, "longitude": 20.0}},
|
||||||
|
])
|
||||||
|
|
||||||
|
# Act
|
||||||
|
estimates = bp.parse_fdr_for_outbound_estimates(
|
||||||
|
fdr, fdr_kind="geo_estimate", lat_key="latitude", lon_key="longitude"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert estimates == [{"lat_deg": 10.0, "lon_deg": 20.0}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_fdr_skips_blank_lines(tmp_path: Path):
|
||||||
|
# Arrange
|
||||||
|
fdr = tmp_path / "fdr.jsonl"
|
||||||
|
fdr.write_text(
|
||||||
|
'\n'
|
||||||
|
+ json.dumps({"kind": "outbound_position_estimate",
|
||||||
|
"payload": {"lat_deg": 1.0, "lon_deg": 2.0}})
|
||||||
|
+ '\n\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
estimates = bp.parse_fdr_for_outbound_estimates(fdr)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert len(estimates) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_fdr_malformed_json_raises(tmp_path: Path):
|
||||||
|
# Arrange
|
||||||
|
fdr = tmp_path / "fdr.jsonl"
|
||||||
|
fdr.write_text(
|
||||||
|
json.dumps({"kind": "x", "payload": {}}) + "\n"
|
||||||
|
+ "{not valid json\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
with pytest.raises(ValueError, match="malformed FDR JSON at .*:2"):
|
||||||
|
bp.parse_fdr_for_outbound_estimates(fdr)
|
||||||
|
|
||||||
|
|
||||||
|
# write_outbound_messages_fixture
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_outbound_messages_length_mismatch_raises(tmp_path: Path):
|
||||||
|
# Assert
|
||||||
|
with pytest.raises(ValueError, match="length mismatch"):
|
||||||
|
bp.write_outbound_messages_fixture(
|
||||||
|
tmp_path / "out.json",
|
||||||
|
image_ids=["a.jpg", "b.jpg"],
|
||||||
|
estimates=[{"lat_deg": 1.0, "lon_deg": 2.0}],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_outbound_messages_preserves_nulls(tmp_path: Path):
|
||||||
|
# Arrange
|
||||||
|
out = tmp_path / "outbound.json"
|
||||||
|
|
||||||
|
# Act
|
||||||
|
bp.write_outbound_messages_fixture(
|
||||||
|
out,
|
||||||
|
image_ids=["a.jpg", "b.jpg", "c.jpg"],
|
||||||
|
estimates=[{"lat_deg": 1.0, "lon_deg": 2.0}, None, {"lat_deg": 3.0, "lon_deg": 4.0}],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
payload = json.loads(out.read_text())
|
||||||
|
assert payload == {
|
||||||
|
"messages": [
|
||||||
|
{"image_id": "a.jpg", "lat_deg": 1.0, "lon_deg": 2.0},
|
||||||
|
None,
|
||||||
|
{"image_id": "c.jpg", "lat_deg": 3.0, "lon_deg": 4.0},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_outbound_messages_creates_parent(tmp_path: Path):
|
||||||
|
# Arrange
|
||||||
|
out = tmp_path / "deeply" / "nested" / "outbound.json"
|
||||||
|
|
||||||
|
# Act
|
||||||
|
bp.write_outbound_messages_fixture(
|
||||||
|
out, image_ids=["a.jpg"], estimates=[{"lat_deg": 1.0, "lon_deg": 2.0}],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert out.is_file()
|
||||||
|
|
||||||
|
|
||||||
|
# write_observer_fixture
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_observer_fixture_schema(tmp_path: Path):
|
||||||
|
# Arrange
|
||||||
|
out = tmp_path / "observer.json"
|
||||||
|
|
||||||
|
# Act
|
||||||
|
bp.write_observer_fixture(out)
|
||||||
|
|
||||||
|
# Assert — round-trips into the same dict consumed by sitl_observer.get_observer.
|
||||||
|
payload = json.loads(out.read_text())
|
||||||
|
assert "gps_state" in payload
|
||||||
|
assert payload["gps_state"]["primary_source"] == "MAV"
|
||||||
|
assert "parameters" in payload
|
||||||
|
|
||||||
|
|
||||||
|
# build_p01_fixtures end-to-end (mocked)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_p01_fixtures_no_images_raises(tmp_path: Path):
|
||||||
|
# Arrange
|
||||||
|
cfg = bp.BuilderConfig(
|
||||||
|
input_dir=tmp_path / "empty", output_dir=tmp_path / "out",
|
||||||
|
fc_kind="ardupilot", host="sitl-host",
|
||||||
|
)
|
||||||
|
(tmp_path / "empty").mkdir()
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
with pytest.raises(FileNotFoundError, match="no AD\\?\\?\\?\\?\\?\\?.jpg images"):
|
||||||
|
bp.build_p01_fixtures(cfg)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_p01_fixtures_end_to_end_with_mocks(tmp_path: Path):
|
||||||
|
# Arrange — synthesize 3 fake AD000NN.jpg files (one per "image"),
|
||||||
|
# mock OpenCV / pymavlink / subprocess, and pre-stage a fake FDR JSONL.
|
||||||
|
input_dir = tmp_path / "in"
|
||||||
|
output_dir = tmp_path / "out"
|
||||||
|
input_dir.mkdir()
|
||||||
|
for n in range(1, 4):
|
||||||
|
(input_dir / f"AD{n:06d}.jpg").touch()
|
||||||
|
|
||||||
|
writer = _mk_fake_writer()
|
||||||
|
frame = types.SimpleNamespace(shape=(480, 640, 3))
|
||||||
|
mav_writer = MagicMock(write=MagicMock(), close=MagicMock())
|
||||||
|
|
||||||
|
def fake_runner(cmd):
|
||||||
|
# Find the --fdr-out path and pre-populate it with 3 records.
|
||||||
|
fdr_path = Path(cmd[cmd.index("--fdr-out") + 1])
|
||||||
|
_write_jsonl(fdr_path, [
|
||||||
|
{"kind": "outbound_position_estimate", "payload": {"lat_deg": 1.0, "lon_deg": 2.0}},
|
||||||
|
{"kind": "outbound_position_estimate", "payload": {"lat_deg": 3.0, "lon_deg": 4.0}},
|
||||||
|
{"kind": "outbound_position_estimate", "payload": {"lat_deg": 5.0, "lon_deg": 6.0}},
|
||||||
|
])
|
||||||
|
return subprocess.CompletedProcess(cmd, 0)
|
||||||
|
|
||||||
|
cfg = bp.BuilderConfig(
|
||||||
|
input_dir=input_dir, output_dir=output_dir,
|
||||||
|
fc_kind="ardupilot", host="sitl-host",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result_dir = bp.build_p01_fixtures(
|
||||||
|
cfg,
|
||||||
|
_runner=fake_runner,
|
||||||
|
_video_writer_factory=lambda out, w, h: writer,
|
||||||
|
_imread=lambda p: frame,
|
||||||
|
_mavlink_writer_factory=lambda out: mav_writer,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert result_dir == output_dir
|
||||||
|
outbound_payload = json.loads((output_dir / "outbound_messages_ardupilot_sitl-host.json").read_text())
|
||||||
|
assert outbound_payload == {
|
||||||
|
"messages": [
|
||||||
|
{"image_id": "AD000001.jpg", "lat_deg": 1.0, "lon_deg": 2.0},
|
||||||
|
{"image_id": "AD000002.jpg", "lat_deg": 3.0, "lon_deg": 4.0},
|
||||||
|
{"image_id": "AD000003.jpg", "lat_deg": 5.0, "lon_deg": 6.0},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
assert (output_dir / "observer_ardupilot_sitl-host.json").is_file()
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_p01_fixtures_fewer_estimates_than_frames_pads_nulls(tmp_path: Path):
|
||||||
|
# Arrange — 3 frames, FDR yields 1 estimate; expect 2 null entries.
|
||||||
|
input_dir = tmp_path / "in"
|
||||||
|
output_dir = tmp_path / "out"
|
||||||
|
input_dir.mkdir()
|
||||||
|
for n in range(1, 4):
|
||||||
|
(input_dir / f"AD{n:06d}.jpg").touch()
|
||||||
|
|
||||||
|
def fake_runner(cmd):
|
||||||
|
fdr_path = Path(cmd[cmd.index("--fdr-out") + 1])
|
||||||
|
_write_jsonl(fdr_path, [
|
||||||
|
{"kind": "outbound_position_estimate", "payload": {"lat_deg": 1.0, "lon_deg": 2.0}},
|
||||||
|
])
|
||||||
|
return subprocess.CompletedProcess(cmd, 0)
|
||||||
|
|
||||||
|
cfg = bp.BuilderConfig(
|
||||||
|
input_dir=input_dir, output_dir=output_dir,
|
||||||
|
fc_kind="ardupilot", host="sitl-host",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
bp.build_p01_fixtures(
|
||||||
|
cfg,
|
||||||
|
_runner=fake_runner,
|
||||||
|
_video_writer_factory=lambda out, w, h: _mk_fake_writer(),
|
||||||
|
_imread=lambda p: types.SimpleNamespace(shape=(480, 640, 3)),
|
||||||
|
_mavlink_writer_factory=lambda out: MagicMock(write=MagicMock(), close=MagicMock()),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
payload = json.loads((output_dir / "outbound_messages_ardupilot_sitl-host.json").read_text())
|
||||||
|
assert payload["messages"][0]["lat_deg"] == 1.0
|
||||||
|
assert payload["messages"][1] is None
|
||||||
|
assert payload["messages"][2] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_p01_fixtures_more_estimates_than_frames_truncates(tmp_path: Path, caplog):
|
||||||
|
# Arrange — 2 frames, FDR yields 4 estimates; expect 2 retained + warn.
|
||||||
|
input_dir = tmp_path / "in"
|
||||||
|
output_dir = tmp_path / "out"
|
||||||
|
input_dir.mkdir()
|
||||||
|
for n in range(1, 3):
|
||||||
|
(input_dir / f"AD{n:06d}.jpg").touch()
|
||||||
|
|
||||||
|
def fake_runner(cmd):
|
||||||
|
fdr_path = Path(cmd[cmd.index("--fdr-out") + 1])
|
||||||
|
_write_jsonl(fdr_path, [
|
||||||
|
{"kind": "outbound_position_estimate", "payload": {"lat_deg": float(i), "lon_deg": float(i)}}
|
||||||
|
for i in range(4)
|
||||||
|
])
|
||||||
|
return subprocess.CompletedProcess(cmd, 0)
|
||||||
|
|
||||||
|
cfg = bp.BuilderConfig(
|
||||||
|
input_dir=input_dir, output_dir=output_dir,
|
||||||
|
fc_kind="ardupilot", host="sitl-host",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
with caplog.at_level("WARNING"):
|
||||||
|
bp.build_p01_fixtures(
|
||||||
|
cfg,
|
||||||
|
_runner=fake_runner,
|
||||||
|
_video_writer_factory=lambda out, w, h: _mk_fake_writer(),
|
||||||
|
_imread=lambda p: types.SimpleNamespace(shape=(480, 640, 3)),
|
||||||
|
_mavlink_writer_factory=lambda out: MagicMock(write=MagicMock(), close=MagicMock()),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
payload = json.loads((output_dir / "outbound_messages_ardupilot_sitl-host.json").read_text())
|
||||||
|
assert len(payload["messages"]) == 2
|
||||||
|
assert any("truncating" in rec.message for rec in caplog.records)
|
||||||
@@ -211,6 +211,204 @@ def test_get_observer_missing_gps_state_raises(replay_dir: Path):
|
|||||||
obs.read_gps_state()
|
obs.read_gps_state()
|
||||||
|
|
||||||
|
|
||||||
|
# wait_for_outbound (AZ-598)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_observer_fixture(replay_dir: Path, fc_kind: str, host: str) -> None:
|
||||||
|
"""Write the minimal `observer_<kind>_<host>.json` so `get_observer` succeeds."""
|
||||||
|
_write_json(
|
||||||
|
replay_dir / f"observer_{fc_kind}_{host}.json",
|
||||||
|
{
|
||||||
|
"gps_state": {
|
||||||
|
"primary_source": "MAV",
|
||||||
|
"last_position_lat_deg": 0.0,
|
||||||
|
"last_position_lon_deg": 0.0,
|
||||||
|
"last_position_alt_m": 0.0,
|
||||||
|
"fix_quality": 3,
|
||||||
|
"horizontal_accuracy_m": 1.0,
|
||||||
|
"last_update_age_ms": 0,
|
||||||
|
},
|
||||||
|
"parameters": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_wait_for_outbound_advances_cursor_in_order(replay_dir: Path):
|
||||||
|
# Arrange
|
||||||
|
_write_observer_fixture(replay_dir, "ardupilot", "sitl-host")
|
||||||
|
_write_json(
|
||||||
|
replay_dir / "outbound_messages_ardupilot_sitl-host.json",
|
||||||
|
{
|
||||||
|
"messages": [
|
||||||
|
{"image_id": "AD000001.jpg", "lat_deg": 48.275292, "lon_deg": 37.385220},
|
||||||
|
{"image_id": "AD000002.jpg", "lat_deg": 48.275001, "lon_deg": 37.382922},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
obs = so.get_observer("ardupilot", "sitl-host")
|
||||||
|
|
||||||
|
# Act
|
||||||
|
first = obs.wait_for_outbound(timeout_s=5.0)
|
||||||
|
second = obs.wait_for_outbound(timeout_s=5.0)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert first.lat_deg == 48.275292 and first.lon_deg == 37.385220
|
||||||
|
assert first.image_id == "AD000001.jpg"
|
||||||
|
assert second.lat_deg == 48.275001 and second.lon_deg == 37.382922
|
||||||
|
assert second.image_id == "AD000002.jpg"
|
||||||
|
|
||||||
|
|
||||||
|
def test_wait_for_outbound_null_entry_raises_timeout(replay_dir: Path):
|
||||||
|
# Arrange
|
||||||
|
_write_observer_fixture(replay_dir, "ardupilot", "sitl-host")
|
||||||
|
_write_json(
|
||||||
|
replay_dir / "outbound_messages_ardupilot_sitl-host.json",
|
||||||
|
{"messages": [None]},
|
||||||
|
)
|
||||||
|
obs = so.get_observer("ardupilot", "sitl-host")
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
with pytest.raises(TimeoutError, match="captured as timeout in fixture"):
|
||||||
|
obs.wait_for_outbound(timeout_s=5.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_wait_for_outbound_advances_cursor_past_timeout(replay_dir: Path):
|
||||||
|
# Arrange — a real timeout in the middle of the sequence does not stall
|
||||||
|
# the cursor; the next call advances normally.
|
||||||
|
_write_observer_fixture(replay_dir, "ardupilot", "sitl-host")
|
||||||
|
_write_json(
|
||||||
|
replay_dir / "outbound_messages_ardupilot_sitl-host.json",
|
||||||
|
{
|
||||||
|
"messages": [
|
||||||
|
{"lat_deg": 1.0, "lon_deg": 2.0},
|
||||||
|
None,
|
||||||
|
{"lat_deg": 3.0, "lon_deg": 4.0},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
obs = so.get_observer("ardupilot", "sitl-host")
|
||||||
|
|
||||||
|
# Act / Assert
|
||||||
|
assert obs.wait_for_outbound().lat_deg == 1.0
|
||||||
|
with pytest.raises(TimeoutError):
|
||||||
|
obs.wait_for_outbound()
|
||||||
|
third = obs.wait_for_outbound()
|
||||||
|
assert third.lat_deg == 3.0 and third.lon_deg == 4.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_wait_for_outbound_exhausted_raises_runtime(replay_dir: Path):
|
||||||
|
# Arrange
|
||||||
|
_write_observer_fixture(replay_dir, "ardupilot", "sitl-host")
|
||||||
|
_write_json(
|
||||||
|
replay_dir / "outbound_messages_ardupilot_sitl-host.json",
|
||||||
|
{"messages": [{"lat_deg": 1.0, "lon_deg": 2.0}]},
|
||||||
|
)
|
||||||
|
obs = so.get_observer("ardupilot", "sitl-host")
|
||||||
|
obs.wait_for_outbound() # drain the only entry
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
with pytest.raises(RuntimeError, match="outbound messages fixture exhausted"):
|
||||||
|
obs.wait_for_outbound()
|
||||||
|
|
||||||
|
|
||||||
|
def test_wait_for_outbound_missing_fixture_raises_runtime(replay_dir: Path):
|
||||||
|
# Arrange — observer fixture present, outbound fixture missing.
|
||||||
|
_write_observer_fixture(replay_dir, "ardupilot", "sitl-host")
|
||||||
|
obs = so.get_observer("ardupilot", "sitl-host")
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
with pytest.raises(RuntimeError, match="outbound_messages_ardupilot_sitl-host.json"):
|
||||||
|
obs.wait_for_outbound()
|
||||||
|
|
||||||
|
|
||||||
|
def test_wait_for_outbound_missing_env_raises_runtime(unset_replay_dir):
|
||||||
|
# Arrange — observer dataclass constructed manually so we don't depend on env var
|
||||||
|
# for the observer-fixture load. Verifies the outbound load itself respects the env.
|
||||||
|
obs = so._FdrReplayObserver(fc_kind="ardupilot", host="sitl-host", _payload={})
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
with pytest.raises(RuntimeError, match="env var not set"):
|
||||||
|
obs.wait_for_outbound()
|
||||||
|
|
||||||
|
|
||||||
|
def test_wait_for_outbound_messages_not_list_raises_runtime(replay_dir: Path):
|
||||||
|
# Arrange
|
||||||
|
_write_observer_fixture(replay_dir, "ardupilot", "sitl-host")
|
||||||
|
_write_json(
|
||||||
|
replay_dir / "outbound_messages_ardupilot_sitl-host.json",
|
||||||
|
{"messages": {"oops": "should be list"}},
|
||||||
|
)
|
||||||
|
obs = so.get_observer("ardupilot", "sitl-host")
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
with pytest.raises(RuntimeError, match="`messages` must be a JSON list"):
|
||||||
|
obs.wait_for_outbound()
|
||||||
|
|
||||||
|
|
||||||
|
def test_wait_for_outbound_entry_wrong_type_raises_runtime(replay_dir: Path):
|
||||||
|
# Arrange
|
||||||
|
_write_observer_fixture(replay_dir, "ardupilot", "sitl-host")
|
||||||
|
_write_json(
|
||||||
|
replay_dir / "outbound_messages_ardupilot_sitl-host.json",
|
||||||
|
{"messages": ["not-an-object"]},
|
||||||
|
)
|
||||||
|
obs = so.get_observer("ardupilot", "sitl-host")
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
with pytest.raises(RuntimeError, match=r"messages\[0\] must be a JSON object or null"):
|
||||||
|
obs.wait_for_outbound()
|
||||||
|
|
||||||
|
|
||||||
|
def test_wait_for_outbound_entry_missing_coords_raises_runtime(replay_dir: Path):
|
||||||
|
# Arrange
|
||||||
|
_write_observer_fixture(replay_dir, "ardupilot", "sitl-host")
|
||||||
|
_write_json(
|
||||||
|
replay_dir / "outbound_messages_ardupilot_sitl-host.json",
|
||||||
|
{"messages": [{"image_id": "AD000001.jpg"}]},
|
||||||
|
)
|
||||||
|
obs = so.get_observer("ardupilot", "sitl-host")
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
with pytest.raises(RuntimeError, match="missing required `lat_deg`/`lon_deg`"):
|
||||||
|
obs.wait_for_outbound()
|
||||||
|
|
||||||
|
|
||||||
|
def test_wait_for_outbound_image_id_optional(replay_dir: Path):
|
||||||
|
# Arrange — entries without `image_id` are valid; consumer only needs coords.
|
||||||
|
_write_observer_fixture(replay_dir, "ardupilot", "sitl-host")
|
||||||
|
_write_json(
|
||||||
|
replay_dir / "outbound_messages_ardupilot_sitl-host.json",
|
||||||
|
{"messages": [{"lat_deg": 10.0, "lon_deg": 20.0}]},
|
||||||
|
)
|
||||||
|
obs = so.get_observer("ardupilot", "sitl-host")
|
||||||
|
|
||||||
|
# Act
|
||||||
|
msg = obs.wait_for_outbound()
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert msg.lat_deg == 10.0 and msg.lon_deg == 20.0
|
||||||
|
assert msg.image_id is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_wait_for_outbound_separate_observers_have_independent_cursors(replay_dir: Path):
|
||||||
|
# Arrange — two observers built from the same fixture file must NOT share cursor.
|
||||||
|
_write_observer_fixture(replay_dir, "ardupilot", "sitl-host")
|
||||||
|
_write_json(
|
||||||
|
replay_dir / "outbound_messages_ardupilot_sitl-host.json",
|
||||||
|
{"messages": [{"lat_deg": 1.0, "lon_deg": 2.0}, {"lat_deg": 3.0, "lon_deg": 4.0}]},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
obs_a = so.get_observer("ardupilot", "sitl-host")
|
||||||
|
obs_b = so.get_observer("ardupilot", "sitl-host")
|
||||||
|
a_first = obs_a.wait_for_outbound()
|
||||||
|
b_first = obs_b.wait_for_outbound()
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert a_first.lat_deg == 1.0
|
||||||
|
assert b_first.lat_deg == 1.0
|
||||||
|
|
||||||
|
|
||||||
# prepare_sitl_*
|
# prepare_sitl_*
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ E2E_ROOT = Path(__file__).resolve().parents[1]
|
|||||||
"runner/helpers/blackout_spoof_evaluator.py",
|
"runner/helpers/blackout_spoof_evaluator.py",
|
||||||
"runner/helpers/fc_proxy_runtime.py",
|
"runner/helpers/fc_proxy_runtime.py",
|
||||||
"runner/helpers/replay_mode.py",
|
"runner/helpers/replay_mode.py",
|
||||||
|
"fixtures/sitl_replay_builder/__init__.py",
|
||||||
|
"fixtures/sitl_replay_builder/build_p01_fixtures.py",
|
||||||
|
"fixtures/sitl_replay_builder/README.md",
|
||||||
"fixtures/mock-suite-sat/Dockerfile",
|
"fixtures/mock-suite-sat/Dockerfile",
|
||||||
"fixtures/mock-suite-sat/app.py",
|
"fixtures/mock-suite-sat/app.py",
|
||||||
"fixtures/mock-suite-sat/requirements.txt",
|
"fixtures/mock-suite-sat/requirements.txt",
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# SITL Replay Fixture Builder (AZ-598)
|
||||||
|
|
||||||
|
Produces the `outbound_messages_<fc_kind>_<host>.json` +
|
||||||
|
`observer_<fc_kind>_<host>.json` fixtures consumed by the b75
|
||||||
|
`sitl_observer` module in offline FDR-replay mode (b75/b78).
|
||||||
|
|
||||||
|
## Vertical-slice scope (this batch)
|
||||||
|
|
||||||
|
Only the FT-P-01 still-image accuracy scenario is supported. Other
|
||||||
|
scenarios (FT-P-02 Derkachi continuous flight, FT-N-04 blackout-spoof,
|
||||||
|
etc.) need their own capture flows and will land as follow-up tickets.
|
||||||
|
|
||||||
|
## Strategy
|
||||||
|
|
||||||
|
Rather than spinning up a SITL container, this builder reuses the
|
||||||
|
production `gps-denied-replay` CLI + `ReplayInputAdapter`:
|
||||||
|
|
||||||
|
1. Encode the 60 `AD0000NN.jpg` still images into a 1 fps MP4.
|
||||||
|
2. Generate a synthetic stationary tlog (zero-motion `RAW_IMU` +
|
||||||
|
`ATTITUDE` pairs at 200 Hz) — bypasses the AZ-405 take-off
|
||||||
|
pre-validator without needing real flight data.
|
||||||
|
3. Run `gps-denied-replay --video stills.mp4 --tlog stationary.tlog
|
||||||
|
--time-offset-ms 0 --fdr-out fdr.jsonl` (auto-sync bypassed
|
||||||
|
because the synthetic tlog has no take-off signal).
|
||||||
|
4. Read `fdr.jsonl`, filter to `kind == outbound_position_estimate`,
|
||||||
|
project each into the `outbound_messages_*` schema.
|
||||||
|
5. Write the two fixture JSON files into `--output-dir`.
|
||||||
|
|
||||||
|
This avoids needing new SUT-side frame-ingestion code (HTTP endpoint,
|
||||||
|
file-watch source, etc.) which would otherwise be required to push
|
||||||
|
individual stills to a running SUT container.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gps-denied-build-p01-fixtures \
|
||||||
|
--input-dir _docs/00_problem/input_data \
|
||||||
|
--output-dir e2e/fixtures/sitl_replay/p01 \
|
||||||
|
--fc-kind ardupilot \
|
||||||
|
--host sitl-host
|
||||||
|
```
|
||||||
|
|
||||||
|
The output directory will contain:
|
||||||
|
|
||||||
|
* `stills.mp4` — the 60 images encoded at 1 fps.
|
||||||
|
* `stationary.tlog` — synthetic 120-s zero-motion tlog at 200 Hz.
|
||||||
|
* `fdr.jsonl` — the FDR JSONL stream from the replay run.
|
||||||
|
* `outbound_messages_ardupilot_sitl-host.json` — the consumed fixture.
|
||||||
|
* `observer_ardupilot_sitl-host.json` — the consumed fixture.
|
||||||
|
|
||||||
|
To activate the fixtures in a scenario run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
E2E_SITL_REPLAY_DIR=e2e/fixtures/sitl_replay/p01 \
|
||||||
|
pytest e2e/tests/positive/test_ft_p_01_still_image_accuracy.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
* The synthetic tlog encodes zero motion — auto-sync MUST be bypassed
|
||||||
|
via `--time-offset-ms 0` (the builder does this automatically).
|
||||||
|
* The FDR record `kind` is assumed to be `outbound_position_estimate`
|
||||||
|
— the `--fdr-kind` CLI flag overrides if the actual schema differs.
|
||||||
|
* Per-image timeout handling: if the SUT emits fewer outbound estimates
|
||||||
|
than pushed frames, trailing image_ids are written as `null` entries
|
||||||
|
(encoded as TimeoutError on scenario replay).
|
||||||
|
* iNav adapter is NOT supported by this batch — only ArduPilot. iNav
|
||||||
|
will land as a follow-up once the AP path is validated end-to-end.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Unit tests under `e2e/_unit_tests/fixtures/test_sitl_replay_builder.py`
|
||||||
|
mock all external dependencies (OpenCV, pymavlink, subprocess) so the
|
||||||
|
test suite runs without a real `gps-denied-replay` install. The actual
|
||||||
|
end-to-end run requires the SUT to be installed (`pip install -e .` at
|
||||||
|
repo root) and is documented as a manual step until CI infrastructure
|
||||||
|
catches up.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""SITL replay fixture builder (AZ-598).
|
||||||
|
|
||||||
|
Vertical-slice tooling that produces the `outbound_messages_<fc_kind>_<host>.json`
|
||||||
|
+ `observer_<fc_kind>_<host>.json` fixtures consumed by the b75 sitl_observer
|
||||||
|
in offline FDR-replay mode.
|
||||||
|
|
||||||
|
Strategy: reuse the production `gps-denied-replay` CLI + `ReplayInputAdapter`
|
||||||
|
to drive the SUT pipeline against a 1 fps MP4 encoded from the FT-P-01 still
|
||||||
|
image set and a synthetic stationary tlog. Read the resulting FDR JSONL and
|
||||||
|
project each per-frame outbound estimate into the fixture schema. This avoids
|
||||||
|
building new SUT-side frame ingestion infrastructure.
|
||||||
|
|
||||||
|
Only the FT-P-01 still-image variant is supported in this batch; FT-P-02 etc.
|
||||||
|
will land as follow-up tickets.
|
||||||
|
|
||||||
|
Public symbols live on the submodule `build_p01_fixtures`; we deliberately
|
||||||
|
do NOT re-export them on the package namespace because the function and
|
||||||
|
the submodule share the name `build_p01_fixtures` and the function would
|
||||||
|
shadow the submodule for `import …build_p01_fixtures as bp` callers.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,471 @@
|
|||||||
|
"""FT-P-01 fixture builder (AZ-598).
|
||||||
|
|
||||||
|
Produces:
|
||||||
|
|
||||||
|
* ``outbound_messages_<fc_kind>_<host>.json`` — per-image SUT outbound GPS
|
||||||
|
estimates, in image-order. ``null`` entries encode per-image timeouts.
|
||||||
|
* ``observer_<fc_kind>_<host>.json`` — minimal observer config so
|
||||||
|
``sitl_observer.get_observer`` succeeds when the fixtures are activated.
|
||||||
|
|
||||||
|
Strategy: drive the production ``gps-denied-replay`` CLI against a 1 fps
|
||||||
|
MP4 encoded from the FT-P-01 still-image set and a synthetic stationary
|
||||||
|
tlog, then read the resulting FDR JSONL for per-frame outbound estimates.
|
||||||
|
Compared with the rejected "live SITL docker capture" path this:
|
||||||
|
|
||||||
|
* Adds no new SUT-side frame-ingestion code (reuses
|
||||||
|
``ReplayInputAdapter`` + ``VideoFileFrameSource``).
|
||||||
|
* Bypasses the SITL container entirely (FT-P-01 tests upstream
|
||||||
|
geo-estimate accuracy; the FC is just a delivery channel).
|
||||||
|
* Runs as a single subprocess instead of a multi-container compose.
|
||||||
|
|
||||||
|
The helpers below are intentionally dependency-injectable so the unit
|
||||||
|
tests can mock OpenCV / pymavlink / subprocess / filesystem without
|
||||||
|
touching real hardware or libraries.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable, Iterable, Sequence
|
||||||
|
|
||||||
|
_LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_FPS = 1.0
|
||||||
|
DEFAULT_TLOG_DURATION_S = 120
|
||||||
|
DEFAULT_TLOG_HZ = 200
|
||||||
|
DEFAULT_FDR_KIND = "outbound_position_estimate"
|
||||||
|
DEFAULT_CLI_BIN = "gps-denied-replay"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BuilderConfig:
|
||||||
|
"""Per-invocation builder configuration."""
|
||||||
|
|
||||||
|
input_dir: Path
|
||||||
|
output_dir: Path
|
||||||
|
fc_kind: str
|
||||||
|
host: str
|
||||||
|
fps: float = DEFAULT_FPS
|
||||||
|
tlog_duration_s: int = DEFAULT_TLOG_DURATION_S
|
||||||
|
tlog_hz: int = DEFAULT_TLOG_HZ
|
||||||
|
fdr_kind: str = DEFAULT_FDR_KIND
|
||||||
|
cli_bin: str = DEFAULT_CLI_BIN
|
||||||
|
|
||||||
|
|
||||||
|
# Step 1 — encode the still images into a 1 fps MP4
|
||||||
|
|
||||||
|
|
||||||
|
def encode_stills_to_mp4(
|
||||||
|
image_paths: Sequence[Path],
|
||||||
|
output_mp4: Path,
|
||||||
|
*,
|
||||||
|
fps: float = DEFAULT_FPS,
|
||||||
|
_video_writer_factory: Callable | None = None,
|
||||||
|
_imread: Callable | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""Encode `image_paths` (in order) as an MP4 at `fps`. Returns frame count.
|
||||||
|
|
||||||
|
Raises ``FileNotFoundError`` when no image paths are supplied or when
|
||||||
|
any input image cannot be read.
|
||||||
|
|
||||||
|
The OpenCV dependencies are injected via the underscore-prefixed
|
||||||
|
parameters so unit tests can run without OpenCV being available.
|
||||||
|
"""
|
||||||
|
if not image_paths:
|
||||||
|
raise FileNotFoundError(
|
||||||
|
"encode_stills_to_mp4: image_paths is empty; nothing to encode"
|
||||||
|
)
|
||||||
|
|
||||||
|
if _video_writer_factory is None or _imread is None:
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
_imread = _imread or (lambda path: cv2.imread(str(path), cv2.IMREAD_COLOR))
|
||||||
|
if _video_writer_factory is None:
|
||||||
|
_fourcc = cv2.VideoWriter_fourcc(*"mp4v")
|
||||||
|
|
||||||
|
def _video_writer_factory(out: Path, width: int, height: int):
|
||||||
|
return cv2.VideoWriter(str(out), _fourcc, fps, (width, height))
|
||||||
|
|
||||||
|
first_frame = _imread(image_paths[0])
|
||||||
|
if first_frame is None:
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"encode_stills_to_mp4: failed to read {image_paths[0]}"
|
||||||
|
)
|
||||||
|
height, width = first_frame.shape[:2]
|
||||||
|
output_mp4.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
writer = _video_writer_factory(output_mp4, width, height)
|
||||||
|
try:
|
||||||
|
writer.write(first_frame)
|
||||||
|
for path in image_paths[1:]:
|
||||||
|
frame = _imread(path)
|
||||||
|
if frame is None:
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"encode_stills_to_mp4: failed to read {path}"
|
||||||
|
)
|
||||||
|
writer.write(frame)
|
||||||
|
finally:
|
||||||
|
writer.release()
|
||||||
|
|
||||||
|
return len(image_paths)
|
||||||
|
|
||||||
|
|
||||||
|
# Step 2 — generate a synthetic stationary tlog
|
||||||
|
|
||||||
|
|
||||||
|
def generate_stationary_tlog(
|
||||||
|
output_tlog: Path,
|
||||||
|
*,
|
||||||
|
duration_s: int = DEFAULT_TLOG_DURATION_S,
|
||||||
|
hz: int = DEFAULT_TLOG_HZ,
|
||||||
|
_mavlink_writer_factory: Callable | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""Write a tlog with `duration_s * hz` stationary RAW_IMU + ATTITUDE pairs.
|
||||||
|
|
||||||
|
The output is the minimum tlog content ``ReplayInputAdapter`` requires:
|
||||||
|
monotonic-timestamp RAW_IMU + ATTITUDE messages so the AZ-405 tlog
|
||||||
|
pre-validator (`AC-13`) doesn't reject the input.
|
||||||
|
|
||||||
|
The samples encode zero accel/gyro/attitude — auto-sync will refuse to
|
||||||
|
find a take-off, so callers MUST drive ``gps-denied-replay`` with an
|
||||||
|
explicit ``--time-offset-ms 0`` to bypass auto-sync.
|
||||||
|
|
||||||
|
Returns the number of message PAIRS written.
|
||||||
|
"""
|
||||||
|
if duration_s <= 0:
|
||||||
|
raise ValueError(f"duration_s must be positive; got {duration_s}")
|
||||||
|
if hz <= 0:
|
||||||
|
raise ValueError(f"hz must be positive; got {hz}")
|
||||||
|
|
||||||
|
if _mavlink_writer_factory is None:
|
||||||
|
from pymavlink import mavutil
|
||||||
|
|
||||||
|
def _mavlink_writer_factory(out: Path):
|
||||||
|
return mavutil.mavlogfile(str(out), write=True)
|
||||||
|
|
||||||
|
output_tlog.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
pairs = 0
|
||||||
|
writer = _mavlink_writer_factory(output_tlog)
|
||||||
|
try:
|
||||||
|
period_us = int(1_000_000 / hz)
|
||||||
|
total_pairs = duration_s * hz
|
||||||
|
for i in range(total_pairs):
|
||||||
|
time_us = i * period_us
|
||||||
|
writer.write(_pack_raw_imu_zero(time_us))
|
||||||
|
writer.write(_pack_attitude_zero(time_us // 1000))
|
||||||
|
pairs += 1
|
||||||
|
finally:
|
||||||
|
close = getattr(writer, "close", None)
|
||||||
|
if callable(close):
|
||||||
|
close()
|
||||||
|
|
||||||
|
return pairs
|
||||||
|
|
||||||
|
|
||||||
|
def _pack_raw_imu_zero(time_usec: int) -> bytes:
|
||||||
|
"""Pack a zero-motion RAW_IMU MAVLink frame (msg id 27).
|
||||||
|
|
||||||
|
Constructed with pymavlink's MAVLink2 packer so the produced bytes are
|
||||||
|
a wire-compatible MAVLink frame including header + CRC. Stationary
|
||||||
|
semantics: all accel/gyro/mag fields are zero except the Z accel which
|
||||||
|
carries one g (gravity, ~9.81 m/s² × 1000 in mg).
|
||||||
|
"""
|
||||||
|
from pymavlink.dialects.v20 import ardupilotmega as mavlink
|
||||||
|
|
||||||
|
packer = mavlink.MAVLink(file=None, srcSystem=1, srcComponent=1)
|
||||||
|
msg = mavlink.MAVLink_raw_imu_message(
|
||||||
|
time_usec=time_usec,
|
||||||
|
xacc=0,
|
||||||
|
yacc=0,
|
||||||
|
zacc=-9810,
|
||||||
|
xgyro=0,
|
||||||
|
ygyro=0,
|
||||||
|
zgyro=0,
|
||||||
|
xmag=0,
|
||||||
|
ymag=0,
|
||||||
|
zmag=0,
|
||||||
|
id=0,
|
||||||
|
temperature=0,
|
||||||
|
)
|
||||||
|
return msg.pack(packer)
|
||||||
|
|
||||||
|
|
||||||
|
def _pack_attitude_zero(time_boot_ms: int) -> bytes:
|
||||||
|
"""Pack a zero-motion ATTITUDE MAVLink frame (msg id 30)."""
|
||||||
|
from pymavlink.dialects.v20 import ardupilotmega as mavlink
|
||||||
|
|
||||||
|
packer = mavlink.MAVLink(file=None, srcSystem=1, srcComponent=1)
|
||||||
|
msg = mavlink.MAVLink_attitude_message(
|
||||||
|
time_boot_ms=time_boot_ms,
|
||||||
|
roll=0.0,
|
||||||
|
pitch=0.0,
|
||||||
|
yaw=0.0,
|
||||||
|
rollspeed=0.0,
|
||||||
|
pitchspeed=0.0,
|
||||||
|
yawspeed=0.0,
|
||||||
|
)
|
||||||
|
return msg.pack(packer)
|
||||||
|
|
||||||
|
|
||||||
|
# Step 3 — drive `gps-denied-replay` against the generated video+tlog
|
||||||
|
|
||||||
|
|
||||||
|
def run_gps_denied_replay(
|
||||||
|
video: Path,
|
||||||
|
tlog: Path,
|
||||||
|
fdr_out: Path,
|
||||||
|
*,
|
||||||
|
cli_bin: str = DEFAULT_CLI_BIN,
|
||||||
|
time_offset_ms: int = 0,
|
||||||
|
extra_args: Sequence[str] = (),
|
||||||
|
_runner: Callable[[Sequence[str]], subprocess.CompletedProcess] | None = None,
|
||||||
|
) -> subprocess.CompletedProcess:
|
||||||
|
"""Run ``gps-denied-replay`` as a subprocess.
|
||||||
|
|
||||||
|
Bypasses auto-sync via ``--time-offset-ms 0`` because the synthetic
|
||||||
|
stationary tlog has no take-off signal to detect.
|
||||||
|
|
||||||
|
Raises ``subprocess.CalledProcessError`` on non-zero exit code (with
|
||||||
|
the FDR path included in the error message). The default subprocess
|
||||||
|
runner can be swapped via the underscore-prefixed parameter for tests.
|
||||||
|
"""
|
||||||
|
fdr_out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
cmd: list[str] = [
|
||||||
|
cli_bin,
|
||||||
|
"--video", str(video),
|
||||||
|
"--tlog", str(tlog),
|
||||||
|
"--time-offset-ms", str(time_offset_ms),
|
||||||
|
"--fdr-out", str(fdr_out),
|
||||||
|
*extra_args,
|
||||||
|
]
|
||||||
|
_LOG.info("running: %s", " ".join(cmd))
|
||||||
|
|
||||||
|
runner = _runner or (lambda c: subprocess.run(c, check=True, capture_output=True, text=True))
|
||||||
|
return runner(cmd)
|
||||||
|
|
||||||
|
|
||||||
|
# Step 4 — extract per-frame outbound estimates from the FDR JSONL
|
||||||
|
|
||||||
|
|
||||||
|
def parse_fdr_for_outbound_estimates(
|
||||||
|
fdr_path: Path,
|
||||||
|
*,
|
||||||
|
fdr_kind: str = DEFAULT_FDR_KIND,
|
||||||
|
lat_key: str = "lat_deg",
|
||||||
|
lon_key: str = "lon_deg",
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Walk `fdr_path` (JSONL) and return outbound-estimate payloads in order.
|
||||||
|
|
||||||
|
A record contributes one entry when its ``kind`` matches `fdr_kind` AND
|
||||||
|
its payload carries both `lat_key` and `lon_key`. Other records are
|
||||||
|
silently skipped (the FDR carries many record types per the
|
||||||
|
`_docs/02_document/contracts/fdr/` schema). Malformed JSON lines raise
|
||||||
|
``ValueError`` with the line number.
|
||||||
|
"""
|
||||||
|
if not fdr_path.is_file():
|
||||||
|
raise FileNotFoundError(f"FDR JSONL not found: {fdr_path}")
|
||||||
|
|
||||||
|
out: list[dict] = []
|
||||||
|
with fdr_path.open("r", encoding="utf-8") as fp:
|
||||||
|
for line_no, line in enumerate(fp, start=1):
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
record = json.loads(line)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ValueError(
|
||||||
|
f"malformed FDR JSON at {fdr_path}:{line_no}: {exc.msg}"
|
||||||
|
) from exc
|
||||||
|
if record.get("kind") != fdr_kind:
|
||||||
|
continue
|
||||||
|
payload = record.get("payload", {})
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
continue
|
||||||
|
if lat_key not in payload or lon_key not in payload:
|
||||||
|
continue
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"lat_deg": float(payload[lat_key]),
|
||||||
|
"lon_deg": float(payload[lon_key]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# Step 5 — write the two fixture files in the b75/b78 schema
|
||||||
|
|
||||||
|
|
||||||
|
def write_outbound_messages_fixture(
|
||||||
|
output_path: Path,
|
||||||
|
image_ids: Sequence[str],
|
||||||
|
estimates: Sequence[dict | None],
|
||||||
|
) -> None:
|
||||||
|
"""Write `outbound_messages_<fc_kind>_<host>.json`.
|
||||||
|
|
||||||
|
`image_ids` and `estimates` must have the same length. `None` entries
|
||||||
|
in `estimates` are persisted as JSON `null` (timeout markers); other
|
||||||
|
entries must carry `lat_deg`/`lon_deg`.
|
||||||
|
"""
|
||||||
|
if len(image_ids) != len(estimates):
|
||||||
|
raise ValueError(
|
||||||
|
f"length mismatch: {len(image_ids)} image_ids vs "
|
||||||
|
f"{len(estimates)} estimates"
|
||||||
|
)
|
||||||
|
messages: list[dict | None] = []
|
||||||
|
for image_id, estimate in zip(image_ids, estimates):
|
||||||
|
if estimate is None:
|
||||||
|
messages.append(None)
|
||||||
|
continue
|
||||||
|
messages.append(
|
||||||
|
{
|
||||||
|
"image_id": image_id,
|
||||||
|
"lat_deg": float(estimate["lat_deg"]),
|
||||||
|
"lon_deg": float(estimate["lon_deg"]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
output_path.write_text(json.dumps({"messages": messages}, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
def write_observer_fixture(output_path: Path) -> None:
|
||||||
|
"""Write minimal `observer_<fc_kind>_<host>.json` so `get_observer` succeeds.
|
||||||
|
|
||||||
|
The FT-P-01 scenario only consumes `wait_for_outbound`, but
|
||||||
|
`get_observer` still requires a valid observer fixture for
|
||||||
|
construction. Populate with safe defaults; per-scenario tests that
|
||||||
|
care about `read_gps_state` carry their own observer fixtures.
|
||||||
|
"""
|
||||||
|
payload = {
|
||||||
|
"gps_state": {
|
||||||
|
"primary_source": "MAV",
|
||||||
|
"last_position_lat_deg": 0.0,
|
||||||
|
"last_position_lon_deg": 0.0,
|
||||||
|
"last_position_alt_m": 0.0,
|
||||||
|
"fix_quality": 3,
|
||||||
|
"horizontal_accuracy_m": 1.0,
|
||||||
|
"last_update_age_ms": 0,
|
||||||
|
},
|
||||||
|
"parameters": {},
|
||||||
|
}
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
output_path.write_text(json.dumps(payload, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
# Orchestration
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_p01_image_paths(input_dir: Path) -> list[Path]:
|
||||||
|
"""Return the AD0000NN.jpg images under `input_dir`, sorted by name."""
|
||||||
|
if not input_dir.is_dir():
|
||||||
|
raise FileNotFoundError(f"input dir not found: {input_dir}")
|
||||||
|
return sorted(input_dir.glob("AD??????.jpg"))
|
||||||
|
|
||||||
|
|
||||||
|
def build_p01_fixtures(
|
||||||
|
cfg: BuilderConfig,
|
||||||
|
*,
|
||||||
|
_runner: Callable[[Sequence[str]], subprocess.CompletedProcess] | None = None,
|
||||||
|
_video_writer_factory: Callable | None = None,
|
||||||
|
_imread: Callable | None = None,
|
||||||
|
_mavlink_writer_factory: Callable | None = None,
|
||||||
|
) -> Path:
|
||||||
|
"""End-to-end FT-P-01 fixture build. Returns the output directory.
|
||||||
|
|
||||||
|
Steps (matches the module docstring):
|
||||||
|
|
||||||
|
1. Resolve the 60 AD0000NN.jpg images under ``cfg.input_dir``.
|
||||||
|
2. Encode them at ``cfg.fps`` into ``stills.mp4`` under ``cfg.output_dir``.
|
||||||
|
3. Generate a stationary ``stationary.tlog`` under ``cfg.output_dir``.
|
||||||
|
4. Run ``gps-denied-replay`` against the pair; write FDR JSONL.
|
||||||
|
5. Project FDR outbound-estimate records into the two fixture files.
|
||||||
|
|
||||||
|
Per-frame timeout handling: if the FDR yields fewer estimates than
|
||||||
|
images, the trailing image_ids get `null` (timeout) entries. If the
|
||||||
|
FDR yields MORE estimates than images (multiple emissions per frame),
|
||||||
|
only the first ``len(image_paths)`` estimates are kept and a WARN is
|
||||||
|
logged so the operator notices the schema mismatch.
|
||||||
|
"""
|
||||||
|
image_paths = _resolve_p01_image_paths(cfg.input_dir)
|
||||||
|
if not image_paths:
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"no AD??????.jpg images found under {cfg.input_dir}"
|
||||||
|
)
|
||||||
|
|
||||||
|
cfg.output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
stills_mp4 = cfg.output_dir / "stills.mp4"
|
||||||
|
stationary_tlog = cfg.output_dir / "stationary.tlog"
|
||||||
|
fdr_jsonl = cfg.output_dir / "fdr.jsonl"
|
||||||
|
|
||||||
|
encode_stills_to_mp4(
|
||||||
|
image_paths, stills_mp4, fps=cfg.fps,
|
||||||
|
_video_writer_factory=_video_writer_factory, _imread=_imread,
|
||||||
|
)
|
||||||
|
generate_stationary_tlog(
|
||||||
|
stationary_tlog,
|
||||||
|
duration_s=cfg.tlog_duration_s,
|
||||||
|
hz=cfg.tlog_hz,
|
||||||
|
_mavlink_writer_factory=_mavlink_writer_factory,
|
||||||
|
)
|
||||||
|
run_gps_denied_replay(
|
||||||
|
stills_mp4, stationary_tlog, fdr_jsonl,
|
||||||
|
cli_bin=cfg.cli_bin, _runner=_runner,
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_estimates = parse_fdr_for_outbound_estimates(fdr_jsonl, fdr_kind=cfg.fdr_kind)
|
||||||
|
estimates: list[dict | None] = list(raw_estimates[: len(image_paths)])
|
||||||
|
if len(raw_estimates) > len(image_paths):
|
||||||
|
_LOG.warning(
|
||||||
|
"FDR carried %d outbound estimates but only %d images were pushed; "
|
||||||
|
"truncating to the per-frame count", len(raw_estimates), len(image_paths)
|
||||||
|
)
|
||||||
|
while len(estimates) < len(image_paths):
|
||||||
|
estimates.append(None)
|
||||||
|
|
||||||
|
outbound_path = cfg.output_dir / f"outbound_messages_{cfg.fc_kind}_{cfg.host}.json"
|
||||||
|
observer_path = cfg.output_dir / f"observer_{cfg.fc_kind}_{cfg.host}.json"
|
||||||
|
write_outbound_messages_fixture(
|
||||||
|
outbound_path,
|
||||||
|
image_ids=[p.name for p in image_paths],
|
||||||
|
estimates=estimates,
|
||||||
|
)
|
||||||
|
write_observer_fixture(observer_path)
|
||||||
|
return cfg.output_dir
|
||||||
|
|
||||||
|
|
||||||
|
def _main(argv: Sequence[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="build_p01_fixtures",
|
||||||
|
description="Build FT-P-01 SITL replay fixtures via gps-denied-replay.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--input-dir", type=Path, required=True,
|
||||||
|
help="Directory containing AD000001..AD000060.jpg")
|
||||||
|
parser.add_argument("--output-dir", type=Path, required=True,
|
||||||
|
help="Output dir for stills.mp4 + stationary.tlog + fixtures")
|
||||||
|
parser.add_argument("--fc-kind", choices=("ardupilot", "inav"), default="ardupilot")
|
||||||
|
parser.add_argument("--host", default="sitl-host")
|
||||||
|
parser.add_argument("--fps", type=float, default=DEFAULT_FPS)
|
||||||
|
parser.add_argument("--cli-bin", default=DEFAULT_CLI_BIN)
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
cfg = BuilderConfig(
|
||||||
|
input_dir=args.input_dir,
|
||||||
|
output_dir=args.output_dir,
|
||||||
|
fc_kind=args.fc_kind,
|
||||||
|
host=args.host,
|
||||||
|
fps=args.fps,
|
||||||
|
cli_bin=args.cli_bin,
|
||||||
|
)
|
||||||
|
build_p01_fixtures(cfg)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
sys.exit(_main())
|
||||||
@@ -25,6 +25,8 @@ Fixture file naming (under `${E2E_SITL_REPLAY_DIR}/`):
|
|||||||
* `gps_health_samples.json` — list[{monotonic_ms, healthy, spoofed}]
|
* `gps_health_samples.json` — list[{monotonic_ms, healthy, spoofed}]
|
||||||
* `consistency_check_events.json` — list[{monotonic_ms, passed}]
|
* `consistency_check_events.json` — list[{monotonic_ms, passed}]
|
||||||
* `observer_<fc_kind>_<host>.json` — {gps_state: {...}, parameters: {...}}
|
* `observer_<fc_kind>_<host>.json` — {gps_state: {...}, parameters: {...}}
|
||||||
|
* `outbound_messages_<fc_kind>_<host>.json` —
|
||||||
|
{messages: [{image_id?, lat_deg, lon_deg} | null, ...]}
|
||||||
* `ap_parameters_<host>.json` — {<param_name>: <value>, ...}
|
* `ap_parameters_<host>.json` — {<param_name>: <value>, ...}
|
||||||
* `ap_tlog_<host>.tlog` — raw mavproxy tlog (any binary content)
|
* `ap_tlog_<host>.tlog` — raw mavproxy tlog (any binary content)
|
||||||
* `inav_handshake_<host>.json` — {established_within_s: float | None}
|
* `inav_handshake_<host>.json` — {established_within_s: float | None}
|
||||||
@@ -39,7 +41,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Iterable, Literal
|
from typing import Iterable, Literal
|
||||||
|
|
||||||
@@ -112,16 +114,41 @@ class InavGpsState:
|
|||||||
provider: str
|
provider: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class OutboundMessage:
|
||||||
|
"""One outbound GPS estimate captured from the SUT.
|
||||||
|
|
||||||
|
Both ArduPilot ``GPS_INPUT`` and iNav ``MSP2_SENSOR_GPS`` are
|
||||||
|
projected into this minimal shape because the scenarios consuming
|
||||||
|
`wait_for_outbound` only care about the geo-coordinates. The
|
||||||
|
optional `image_id` round-trips for diagnostics but is not part
|
||||||
|
of the consumer contract.
|
||||||
|
"""
|
||||||
|
|
||||||
|
lat_deg: float
|
||||||
|
lon_deg: float
|
||||||
|
image_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
# Observer interface (returned by ``get_observer``)
|
# Observer interface (returned by ``get_observer``)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass
|
||||||
class _FdrReplayObserver:
|
class _FdrReplayObserver:
|
||||||
"""FDR-replay observer — reads gps_state + parameters from one JSON file."""
|
"""FDR-replay observer — reads SUT state from JSON fixtures.
|
||||||
|
|
||||||
|
`_payload` holds the observer configuration fixture
|
||||||
|
(`observer_<fc_kind>_<host>.json`). Cursor state for
|
||||||
|
`wait_for_outbound` is intentionally lazy — the outbound-messages
|
||||||
|
fixture is loaded on the first call so observers constructed for
|
||||||
|
scenarios that never call `wait_for_outbound` don't pay the I/O.
|
||||||
|
"""
|
||||||
|
|
||||||
fc_kind: FcKind
|
fc_kind: FcKind
|
||||||
host: str
|
host: str
|
||||||
_payload: dict
|
_payload: dict
|
||||||
|
_outbound_cursor: int = 0
|
||||||
|
_outbound_messages: list[dict | None] | None = field(default=None, repr=False)
|
||||||
|
|
||||||
def read_gps_state(self) -> FcGpsState:
|
def read_gps_state(self) -> FcGpsState:
|
||||||
gps = self._payload.get("gps_state")
|
gps = self._payload.get("gps_state")
|
||||||
@@ -147,6 +174,78 @@ class _FdrReplayObserver:
|
|||||||
)
|
)
|
||||||
return params.get(name)
|
return params.get(name)
|
||||||
|
|
||||||
|
def wait_for_outbound(self, timeout_s: float | None = None) -> OutboundMessage:
|
||||||
|
"""Return the next captured outbound GPS estimate (cursor-based replay).
|
||||||
|
|
||||||
|
`timeout_s` is accepted for live-mode parity and ignored in
|
||||||
|
replay mode — the fixture already encodes per-call timeouts
|
||||||
|
as `null` entries.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TimeoutError: cursor entry is `null` (SUT didn't emit
|
||||||
|
anything for the corresponding image during capture).
|
||||||
|
RuntimeError: fixture missing OR malformed OR cursor
|
||||||
|
advanced past the messages list length.
|
||||||
|
"""
|
||||||
|
if self._outbound_messages is None:
|
||||||
|
self._outbound_messages = _load_outbound_messages(self.fc_kind, self.host)
|
||||||
|
|
||||||
|
if self._outbound_cursor >= len(self._outbound_messages):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"sitl_observer ({self.fc_kind}/{self.host}): "
|
||||||
|
f"outbound messages fixture exhausted after "
|
||||||
|
f"{self._outbound_cursor} call(s); scenario expects more"
|
||||||
|
)
|
||||||
|
|
||||||
|
entry = self._outbound_messages[self._outbound_cursor]
|
||||||
|
self._outbound_cursor += 1
|
||||||
|
|
||||||
|
if entry is None:
|
||||||
|
raise TimeoutError(
|
||||||
|
f"sitl_observer ({self.fc_kind}/{self.host}): "
|
||||||
|
f"outbound message #{self._outbound_cursor} captured as "
|
||||||
|
f"timeout in fixture (timeout_s={timeout_s})"
|
||||||
|
)
|
||||||
|
|
||||||
|
return OutboundMessage(
|
||||||
|
lat_deg=float(entry["lat_deg"]),
|
||||||
|
lon_deg=float(entry["lon_deg"]),
|
||||||
|
image_id=entry.get("image_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_outbound_messages(fc_kind: FcKind, host: str) -> list[dict | None]:
|
||||||
|
"""Load + validate `outbound_messages_<fc_kind>_<host>.json`.
|
||||||
|
|
||||||
|
Returns the validated `messages` list (None entries preserved).
|
||||||
|
Raises RuntimeError on any malformed shape so observers fail
|
||||||
|
loudly rather than hand out garbage.
|
||||||
|
"""
|
||||||
|
payload, path = _load_required_json(f"outbound_messages_{fc_kind}_{host}.json")
|
||||||
|
raw = payload.get("messages")
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"sitl_observer outbound fixture {path}: "
|
||||||
|
f"`messages` must be a JSON list; got {type(raw).__name__}"
|
||||||
|
)
|
||||||
|
validated: list[dict | None] = []
|
||||||
|
for idx, entry in enumerate(raw):
|
||||||
|
if entry is None:
|
||||||
|
validated.append(None)
|
||||||
|
continue
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"sitl_observer outbound fixture {path}: "
|
||||||
|
f"messages[{idx}] must be a JSON object or null; got {type(entry).__name__}"
|
||||||
|
)
|
||||||
|
if "lat_deg" not in entry or "lon_deg" not in entry:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"sitl_observer outbound fixture {path}: "
|
||||||
|
f"messages[{idx}] missing required `lat_deg`/`lon_deg` keys"
|
||||||
|
)
|
||||||
|
validated.append(entry)
|
||||||
|
return validated
|
||||||
|
|
||||||
|
|
||||||
# Module-level helpers
|
# Module-level helpers
|
||||||
|
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ def test_ft_p_01_still_image_accuracy(
|
|||||||
|
|
||||||
# 2. Resolve the SITL listener for the requested FC adapter.
|
# 2. Resolve the SITL listener for the requested FC adapter.
|
||||||
sitl_host = "sitl-ardupilot" if fc_adapter == "ardupilot" else "sitl-inav"
|
sitl_host = "sitl-ardupilot" if fc_adapter == "ardupilot" else "sitl-inav"
|
||||||
observer = sitl_observer.get_observer(fc_adapter=fc_adapter, host=sitl_host)
|
observer = sitl_observer.get_observer(fc_kind=fc_adapter, host=sitl_host)
|
||||||
sink = _resolve_frame_sink()
|
sink = _resolve_frame_sink()
|
||||||
replayer = FrameSourceReplayer(sink)
|
replayer = FrameSourceReplayer(sink)
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ def test_ft_p_05_sat_anchor(
|
|||||||
|
|
||||||
# 2. Push images, collect (est_lat, est_lon, mre_px) per image.
|
# 2. Push images, collect (est_lat, est_lon, mre_px) per image.
|
||||||
sitl_host = "sitl-ardupilot" if fc_adapter == "ardupilot" else "sitl-inav"
|
sitl_host = "sitl-ardupilot" if fc_adapter == "ardupilot" else "sitl-inav"
|
||||||
observer = sitl_observer.get_observer(fc_adapter=fc_adapter, host=sitl_host)
|
observer = sitl_observer.get_observer(fc_kind=fc_adapter, host=sitl_host)
|
||||||
sink = _resolve_frame_sink()
|
sink = _resolve_frame_sink()
|
||||||
replayer = FrameSourceReplayer(sink)
|
replayer = FrameSourceReplayer(sink)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user