[AZ-600] Batch 80: refactor sitl_replay_builder to strategy pattern

Replace per-scenario fixture builders with a parameterized strategy
framework so future Derkachi-based scenarios compose existing pieces
instead of duplicating ~200 lines of orchestration per scenario.

New e2e/fixtures/sitl_replay_builder/builder.py:
- VideoSource ABC + StillImagesSource, Mp4PassthroughSource
- TlogSource ABC + SyntheticStationaryTlog, ImuCsvTlog
- FdrProjection ABC + RawFdrPassthrough, OutboundMessagesProjection
- FixtureBuilderConfig + build_fixtures(cfg) orchestrator
- Consolidated MAVLink pack_raw_imu / pack_attitude helpers
- Consolidated run_gps_denied_replay + write_observer_fixture

build_p01_fixtures.py: 423 -> 107 lines (75% reduction).
build_p02_fixtures.py: 292 -> 98 lines (66% reduction).
_common.py: deleted (folded into builder.py).

Tests reorganized:
- test_sitl_replay_builder_builder.py (new, 33 strategy-level tests)
- test_sitl_replay_builder.py (slimmed, 6 FT-P-01 integration)
- test_sitl_replay_builder_p02.py (slimmed, 7 FT-P-02 integration)

README documents the strategy framework + a worked example for
adding FT-P-04 in ~30 lines (no new strategy code required).

