mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 22:01:14 +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:
@@ -25,6 +25,8 @@ Fixture file naming (under `${E2E_SITL_REPLAY_DIR}/`):
|
||||
* `gps_health_samples.json` — list[{monotonic_ms, healthy, spoofed}]
|
||||
* `consistency_check_events.json` — list[{monotonic_ms, passed}]
|
||||
* `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_tlog_<host>.tlog` — raw mavproxy tlog (any binary content)
|
||||
* `inav_handshake_<host>.json` — {established_within_s: float | None}
|
||||
@@ -39,7 +41,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Literal
|
||||
|
||||
@@ -112,16 +114,41 @@ class InavGpsState:
|
||||
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``)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@dataclass
|
||||
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
|
||||
host: str
|
||||
_payload: dict
|
||||
_outbound_cursor: int = 0
|
||||
_outbound_messages: list[dict | None] | None = field(default=None, repr=False)
|
||||
|
||||
def read_gps_state(self) -> FcGpsState:
|
||||
gps = self._payload.get("gps_state")
|
||||
@@ -147,6 +174,78 @@ class _FdrReplayObserver:
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user