[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
@@ -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()
# 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_*
+3
View File
@@ -57,6 +57,9 @@ E2E_ROOT = Path(__file__).resolve().parents[1]
"runner/helpers/blackout_spoof_evaluator.py",
"runner/helpers/fc_proxy_runtime.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/app.py",
"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())
+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
@@ -87,7 +87,7 @@ def test_ft_p_01_still_image_accuracy(
# 2. Resolve the SITL listener for the requested FC adapter.
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()
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.
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()
replayer = FrameSourceReplayer(sink)