Files
gps-denied-onboard/tests/e2e/replay/conftest.py
T
Oleksandr Bezdieniezhnykh 64d961f60c [AZ-697] [AZ-702] tlog GPS truth + KHP20S30 factory calibration
Batch 98 (cycle 2) — first two PBIs of epic AZ-696 (real-flight
validation harness):

AZ-697: direct binary-tlog GPS-truth extractor

- New src/gps_denied_onboard/replay_input/tlog_ground_truth.py reads
  GLOBAL_POSITION_INT (with GPS_RAW_INT fallback) from a binary
  ArduPilot tlog via pymavlink.mavutil and returns a frozen+slotted
  TlogGroundTruth DTO with per-record ts_ns / lat_deg / lon_deg / alt_m
  / hdg_deg / vx_m_s / vy_m_s / vz_m_s.
- Promoted l2_horizontal_m + match_percentage + GroundTruthRow from
  tests/e2e/replay/_helpers.py into the new production module
  src/gps_denied_onboard/helpers/gps_compare.py. The e2e helper now
  re-exports the same objects (identity, not copies) so existing test
  imports continue working untouched.
- tests/e2e/replay/conftest.py prefers the real derkachi.tlog when
  present, falls back to the CSV synth path otherwise.
- 22 new unit tests cover AC-1..AC-5 (mypy --strict subprocess test
  included). All passing.

AZ-702: Topotek KHP20S30 factory-sheet camera calibration

- New _docs/00_problem/input_data/flight_derkachi/khp20s30_factory.json:
  fx = fy = 4644.444, cx = 960, cy = 540, HFOV ~ 23.3 deg, VFOV ~ 13.2
  deg, computed from the published 8.5 mm focal length + 1/2.8" sensor
  + 1920x1080 capture at lowest zoom step. Distortion zeroed,
  body_to_camera_se3 = identity with nadir convention. Acquisition
  method explicitly recorded as factory_sheet so downstream code can
  expect higher residual error than a lab calibration.
- _docs/00_problem/input_data/flight_derkachi/camera_info.md updated
  to document the assumptions, expected residual error window, and
  conftest pick-up rule.
- tests/e2e/replay/conftest.py::_calibration_path() prefers
  khp20s30_factory.json when present, falls back to adti26.json.
- 9 new unit tests cover AC-1..AC-4 (schema, intrinsics traceback,
  doc reference, conftest pick-up). All passing.

Test run: 45 new tests, all passing. Full-suite gate deferred to
Step 16 (after the last batch in cycle 2 per the implement skill).

Adjacent note (not fixed in this batch, recorded in the batch report):
auto_sync.py has the same redundant pymavlink type:ignore + a few
numpy/cv2 mypy --strict issues. None on this batch's path.

Refs: _docs/03_implementation/batch_98_cycle2_report.md
Refs: _docs/02_tasks/done/AZ-697_tlog_ground_truth_extractor.md
Refs: _docs/02_tasks/done/AZ-702_khp20s30_calibration.md

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 16:09:03 +03:00

287 lines
9.9 KiB
Python

