mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 21:31:13 +00:00
bd41956164
Mid-flight fixtures (Derkachi) and stationary-still scenarios (FT-P-01) have no take-off spike for the IMU detector and produce false-positive video motion onsets, so the AC-9 frame-window validator rejects every plausible offset. Add an operator-acknowledged opt-out: a new ReplayConfig.skip_auto_sync_validation flag that suppresses validation, paired with a hard requirement that time_offset_ms also be set (silent-zero guard at both schema and adapter layers). Wired through schema -> CLI (--skip-auto-sync) -> composition root -> ReplayInputAdapter; Derkachi e2e fixture now passes time_offset_ms=0 + skip_auto_sync=True by default since the synth tlog and the video share the same t=0 anchor by construction. 5 new unit tests: * schema gate rejects skip=True without manual offset * schema gate accepts the legal pair * default field value is False (default-construction safety) * adapter constructor mirrors the schema gate * adapter open() bypasses validate_offset_or_fail when flag is set All 38 unit tests in test_az401 + test_az405 pass on Mac. Co-authored-by: Cursor <cursoragent@cursor.com>
253 lines
8.4 KiB
Python
253 lines
8.4 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 tests.e2e.replay._helpers import GroundTruthRow, load_ground_truth_csv
|
|
from tests.e2e.replay._tlog_synth import synthesize_tlog
|
|
|
|
|
|
# Derkachi clip range — anchored at the start of the data_imu.csv
|
|
# (Time=0.0). 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_START_S: float = 0.0
|
|
_CLIP_END_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:
|
|
# Placeholder calibration: the real Topotek KHP20S30 intrinsics
|
|
# are unknown per `_docs/00_problem/input_data/flight_derkachi/
|
|
# camera_info.md`. AC-3 is `xfail`ed until a real calibration
|
|
# ships; AC-1 / AC-2 / AC-5 / AC-6 do not depend on intrinsics
|
|
# accuracy.
|
|
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"
|
|
if not csv_path.is_file():
|
|
pytest.fail(
|
|
f"Derkachi fixture missing: {csv_path} — see "
|
|
"_docs/00_problem/input_data/flight_derkachi/README.md"
|
|
)
|
|
if not video_path.is_file():
|
|
pytest.fail(f"Derkachi fixture missing: {video_path}")
|
|
|
|
work_dir = tmp_path_factory.mktemp("derkachi")
|
|
tlog_path = work_dir / "synth.tlog"
|
|
synthesize_tlog(csv_path, tlog_path)
|
|
|
|
# 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_full = load_ground_truth_csv(csv_path)
|
|
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
|