mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 20:21:13 +00:00
[AZ-596] Batch 76: fc_proxy_runtime driver (FDR-replay mode)
Add `runner/helpers/fc_proxy_runtime.py` wrapping the existing
`BlackoutSpoofProxy` (AZ-406) with a scenario-facing `drive_fc_proxy`
entry point. FDR-replay mode only: loads `schedule.json`, optionally
activates the proxy against a caller clock for alignment verification,
and writes a `proxy_drive_report.json` audit record into
`${E2E_SITL_REPLAY_DIR}` for downstream evaluators.
Replaces the local `_drive_fc_proxy` stub in FT-N-04. Adds 3
@property accessors on `BlackoutSpoofProxy` so the wrapper does not
reach into private attributes. +11 unit tests (608 total, up from
596). Live-mode router wiring remains out of scope (future ticket).
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
"""Unit tests for `e2e/runner/helpers/fc_proxy_runtime.py` (AZ-596)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from e2e.runner.helpers import fc_proxy_runtime as fpr
|
||||
|
||||
|
||||
def _schedule_payload(
|
||||
*,
|
||||
window_start_ms: int = 10_000,
|
||||
window_end_ms: int = 25_000,
|
||||
spoof_frame_count: int = 5,
|
||||
) -> dict:
|
||||
"""Build a minimally valid `schedule.json` payload that `BlackoutSpoofProxy.from_schedule_file` accepts."""
|
||||
return {
|
||||
"window_start_ms": window_start_ms,
|
||||
"window_end_ms": window_end_ms,
|
||||
"max_alignment_err_ms": 40.0,
|
||||
"spoof_gps": [
|
||||
{
|
||||
"monotonic_ms": window_start_ms + (i * 200),
|
||||
"lat_deg": 50.0 + (i * 0.0001),
|
||||
"lon_deg": 36.2 + (i * 0.0001),
|
||||
"alt_m": 200.0,
|
||||
"fix_type": 3,
|
||||
"hdop": 1.0,
|
||||
}
|
||||
for i in range(spoof_frame_count)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _write_schedule(path: Path, payload: dict) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(payload))
|
||||
|
||||
|
||||
# AC-1: schedule load + error branches
|
||||
|
||||
|
||||
def test_missing_schedule_raises_file_not_found(tmp_path: Path):
|
||||
# Assert
|
||||
with pytest.raises(FileNotFoundError, match="schedule.json not found"):
|
||||
fpr.drive_fc_proxy(tmp_path / "nope.json")
|
||||
|
||||
|
||||
def test_malformed_json_raises_value_error(tmp_path: Path):
|
||||
# Arrange
|
||||
bad = tmp_path / "schedule.json"
|
||||
bad.write_text("{not valid json")
|
||||
|
||||
# Assert
|
||||
with pytest.raises(ValueError, match="malformed schedule JSON"):
|
||||
fpr.drive_fc_proxy(bad)
|
||||
|
||||
|
||||
def test_happy_path_returns_well_formed_report(tmp_path: Path):
|
||||
# Arrange
|
||||
schedule = tmp_path / "schedule.json"
|
||||
_write_schedule(schedule, _schedule_payload(spoof_frame_count=7))
|
||||
|
||||
# Act
|
||||
report = fpr.drive_fc_proxy(schedule)
|
||||
|
||||
# Assert
|
||||
assert report.schedule_path == str(schedule)
|
||||
assert report.window_start_ms == 10_000
|
||||
assert report.window_end_ms == 25_000
|
||||
assert report.spoof_frame_count == 7
|
||||
assert report.alignment_err_ms == 0
|
||||
assert report.was_replay_mode is True
|
||||
|
||||
|
||||
# AC-2: now_ms_provider activation + alignment_err_ms
|
||||
|
||||
|
||||
def test_now_ms_provider_activates_proxy_and_reports_alignment(tmp_path: Path):
|
||||
# Arrange
|
||||
schedule = tmp_path / "schedule.json"
|
||||
_write_schedule(schedule, _schedule_payload(window_start_ms=5_000))
|
||||
|
||||
def clock() -> int:
|
||||
return 5_002 # 2 ms drift from window_start_ms
|
||||
|
||||
# Act
|
||||
report = fpr.drive_fc_proxy(schedule, now_ms_provider=clock)
|
||||
|
||||
# Assert
|
||||
assert report.alignment_err_ms == 0 # `activate(...)` with no first_blackout_ms anchors at `now`
|
||||
assert report.was_replay_mode is False
|
||||
|
||||
|
||||
def test_now_ms_provider_with_replay_mode_false_distinguishes_from_default(tmp_path: Path):
|
||||
# Arrange
|
||||
schedule = tmp_path / "schedule.json"
|
||||
_write_schedule(schedule, _schedule_payload())
|
||||
|
||||
# Act
|
||||
replay_report = fpr.drive_fc_proxy(schedule)
|
||||
live_report = fpr.drive_fc_proxy(schedule, now_ms_provider=lambda: 12_345)
|
||||
|
||||
# Assert
|
||||
assert replay_report.was_replay_mode is True
|
||||
assert live_report.was_replay_mode is False
|
||||
|
||||
|
||||
# AC-3: replay_dir / E2E_SITL_REPLAY_DIR JSON write
|
||||
|
||||
|
||||
def test_writes_report_when_replay_dir_supplied(tmp_path: Path):
|
||||
# Arrange
|
||||
schedule = tmp_path / "schedule.json"
|
||||
_write_schedule(schedule, _schedule_payload(spoof_frame_count=3))
|
||||
replay_dir = tmp_path / "replay"
|
||||
|
||||
# Act
|
||||
fpr.drive_fc_proxy(schedule, replay_dir=replay_dir)
|
||||
|
||||
# Assert
|
||||
report_path = replay_dir / "proxy_drive_report.json"
|
||||
assert report_path.is_file()
|
||||
written = json.loads(report_path.read_text())
|
||||
assert written["spoof_frame_count"] == 3
|
||||
assert written["was_replay_mode"] is True
|
||||
|
||||
|
||||
def test_writes_report_when_env_var_set(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
# Arrange
|
||||
schedule = tmp_path / "schedule.json"
|
||||
_write_schedule(schedule, _schedule_payload())
|
||||
env_dir = tmp_path / "from-env"
|
||||
monkeypatch.setenv("E2E_SITL_REPLAY_DIR", str(env_dir))
|
||||
|
||||
# Act
|
||||
fpr.drive_fc_proxy(schedule)
|
||||
|
||||
# Assert
|
||||
assert (env_dir / "proxy_drive_report.json").is_file()
|
||||
|
||||
|
||||
def test_explicit_replay_dir_overrides_env_var(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
# Arrange
|
||||
schedule = tmp_path / "schedule.json"
|
||||
_write_schedule(schedule, _schedule_payload())
|
||||
env_dir = tmp_path / "from-env"
|
||||
explicit_dir = tmp_path / "explicit"
|
||||
monkeypatch.setenv("E2E_SITL_REPLAY_DIR", str(env_dir))
|
||||
|
||||
# Act
|
||||
fpr.drive_fc_proxy(schedule, replay_dir=explicit_dir)
|
||||
|
||||
# Assert
|
||||
assert (explicit_dir / "proxy_drive_report.json").is_file()
|
||||
assert not (env_dir / "proxy_drive_report.json").exists()
|
||||
|
||||
|
||||
def test_no_file_written_when_neither_supplied(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
# Arrange
|
||||
schedule = tmp_path / "schedule.json"
|
||||
_write_schedule(schedule, _schedule_payload())
|
||||
monkeypatch.delenv("E2E_SITL_REPLAY_DIR", raising=False)
|
||||
|
||||
# Act
|
||||
fpr.drive_fc_proxy(schedule)
|
||||
|
||||
# Assert: nothing written next to the schedule (the only writable dir)
|
||||
assert list(tmp_path.iterdir()) == [schedule]
|
||||
|
||||
|
||||
def test_no_file_written_when_env_var_empty(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
# Arrange
|
||||
schedule = tmp_path / "schedule.json"
|
||||
_write_schedule(schedule, _schedule_payload())
|
||||
monkeypatch.setenv("E2E_SITL_REPLAY_DIR", " ")
|
||||
|
||||
# Act
|
||||
fpr.drive_fc_proxy(schedule)
|
||||
|
||||
# Assert
|
||||
assert list(tmp_path.iterdir()) == [schedule]
|
||||
|
||||
|
||||
def test_replay_dir_is_created_when_missing(tmp_path: Path):
|
||||
# Arrange
|
||||
schedule = tmp_path / "schedule.json"
|
||||
_write_schedule(schedule, _schedule_payload())
|
||||
replay_dir = tmp_path / "deep" / "nested" / "replay"
|
||||
|
||||
# Act
|
||||
fpr.drive_fc_proxy(schedule, replay_dir=replay_dir)
|
||||
|
||||
# Assert
|
||||
assert (replay_dir / "proxy_drive_report.json").is_file()
|
||||
@@ -55,6 +55,7 @@ E2E_ROOT = Path(__file__).resolve().parents[1]
|
||||
"runner/helpers/outlier_tolerance_evaluator.py",
|
||||
"runner/helpers/outage_request_evaluator.py",
|
||||
"runner/helpers/blackout_spoof_evaluator.py",
|
||||
"runner/helpers/fc_proxy_runtime.py",
|
||||
"fixtures/mock-suite-sat/Dockerfile",
|
||||
"fixtures/mock-suite-sat/app.py",
|
||||
"fixtures/mock-suite-sat/requirements.txt",
|
||||
|
||||
@@ -154,6 +154,18 @@ class BlackoutSpoofProxy:
|
||||
def activation_report(self) -> ProxyAlignmentReport | None:
|
||||
return self._activation_report
|
||||
|
||||
@property
|
||||
def window_start_ms(self) -> int:
|
||||
return self._window_start_ms
|
||||
|
||||
@property
|
||||
def window_end_ms(self) -> int:
|
||||
return self._window_end_ms
|
||||
|
||||
@property
|
||||
def spoof_frame_count(self) -> int:
|
||||
return len(self._spoof_gps)
|
||||
|
||||
def _proxy_time_ms(self) -> int:
|
||||
if not self._activated or self._now_ms_provider is None or self._t0_ms is None:
|
||||
raise RuntimeError("proxy not activated — call activate(...) first")
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"""FC-inbound proxy runtime driver — wraps `BlackoutSpoofProxy` for scenarios.
|
||||
|
||||
This is the runtime piece invoked by FT-N-04 (`test_ft_n_04_blackout_spoof`)
|
||||
to drive a coordinated GPS spoofing window. The schedule itself is owned
|
||||
by `fixtures/injectors/blackout_spoof.py`; the proxy state machine
|
||||
(activate / process_inbound_message / in_window) is owned by
|
||||
`fixtures/injectors/fc_proxy.BlackoutSpoofProxy`. What was missing — and
|
||||
what this module adds — is the scenario-facing entry point that:
|
||||
|
||||
1. Loads the schedule from disk via `BlackoutSpoofProxy.from_schedule_file`.
|
||||
2. Optionally activates the proxy against a caller-supplied monotonic
|
||||
clock (so the scenario can later verify wall-clock alignment).
|
||||
3. Writes a small `proxy_drive_report.json` audit record into
|
||||
`${E2E_SITL_REPLAY_DIR}` so downstream evaluators (the
|
||||
`sitl_observer.read_gps_health_samples` /
|
||||
`read_consistency_check_events` consumers in FT-N-04) can correlate.
|
||||
|
||||
This driver is **FDR-replay mode only** — it does NOT plumb the proxy
|
||||
into a live MAVLink router/FC inbound transport. The replay-fixture
|
||||
builder pre-bakes the spoofed-GPS-rejected events into the FDR JSON
|
||||
files that the offline `sitl_observer` reads. Live-mode driving (real
|
||||
router + real FC) is a separate live-mode infrastructure ticket.
|
||||
|
||||
Public-boundary discipline: imports only stdlib +
|
||||
`fixtures.injectors.fc_proxy` (an existing test-side module). Zero
|
||||
`from gps_denied_onboard ...` imports.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from fixtures.injectors.fc_proxy import BlackoutSpoofProxy
|
||||
|
||||
NowMsProvider = Callable[[], int]
|
||||
|
||||
_ENV_VAR = "E2E_SITL_REPLAY_DIR"
|
||||
_REPORT_FILENAME = "proxy_drive_report.json"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProxyDriveReport:
|
||||
"""Audit record of one `drive_fc_proxy` invocation.
|
||||
|
||||
Persisted as `proxy_drive_report.json` under the replay-dir root so
|
||||
downstream evaluators can confirm the schedule was actually applied
|
||||
rather than silently skipped.
|
||||
"""
|
||||
|
||||
schedule_path: str
|
||||
window_start_ms: int
|
||||
window_end_ms: int
|
||||
spoof_frame_count: int
|
||||
alignment_err_ms: int
|
||||
was_replay_mode: bool
|
||||
|
||||
|
||||
def drive_fc_proxy(
|
||||
schedule_path: Path,
|
||||
*,
|
||||
now_ms_provider: NowMsProvider | None = None,
|
||||
replay_dir: Path | None = None,
|
||||
) -> ProxyDriveReport:
|
||||
"""Drive the FC-inbound spoof proxy from `schedule_path`.
|
||||
|
||||
`schedule_path` — JSON written by `blackout_spoof.materialize`.
|
||||
`now_ms_provider` — when supplied, the proxy is activated and the
|
||||
report carries the resulting `alignment_err_ms`. When None,
|
||||
the driver runs in replay mode and reports `alignment_err_ms=0`.
|
||||
`replay_dir` — when supplied (or resolved from `E2E_SITL_REPLAY_DIR`),
|
||||
the report is written as JSON into that directory. When both
|
||||
are absent, no file is written.
|
||||
|
||||
Raises `FileNotFoundError` when `schedule_path` is missing
|
||||
(propagated from `BlackoutSpoofProxy.from_schedule_file`) and
|
||||
`ValueError` (wrapped) when the JSON cannot be parsed.
|
||||
"""
|
||||
try:
|
||||
proxy = BlackoutSpoofProxy.from_schedule_file(schedule_path)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(
|
||||
f"malformed schedule JSON at {schedule_path}: {exc.msg}"
|
||||
) from exc
|
||||
|
||||
if now_ms_provider is not None:
|
||||
activation = proxy.activate(now_ms_provider=now_ms_provider)
|
||||
alignment_err_ms = int(activation.alignment_err_ms)
|
||||
was_replay_mode = False
|
||||
else:
|
||||
alignment_err_ms = 0
|
||||
was_replay_mode = True
|
||||
|
||||
report = ProxyDriveReport(
|
||||
schedule_path=str(schedule_path),
|
||||
window_start_ms=proxy.window_start_ms,
|
||||
window_end_ms=proxy.window_end_ms,
|
||||
spoof_frame_count=proxy.spoof_frame_count,
|
||||
alignment_err_ms=alignment_err_ms,
|
||||
was_replay_mode=was_replay_mode,
|
||||
)
|
||||
|
||||
target_dir = replay_dir if replay_dir is not None else _resolve_replay_dir()
|
||||
if target_dir is not None:
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
(target_dir / _REPORT_FILENAME).write_text(json.dumps(asdict(report)))
|
||||
|
||||
return report
|
||||
|
||||
|
||||
def _resolve_replay_dir() -> Path | None:
|
||||
"""Resolve `E2E_SITL_REPLAY_DIR`. Returns None when unset or empty."""
|
||||
raw = os.environ.get(_ENV_VAR, "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
return Path(raw)
|
||||
@@ -222,9 +222,9 @@ def _resolve_frame_sink(): # type: ignore[no-untyped-def]
|
||||
|
||||
|
||||
def _drive_fc_proxy(schedule_path: Path) -> None:
|
||||
raise NotImplementedError(
|
||||
"FC-inbound spoof proxy driver is owned by AZ-441 / runner.helpers.fc_proxy_runtime"
|
||||
)
|
||||
from runner.helpers.fc_proxy_runtime import drive_fc_proxy
|
||||
|
||||
drive_fc_proxy(schedule_path)
|
||||
|
||||
|
||||
def _resolve_frame_period_ms() -> int:
|
||||
|
||||
Reference in New Issue
Block a user