"""Pytest fixtures for the AZ-404 E2E replay tests.
The fixtures are import-clean on dev macOS — the heavy work
(synthesizing the tlog, invoking the airborne CLI in a subprocess)
runs only when ``RUN_REPLAY_E2E=1`` is set in the environment.
Without the env var, the test module's collection-time skip marker
prevents the fixtures from being requested.
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
from collections.abc import Iterator
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import pytest
from gps_denied_onboard.replay_input import load_tlog_ground_truth
from tests.e2e.replay._helpers import GroundTruthRow, load_ground_truth_csv
from tests.e2e.replay._tlog_synth import synthesize_tlog
# Derkachi clip range — 60 s starting at the start of the GT series.
# For the CSV-synth fallback, the series begins at Time=0.0; for the
# real-tlog branch, the series begins at the wall-clock timestamp of
# the first GPS message (and the clip becomes [t0, t0 + 60]). The
# fixture clip is deliberately the first 60 s rather than a mid-flight
# slice: the take-off region exercises the AZ-405 IMU-take-off
# auto-sync detector, and the steady cruise that follows stresses the
# satellite-anchor + VIO drift-correction path. The trim is documented
# in `tests/e2e/replay/README.md`.
_CLIP_DURATION_S: float = 60.0
# ----------------------------------------------------------------------
# Path helpers
def _repo_root() -> Path:
return Path(__file__).resolve().parents[3]
def _derkachi_dir() -> Path:
return _repo_root() / "_docs" / "00_problem" / "input_data" / "flight_derkachi"
def _calibration_path() -> Path:
# AZ-702 ships a factory-sheet approximation for the Topotek
# KHP20S30 nadir camera at
# `_docs/00_problem/input_data/flight_derkachi/khp20s30_factory.json`.
# When present we use it; otherwise we fall back to the
# `adti26.json` placeholder so the AC-1/2/5/6 path stays
# exercisable on dev macOS without the AZ-702 deliverable.
factory_path = _derkachi_dir() / "khp20s30_factory.json"
if factory_path.is_file():
return factory_path
return _repo_root() / "tests" / "fixtures" / "calibration" / "adti26.json"
# ----------------------------------------------------------------------
# Fixtures
@dataclass(frozen=True)
class DerkachiReplayInputs:
"""Bundle of paths the AZ-402 CLI consumes for a Derkachi replay run."""
video_path: Path
tlog_path: Path
calibration_path: Path
config_path: Path
signing_key_path: Path
output_path: Path
ground_truth: list[GroundTruthRow]
@pytest.fixture(scope="session")
def derkachi_replay_inputs(tmp_path_factory: pytest.TempPathFactory) -> DerkachiReplayInputs:
"""Materialise Derkachi inputs + a synthesized tlog for the e2e run.
Session-scoped so the tlog synthesizer runs once across the whole
e2e collection. The tlog is cached at
``tmp_path_factory.mktemp("derkachi") / "synth.tlog"`` so each
pytest invocation gets a fresh copy; the synthesizer is fast
enough (~1 s for 60 s of data) that disk caching across invocations
is unnecessary.
"""
derkachi = _derkachi_dir()
csv_path = derkachi / "data_imu.csv"
video_path = derkachi / "flight_derkachi.mp4"
real_tlog_path = derkachi / "derkachi.tlog"
if not video_path.is_file():
pytest.fail(f"Derkachi fixture missing: {video_path}")
work_dir = tmp_path_factory.mktemp("derkachi")
# AZ-697: prefer the real binary tlog when present; fall back to
# synthesizing one from the CSV so dev environments without the
# 5.8 MB binary blob still exercise the e2e path.
if real_tlog_path.is_file():
tlog_path = real_tlog_path
gt_series = load_tlog_ground_truth(real_tlog_path).records
if gt_series:
t0_s = gt_series[0].ts_ns / 1e9
ground_truth_full = [
GroundTruthRow(
t_s=fix.ts_ns / 1e9,
lat_deg=fix.lat_deg,
lon_deg=fix.lon_deg,
alt_m=fix.alt_m,
)
for fix in gt_series
]
clip_start_s = t0_s
clip_end_s = t0_s + _CLIP_DURATION_S
else:
ground_truth_full = []
clip_start_s = 0.0
clip_end_s = _CLIP_DURATION_S
else:
if not csv_path.is_file():
pytest.fail(
f"Derkachi fixture missing: {csv_path} — see "
"_docs/00_problem/input_data/flight_derkachi/README.md"
)
tlog_path = work_dir / "synth.tlog"
synthesize_tlog(csv_path, tlog_path)
ground_truth_full = load_ground_truth_csv(csv_path)
clip_start_s = 0.0
clip_end_s = _CLIP_DURATION_S
# Empty signing key — the airborne replay path runs the signing
# handshake against `NoopMavlinkTransport`, so the key contents do
# not affect any wire output. We still need a real file because
# the CLI's path-validation gate requires it.
signing_key_path = work_dir / "signing_key.bin"
signing_key_path.write_bytes(b"\x00" * 32)
config_path = work_dir / "config.yaml"
config_path.write_text(
# Replay-specific overrides; the rest comes from the env vars
# the airborne binary's `load_config` honours by default.
"mode: replay\n"
"replay:\n"
" pace: asap\n"
" target_fc_dialect: ardupilot_plane\n"
)
output_path = work_dir / "estimator_output.jsonl"
ground_truth = [
r for r in ground_truth_full if clip_start_s <= r.t_s <= clip_end_s
]
return DerkachiReplayInputs(
video_path=video_path,
tlog_path=tlog_path,
calibration_path=_calibration_path(),
config_path=config_path,
signing_key_path=signing_key_path,
output_path=output_path,
ground_truth=ground_truth,
)
@dataclass(frozen=True)
class ReplayRunResult:
"""Outcome of a single ``gps-denied-replay`` subprocess run."""
returncode: int
stdout: str
stderr: str
output_path: Path
wall_clock_s: float
@pytest.fixture
def replay_runner(derkachi_replay_inputs: DerkachiReplayInputs) -> Any:
"""Return a callable that invokes the ``gps-denied-replay`` console-script.
The callable accepts keyword overrides for ``pace``,
``time_offset_ms``, and ``skip_auto_sync`` (AZ-611); everything
else is taken from ``derkachi_replay_inputs``. Output is written
to a fresh path per invocation so determinism comparisons (AC-5)
get two independent files.
Derkachi is a mid-flight fixture (no take-off spike) and the only
motion the video detector sees in the first 60 s is camera shake
and scenery change — neither tlog nor video can produce a
reliable auto-sync signal. The synth tlog and the video share
the same ``t=0`` anchor by construction (see
``_tlog_synth.py``), so the correct offset is exactly ``0``. The
fixture defaults reflect that — heavy ACs pass
``time_offset_ms=0`` + ``skip_auto_sync=True`` so the run never
touches the AC-9 validator that would otherwise reject the
fixture's false-positive video motion onset.
"""
binary = shutil.which("gps-denied-replay")
if binary is None:
venv_bin = Path(sys.executable).parent / "gps-denied-replay"
if venv_bin.exists():
binary = str(venv_bin)
if binary is None:
pytest.skip(
"gps-denied-replay console-script not on PATH; "
"install the package in the test venv"
)
invocation_count = {"n": 0}
def _run(
*,
pace: str = "asap",
time_offset_ms: int | None = 0,
skip_auto_sync: bool = True,
) -> ReplayRunResult:
import time
invocation_count["n"] += 1
out_path = derkachi_replay_inputs.output_path.with_name(
f"estimator_output_{invocation_count['n']}.jsonl"
)
argv = [
binary,
"--video",
str(derkachi_replay_inputs.video_path),
"--tlog",
str(derkachi_replay_inputs.tlog_path),
"--output",
str(out_path),
"--camera-calibration",
str(derkachi_replay_inputs.calibration_path),
"--config",
str(derkachi_replay_inputs.config_path),
"--mavlink-signing-key",
str(derkachi_replay_inputs.signing_key_path),
"--pace",
pace,
]
if time_offset_ms is not None:
argv.extend(["--time-offset-ms", str(time_offset_ms)])
if skip_auto_sync:
argv.append("--skip-auto-sync")
t0 = time.monotonic()
completed = subprocess.run(
argv,
capture_output=True,
text=True,
timeout=180,
)
wall_s = time.monotonic() - t0
return ReplayRunResult(
returncode=completed.returncode,
stdout=completed.stdout,
stderr=completed.stderr,
output_path=out_path,
wall_clock_s=wall_s,
)
return _run
@pytest.fixture
def operator_pre_flight_setup(tmp_path: Path) -> Iterator[Path]:
"""Operator C12 pre-flight rehearsal stub.
Per AZ-404's spec this fixture should run the operator's full
C10/C11/C12 pre-flight against a ``mock-suite-sat-service``
fixture and yield the populated cache directory. The current
``tests/fixtures/mock-suite-sat-service`` is a bootstrap stub
(only ``GET /healthz`` per its README) — the full D-PROJ-2
contract is not implemented. Until that ships, AC-8 (operator
workflow rehearsal) is skipped at the test level; this fixture
yields a placeholder cache directory so test bodies that
request it can fail-fast with a documented reason rather than a
surprise ImportError.
"""
cache_dir = tmp_path / "operator_cache"
cache_dir.mkdir()
yield cache_dir