Regression gate: 700 passing (was 686; +14 from finer-grained
coverage of new strategy classes and the build_fixtures orchestrator).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-17 14:19:08 +03:00
parent 4e0717e543
commit 7fb3cb3f34
13 changed files with 2050 additions and 1272 deletions
@@ -1,9 +1,8 @@
"""Unit tests for `e2e/fixtures/sitl_replay_builder/build_p01_fixtures.py` (AZ-598).
"""Integration tests for `e2e/fixtures/sitl_replay_builder/build_p01_fixtures.py` (FT-P-01).
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).
Strategy-level unit tests for the underlying ``builder.py`` machinery live
in ``test_sitl_replay_builder_builder.py``. This file exercises the
FT-P-01 scenario composition end-to-end (with all external deps mocked).
"""
from __future__ import annotations
@@ -12,378 +11,76 @@ 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
import e2e.fixtures.sitl_replay_builder.build_p01_fixtures as bp01
# encode_stills_to_mp4
def _mk_fake_writer():
def _mk_fake_video_writer() -> MagicMock:
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):
# resolve_p01_image_paths
def test_resolve_p01_image_paths_missing_dir_raises(tmp_path: Path):
# Assert
with pytest.raises(FileNotFoundError, match="FDR JSONL not found"):
bp.parse_fdr_for_outbound_estimates(tmp_path / "missing.jsonl")
with pytest.raises(FileNotFoundError, match="input dir not found"):
bp01.resolve_p01_image_paths(tmp_path / "missing")
def test_parse_fdr_filters_by_kind(tmp_path: Path):
def test_resolve_p01_image_paths_sorted(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}},
])
for n in (3, 1, 2):
(tmp_path / f"AD{n:06d}.jpg").touch()
(tmp_path / "ignored.txt").touch()
# Act
estimates = bp.parse_fdr_for_outbound_estimates(fdr)
paths = bp01.resolve_p01_image_paths(tmp_path)
# Assert
assert estimates == [
{"lat_deg": 1.0, "lon_deg": 2.0},
{"lat_deg": 3.0, "lon_deg": 4.0},
]
# Assert — only AD*.jpg, sorted by name
assert [p.name for p in paths] == ["AD000001.jpg", "AD000002.jpg", "AD000003.jpg"]
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)
# build_p01_fixtures end-to-end
def test_build_p01_fixtures_no_images_raises(tmp_path: Path):
# Arrange
cfg = bp.BuilderConfig(
(tmp_path / "empty").mkdir()
cfg = bp01.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)
bp01.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.
# Arrange — 3 fake AD000NN.jpg files, mocked OpenCV / pymavlink / subprocess
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()
writer = _mk_fake_video_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}},
@@ -392,13 +89,13 @@ def test_build_p01_fixtures_end_to_end_with_mocks(tmp_path: Path):
])
return subprocess.CompletedProcess(cmd, 0)
cfg = bp.BuilderConfig(
cfg = bp01.BuilderConfig(
input_dir=input_dir, output_dir=output_dir,
fc_kind="ardupilot", host="sitl-host",
)
# Act
result_dir = bp.build_p01_fixtures(
result_dir = bp01.build_p01_fixtures(
cfg,
_runner=fake_runner,
_video_writer_factory=lambda out, w, h: writer,
@@ -420,7 +117,7 @@ def test_build_p01_fixtures_end_to_end_with_mocks(tmp_path: Path):
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.
# Arrange — 3 frames, FDR yields 1 estimate; expect 2 null entries
input_dir = tmp_path / "in"
output_dir = tmp_path / "out"
input_dir.mkdir()
@@ -434,16 +131,16 @@ def test_build_p01_fixtures_fewer_estimates_than_frames_pads_nulls(tmp_path: Pat
])
return subprocess.CompletedProcess(cmd, 0)
cfg = bp.BuilderConfig(
cfg = bp01.BuilderConfig(
input_dir=input_dir, output_dir=output_dir,
fc_kind="ardupilot", host="sitl-host",
)
# Act
bp.build_p01_fixtures(
bp01.build_p01_fixtures(
cfg,
_runner=fake_runner,
_video_writer_factory=lambda out, w, h: _mk_fake_writer(),
_video_writer_factory=lambda out, w, h: _mk_fake_video_writer(),
_imread=lambda p: types.SimpleNamespace(shape=(480, 640, 3)),
_mavlink_writer_factory=lambda out: MagicMock(write=MagicMock(), close=MagicMock()),
)
@@ -456,7 +153,7 @@ def test_build_p01_fixtures_fewer_estimates_than_frames_pads_nulls(tmp_path: Pat
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.
# Arrange — 2 frames, FDR yields 4 estimates; expect 2 retained + WARN
input_dir = tmp_path / "in"
output_dir = tmp_path / "out"
input_dir.mkdir()
@@ -471,17 +168,17 @@ def test_build_p01_fixtures_more_estimates_than_frames_truncates(tmp_path: Path,
])
return subprocess.CompletedProcess(cmd, 0)
cfg = bp.BuilderConfig(
cfg = bp01.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(
bp01.build_p01_fixtures(
cfg,
_runner=fake_runner,
_video_writer_factory=lambda out, w, h: _mk_fake_writer(),
_video_writer_factory=lambda out, w, h: _mk_fake_video_writer(),
_imread=lambda p: types.SimpleNamespace(shape=(480, 640, 3)),
_mavlink_writer_factory=lambda out: MagicMock(write=MagicMock(), close=MagicMock()),
)
@@ -0,0 +1,749 @@
"""Strategy-level unit tests for `e2e/fixtures/sitl_replay_builder/builder.py` (AZ-600).
These tests exercise the parameterized strategies + helpers in isolation.
Per-scenario integration tests live next to each scenario builder
(`test_sitl_replay_builder.py` for FT-P-01, `test_sitl_replay_builder_p02.py`
for FT-P-02).
"""
from __future__ import annotations
import csv
import json
import math
import subprocess
import types
from pathlib import Path
from typing import Sequence
from unittest.mock import MagicMock
import pytest
from e2e.fixtures.sitl_replay_builder import builder as bd
# ---------------------------------------------------------------------------
# Test helpers
# ---------------------------------------------------------------------------
def _mk_fake_video_writer() -> MagicMock:
w = MagicMock(name="VideoWriter")
w.write = MagicMock()
w.release = MagicMock()
return w
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))
_IMU_HEADER_ROW = (
"timestamp(ms),Time,SCALED_IMU2.xacc,SCALED_IMU2.yacc,SCALED_IMU2.zacc,"
"SCALED_IMU2.xgyro,SCALED_IMU2.ygyro,SCALED_IMU2.zgyro,"
"SCALED_IMU2.xmag,SCALED_IMU2.ymag,SCALED_IMU2.zmag,"
"GLOBAL_POSITION_INT.lat,GLOBAL_POSITION_INT.lon,GLOBAL_POSITION_INT.alt,"
"GLOBAL_POSITION_INT.relative_alt,GLOBAL_POSITION_INT.vx,GLOBAL_POSITION_INT.vy,"
"GLOBAL_POSITION_INT.vz,GLOBAL_POSITION_INT.hdg"
)
def _good_imu_row(ts_ms: float, hdg: int = 35041) -> list:
return [
ts_ms, 0.0,
21, -3, -984,
52, 32, -5,
312, -1048, 442,
50_080_963_4, 36_111_544_2, 141_290, 23_182,
-4, -6, -88,
hdg,
]
def _write_imu_csv(path: Path, rows: list[list]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", newline="", encoding="utf-8") as fp:
fp.write(_IMU_HEADER_ROW + "\n")
writer = csv.writer(fp)
for row in rows:
writer.writerow(row)
# ---------------------------------------------------------------------------
# StillImagesSource
# ---------------------------------------------------------------------------
def test_still_images_source_empty_paths_raises(tmp_path: Path):
# Assert
with pytest.raises(FileNotFoundError, match="image_paths is empty"):
bd.StillImagesSource(image_paths=[]).materialize(
tmp_path / "out.mp4",
_video_writer_factory=lambda *a, **kw: _mk_fake_video_writer(),
_imread=lambda p: None,
)
def test_still_images_source_writes_each_frame(tmp_path: Path):
# Arrange
writer = _mk_fake_video_writer()
frame = types.SimpleNamespace(shape=(480, 640, 3))
paths = [tmp_path / f"img-{i}.jpg" for i in range(3)]
# Act
result = bd.StillImagesSource(image_paths=paths).materialize(
tmp_path / "out.mp4",
_video_writer_factory=lambda out, w, h: writer,
_imread=lambda p: frame,
)
# Assert
assert result == tmp_path / "out.mp4"
assert writer.write.call_count == 3
assert writer.release.call_count == 1
def test_still_images_source_failed_read_raises(tmp_path: Path):
# Arrange
writer = _mk_fake_video_writer()
frame_ok = types.SimpleNamespace(shape=(480, 640, 3))
def imread(path: 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"):
bd.StillImagesSource(
image_paths=[tmp_path / f"img-{i}.jpg" for i in range(3)],
).materialize(
tmp_path / "out.mp4",
_video_writer_factory=lambda out, w, h: writer,
_imread=imread,
)
# ---------------------------------------------------------------------------
# Mp4PassthroughSource
# ---------------------------------------------------------------------------
def test_mp4_passthrough_returns_real_path(tmp_path: Path):
# Arrange
mp4 = tmp_path / "flight.mp4"
mp4.touch()
# Act
result = bd.Mp4PassthroughSource(mp4_path=mp4).materialize(tmp_path / "ignored.mp4")
# Assert — pass-through returns the real path, not output_path
assert result == mp4
def test_mp4_passthrough_missing_raises(tmp_path: Path):
# Assert
with pytest.raises(FileNotFoundError, match="MP4 not found"):
bd.Mp4PassthroughSource(mp4_path=tmp_path / "missing.mp4").materialize(
tmp_path / "ignored.mp4"
)
# ---------------------------------------------------------------------------
# SyntheticStationaryTlog
# ---------------------------------------------------------------------------
def test_synthetic_stationary_writes_pairs(tmp_path: Path):
# Arrange
writer = MagicMock(write=MagicMock(), close=MagicMock())
# Act
result = bd.SyntheticStationaryTlog(duration_s=2, hz=10).materialize(
tmp_path / "out.tlog", _mavlink_writer_factory=lambda out: writer,
)
# Assert — 20 pairs × 2 messages = 40 writes
assert result == tmp_path / "out.tlog"
assert writer.write.call_count == 40
assert writer.close.call_count == 1
def test_synthetic_stationary_rejects_nonpositive_duration(tmp_path: Path):
# Assert
with pytest.raises(ValueError, match="duration_s must be positive"):
bd.SyntheticStationaryTlog(duration_s=0).materialize(
tmp_path / "out.tlog", _mavlink_writer_factory=lambda out: MagicMock(),
)
def test_synthetic_stationary_rejects_nonpositive_hz(tmp_path: Path):
# Assert
with pytest.raises(ValueError, match="hz must be positive"):
bd.SyntheticStationaryTlog(hz=0).materialize(
tmp_path / "out.tlog", _mavlink_writer_factory=lambda out: MagicMock(),
)
def test_synthetic_stationary_real_pymavlink_round_trip(tmp_path: Path):
# Act — use the real pymavlink packers
result = bd.SyntheticStationaryTlog(duration_s=1, hz=10).materialize(tmp_path / "out.tlog")
# Assert
assert result.is_file()
assert result.stat().st_size > 0
# ---------------------------------------------------------------------------
# ImuCsvTlog
# ---------------------------------------------------------------------------
def test_imu_csv_tlog_missing_file_raises(tmp_path: Path):
# Assert
with pytest.raises(FileNotFoundError, match="IMU CSV not found"):
bd.ImuCsvTlog(csv_path=tmp_path / "missing.csv").materialize(
tmp_path / "out.tlog", _mavlink_writer_factory=lambda out: MagicMock(),
)
def test_imu_csv_tlog_empty_raises(tmp_path: Path):
# Arrange — header only, no data rows
csv_path = tmp_path / "imu.csv"
_write_imu_csv(csv_path, [])
# Assert
with pytest.raises(ValueError, match="IMU CSV is empty"):
bd.ImuCsvTlog(csv_path=csv_path).materialize(
tmp_path / "out.tlog", _mavlink_writer_factory=lambda out: MagicMock(),
)
def test_imu_csv_tlog_missing_required_column_raises(tmp_path: Path):
# Arrange
csv_path = tmp_path / "imu.csv"
csv_path.write_text("timestamp(ms),Time\n0,0\n")
# Assert
with pytest.raises(ValueError, match="missing required columns"):
bd.ImuCsvTlog(csv_path=csv_path).materialize(
tmp_path / "out.tlog", _mavlink_writer_factory=lambda out: MagicMock(),
)
def test_imu_csv_tlog_malformed_numeric_raises(tmp_path: Path):
# Arrange — column 2 (xacc) is non-numeric
csv_path = tmp_path / "imu.csv"
row = _good_imu_row(0.0)
row[2] = "not-a-number"
_write_imu_csv(csv_path, [row])
# Assert
with pytest.raises(ValueError, match="malformed IMU CSV row"):
bd.ImuCsvTlog(csv_path=csv_path).materialize(
tmp_path / "out.tlog", _mavlink_writer_factory=lambda out: MagicMock(),
)
def test_imu_csv_tlog_writes_pair_per_row(tmp_path: Path):
# Arrange
csv_path = tmp_path / "imu.csv"
_write_imu_csv(csv_path, [_good_imu_row(0.0), _good_imu_row(100.0), _good_imu_row(200.0)])
writer = MagicMock(write=MagicMock(), close=MagicMock())
# Act
result = bd.ImuCsvTlog(csv_path=csv_path).materialize(
tmp_path / "out.tlog", _mavlink_writer_factory=lambda out: writer,
)
# Assert — 3 rows → 3 pairs → 6 writes
assert result == tmp_path / "out.tlog"
assert writer.write.call_count == 6
assert writer.close.call_count == 1
def test_imu_csv_tlog_real_pymavlink_round_trip(tmp_path: Path):
# Arrange
csv_path = tmp_path / "imu.csv"
_write_imu_csv(csv_path, [_good_imu_row(0.0), _good_imu_row(100.0)])
# Act
result = bd.ImuCsvTlog(csv_path=csv_path).materialize(tmp_path / "out.tlog")
# Assert
assert result.is_file()
assert result.stat().st_size > 0
# ---------------------------------------------------------------------------
# hdg_centideg_to_rad
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"centideg,expected_rad",
[
(0, 0.0),
(9000, math.pi / 2),
(18000, math.pi),
(27000, 3 * math.pi / 2),
(35990, 35990 * math.pi / 18000),
],
)
def test_hdg_centideg_to_rad(centideg: int, expected_rad: float):
# Assert
assert bd.hdg_centideg_to_rad(centideg) == pytest.approx(expected_rad)
# ---------------------------------------------------------------------------
# pack_raw_imu / pack_attitude
# ---------------------------------------------------------------------------
def test_pack_raw_imu_returns_nonempty_bytes():
# Act
stationary = bd.pack_raw_imu(0, zacc=bd.STATIONARY_Z_ACCEL_MG)
real_motion = bd.pack_raw_imu(100, xacc=21, yacc=-3, zacc=-984)
# Assert
assert isinstance(stationary, (bytes, bytearray)) and len(stationary) > 0
assert isinstance(real_motion, (bytes, bytearray)) and len(real_motion) > 0
assert stationary != real_motion
def test_pack_attitude_returns_nonempty_bytes():
# Act
zero_yaw = bd.pack_attitude(0)
real_yaw = bd.pack_attitude(100, yaw=math.pi / 2)
# Assert
assert isinstance(zero_yaw, (bytes, bytearray)) and len(zero_yaw) > 0
assert isinstance(real_yaw, (bytes, bytearray)) and len(real_yaw) > 0
assert zero_yaw != real_yaw
# ---------------------------------------------------------------------------
# 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
bd.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
bd.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
bd.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
assert "--pace=ASAP" in captured[0] and "--log-level=INFO" in captured[0]
# ---------------------------------------------------------------------------
# parse_fdr_for_outbound_estimates
# ---------------------------------------------------------------------------
def test_parse_fdr_missing_file_raises(tmp_path: Path):
# Assert
with pytest.raises(FileNotFoundError, match="FDR JSONL not found"):
bd.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 = bd.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}},
{"kind": "outbound_position_estimate", "payload": {"lon_deg": 2.0}},
{"kind": "outbound_position_estimate", "payload": {"lat_deg": 1.0, "lon_deg": 2.0}},
])
# Act
estimates = bd.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 = bd.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 = bd.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"):
bd.parse_fdr_for_outbound_estimates(fdr)
# ---------------------------------------------------------------------------
# verify_fdr_has_estimates
# ---------------------------------------------------------------------------
def test_verify_fdr_missing_file_raises(tmp_path: Path):
# Assert
with pytest.raises(FileNotFoundError, match="FDR JSONL not found"):
bd.verify_fdr_has_estimates(tmp_path / "missing.jsonl")
def test_verify_fdr_no_estimates_raises(tmp_path: Path):
# Arrange
fdr = tmp_path / "fdr.jsonl"
_write_jsonl(fdr, [
{"record_type": "other", "payload": {}},
{"record_type": "imu_tick", "payload": {}},
])
# Assert
with pytest.raises(ValueError, match="zero estimate records"):
bd.verify_fdr_has_estimates(fdr)
def test_verify_fdr_counts_estimates(tmp_path: Path):
# Arrange
fdr = tmp_path / "fdr.jsonl"
_write_jsonl(fdr, [
{"record_type": "estimate", "payload": {}},
{"record_type": "other", "payload": {}},
{"record_type": "estimate", "payload": {}},
{"record_type": "estimate", "payload": {}},
])
# Act
count = bd.verify_fdr_has_estimates(fdr)
# Assert
assert count == 3
def test_verify_fdr_tolerates_malformed_lines(tmp_path: Path):
# Arrange — one bad JSON line interleaved with good estimate records
fdr = tmp_path / "fdr.jsonl"
fdr.write_text(
json.dumps({"record_type": "estimate"}) + "\n"
+ "{not valid json\n"
+ json.dumps({"record_type": "estimate"}) + "\n"
)
# Act
count = bd.verify_fdr_has_estimates(fdr)
# Assert
assert count == 2
# ---------------------------------------------------------------------------
# RawFdrPassthrough
# ---------------------------------------------------------------------------
def test_raw_fdr_passthrough_verifies_by_default(tmp_path: Path):
# Arrange
fdr = tmp_path / "fdr.jsonl"
_write_jsonl(fdr, [{"record_type": "imu_tick"}])
# Assert
with pytest.raises(ValueError, match="zero estimate records"):
bd.RawFdrPassthrough().materialize(fdr, tmp_path, "ardupilot", "sitl-host")
def test_raw_fdr_passthrough_skips_verify_when_disabled(tmp_path: Path):
# Arrange — file with no estimates, verify disabled
fdr = tmp_path / "fdr.jsonl"
_write_jsonl(fdr, [{"record_type": "imu_tick"}])
# Act — should not raise
bd.RawFdrPassthrough(verify_estimates=False).materialize(
fdr, tmp_path, "ardupilot", "sitl-host",
)
# ---------------------------------------------------------------------------
# OutboundMessagesProjection
# ---------------------------------------------------------------------------
def test_outbound_projection_writes_full_messages(tmp_path: Path):
# Arrange
fdr = tmp_path / "fdr.jsonl"
_write_jsonl(fdr, [
{"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}},
])
# Act
bd.OutboundMessagesProjection(image_ids=["a.jpg", "b.jpg"]).materialize(
fdr, tmp_path, "ardupilot", "sitl-host",
)
# Assert
payload = json.loads((tmp_path / "outbound_messages_ardupilot_sitl-host.json").read_text())
assert payload == {
"messages": [
{"image_id": "a.jpg", "lat_deg": 1.0, "lon_deg": 2.0},
{"image_id": "b.jpg", "lat_deg": 3.0, "lon_deg": 4.0},
]
}
def test_outbound_projection_pads_with_null(tmp_path: Path):
# Arrange — 3 image_ids, FDR has only 1 estimate
fdr = tmp_path / "fdr.jsonl"
_write_jsonl(fdr, [
{"kind": "outbound_position_estimate", "payload": {"lat_deg": 1.0, "lon_deg": 2.0}},
])
# Act
bd.OutboundMessagesProjection(image_ids=["a.jpg", "b.jpg", "c.jpg"]).materialize(
fdr, tmp_path, "ardupilot", "sitl-host",
)
# Assert
payload = json.loads((tmp_path / "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_outbound_projection_truncates_and_warns(tmp_path: Path, caplog):
# Arrange — 2 image_ids, FDR has 4 estimates
fdr = tmp_path / "fdr.jsonl"
_write_jsonl(fdr, [
{"kind": "outbound_position_estimate", "payload": {"lat_deg": float(i), "lon_deg": float(i)}}
for i in range(4)
])
# Act
with caplog.at_level("WARNING"):
bd.OutboundMessagesProjection(image_ids=["a.jpg", "b.jpg"]).materialize(
fdr, tmp_path, "ardupilot", "sitl-host",
)
# Assert
payload = json.loads((tmp_path / "outbound_messages_ardupilot_sitl-host.json").read_text())
assert len(payload["messages"]) == 2
assert any("truncating" in rec.message for rec in caplog.records)
def test_outbound_projection_length_mismatch_safe(tmp_path: Path):
# Arrange — projection always reconciles to image_ids count (no length mismatch raises)
fdr = tmp_path / "fdr.jsonl"
_write_jsonl(fdr, [
{"kind": "outbound_position_estimate", "payload": {"lat_deg": 1.0, "lon_deg": 2.0}},
])
# Act — single image, single estimate
bd.OutboundMessagesProjection(image_ids=["only.jpg"]).materialize(
fdr, tmp_path, "ardupilot", "sitl-host",
)
# Assert
payload = json.loads((tmp_path / "outbound_messages_ardupilot_sitl-host.json").read_text())
assert payload["messages"] == [{"image_id": "only.jpg", "lat_deg": 1.0, "lon_deg": 2.0}]
# ---------------------------------------------------------------------------
# write_observer_fixture
# ---------------------------------------------------------------------------
def test_write_observer_fixture_schema(tmp_path: Path):
# Arrange
out = tmp_path / "observer.json"
# Act
bd.write_observer_fixture(out)
# Assert
payload = json.loads(out.read_text())
assert "gps_state" in payload
assert payload["gps_state"]["primary_source"] == "MAV"
assert "parameters" in payload
def test_write_observer_fixture_creates_parent(tmp_path: Path):
# Arrange
out = tmp_path / "deep" / "nested" / "observer.json"
# Act
bd.write_observer_fixture(out)
# Assert
assert out.is_file()
# ---------------------------------------------------------------------------
# build_fixtures orchestrator
# ---------------------------------------------------------------------------
def test_build_fixtures_orchestrator_composes_strategies(tmp_path: Path):
# Arrange — synthetic stationary tlog + still images + outbound projection
input_dir = tmp_path / "in"
output_dir = tmp_path / "out"
input_dir.mkdir()
image_paths = []
for n in range(1, 3):
p = input_dir / f"AD{n:06d}.jpg"
p.touch()
image_paths.append(p)
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}},
{"kind": "outbound_position_estimate", "payload": {"lat_deg": 3.0, "lon_deg": 4.0}},
])
return subprocess.CompletedProcess(cmd, 0)
cfg = bd.FixtureBuilderConfig(
video_source=bd.StillImagesSource(image_paths=image_paths),
tlog_source=bd.SyntheticStationaryTlog(duration_s=1, hz=10),
fdr_projection=bd.OutboundMessagesProjection(
image_ids=[p.name for p in image_paths],
),
output_dir=output_dir,
)
# Act
result = bd.build_fixtures(
cfg,
_runner=fake_runner,
_video_writer_factory=lambda out, w, h: _mk_fake_video_writer(),
_imread=lambda p: types.SimpleNamespace(shape=(480, 640, 3)),
_mavlink_writer_factory=lambda out: MagicMock(write=MagicMock(), close=MagicMock()),
)
# Assert
assert result == output_dir
assert (output_dir / "observer_ardupilot_sitl-host.json").is_file()
payload = json.loads((output_dir / "outbound_messages_ardupilot_sitl-host.json").read_text())
assert len(payload["messages"]) == 2
def test_build_fixtures_uses_passthrough_video(tmp_path: Path):
# Arrange — Mp4PassthroughSource returns the real path; verify the CLI got it
mp4 = tmp_path / "flight.mp4"
mp4.touch()
output_dir = tmp_path / "out"
captured_cmd: list = []
def fake_runner(cmd):
captured_cmd.append(list(cmd))
fdr_path = Path(cmd[cmd.index("--fdr-out") + 1])
_write_jsonl(fdr_path, [{"record_type": "estimate"}])
return subprocess.CompletedProcess(cmd, 0)
cfg = bd.FixtureBuilderConfig(
video_source=bd.Mp4PassthroughSource(mp4_path=mp4),
tlog_source=bd.SyntheticStationaryTlog(duration_s=1, hz=10),
fdr_projection=bd.RawFdrPassthrough(verify_estimates=True),
output_dir=output_dir,
fdr_subdir="fdr", fdr_filename="fdr.jsonl",
)
# Act
bd.build_fixtures(
cfg, _runner=fake_runner,
_mavlink_writer_factory=lambda out: MagicMock(write=MagicMock(), close=MagicMock()),
)
# Assert — the CLI received the real MP4 path, not output_dir/video.mp4
assert str(mp4) in captured_cmd[0]
@@ -1,19 +1,17 @@
"""Unit tests for `e2e/fixtures/sitl_replay_builder/build_p02_fixtures.py` (AZ-599).
"""Integration tests for `e2e/fixtures/sitl_replay_builder/build_p02_fixtures.py` (FT-P-02).
All external dependencies (pymavlink, subprocess) are injected via the
underscore-prefixed parameters. The IMU CSV is small enough that we
hand-author it inline for each test rather than depending on the real
Derkachi data file.
Strategy-level unit tests for the underlying ``builder.py`` machinery
(including ``ImuCsvTlog``, ``Mp4PassthroughSource``, ``RawFdrPassthrough``,
``verify_fdr_has_estimates``) live in ``test_sitl_replay_builder_builder.py``.
This file exercises the FT-P-02 scenario composition end-to-end.
"""
from __future__ import annotations
import csv
import json
import math
import subprocess
from pathlib import Path
from typing import Sequence
from unittest.mock import MagicMock
import pytest
@@ -21,7 +19,7 @@ import pytest
import e2e.fixtures.sitl_replay_builder.build_p02_fixtures as bp02
_HEADER_ROW = (
_IMU_HEADER_ROW = (
"timestamp(ms),Time,SCALED_IMU2.xacc,SCALED_IMU2.yacc,SCALED_IMU2.zacc,"
"SCALED_IMU2.xgyro,SCALED_IMU2.ygyro,SCALED_IMU2.zgyro,"
"SCALED_IMU2.xmag,SCALED_IMU2.ymag,SCALED_IMU2.zmag,"
@@ -31,18 +29,7 @@ _HEADER_ROW = (
)
def _write_imu_csv(path: Path, rows: list[list]) -> None:
"""Write a CSV with the full Derkachi header + the supplied data rows."""
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", newline="", encoding="utf-8") as fp:
fp.write(_HEADER_ROW + "\n")
writer = csv.writer(fp)
for row in rows:
writer.writerow(row)
def _good_row(ts_ms: float, hdg: int = 35041) -> list:
"""One well-formed Derkachi row at `ts_ms` and heading `hdg` cdeg."""
return [
ts_ms, 0.0,
21, -3, -984,
@@ -54,110 +41,13 @@ def _good_row(ts_ms: float, hdg: int = 35041) -> list:
]
# convert_imu_csv_to_tlog
def test_convert_imu_csv_missing_file_raises(tmp_path: Path):
# Assert
with pytest.raises(FileNotFoundError, match="IMU CSV not found"):
bp02.convert_imu_csv_to_tlog(
tmp_path / "missing.csv", tmp_path / "out.tlog",
_mavlink_writer_factory=lambda out: MagicMock(),
)
def test_convert_imu_csv_empty_raises(tmp_path: Path):
# Arrange — header only, no data rows
csv_path = tmp_path / "imu.csv"
_write_imu_csv(csv_path, [])
# Assert
with pytest.raises(ValueError, match="IMU CSV is empty"):
bp02.convert_imu_csv_to_tlog(
csv_path, tmp_path / "out.tlog",
_mavlink_writer_factory=lambda out: MagicMock(),
)
def test_convert_imu_csv_missing_required_column_raises(tmp_path: Path):
# Arrange — header missing `GLOBAL_POSITION_INT.hdg`
csv_path = tmp_path / "imu.csv"
csv_path.write_text("timestamp(ms),Time\n0,0\n")
# Assert
with pytest.raises(ValueError, match="missing required columns"):
bp02.convert_imu_csv_to_tlog(
csv_path, tmp_path / "out.tlog",
_mavlink_writer_factory=lambda out: MagicMock(),
)
def test_convert_imu_csv_malformed_numeric_raises(tmp_path: Path):
# Arrange — second-to-last value (xacc) is non-numeric
csv_path = tmp_path / "imu.csv"
row = _good_row(0.0)
row[2] = "not-a-number"
_write_imu_csv(csv_path, [row])
# Assert
with pytest.raises(ValueError, match="malformed IMU CSV row"):
bp02.convert_imu_csv_to_tlog(
csv_path, tmp_path / "out.tlog",
_mavlink_writer_factory=lambda out: MagicMock(),
)
def test_convert_imu_csv_writes_pair_per_row(tmp_path: Path):
# Arrange
csv_path = tmp_path / "imu.csv"
_write_imu_csv(csv_path, [_good_row(0.0), _good_row(100.0), _good_row(200.0)])
writer = MagicMock(write=MagicMock(), close=MagicMock())
# Act
pairs = bp02.convert_imu_csv_to_tlog(
csv_path, tmp_path / "out.tlog",
_mavlink_writer_factory=lambda out: writer,
)
# Assert — 3 rows → 3 pairs → 6 message writes
assert pairs == 3
assert writer.write.call_count == 6
assert writer.close.call_count == 1
def test_convert_imu_csv_real_pymavlink_round_trip(tmp_path: Path):
"""Sanity-check the real packers; tlog file is well-formed."""
# Arrange
csv_path = tmp_path / "imu.csv"
_write_imu_csv(csv_path, [_good_row(0.0), _good_row(100.0)])
# Act — use real pymavlink (it's in pyproject.toml deps)
pairs = bp02.convert_imu_csv_to_tlog(csv_path, tmp_path / "out.tlog")
# Assert
assert pairs == 2
assert (tmp_path / "out.tlog").stat().st_size > 0
# _hdg_centideg_to_rad
@pytest.mark.parametrize(
"centideg,expected_rad",
[
(0, 0.0),
(9000, math.pi / 2),
(18000, math.pi),
(27000, 3 * math.pi / 2),
(35990, 35990 * math.pi / 18000),
],
)
def test_hdg_centideg_to_rad(centideg: int, expected_rad: float):
# Assert
assert bp02._hdg_centideg_to_rad(centideg) == pytest.approx(expected_rad)
# verify_fdr_has_estimates
def _write_imu_csv(path: Path, rows: list[list]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", newline="", encoding="utf-8") as fp:
fp.write(_IMU_HEADER_ROW + "\n")
writer = csv.writer(fp)
for row in rows:
writer.writerow(row)
def _write_jsonl(path: Path, records: list[dict]) -> None:
@@ -165,66 +55,54 @@ def _write_jsonl(path: Path, records: list[dict]) -> None:
path.write_text("\n".join(json.dumps(r) for r in records))
def test_verify_fdr_missing_file_raises(tmp_path: Path):
# Assert
with pytest.raises(FileNotFoundError, match="FDR JSONL not found"):
bp02.verify_fdr_has_estimates(tmp_path / "missing.jsonl")
# resolve_derkachi_inputs
def test_verify_fdr_no_estimates_raises(tmp_path: Path):
def test_resolve_derkachi_inputs_missing_video_raises(tmp_path: Path):
# Arrange
fdr = tmp_path / "fdr.jsonl"
_write_jsonl(fdr, [
{"record_type": "other", "payload": {}},
{"record_type": "imu_tick", "payload": {}},
])
derkachi_dir = tmp_path / "derkachi"
derkachi_dir.mkdir()
(derkachi_dir / "data_imu.csv").write_text(_IMU_HEADER_ROW + "\n")
# Assert
with pytest.raises(ValueError, match="zero estimate records"):
bp02.verify_fdr_has_estimates(fdr)
with pytest.raises(FileNotFoundError, match="Derkachi MP4 not found"):
bp02.resolve_derkachi_inputs(derkachi_dir)
def test_verify_fdr_counts_estimates(tmp_path: Path):
def test_resolve_derkachi_inputs_missing_csv_raises(tmp_path: Path):
# Arrange
fdr = tmp_path / "fdr.jsonl"
_write_jsonl(fdr, [
{"record_type": "estimate", "payload": {}},
{"record_type": "other", "payload": {}},
{"record_type": "estimate", "payload": {}},
{"record_type": "estimate", "payload": {}},
])
derkachi_dir = tmp_path / "derkachi"
derkachi_dir.mkdir()
(derkachi_dir / "flight_derkachi.mp4").touch()
# Assert
with pytest.raises(FileNotFoundError, match="Derkachi IMU CSV not found"):
bp02.resolve_derkachi_inputs(derkachi_dir)
def test_resolve_derkachi_inputs_returns_both(tmp_path: Path):
# Arrange
derkachi_dir = tmp_path / "derkachi"
derkachi_dir.mkdir()
(derkachi_dir / "flight_derkachi.mp4").touch()
_write_imu_csv(derkachi_dir / "data_imu.csv", [])
# Act
count = bp02.verify_fdr_has_estimates(fdr)
mp4, csv_path = bp02.resolve_derkachi_inputs(derkachi_dir)
# Assert
assert count == 3
assert mp4 == derkachi_dir / "flight_derkachi.mp4"
assert csv_path == derkachi_dir / "data_imu.csv"
def test_verify_fdr_tolerates_malformed_lines(tmp_path: Path):
# Arrange — one bad JSON line interleaved with good estimate records
fdr = tmp_path / "fdr.jsonl"
fdr.write_text(
json.dumps({"record_type": "estimate"}) + "\n"
+ "{not valid json\n"
+ json.dumps({"record_type": "estimate"}) + "\n"
)
# Act
count = bp02.verify_fdr_has_estimates(fdr)
# Assert
assert count == 2
# build_p02_fixtures end-to-end (mocked)
# build_p02_fixtures end-to-end
def test_build_p02_missing_video_raises(tmp_path: Path):
# Arrange
derkachi_dir = tmp_path / "derkachi"
derkachi_dir.mkdir()
(derkachi_dir / "data_imu.csv").write_text(_HEADER_ROW + "\n")
(derkachi_dir / "data_imu.csv").write_text(_IMU_HEADER_ROW + "\n")
cfg = bp02.P02BuilderConfig(
derkachi_dir=derkachi_dir, output_dir=tmp_path / "out",
@@ -286,7 +164,7 @@ def test_build_p02_end_to_end_with_mocks(tmp_path: Path):
def test_build_p02_propagates_verify_failure(tmp_path: Path):
# Arrange — fake runner writes an FDR with no estimates; default verifier raises.
# Arrange — runner writes an FDR with no estimates; default RawFdrPassthrough raises
derkachi_dir = tmp_path / "derkachi"
derkachi_dir.mkdir()
(derkachi_dir / "flight_derkachi.mp4").touch()
@@ -308,17 +186,3 @@ def test_build_p02_propagates_verify_failure(tmp_path: Path):
_runner=fake_runner,
_mavlink_writer_factory=lambda out: MagicMock(write=MagicMock(), close=MagicMock()),
)
# `_common.py` is shared with b78
def test_common_module_exports_used_by_b01():
"""AC-5: b78 builder still imports from _common.py after refactor."""
# Arrange
import e2e.fixtures.sitl_replay_builder.build_p01_fixtures as bp01
import e2e.fixtures.sitl_replay_builder._common as common
# Assert
assert bp01.run_gps_denied_replay is common.run_gps_denied_replay
assert bp01.write_observer_fixture is common.write_observer_fixture
+1 -1
View File
@@ -58,7 +58,7 @@ E2E_ROOT = Path(__file__).resolve().parents[1]
"runner/helpers/fc_proxy_runtime.py",
"runner/helpers/replay_mode.py",
"fixtures/sitl_replay_builder/__init__.py",
"fixtures/sitl_replay_builder/_common.py",
"fixtures/sitl_replay_builder/builder.py",
"fixtures/sitl_replay_builder/build_p01_fixtures.py",
"fixtures/sitl_replay_builder/build_p02_fixtures.py",
"fixtures/sitl_replay_builder/README.md",
+104 -94
View File
@@ -1,53 +1,93 @@
# SITL Replay Fixture Builder (AZ-598, AZ-599)
# SITL Replay Fixture Builder (AZ-598, AZ-599, AZ-600)
Per-scenario fixture builders for the offline FDR-replay path used
by the b75 `sitl_observer` module + FT-* blackbox scenarios. Each
builder takes recorded flight inputs (still images / video / IMU
CSV / tlog) and produces the artifacts a specific scenario needs.
Parameterized fixture-builder framework for the offline FDR-replay path
used by the b75 `sitl_observer` module + FT-* blackbox scenarios. A new
scenario typically only writes a ~60-line config factory + CLI on top of
the framework — no new strategy code required.
| Scenario | Builder | Inputs | Outputs |
|----------|---------|--------|---------|
| FT-P-01 (still-image accuracy) | `build_p01_fixtures.py` | 60 `AD0000NN.jpg` + coordinates CSV | `outbound_messages_<fc>_<host>.json` + `observer_<fc>_<host>.json` + `stills.mp4` + `stationary.tlog` + `fdr.jsonl` |
| FT-P-01 (still-image accuracy) | `build_p01_fixtures.py` | 60 `AD0000NN.jpg` | `outbound_messages_<fc>_<host>.json` + `observer_<fc>_<host>.json` + `stills.mp4` + `stationary.tlog` + `fdr.jsonl` |
| FT-P-02 (Derkachi drift) | `build_p02_fixtures.py` | `flight_derkachi.mp4` + `data_imu.csv` | `derkachi.tlog` + `fdr/fdr.jsonl` (FDR archive) + `observer_<fc>_<host>.json` |
Other scenarios (FT-P-03 / 04 / 05 / 07 / 08 / 10 / 11, FT-N-01 / 02 / 03 / 04)
need their own capture flows and will land as follow-up tickets.
Other scenarios (FT-P-03 / 04 / 05 / 07 / 08 / 10 / 11, FT-N-01..04) will
land as follow-ups; each will reuse the strategies below.
## Shared helpers (`_common.py`)
## Framework (`builder.py`)
Both builders shell out to the production `gps-denied-replay` CLI and
write the same minimal `observer_<fc_kind>_<host>.json`. These two
operations live in `_common.py`:
Three strategy ABCs decompose the per-scenario variance:
* `run_gps_denied_replay(video, tlog, fdr_out, *, time_offset_ms=0, ...)`
* `write_observer_fixture(output_path)`
| Strategy | Concrete impls | Used by |
|----------|----------------|---------|
| `VideoSource` — materialize the MP4 the replay CLI consumes | `StillImagesSource(image_paths, fps)`, `Mp4PassthroughSource(mp4_path)` | b78 / b79 |
| `TlogSource` — materialize the tlog the replay CLI consumes | `SyntheticStationaryTlog(duration_s, hz)`, `ImuCsvTlog(csv_path, schema=DEFAULT_DERKACHI_IMU_SCHEMA)` | b78 / b79 |
| `FdrProjection` — translate the FDR JSONL into scenario fixture shape | `RawFdrPassthrough(verify_estimates=True)`, `OutboundMessagesProjection(image_ids, fdr_kind="outbound_position_estimate")` | b79 / b78 |
Future per-scenario builders should import from `_common.py` rather
than re-implementing.
The `build_fixtures(cfg: FixtureBuilderConfig)` orchestrator composes the
three strategies plus the shared `run_gps_denied_replay` subprocess driver
and `write_observer_fixture` helper.
## FT-P-01 (`build_p01_fixtures.py`)
Shared helpers (in `builder.py`):
### Strategy
* `run_gps_denied_replay(video, tlog, fdr_out, *, time_offset_ms=0, ...)` — shells out to the production CLI.
* `write_observer_fixture(output_path)` — writes the minimal `observer_*.json` `sitl_observer.get_observer` requires.
* `pack_raw_imu(time_usec, *, xacc=0, yacc=0, zacc=0, xgyro=0, ygyro=0, zgyro=0)` — parameterized RAW_IMU packer. Stationary callers pass `zacc=STATIONARY_Z_ACCEL_MG` (gravity).
* `pack_attitude(time_boot_ms, *, roll=0.0, pitch=0.0, yaw=0.0)` — parameterized ATTITUDE packer.
* `parse_fdr_for_outbound_estimates(fdr_path, *, fdr_kind, lat_key, lon_key)` — read FDR JSONL into per-image dicts.
* `verify_fdr_has_estimates(fdr_path)` — assert ≥1 `record_type=="estimate"` record.
* `hdg_centideg_to_rad(hdg_cdeg)` — utility for ATTITUDE yaw synthesis.
Rather than spinning up a SITL container, this builder reuses the
production `gps-denied-replay` CLI + `ReplayInputAdapter`:
## Adding a new scenario (worked example: FT-P-04)
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`.
FT-P-04 (Derkachi frame-to-frame registration) reuses the same Derkachi MP4
+ IMU CSV as FT-P-02 but consumes the FDR archive differently. With the
framework in place, the new builder is purely a config factory:
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.
```python
# e2e/fixtures/sitl_replay_builder/build_p04_fixtures.py (sketch)
from dataclasses import dataclass
from pathlib import Path
from e2e.fixtures.sitl_replay_builder.builder import (
DEFAULT_CLI_BIN,
FixtureBuilderConfig,
ImuCsvTlog,
Mp4PassthroughSource,
RawFdrPassthrough,
build_fixtures,
)
### Usage
@dataclass(frozen=True)
class P04BuilderConfig:
derkachi_dir: Path
output_dir: Path
fc_kind: str = "ardupilot"
host: str = "sitl-host"
def build_p04_fixtures(cfg, **deps):
mp4 = cfg.derkachi_dir / "flight_derkachi.mp4"
csv_path = cfg.derkachi_dir / "data_imu.csv"
builder_cfg = FixtureBuilderConfig(
video_source=Mp4PassthroughSource(mp4_path=mp4),
tlog_source=ImuCsvTlog(csv_path=csv_path),
fdr_projection=RawFdrPassthrough(verify_estimates=True),
output_dir=cfg.output_dir,
fc_kind=cfg.fc_kind, host=cfg.host,
tlog_filename="derkachi.tlog", fdr_subdir="fdr",
)
return build_fixtures(builder_cfg, **deps)
```
Total new code: ~30 lines + argparse CLI. No new strategy class is needed
because every Derkachi-based scenario consumes the same `Mp4PassthroughSource +
ImuCsvTlog + RawFdrPassthrough` triple. A scenario that emits a *new* fixture
shape (e.g. FT-P-13's "anchor-search-region" record extraction) writes a new
`FdrProjection` subclass alongside.
## Per-scenario usage
### FT-P-01
```bash
python -m e2e.fixtures.sitl_replay_builder.build_p01_fixtures \
@@ -57,51 +97,14 @@ python -m e2e.fixtures.sitl_replay_builder.build_p01_fixtures \
--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:
Activation:
```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.
## FT-P-02 (`build_p02_fixtures.py`)
### Strategy
Same overall shape as FT-P-01 (drive `gps-denied-replay` against a
video + tlog pair), with two differences:
1. Video is already MP4 — skip the OpenCV still-image encoding step.
2. IMU is recorded telemetry (`data_imu.csv`, 10 Hz `SCALED_IMU2` +
`GLOBAL_POSITION_INT`). A CSV → tlog conversion packs each row as
a `RAW_IMU` + `ATTITUDE` MAVLink pair, with yaw synthesised from
`GLOBAL_POSITION_INT.hdg` (centidegrees → radians) and roll/pitch
= 0 (acceptable for the fixed-wing cruise data this represents).
Output is the SUT's natural FDR archive directory; the FT-P-02
scenario reads it via `runner.helpers.fdr_reader.iter_records`.
### Usage
### FT-P-02
```bash
python -m e2e.fixtures.sitl_replay_builder.build_p02_fixtures \
@@ -111,28 +114,35 @@ python -m e2e.fixtures.sitl_replay_builder.build_p02_fixtures \
--host sitl-host
```
Output:
## Limitations
* `derkachi.tlog` — generated from `data_imu.csv`.
* `fdr/fdr.jsonl` — the FDR archive from the replay run.
* `observer_ardupilot_sitl-host.json` — minimal observer config.
### Limitations
* The synthesised ATTITUDE has roll/pitch = 0 — acceptable for
fixed-wing cruise but unrealistic for aggressive manoeuvres.
* RAW_IMU is packed from `SCALED_IMU2` columns as pass-through (no
true scaled → raw unit conversion). If the SUT's tlog parser
strictly demands true raw counts the builder will need a units
conversion pass — surfaced as a follow-up after live-run.
* Auto-sync is bypassed via `--time-offset-ms 0` because the
Derkachi CSV is already aligned with the video.
* The synthesised ATTITUDE has roll/pitch = 0 — acceptable for fixed-wing
cruise but unrealistic for aggressive manoeuvres. Override the packer call
inside a custom `TlogSource` when needed.
* RAW_IMU is packed from `SCALED_IMU2` columns as pass-through (no true
scaled → raw unit conversion). If the SUT's tlog parser strictly demands
true raw counts the builder will need a units conversion pass — surfaced
as a follow-up after the first live run.
* Auto-sync (`time_offset_ms != 0`) is bypassed by every scenario currently;
operators running this against truly independent tlog+video pairs should
override `FixtureBuilderConfig.time_offset_ms`.
* iNav adapter is NOT supported by the existing builders — ArduPilot only.
* The FDR record `kind`/`record_type` schemas are assumed to match the
production contract; overrides live on each projection class.
## 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.
Unit tests under `e2e/_unit_tests/fixtures/`:
* `test_sitl_replay_builder_builder.py` — strategy-level tests
(`VideoSource`, `TlogSource`, `FdrProjection` impls + shared helpers +
`build_fixtures` orchestrator).
* `test_sitl_replay_builder.py` — FT-P-01 scenario integration.
* `test_sitl_replay_builder_p02.py` — FT-P-02 scenario integration.
All external dependencies (OpenCV, pymavlink, subprocess) are mocked via
the underscore-prefixed `_runner` / `_video_writer_factory` / `_imread` /
`_mavlink_writer_factory` injection points so the 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.
@@ -1,85 +0,0 @@
"""Shared helpers for the per-scenario fixture builders (AZ-599).
Both `build_p01_fixtures.py` (still-image FT-P-01) and
`build_p02_fixtures.py` (Derkachi FT-P-02) shell out to the production
`gps-denied-replay` CLI and write the same minimal `observer_*.json`
config; the helpers below live here so there's one canonical
implementation.
Future per-scenario builders (FT-P-04 / FT-P-05 / FT-P-10 / …) should
also import from this module.
"""
from __future__ import annotations
import json
import logging
import subprocess
from pathlib import Path
from typing import Callable, Sequence
_LOG = logging.getLogger(__name__)
DEFAULT_CLI_BIN = "gps-denied-replay"
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.
The `time_offset_ms` defaults to 0 because both b78 (synthetic
stationary tlog) and b79 (Derkachi real-motion tlog) intentionally
bypass auto-sync — b78 because there's no take-off signal, b79
because the IMU CSV is already aligned with the video. Operators
running this against truly independent tlog+video pairs SHOULD
omit ``time_offset_ms`` and let the production auto-sync run.
Raises ``subprocess.CalledProcessError`` on non-zero exit code.
The default subprocess runner can be swapped via ``_runner`` for
unit 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)
def write_observer_fixture(output_path: Path) -> None:
"""Write minimal `observer_<fc_kind>_<host>.json` so `get_observer` succeeds.
Scenarios that only consume `wait_for_outbound` or `iter_records`
still trigger `sitl_observer.get_observer(...)` for construction.
Populate with safe defaults; scenarios 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))
@@ -1,56 +1,44 @@
"""FT-P-01 fixture builder (AZ-598).
"""FT-P-01 fixture builder (AZ-598; refactored to strategy pattern in AZ-600).
Produces:
Composes the parameterized fixture-builder framework
(``e2e.fixtures.sitl_replay_builder.builder``) into the FT-P-01 scenario:
* ``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.
* Video source: 60 ``AD000NN.jpg`` still images encoded at ``fps``.
* Tlog source: synthetic stationary RAW_IMU + ATTITUDE pairs.
* FDR projection: parse ``outbound_position_estimate`` records and write
``outbound_messages_<fc_kind>_<host>.json`` (the FT-P-01 fixture shape).
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.
This module is intentionally thin — strategy implementations + the
orchestrator live in ``builder.py``. Adding a new scenario typically only
requires writing a similar ~60-line config factory + CLI module.
"""
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
from typing import Callable, Sequence
from e2e.fixtures.sitl_replay_builder._common import (
from e2e.fixtures.sitl_replay_builder.builder import (
DEFAULT_CLI_BIN,
run_gps_denied_replay,
write_observer_fixture,
DEFAULT_FPS,
DEFAULT_TLOG_DURATION_S,
DEFAULT_TLOG_HZ,
FixtureBuilderConfig,
OutboundMessagesProjection,
StillImagesSource,
SyntheticStationaryTlog,
build_fixtures,
)
_LOG = logging.getLogger(__name__)
DEFAULT_FPS = 1.0
DEFAULT_TLOG_DURATION_S = 120
DEFAULT_TLOG_HZ = 200
DEFAULT_FDR_KIND = "outbound_position_estimate"
@dataclass(frozen=True)
class BuilderConfig:
"""Per-invocation builder configuration."""
"""Per-invocation FT-P-01 builder configuration."""
input_dir: Path
output_dir: Path
@@ -59,261 +47,11 @@ class BuilderConfig:
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
# (`run_gps_denied_replay` is re-exported from `_common.py` so b78 + b79 share one impl.)
# 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))
# `write_observer_fixture` is re-exported from `_common.py` (used by both b78 + b79).
# Orchestration
def _resolve_p01_image_paths(input_dir: Path) -> list[Path]:
"""Return the AD0000NN.jpg images under `input_dir`, sorted by name."""
def resolve_p01_image_paths(input_dir: Path) -> list[Path]:
"""Return the ``AD000NN.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"))
@@ -327,67 +65,25 @@ def build_p01_fixtures(
_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)
"""End-to-end FT-P-01 fixture build. Returns the output directory."""
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}"
)
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,
builder_cfg = FixtureBuilderConfig(
video_source=StillImagesSource(image_paths=image_paths, fps=cfg.fps),
tlog_source=SyntheticStationaryTlog(duration_s=cfg.tlog_duration_s, hz=cfg.tlog_hz),
fdr_projection=OutboundMessagesProjection(image_ids=[p.name for p in image_paths]),
output_dir=cfg.output_dir,
fc_kind=cfg.fc_kind, host=cfg.host, cli_bin=cfg.cli_bin,
video_filename="stills.mp4", tlog_filename="stationary.tlog",
fdr_subdir=".", fdr_filename="fdr.jsonl",
)
generate_stationary_tlog(
stationary_tlog,
duration_s=cfg.tlog_duration_s,
hz=cfg.tlog_hz,
_mavlink_writer_factory=_mavlink_writer_factory,
return build_fixtures(
builder_cfg,
_runner=_runner, _video_writer_factory=_video_writer_factory,
_imread=_imread, _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:
@@ -407,12 +103,8 @@ def _main(argv: Sequence[str] | None = None) -> int:
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,
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
@@ -1,53 +1,36 @@
"""FT-P-02 Derkachi fixture builder (AZ-599).
"""FT-P-02 Derkachi fixture builder (AZ-599; refactored to strategy pattern in AZ-600).
Drives the production ``gps-denied-replay`` CLI against the recorded
Derkachi MP4 + a tlog converted from ``data_imu.csv``, producing an
FDR archive consumable by the FT-P-02 scenario (it walks the FDR via
``fdr_reader.iter_records`` and computes drift between satellite
anchors).
Composes the parameterized fixture-builder framework
(``e2e.fixtures.sitl_replay_builder.builder``) into the FT-P-02 scenario:
Differences from the b78 FT-P-01 builder (`build_p01_fixtures.py`):
* Video source: pass-through of the recorded ``flight_derkachi.mp4``.
* Tlog source: real-motion tlog converted from ``data_imu.csv`` rows
(10 Hz ``SCALED_IMU2`` accel/gyro + ``GLOBAL_POSITION_INT.hdg`` yaw;
roll/pitch=0 fixed-wing-cruise approximation).
* FDR projection: raw passthrough + assert ≥1 ``record_type=="estimate"``
record (the FT-P-02 scenario walks the FDR via ``fdr_reader.iter_records``).
* Video is already MP4 — no encoding step.
* IMU is real recorded telemetry — needs CSV → tlog conversion with
real motion data (vs. b78's synthetic stationary tlog).
* Output is the SUT's natural FDR archive directory — no per-call
schema projection.
Shared helpers (`run_gps_denied_replay`, `write_observer_fixture`)
live in `_common.py`.
This module is intentionally thin — strategy implementations + the
orchestrator live in ``builder.py``.
"""
from __future__ import annotations
import argparse
import csv
import json
import logging
import math
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Iterator, Sequence
from typing import Callable, Sequence
from e2e.fixtures.sitl_replay_builder._common import (
from e2e.fixtures.sitl_replay_builder.builder import (
DEFAULT_CLI_BIN,
run_gps_denied_replay,
write_observer_fixture,
)
_LOG = logging.getLogger(__name__)
REQUIRED_IMU_COLUMNS = (
"timestamp(ms)",
"SCALED_IMU2.xacc",
"SCALED_IMU2.yacc",
"SCALED_IMU2.zacc",
"SCALED_IMU2.xgyro",
"SCALED_IMU2.ygyro",
"SCALED_IMU2.zgyro",
"GLOBAL_POSITION_INT.hdg",
FixtureBuilderConfig,
ImuCsvTlog,
Mp4PassthroughSource,
RawFdrPassthrough,
build_fixtures,
)
@@ -62,158 +45,15 @@ class P02BuilderConfig:
cli_bin: str = DEFAULT_CLI_BIN
# Step 1 — convert IMU CSV to tlog
def convert_imu_csv_to_tlog(
csv_path: Path,
output_tlog: Path,
*,
_mavlink_writer_factory: Callable | None = None,
) -> int:
"""Read `csv_path`, write one RAW_IMU + one ATTITUDE pair per row.
The Derkachi CSV ships at 10 Hz with ``SCALED_IMU2.*`` accelerometer
+ gyro fields and ``GLOBAL_POSITION_INT.hdg`` heading in
centidegrees. We pack RAW_IMU from the IMU columns (pass-through;
units may need conversion if the SUT's tlog parser rejects), and
synthesise ATTITUDE with yaw = `hdg_cdeg * pi / 18000` and
roll/pitch = 0 — acceptable for fixed-wing cruise.
Returns the number of pairs written.
Raises:
FileNotFoundError: `csv_path` missing.
ValueError: empty CSV, missing required column, OR malformed
numeric row.
"""
def resolve_derkachi_inputs(derkachi_dir: Path) -> tuple[Path, Path]:
"""Return ``(mp4_path, imu_csv_path)`` under ``derkachi_dir`` or raise."""
mp4 = derkachi_dir / "flight_derkachi.mp4"
csv_path = derkachi_dir / "data_imu.csv"
if not mp4.is_file():
raise FileNotFoundError(f"Derkachi MP4 not found: {mp4}")
if not csv_path.is_file():
raise FileNotFoundError(f"IMU CSV not found: {csv_path}")
rows = list(_iter_imu_rows(csv_path))
if not rows:
raise ValueError(f"IMU CSV is empty: {csv_path}")
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:
for row in rows:
try:
ts_ms = float(row["timestamp(ms)"])
xacc = int(float(row["SCALED_IMU2.xacc"]))
yacc = int(float(row["SCALED_IMU2.yacc"]))
zacc = int(float(row["SCALED_IMU2.zacc"]))
xgyro = int(float(row["SCALED_IMU2.xgyro"]))
ygyro = int(float(row["SCALED_IMU2.ygyro"]))
zgyro = int(float(row["SCALED_IMU2.zgyro"]))
hdg_cdeg = float(row["GLOBAL_POSITION_INT.hdg"])
except (ValueError, KeyError) as exc:
raise ValueError(
f"malformed IMU CSV row at {csv_path} row#{pairs + 1}: {exc}"
) from exc
yaw_rad = _hdg_centideg_to_rad(hdg_cdeg)
writer.write(_pack_raw_imu(int(ts_ms * 1000), xacc, yacc, zacc, xgyro, ygyro, zgyro))
writer.write(_pack_attitude(int(ts_ms), yaw_rad))
pairs += 1
finally:
close = getattr(writer, "close", None)
if callable(close):
close()
return pairs
def _iter_imu_rows(csv_path: Path) -> Iterator[dict[str, str]]:
"""Yield CSV rows; validates required columns are present in the header."""
with csv_path.open("r", newline="", encoding="utf-8") as fp:
reader = csv.DictReader(fp)
if reader.fieldnames is None:
raise ValueError(f"IMU CSV missing header: {csv_path}")
missing = [col for col in REQUIRED_IMU_COLUMNS if col not in reader.fieldnames]
if missing:
raise ValueError(
f"IMU CSV {csv_path} missing required columns: {missing}"
)
yield from reader
def _hdg_centideg_to_rad(hdg_cdeg: float) -> float:
"""Convert centidegrees [0, 36000) to radians [0, 2pi)."""
return (hdg_cdeg * math.pi) / 18000.0
def _pack_raw_imu(time_usec: int, xacc: int, yacc: int, zacc: int,
xgyro: int, ygyro: int, zgyro: int) -> bytes:
"""Pack a RAW_IMU MAVLink frame (msg id 27) with real motion data."""
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=xacc, yacc=yacc, zacc=zacc,
xgyro=xgyro, ygyro=ygyro, zgyro=zgyro,
xmag=0, ymag=0, zmag=0,
id=0, temperature=0,
)
return msg.pack(packer)
def _pack_attitude(time_boot_ms: int, yaw_rad: float) -> bytes:
"""Pack an ATTITUDE MAVLink frame (msg id 30) with synthesised yaw."""
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=float(yaw_rad),
rollspeed=0.0, pitchspeed=0.0, yawspeed=0.0,
)
return msg.pack(packer)
# Step 2 — verify the FDR archive has at least one estimate record
def verify_fdr_has_estimates(fdr_path: Path) -> int:
"""Return the count of `record_type=="estimate"` records in `fdr_path`.
Raises ``ValueError`` if the file has zero such records — that
means the replay produced nothing useful for FT-P-02 to analyze.
Tolerates missing fields per record (only `record_type` is required
for filtering).
"""
if not fdr_path.is_file():
raise FileNotFoundError(f"FDR JSONL not found: {fdr_path}")
count = 0
with fdr_path.open("r", encoding="utf-8") as fp:
for line in fp:
line = line.strip()
if not line:
continue
try:
record = json.loads(line)
except json.JSONDecodeError:
continue
if record.get("record_type") == "estimate":
count += 1
if count == 0:
raise ValueError(
f"FDR archive {fdr_path} contains zero estimate records; "
f"the replay did not produce any outbound estimates for FT-P-02 to analyze"
)
return count
# Orchestration
raise FileNotFoundError(f"Derkachi IMU CSV not found: {csv_path}")
return mp4, csv_path
def build_p02_fixtures(
@@ -221,44 +61,22 @@ def build_p02_fixtures(
*,
_runner: Callable[[Sequence[str]], subprocess.CompletedProcess] | None = None,
_mavlink_writer_factory: Callable | None = None,
_verify_fdr: Callable[[Path], int] | None = None,
) -> Path:
"""End-to-end FT-P-02 fixture build. Returns the output directory.
Steps:
1. Resolve the Derkachi MP4 + IMU CSV under ``cfg.derkachi_dir``.
2. Convert IMU CSV to ``derkachi.tlog`` under ``cfg.output_dir``.
3. Run ``gps-denied-replay`` against the MP4 + tlog; write FDR JSONL
at ``<output_dir>/fdr/fdr.jsonl``.
4. Verify the FDR archive contains ≥1 estimate record.
5. Write the companion ``observer_<fc_kind>_<host>.json``.
"""
mp4 = cfg.derkachi_dir / "flight_derkachi.mp4"
csv_path = cfg.derkachi_dir / "data_imu.csv"
if not mp4.is_file():
raise FileNotFoundError(f"Derkachi MP4 not found: {mp4}")
if not csv_path.is_file():
raise FileNotFoundError(f"Derkachi IMU CSV not found: {csv_path}")
cfg.output_dir.mkdir(parents=True, exist_ok=True)
tlog = cfg.output_dir / "derkachi.tlog"
fdr_dir = cfg.output_dir / "fdr"
fdr_jsonl = fdr_dir / "fdr.jsonl"
convert_imu_csv_to_tlog(
csv_path, tlog, _mavlink_writer_factory=_mavlink_writer_factory,
"""End-to-end FT-P-02 fixture build. Returns the output directory."""
mp4, csv_path = resolve_derkachi_inputs(cfg.derkachi_dir)
builder_cfg = FixtureBuilderConfig(
video_source=Mp4PassthroughSource(mp4_path=mp4),
tlog_source=ImuCsvTlog(csv_path=csv_path),
fdr_projection=RawFdrPassthrough(verify_estimates=True),
output_dir=cfg.output_dir,
fc_kind=cfg.fc_kind, host=cfg.host, cli_bin=cfg.cli_bin,
video_filename="video_unused.mp4", # Mp4PassthroughSource returns mp4 directly
tlog_filename="derkachi.tlog",
fdr_subdir="fdr", fdr_filename="fdr.jsonl",
)
run_gps_denied_replay(
mp4, tlog, fdr_jsonl, cli_bin=cfg.cli_bin, _runner=_runner,
return build_fixtures(
builder_cfg, _runner=_runner, _mavlink_writer_factory=_mavlink_writer_factory,
)
verifier = _verify_fdr or verify_fdr_has_estimates
estimate_count = verifier(fdr_jsonl)
_LOG.info("FT-P-02 FDR archive contains %d estimate records", estimate_count)
observer_path = cfg.output_dir / f"observer_{cfg.fc_kind}_{cfg.host}.json"
write_observer_fixture(observer_path)
return cfg.output_dir
def _main(argv: Sequence[str] | None = None) -> int:
@@ -277,11 +95,8 @@ def _main(argv: Sequence[str] | None = None) -> int:
logging.basicConfig(level=logging.INFO)
cfg = P02BuilderConfig(
derkachi_dir=args.derkachi_dir,
output_dir=args.output_dir,
fc_kind=args.fc_kind,
host=args.host,
cli_bin=args.cli_bin,
derkachi_dir=args.derkachi_dir, output_dir=args.output_dir,
fc_kind=args.fc_kind, host=args.host, cli_bin=args.cli_bin,
)
build_p02_fixtures(cfg)
return 0
+618
View File
@@ -0,0 +1,618 @@
"""Parameterized fixture-builder framework for SITL replay scenarios (AZ-600).
The per-scenario fixture builders (`build_p01_fixtures.py`,
`build_p02_fixtures.py`, and future FT-P-04/05/07/08/10/11 builders) all
share the same shape:
1. Materialize a video file (MP4) from some source.
2. Materialize a tlog file from some source.
3. Run the production ``gps-denied-replay`` CLI against the pair.
4. Project the resulting FDR JSONL into the scenario's fixture shape.
5. Write the companion ``observer_<fc_kind>_<host>.json``.
Only steps 1, 2, and 4 vary across scenarios; the rest is shared. This
module exposes three strategy ABCs (``VideoSource``, ``TlogSource``,
``FdrProjection``) plus the four concrete impls used by FT-P-01 + FT-P-02,
and a single ``build_fixtures(cfg)`` orchestrator that composes them.
Adding a new scenario typically means writing a ~30-line config factory in
a thin per-scenario module (see ``build_p01_fixtures.py`` /
``build_p02_fixtures.py`` for working examples); no new strategy code is
required unless the scenario has a genuinely new video / tlog / FDR shape.
"""
from __future__ import annotations
import abc
import csv
import json
import logging
import math
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable, Iterator, Sequence
_LOG = logging.getLogger(__name__)
DEFAULT_CLI_BIN = "gps-denied-replay"
DEFAULT_FPS = 1.0
DEFAULT_TLOG_DURATION_S = 120
DEFAULT_TLOG_HZ = 200
DEFAULT_FDR_KIND = "outbound_position_estimate"
# Gravity in mg, used as the stationary z-accel sample (RAW_IMU is in mg).
STATIONARY_Z_ACCEL_MG = -9810
# ---------------------------------------------------------------------------
# Subprocess driver + observer-fixture writer (shared by every scenario)
# ---------------------------------------------------------------------------
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.
``time_offset_ms`` defaults to 0 because most synthetic / aligned-input
scenarios intentionally bypass auto-sync. Operators running this
against truly independent tlog+video pairs SHOULD omit it and let the
production auto-sync run.
Raises ``subprocess.CalledProcessError`` on non-zero exit code. The
default subprocess runner can be swapped via ``_runner`` for unit 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)
def write_observer_fixture(output_path: Path) -> None:
"""Write the minimal ``observer_<fc_kind>_<host>.json`` ``get_observer`` needs.
Scenarios that only consume ``wait_for_outbound`` or ``iter_records``
still trigger ``sitl_observer.get_observer(...)`` for construction.
Populate with safe defaults; scenarios that care about
``read_gps_state`` ship 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))
# ---------------------------------------------------------------------------
# Parameterized MAVLink packers (shared by every TlogSource)
# ---------------------------------------------------------------------------
def pack_raw_imu(
time_usec: int,
*,
xacc: int = 0,
yacc: int = 0,
zacc: int = 0,
xgyro: int = 0,
ygyro: int = 0,
zgyro: int = 0,
) -> bytes:
"""Pack a RAW_IMU MAVLink frame (msg id 27).
All values pass-through to the MAVLink wire format. Stationary callers
use ``zacc=STATIONARY_Z_ACCEL_MG`` (≈ -9810 mg ≈ 1 g) to encode gravity.
"""
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=xacc, yacc=yacc, zacc=zacc,
xgyro=xgyro, ygyro=ygyro, zgyro=zgyro,
xmag=0, ymag=0, zmag=0,
id=0, temperature=0,
)
return msg.pack(packer)
def pack_attitude(
time_boot_ms: int,
*,
roll: float = 0.0,
pitch: float = 0.0,
yaw: float = 0.0,
) -> bytes:
"""Pack an 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=float(roll), pitch=float(pitch), yaw=float(yaw),
rollspeed=0.0, pitchspeed=0.0, yawspeed=0.0,
)
return msg.pack(packer)
def _default_mavlink_writer_factory(out: Path):
"""Return a pymavlink ``mavlogfile`` open for write."""
from pymavlink import mavutil
return mavutil.mavlogfile(str(out), write=True)
def hdg_centideg_to_rad(hdg_cdeg: float) -> float:
"""Convert centidegrees [0, 36000) to radians [0, 2pi)."""
return (hdg_cdeg * math.pi) / 18000.0
# ---------------------------------------------------------------------------
# VideoSource strategy
# ---------------------------------------------------------------------------
class VideoSource(abc.ABC):
"""Strategy: materialize the MP4 the replay CLI consumes."""
@abc.abstractmethod
def materialize(
self,
output_path: Path,
*,
_video_writer_factory: Callable | None = None,
_imread: Callable | None = None,
) -> Path:
"""Return the path of a ready-to-consume MP4.
Implementations may either write a new file at ``output_path`` (and
return ``output_path``) or pass through an already-existing MP4
(returning its real location, ignoring ``output_path``).
"""
@dataclass(frozen=True)
class StillImagesSource(VideoSource):
"""Encode a sequence of still images into an MP4 at ``fps``."""
image_paths: Sequence[Path]
fps: float = DEFAULT_FPS
def materialize(
self,
output_path: Path,
*,
_video_writer_factory: Callable | None = None,
_imread: Callable | None = None,
) -> Path:
if not self.image_paths:
raise FileNotFoundError(
"StillImagesSource: 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")
fps = self.fps
def _video_writer_factory(out: Path, width: int, height: int):
return cv2.VideoWriter(str(out), _fourcc, fps, (width, height))
first_frame = _imread(self.image_paths[0])
if first_frame is None:
raise FileNotFoundError(
f"StillImagesSource: failed to read {self.image_paths[0]}"
)
height, width = first_frame.shape[:2]
output_path.parent.mkdir(parents=True, exist_ok=True)
writer = _video_writer_factory(output_path, width, height)
try:
writer.write(first_frame)
for path in self.image_paths[1:]:
frame = _imread(path)
if frame is None:
raise FileNotFoundError(
f"StillImagesSource: failed to read {path}"
)
writer.write(frame)
finally:
writer.release()
return output_path
@dataclass(frozen=True)
class Mp4PassthroughSource(VideoSource):
"""Use an already-existing MP4 (no copy, no encode)."""
mp4_path: Path
def materialize(self, output_path: Path, **_deps) -> Path:
if not self.mp4_path.is_file():
raise FileNotFoundError(f"Mp4PassthroughSource: MP4 not found: {self.mp4_path}")
return self.mp4_path
# ---------------------------------------------------------------------------
# TlogSource strategy
# ---------------------------------------------------------------------------
class TlogSource(abc.ABC):
"""Strategy: materialize the tlog the replay CLI consumes."""
@abc.abstractmethod
def materialize(
self,
output_path: Path,
*,
_mavlink_writer_factory: Callable | None = None,
) -> Path:
"""Return the path of a ready-to-consume tlog."""
@dataclass(frozen=True)
class SyntheticStationaryTlog(TlogSource):
"""Write a tlog of zero-motion RAW_IMU + ATTITUDE pairs (z-accel = gravity)."""
duration_s: int = DEFAULT_TLOG_DURATION_S
hz: int = DEFAULT_TLOG_HZ
def materialize(
self,
output_path: Path,
*,
_mavlink_writer_factory: Callable | None = None,
) -> Path:
if self.duration_s <= 0:
raise ValueError(f"duration_s must be positive; got {self.duration_s}")
if self.hz <= 0:
raise ValueError(f"hz must be positive; got {self.hz}")
factory = _mavlink_writer_factory or _default_mavlink_writer_factory
output_path.parent.mkdir(parents=True, exist_ok=True)
writer = factory(output_path)
try:
period_us = int(1_000_000 / self.hz)
total_pairs = self.duration_s * self.hz
for i in range(total_pairs):
time_us = i * period_us
writer.write(pack_raw_imu(time_us, zacc=STATIONARY_Z_ACCEL_MG))
writer.write(pack_attitude(time_us // 1000))
finally:
close = getattr(writer, "close", None)
if callable(close):
close()
return output_path
@dataclass(frozen=True)
class ImuCsvSchema:
"""Column-name map for a flight-recorded IMU CSV (Derkachi default)."""
timestamp_ms_col: str = "timestamp(ms)"
xacc_col: str = "SCALED_IMU2.xacc"
yacc_col: str = "SCALED_IMU2.yacc"
zacc_col: str = "SCALED_IMU2.zacc"
xgyro_col: str = "SCALED_IMU2.xgyro"
ygyro_col: str = "SCALED_IMU2.ygyro"
zgyro_col: str = "SCALED_IMU2.zgyro"
hdg_centideg_col: str = "GLOBAL_POSITION_INT.hdg"
@property
def required_columns(self) -> tuple[str, ...]:
return (
self.timestamp_ms_col, self.xacc_col, self.yacc_col, self.zacc_col,
self.xgyro_col, self.ygyro_col, self.zgyro_col, self.hdg_centideg_col,
)
DEFAULT_DERKACHI_IMU_SCHEMA = ImuCsvSchema()
@dataclass(frozen=True)
class ImuCsvTlog(TlogSource):
"""Convert a recorded IMU CSV to a tlog with real RAW_IMU + ATTITUDE values."""
csv_path: Path
schema: ImuCsvSchema = DEFAULT_DERKACHI_IMU_SCHEMA
def materialize(
self,
output_path: Path,
*,
_mavlink_writer_factory: Callable | None = None,
) -> Path:
if not self.csv_path.is_file():
raise FileNotFoundError(f"IMU CSV not found: {self.csv_path}")
rows = list(self._iter_rows())
if not rows:
raise ValueError(f"IMU CSV is empty: {self.csv_path}")
factory = _mavlink_writer_factory or _default_mavlink_writer_factory
output_path.parent.mkdir(parents=True, exist_ok=True)
writer = factory(output_path)
try:
for index, row in enumerate(rows, start=1):
try:
ts_ms = float(row[self.schema.timestamp_ms_col])
xacc = int(float(row[self.schema.xacc_col]))
yacc = int(float(row[self.schema.yacc_col]))
zacc = int(float(row[self.schema.zacc_col]))
xgyro = int(float(row[self.schema.xgyro_col]))
ygyro = int(float(row[self.schema.ygyro_col]))
zgyro = int(float(row[self.schema.zgyro_col]))
hdg_cdeg = float(row[self.schema.hdg_centideg_col])
except (ValueError, KeyError) as exc:
raise ValueError(
f"malformed IMU CSV row at {self.csv_path} row#{index}: {exc}"
) from exc
yaw_rad = hdg_centideg_to_rad(hdg_cdeg)
writer.write(pack_raw_imu(
int(ts_ms * 1000),
xacc=xacc, yacc=yacc, zacc=zacc,
xgyro=xgyro, ygyro=ygyro, zgyro=zgyro,
))
writer.write(pack_attitude(int(ts_ms), yaw=yaw_rad))
finally:
close = getattr(writer, "close", None)
if callable(close):
close()
return output_path
def _iter_rows(self) -> Iterator[dict[str, str]]:
with self.csv_path.open("r", newline="", encoding="utf-8") as fp:
reader = csv.DictReader(fp)
if reader.fieldnames is None:
raise ValueError(f"IMU CSV missing header: {self.csv_path}")
missing = [c for c in self.schema.required_columns if c not in reader.fieldnames]
if missing:
raise ValueError(
f"IMU CSV {self.csv_path} missing required columns: {missing}"
)
yield from reader
# ---------------------------------------------------------------------------
# FdrProjection strategy
# ---------------------------------------------------------------------------
class FdrProjection(abc.ABC):
"""Strategy: translate the FDR JSONL into the scenario's fixture shape."""
@abc.abstractmethod
def materialize(
self,
fdr_jsonl: Path,
output_dir: Path,
fc_kind: str,
host: str,
) -> None:
"""Read ``fdr_jsonl`` and write any scenario-specific fixture artifacts."""
@dataclass(frozen=True)
class RawFdrPassthrough(FdrProjection):
"""Leave the FDR archive as-is; optionally assert it has ≥1 estimate record."""
verify_estimates: bool = True
def materialize(self, fdr_jsonl: Path, output_dir: Path, fc_kind: str, host: str) -> None:
if not self.verify_estimates:
return
count = verify_fdr_has_estimates(fdr_jsonl)
_LOG.info("FDR archive %s contains %d estimate records", fdr_jsonl, count)
@dataclass(frozen=True)
class OutboundMessagesProjection(FdrProjection):
"""Parse FDR ``outbound_position_estimate`` records into ``outbound_messages_*.json``."""
image_ids: Sequence[str] = field(default_factory=tuple)
fdr_kind: str = DEFAULT_FDR_KIND
lat_key: str = "lat_deg"
lon_key: str = "lon_deg"
def materialize(self, fdr_jsonl: Path, output_dir: Path, fc_kind: str, host: str) -> None:
raw_estimates = parse_fdr_for_outbound_estimates(
fdr_jsonl, fdr_kind=self.fdr_kind,
lat_key=self.lat_key, lon_key=self.lon_key,
)
estimates: list[dict | None] = list(raw_estimates[: len(self.image_ids)])
if len(raw_estimates) > len(self.image_ids):
_LOG.warning(
"FDR carried %d outbound estimates but only %d images were pushed; "
"truncating to the per-frame count",
len(raw_estimates), len(self.image_ids),
)
while len(estimates) < len(self.image_ids):
estimates.append(None)
output_path = output_dir / f"outbound_messages_{fc_kind}_{host}.json"
_write_outbound_messages_fixture(output_path, self.image_ids, estimates)
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."""
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
def verify_fdr_has_estimates(fdr_path: Path) -> int:
"""Return the count of ``record_type == "estimate"`` records in ``fdr_path``.
Raises ``ValueError`` if the file has zero such records — that means
the replay produced nothing useful for the scenario to analyze.
"""
if not fdr_path.is_file():
raise FileNotFoundError(f"FDR JSONL not found: {fdr_path}")
count = 0
with fdr_path.open("r", encoding="utf-8") as fp:
for line in fp:
line = line.strip()
if not line:
continue
try:
record = json.loads(line)
except json.JSONDecodeError:
continue
if record.get("record_type") == "estimate":
count += 1
if count == 0:
raise ValueError(
f"FDR archive {fdr_path} contains zero estimate records; "
f"the replay did not produce any outbound estimates for the scenario to analyze"
)
return count
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 {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))
# ---------------------------------------------------------------------------
# Orchestrator
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class FixtureBuilderConfig:
"""Per-invocation config consumed by ``build_fixtures``."""
video_source: VideoSource
tlog_source: TlogSource
fdr_projection: FdrProjection
output_dir: Path
fc_kind: str = "ardupilot"
host: str = "sitl-host"
cli_bin: str = DEFAULT_CLI_BIN
video_filename: str = "video.mp4"
tlog_filename: str = "telemetry.tlog"
fdr_subdir: str = "fdr"
fdr_filename: str = "fdr.jsonl"
time_offset_ms: int = 0
def build_fixtures(
cfg: FixtureBuilderConfig,
*,
_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 fixture build. Returns the output directory.
Steps:
1. Ask the ``VideoSource`` to materialize the MP4.
2. Ask the ``TlogSource`` to materialize the tlog.
3. Run the production ``gps-denied-replay`` CLI against the pair.
4. Ask the ``FdrProjection`` to translate the FDR JSONL.
5. Write the companion observer fixture.
"""
cfg.output_dir.mkdir(parents=True, exist_ok=True)
fdr_jsonl = cfg.output_dir / cfg.fdr_subdir / cfg.fdr_filename
video = cfg.video_source.materialize(
cfg.output_dir / cfg.video_filename,
_video_writer_factory=_video_writer_factory, _imread=_imread,
)
tlog = cfg.tlog_source.materialize(
cfg.output_dir / cfg.tlog_filename,
_mavlink_writer_factory=_mavlink_writer_factory,
)
run_gps_denied_replay(
video, tlog, fdr_jsonl,
cli_bin=cfg.cli_bin, time_offset_ms=cfg.time_offset_ms, _runner=_runner,
)
cfg.fdr_projection.materialize(fdr_jsonl, cfg.output_dir, cfg.fc_kind, cfg.host)
write_observer_fixture(cfg.output_dir / f"observer_{cfg.fc_kind}_{cfg.host}.json")
return cfg.output_dir