mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 21:01:12 +00:00
823c0f1b2e
Ship the two Layer-1 cross-cutting Protocols replay mode needs to leave production C1-C5 components mode-agnostic (Invariant 1) and replay- deterministic (Invariant 2). Live + replay binaries see the same interfaces; only the strategy differs. * Clock Protocol (monotonic_ns / time_ns / sleep_until_ns) + WallClock (live + REALTIME replay) + TlogDerivedClock (ASAP replay; advance-on-call; non-monotonic source → ClockOrderingError). * FrameSource Protocol (next_frame -> NavCameraFrame | None / close) + LiveCameraFrameSource (cv2.VideoCapture device index) + VideoFileFrameSource (cv2.VideoCapture file). * Build-flag gating: BUILD_VIDEO_FILE_FRAME_SOURCE, BUILD_LIVE_CAMERA_FRAME_SOURCE (constructor-time check; Tier-0 OFF refuses construction with FrameSourceConfigError). * Composition-root factories: build_clock + build_frame_source. * Injected Clock across every component that previously called time.monotonic_ns() / time.sleep() directly: c5_state (estimator, ESKF, fallback watcher, source-label SM, isam2 handle), c8_fc_adapter (inbound MAVLink + MSP2, AP outbound, iNav outbound, QGC GCS), c13_fdr writer, c12_operator_tooling httpx flights client. All constructors default to WallClock() so existing call sites keep live-binary behaviour without a wiring change. * AC-4 CI guard (tests/_meta/test_no_direct_time_in_components.py) AST-scans components/**/*.py for direct time.monotonic_ns / time.time_ns / time.sleep references and fails loudly with file:line. * Conformance + factory tests: tests/unit/clock + tests/unit/frame_source. * Test fixture updates: FallbackWatcher / SourceLabelStateMachine clock_ns is now required (removed time.monotonic_ns default); test_az388 patches estimator._clock instead of a module-level time; test_az393 ardupilot adapter uses a _FixedClock test double. Excluded per the task spec: TlogReplayFcAdapter (AZ-399), ReplaySink (AZ-400), compose_replay (AZ-401), CLI (AZ-402), Docker/CI (AZ-403), E2E fixture (AZ-404), IMU auto-sync (AZ-405). Co-authored-by: Cursor <cursoragent@cursor.com>
353 lines
12 KiB
Python
353 lines
12 KiB
Python
"""AZ-393 — PymavlinkArdupilotAdapter outbound AC tests.
|
|
|
|
Covers all 10 ACs of AZ-393. The AP signing path is covered by
|
|
``test_az395_mavlink_signing.py``; this file uses
|
|
``signing_key_source="none"`` so the AP open path is signing-free.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import threading
|
|
from typing import Any
|
|
from unittest import mock
|
|
from uuid import UUID, uuid4
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from gps_denied_onboard._types.fc import FcKind, PortConfig
|
|
from gps_denied_onboard._types.geo import LatLonAlt
|
|
from gps_denied_onboard._types.state import (
|
|
EstimatorOutput,
|
|
PoseSourceLabel,
|
|
Quat,
|
|
)
|
|
from gps_denied_onboard.components.c8_fc_adapter._covariance_projector import (
|
|
CovarianceProjector,
|
|
)
|
|
from gps_denied_onboard.components.c8_fc_adapter._outbound_provenance import (
|
|
source_label_to_float,
|
|
)
|
|
from gps_denied_onboard.components.c8_fc_adapter.errors import FcEmitError
|
|
from gps_denied_onboard.components.c8_fc_adapter.pymavlink_ardupilot_adapter import (
|
|
PymavlinkArdupilotAdapter,
|
|
)
|
|
from gps_denied_onboard.config import load_config
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Helpers — pymavlink stand-in
|
|
|
|
|
|
class _MavStub:
|
|
"""Captures pymavlink ``mav.*_send`` calls for wire-level assertions."""
|
|
|
|
def __init__(self) -> None:
|
|
self.gps_input_calls: list[tuple[Any, ...]] = []
|
|
self.named_value_float_calls: list[tuple[Any, ...]] = []
|
|
self.statustext_calls: list[tuple[int, bytes]] = []
|
|
|
|
def gps_input_send(self, *args: Any) -> None:
|
|
self.gps_input_calls.append(args)
|
|
|
|
def named_value_float_send(self, time_boot_ms: int, name: bytes, value: float) -> None:
|
|
self.named_value_float_calls.append((time_boot_ms, name, value))
|
|
|
|
def statustext_send(self, severity: int, text: bytes) -> None:
|
|
self.statustext_calls.append((severity, text))
|
|
|
|
|
|
class _ConnStub:
|
|
def __init__(self) -> None:
|
|
self.mav = _MavStub()
|
|
self.setup_signing_calls: list[bytes] = []
|
|
self.closed = False
|
|
|
|
def setup_signing(self, key: bytes) -> None:
|
|
self.setup_signing_calls.append(bytes(key))
|
|
|
|
def close(self) -> None:
|
|
self.closed = True
|
|
|
|
|
|
class _FixedClock:
|
|
"""Test :class:`Clock` stand-in returning constant ``monotonic_ns``."""
|
|
|
|
def __init__(self, ns: int) -> None:
|
|
self._ns = ns
|
|
|
|
def monotonic_ns(self) -> int:
|
|
return self._ns
|
|
|
|
def time_ns(self) -> int:
|
|
return self._ns
|
|
|
|
def sleep_until_ns(self, target_ns: int) -> None:
|
|
return None
|
|
|
|
|
|
@pytest.fixture
|
|
def conn() -> _ConnStub:
|
|
return _ConnStub()
|
|
|
|
|
|
@pytest.fixture
|
|
def adapter(conn: _ConnStub, tmp_path) -> PymavlinkArdupilotAdapter:
|
|
cfg = _config_for_ap(tmp_path, signing_key_source="none")
|
|
fdr = mock.MagicMock()
|
|
cov = CovarianceProjector(fdr_client=fdr)
|
|
adapter = PymavlinkArdupilotAdapter(
|
|
config=cfg,
|
|
wgs_converter=mock.MagicMock(),
|
|
covariance_projector=cov,
|
|
fdr_client=fdr,
|
|
clock=_FixedClock(1_000_000_000),
|
|
flight_id="flt-test",
|
|
connect_factory=lambda device, baud: conn,
|
|
)
|
|
port = PortConfig(
|
|
fc_kind=FcKind.ARDUPILOT_PLANE,
|
|
device="/dev/null",
|
|
baud=921600,
|
|
)
|
|
adapter.open(port, signing_key=None)
|
|
yield adapter
|
|
adapter.close()
|
|
|
|
|
|
def _config_for_ap(tmp_path, *, signing_key_source: str = "none"):
|
|
"""Build a Config with ``fc.adapter='ardupilot_plane'``."""
|
|
env = {
|
|
"FC_ADAPTER": "ardupilot_plane",
|
|
"FC_PORT_DEVICE": "/dev/null",
|
|
"FC_PORT_BAUD": "921600",
|
|
"FC_SIGNING_KEY_SOURCE": signing_key_source,
|
|
}
|
|
return load_config(env=env, paths=(), require_env=False)
|
|
|
|
|
|
def _make_output(
|
|
*,
|
|
source_label: PoseSourceLabel = PoseSourceLabel.VISUAL_PROPAGATED,
|
|
smoothed: bool = False,
|
|
cov: np.ndarray | None = None,
|
|
wgs: LatLonAlt | None = None,
|
|
frame_id: UUID | int | None = None,
|
|
) -> EstimatorOutput:
|
|
if cov is None:
|
|
cov = np.eye(6, dtype=np.float64) * 0.25
|
|
if wgs is None:
|
|
wgs = LatLonAlt(lat_deg=50.0, lon_deg=30.0, alt_m=100.0)
|
|
if frame_id is None:
|
|
frame_id = uuid4()
|
|
elif isinstance(frame_id, int):
|
|
# Deterministic UUID for legacy int-keyed tests.
|
|
frame_id = UUID(int=frame_id)
|
|
return EstimatorOutput(
|
|
frame_id=frame_id,
|
|
position_wgs84=wgs,
|
|
orientation_world_T_body=Quat(w=1.0, x=0.0, y=0.0, z=0.0),
|
|
velocity_world_mps=(0.0, 0.0, 0.0),
|
|
covariance_6x6=cov,
|
|
source_label=source_label,
|
|
last_satellite_anchor_age_ms=0,
|
|
smoothed=smoothed,
|
|
emitted_at=0,
|
|
)
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-1: GPS_INPUT field fidelity
|
|
|
|
|
|
def test_ac1_gps_input_field_fidelity(adapter: PymavlinkArdupilotAdapter, conn: _ConnStub) -> None:
|
|
# Arrange
|
|
cov = np.diag([0.25, 0.25, 9.0, 1.0, 1.0, 1.0]).astype(np.float64)
|
|
wgs = LatLonAlt(lat_deg=50.4501, lon_deg=30.5234, alt_m=180.0)
|
|
output = _make_output(cov=cov, wgs=wgs)
|
|
expected_horiz_m = CovarianceProjector(
|
|
fdr_client=mock.MagicMock()
|
|
).to_ardupilot_horiz_accuracy_m(output)
|
|
|
|
# Act
|
|
result = adapter.emit_external_position(output)
|
|
|
|
# Assert
|
|
assert len(conn.mav.gps_input_calls) == 1
|
|
call = conn.mav.gps_input_calls[0]
|
|
# gps_input_send signature: (time_us, gps_id, ignore_flags, time_week_ms,
|
|
# time_week, fix_type, lat_e7, lon_e7, alt, hdop, vdop, vn, ve, vd,
|
|
# speed_accuracy, horiz_accuracy, vert_accuracy, sat_visible, yaw)
|
|
assert call[5] == 3 # fix type 3D
|
|
assert call[6] == int(wgs.lat_deg * 1e7)
|
|
assert call[7] == int(wgs.lon_deg * 1e7)
|
|
assert call[8] == pytest.approx(wgs.alt_m)
|
|
assert call[15] == pytest.approx(expected_horiz_m, abs=1e-3)
|
|
assert result.horiz_accuracy_m == pytest.approx(expected_horiz_m)
|
|
assert result.fc_kind is FcKind.ARDUPILOT_PLANE
|
|
assert result.sequence_number == 1
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-2: GPS_INPUT every frame
|
|
|
|
|
|
def test_ac2_gps_input_every_frame(adapter: PymavlinkArdupilotAdapter, conn: _ConnStub) -> None:
|
|
for i in range(100):
|
|
adapter.emit_external_position(_make_output(frame_id=i))
|
|
assert len(conn.mav.gps_input_calls) == 100
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-3: NAMED_VALUE_FLOAT every frame, correct mapping
|
|
|
|
|
|
def test_ac3_named_value_float_every_frame(
|
|
adapter: PymavlinkArdupilotAdapter, conn: _ConnStub
|
|
) -> None:
|
|
for i in range(100):
|
|
adapter.emit_external_position(_make_output(frame_id=i))
|
|
assert len(conn.mav.named_value_float_calls) == 100
|
|
for _, name, value in conn.mav.named_value_float_calls:
|
|
assert name == b"src_lbl"
|
|
assert value == pytest.approx(source_label_to_float(PoseSourceLabel.VISUAL_PROPAGATED))
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-4: STATUSTEXT rate-limited on transition
|
|
|
|
|
|
def test_ac4_statustext_only_on_transition(
|
|
adapter: PymavlinkArdupilotAdapter, conn: _ConnStub
|
|
) -> None:
|
|
"""100 frames, source_label toggles every 10; expect 10 transitions."""
|
|
# Loosen the 1 s per-severity hard cap for the test by patching the
|
|
# rate-limiter's min_interval_s — AC-4 measures the transition
|
|
# behaviour, not the secondary 1 Hz spam-defence cap.
|
|
adapter._provenance._min_interval_s = 0.0 # type: ignore[attr-defined]
|
|
labels = [PoseSourceLabel.VISUAL_PROPAGATED, PoseSourceLabel.SATELLITE_ANCHORED]
|
|
for i in range(100):
|
|
label = labels[(i // 10) % 2]
|
|
adapter.emit_external_position(_make_output(source_label=label, frame_id=i))
|
|
# Each block of 10 frames is a single label; that's 10 blocks → 9
|
|
# transitions between adjacent blocks plus 1 for the initial
|
|
# None->visual_propagated bootstrap.
|
|
assert len(conn.mav.statustext_calls) == 10
|
|
|
|
|
|
def test_ac4_statustext_zero_within_state(
|
|
adapter: PymavlinkArdupilotAdapter, conn: _ConnStub
|
|
) -> None:
|
|
"""100 frames all on the same source label → exactly 1 STATUSTEXT (the bootstrap)."""
|
|
adapter._provenance._min_interval_s = 0.0 # type: ignore[attr-defined]
|
|
for i in range(100):
|
|
adapter.emit_external_position(_make_output(frame_id=i))
|
|
assert len(conn.mav.statustext_calls) == 1
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-5: Smoothed output rejected
|
|
|
|
|
|
def test_ac5_smoothed_output_rejected(
|
|
adapter: PymavlinkArdupilotAdapter, conn: _ConnStub, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
output = _make_output(smoothed=True)
|
|
with caplog.at_level(logging.ERROR), pytest.raises(FcEmitError, match="smoothed"):
|
|
adapter.emit_external_position(output)
|
|
assert len(conn.mav.gps_input_calls) == 0
|
|
assert any(getattr(rec, "kind", None) == "c8.ap.emit_failed" for rec in caplog.records)
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-6: Non-SPD covariance rejected
|
|
|
|
|
|
def test_ac6_non_spd_covariance_rejected(
|
|
adapter: PymavlinkArdupilotAdapter, conn: _ConnStub
|
|
) -> None:
|
|
bad_cov = np.eye(6, dtype=np.float64)
|
|
bad_cov[0, 0] = -1.0 # not positive-definite
|
|
output = _make_output(cov=bad_cov)
|
|
with pytest.raises(FcEmitError):
|
|
adapter.emit_external_position(output)
|
|
assert len(conn.mav.gps_input_calls) == 0
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-7: Single-writer thread
|
|
|
|
|
|
def test_ac7_single_writer_thread(adapter: PymavlinkArdupilotAdapter) -> None:
|
|
adapter.emit_external_position(_make_output()) # binds to main thread
|
|
|
|
err: list[BaseException] = []
|
|
|
|
def run() -> None:
|
|
try:
|
|
adapter.emit_external_position(_make_output(frame_id=2))
|
|
except RuntimeError as e:
|
|
err.append(e)
|
|
|
|
t = threading.Thread(target=run)
|
|
t.start()
|
|
t.join(timeout=2.0)
|
|
assert len(err) == 1
|
|
assert "single-writer" in str(err[0]).lower()
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-8: Open without signing key (placeholder; AZ-395 tightens this)
|
|
|
|
|
|
def test_ac8_open_without_signing_key_succeeds(conn: _ConnStub, tmp_path) -> None:
|
|
cfg = _config_for_ap(tmp_path, signing_key_source="none")
|
|
fdr = mock.MagicMock()
|
|
a = PymavlinkArdupilotAdapter(
|
|
config=cfg,
|
|
wgs_converter=mock.MagicMock(),
|
|
covariance_projector=CovarianceProjector(fdr_client=fdr),
|
|
fdr_client=fdr,
|
|
connect_factory=lambda device, baud: conn,
|
|
)
|
|
port = PortConfig(
|
|
fc_kind=FcKind.ARDUPILOT_PLANE,
|
|
device="/dev/null",
|
|
baud=921600,
|
|
)
|
|
a.open(port, signing_key=None)
|
|
try:
|
|
assert a._opened is True
|
|
finally:
|
|
a.close()
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-9: source-set switch is wired (AZ-396 replaced AZ-393's placeholder)
|
|
#
|
|
# AZ-393's original AC-9 asserted `request_source_set_switch()` raises
|
|
# `NotImplementedError`; AZ-396 (batch 11) replaced that placeholder
|
|
# with the real body. The AZ-396 AC tests cover the new behaviour;
|
|
# the AZ-393 surface is now exercised by those tests rather than this
|
|
# one. The check left here verifies the method is present and callable.
|
|
|
|
|
|
def test_ac9_source_set_switch_method_callable(adapter: PymavlinkArdupilotAdapter) -> None:
|
|
assert callable(adapter.request_source_set_switch)
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-10: First emit logged once
|
|
|
|
|
|
def test_ac10_first_emit_logged_once(
|
|
adapter: PymavlinkArdupilotAdapter, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
with caplog.at_level(logging.INFO):
|
|
for i in range(5):
|
|
adapter.emit_external_position(_make_output(frame_id=i))
|
|
first_emit_records = [
|
|
rec for rec in caplog.records if getattr(rec, "kind", None) == "c8.ap.first_emit"
|
|
]
|
|
assert len(first_emit_records) == 1
|