[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:
Oleksandr Bezdieniezhnykh
2026-05-17 12:08:02 +03:00
parent f49d803252
commit 47ad43f913
14 changed files with 1940 additions and 8 deletions
+102 -3
View File
@@ -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