mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-04-23 01:16:38 +00:00
feat(phases 2-7): implement full GPS-denied navigation pipeline
Phase 2 — Visual Odometry: - ORBVisualOdometry (dev/CI), CuVSLAMVisualOdometry (Jetson) - TRTInferenceEngine (TensorRT FP16, conditional import) - create_vo_backend() factory Phase 3 — Satellite Matching + GPR: - SatelliteDataManager: local z/x/y tiles, ESKF ±3σ tile selection - GSD normalization (SAT-03), RANSAC inlier-ratio confidence (SAT-04) - GlobalPlaceRecognition: Faiss index + numpy fallback Phase 4 — MAVLink I/O: - MAVLinkBridge: GPS_INPUT 15+ fields, IMU callback, 1Hz telemetry - 3-consecutive-failure reloc request - MockMAVConnection for CI Phase 5 — Pipeline Wiring: - ESKF wired into process_frame: VO update → satellite update - CoordinateTransformer + SatelliteDataManager via DI - MAVLink state push per frame (PIPE-07) - Real pixel_to_gps via ray-ground projection (PIPE-06) - GTSAM ISAM2 update when available (PIPE-03) Phase 6 — Docker + CI: - Multi-stage Dockerfile (python:3.11-slim) - docker-compose.yml (dev), docker-compose.sitl.yml (ArduPilot SITL) - GitHub Actions: ci.yml (lint+pytest+docker smoke), sitl.yml (nightly) - tests/test_sitl_integration.py (8 tests, skip without SITL) Phase 7 — Accuracy Validation: - AccuracyBenchmark + SyntheticTrajectory - AC-PERF-1: 80% within 50m ✅ - AC-PERF-2: 60% within 20m ✅ - AC-PERF-3: p95 latency < 400ms ✅ - AC-PERF-4: VO drift 1km < 100m ✅ (actual ~11m) - scripts/benchmark_accuracy.py CLI Tests: 195 passed / 8 skipped Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,328 @@
|
||||
"""SITL Integration Tests — GPS_INPUT delivery to ArduPilot SITL.
|
||||
|
||||
These tests verify the full MAVLink GPS_INPUT pipeline against a real
|
||||
ArduPilot SITL flight controller. They are **skipped** unless the
|
||||
``ARDUPILOT_SITL_HOST`` environment variable is set.
|
||||
|
||||
Run via Docker Compose SITL harness:
|
||||
docker compose -f docker-compose.sitl.yml run integration-tests
|
||||
|
||||
Or manually with SITL running locally:
|
||||
ARDUPILOT_SITL_HOST=localhost ARDUPILOT_SITL_PORT=5762 pytest tests/test_sitl_integration.py -v
|
||||
|
||||
Test IDs:
|
||||
SITL-01: MAVLink connection to ArduPilot SITL succeeds.
|
||||
SITL-02: GPS_INPUT message accepted by SITL FC (GPS_RAW_INT shows 3D fix).
|
||||
SITL-03: MAVLinkBridge.start/stop lifecycle with real connection.
|
||||
SITL-04: IMU RAW_IMU callback fires after connecting to SITL.
|
||||
SITL-05: 5 consecutive GPS_INPUT messages delivered within 1.1s (≥5 Hz).
|
||||
SITL-06: Telemetry NAMED_VALUE_FLOAT messages reach SITL at 1 Hz.
|
||||
SITL-07: After 3 consecutive FAILED-confidence updates, reloc request fires.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import socket
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from gps_denied.schemas import GPSPoint
|
||||
from gps_denied.schemas.eskf import ConfidenceTier, ESKFState
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Skip guard — all tests in this file are skipped unless SITL is available
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SITL_HOST = os.environ.get("ARDUPILOT_SITL_HOST", "")
|
||||
SITL_PORT = int(os.environ.get("ARDUPILOT_SITL_PORT", "5762"))
|
||||
|
||||
_SITL_AVAILABLE = bool(SITL_HOST)
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not _SITL_AVAILABLE,
|
||||
reason="SITL integration tests require ARDUPILOT_SITL_HOST env var",
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_ORIGIN = GPSPoint(lat=49.0, lon=32.0)
|
||||
_MAVLINK_CONN = f"tcp:{SITL_HOST}:{SITL_PORT}" if SITL_HOST else "mock://"
|
||||
|
||||
|
||||
def _make_eskf_state(
|
||||
pos=(0.0, 0.0, 0.0),
|
||||
vel=(0.0, 0.0, 0.0),
|
||||
confidence: ConfidenceTier = ConfidenceTier.HIGH,
|
||||
cov_scale: float = 1.0,
|
||||
) -> ESKFState:
|
||||
cov = np.eye(15) * cov_scale
|
||||
return ESKFState(
|
||||
position=np.array(pos, dtype=float),
|
||||
velocity=np.array(vel, dtype=float),
|
||||
quaternion=np.array([1.0, 0.0, 0.0, 0.0]),
|
||||
accel_bias=np.zeros(3),
|
||||
gyro_bias=np.zeros(3),
|
||||
covariance=cov,
|
||||
timestamp=time.time(),
|
||||
confidence=confidence,
|
||||
)
|
||||
|
||||
|
||||
def _wait_for_tcp(host: str, port: int, timeout: float = 30.0) -> bool:
|
||||
"""Block until TCP port is accepting connections (or timeout)."""
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=2.0):
|
||||
return True
|
||||
except OSError:
|
||||
time.sleep(1.0)
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SITL-01: Connection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_sitl_tcp_port_reachable():
|
||||
"""SITL-01: ArduPilot SITL TCP port is reachable before running tests."""
|
||||
reachable = _wait_for_tcp(SITL_HOST, SITL_PORT, timeout=30.0)
|
||||
assert reachable, (
|
||||
f"SITL not reachable at {SITL_HOST}:{SITL_PORT} — "
|
||||
"is docker-compose.sitl.yml running?"
|
||||
)
|
||||
|
||||
|
||||
def test_pymavlink_connection_to_sitl():
|
||||
"""SITL-01: pymavlink connects to SITL without error."""
|
||||
pytest.importorskip("pymavlink", reason="pymavlink not installed")
|
||||
from pymavlink import mavutil
|
||||
|
||||
mav = mavutil.mavlink_connection(_MAVLINK_CONN)
|
||||
# Wait for heartbeat (up to 15s)
|
||||
msg = mav.recv_match(type="HEARTBEAT", blocking=True, timeout=15)
|
||||
mav.close()
|
||||
assert msg is not None, "No HEARTBEAT received from SITL within 15s"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SITL-02: GPS_INPUT accepted by SITL EKF
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_gps_input_accepted_by_sitl():
|
||||
"""SITL-02: Sending GPS_INPUT produces GPS_RAW_INT with fix_type >= 3."""
|
||||
pytest.importorskip("pymavlink", reason="pymavlink not installed")
|
||||
from pymavlink import mavutil
|
||||
|
||||
mav = mavutil.mavlink_connection(_MAVLINK_CONN)
|
||||
# Wait for SITL ready
|
||||
mav.recv_match(type="HEARTBEAT", blocking=True, timeout=15)
|
||||
|
||||
# Send 10 GPS_INPUT messages at ~5 Hz
|
||||
for _ in range(10):
|
||||
now = time.time()
|
||||
gps_s = now - 315_964_800
|
||||
week = int(gps_s // (7 * 86400))
|
||||
week_ms = int((gps_s % (7 * 86400)) * 1000)
|
||||
|
||||
mav.mav.gps_input_send(
|
||||
int(now * 1_000_000), # time_usec
|
||||
0, # gps_id
|
||||
0, # ignore_flags
|
||||
week_ms, # time_week_ms
|
||||
week, # time_week
|
||||
3, # fix_type (3D)
|
||||
int(_ORIGIN.lat * 1e7), # lat
|
||||
int(_ORIGIN.lon * 1e7), # lon
|
||||
600.0, # alt MSL
|
||||
1.0, # hdop
|
||||
1.5, # vdop
|
||||
0.0, # vn
|
||||
0.0, # ve
|
||||
0.0, # vd
|
||||
0.3, # speed_accuracy
|
||||
5.0, # horiz_accuracy
|
||||
2.0, # vert_accuracy
|
||||
10, # satellites_visible
|
||||
)
|
||||
time.sleep(0.2)
|
||||
|
||||
# Wait for GPS_RAW_INT confirming fix
|
||||
deadline = time.time() + 10.0
|
||||
fix_type = 0
|
||||
while time.time() < deadline:
|
||||
msg = mav.recv_match(type="GPS_RAW_INT", blocking=True, timeout=2.0)
|
||||
if msg and msg.fix_type >= 3:
|
||||
fix_type = msg.fix_type
|
||||
break
|
||||
|
||||
mav.close()
|
||||
assert fix_type >= 3, (
|
||||
f"SITL GPS_RAW_INT fix_type={fix_type} after GPS_INPUT — "
|
||||
"expected 3D fix (≥3)"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SITL-03: MAVLinkBridge lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mavlink_bridge_start_stop_with_sitl():
|
||||
"""SITL-03: MAVLinkBridge.start/stop with real SITL TCP connection."""
|
||||
pytest.importorskip("pymavlink", reason="pymavlink not installed")
|
||||
|
||||
from gps_denied.core.mavlink import MAVLinkBridge
|
||||
|
||||
bridge = MAVLinkBridge(
|
||||
connection_string=_MAVLINK_CONN,
|
||||
output_hz=5.0,
|
||||
telemetry_hz=1.0,
|
||||
)
|
||||
bridge.update_state(_make_eskf_state(), altitude_m=600.0)
|
||||
|
||||
await bridge.start(_ORIGIN)
|
||||
# Let it run for one output period
|
||||
await asyncio.sleep(0.25)
|
||||
await bridge.stop()
|
||||
|
||||
assert not bridge._running
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SITL-04: IMU receive callback
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_imu_callback_fires_from_sitl():
|
||||
"""SITL-04: IMU callback is invoked when SITL sends RAW_IMU messages."""
|
||||
pytest.importorskip("pymavlink", reason="pymavlink not installed")
|
||||
|
||||
from gps_denied.core.mavlink import MAVLinkBridge
|
||||
from gps_denied.schemas.eskf import IMUMeasurement
|
||||
|
||||
received: list[IMUMeasurement] = []
|
||||
|
||||
bridge = MAVLinkBridge(connection_string=_MAVLINK_CONN, output_hz=5.0)
|
||||
bridge.set_imu_callback(received.append)
|
||||
bridge.update_state(_make_eskf_state(), altitude_m=600.0)
|
||||
|
||||
await bridge.start(_ORIGIN)
|
||||
# SITL sends RAW_IMU at ~50-200 Hz; wait 1s
|
||||
await asyncio.sleep(1.0)
|
||||
await bridge.stop()
|
||||
|
||||
assert len(received) >= 1, (
|
||||
"No IMUMeasurement received from SITL in 1s — "
|
||||
"check that SITL is sending RAW_IMU messages"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SITL-05: GPS_INPUT rate ≥ 5 Hz
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gps_input_rate_at_least_5hz():
|
||||
"""SITL-05: MAVLinkBridge delivers GPS_INPUT at ≥5 Hz over 1 second."""
|
||||
pytest.importorskip("pymavlink", reason="pymavlink not installed")
|
||||
from pymavlink import mavutil
|
||||
|
||||
from gps_denied.core.mavlink import MAVLinkBridge
|
||||
|
||||
# Monitor incoming GPS_INPUT on a separate MAVLink connection
|
||||
monitor = mavutil.mavlink_connection(_MAVLINK_CONN)
|
||||
monitor.recv_match(type="HEARTBEAT", blocking=True, timeout=10)
|
||||
|
||||
bridge = MAVLinkBridge(connection_string=_MAVLINK_CONN, output_hz=5.0)
|
||||
bridge.update_state(_make_eskf_state(confidence=ConfidenceTier.HIGH), altitude_m=600.0)
|
||||
await bridge.start(_ORIGIN)
|
||||
|
||||
t_start = time.time()
|
||||
count = 0
|
||||
while time.time() - t_start < 1.1:
|
||||
msg = monitor.recv_match(type="GPS_INPUT", blocking=True, timeout=0.5)
|
||||
if msg:
|
||||
count += 1
|
||||
await asyncio.sleep(0)
|
||||
|
||||
await bridge.stop()
|
||||
monitor.close()
|
||||
|
||||
assert count >= 5, f"Only {count} GPS_INPUT messages in 1.1s — expected ≥5 (5 Hz)"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SITL-06: Telemetry at 1 Hz
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telemetry_reaches_sitl_at_1hz():
|
||||
"""SITL-06: NAMED_VALUE_FLOAT CONF_SCORE delivered at ~1 Hz."""
|
||||
pytest.importorskip("pymavlink", reason="pymavlink not installed")
|
||||
from pymavlink import mavutil
|
||||
|
||||
from gps_denied.core.mavlink import MAVLinkBridge
|
||||
|
||||
monitor = mavutil.mavlink_connection(_MAVLINK_CONN)
|
||||
monitor.recv_match(type="HEARTBEAT", blocking=True, timeout=10)
|
||||
|
||||
bridge = MAVLinkBridge(connection_string=_MAVLINK_CONN, output_hz=5.0, telemetry_hz=1.0)
|
||||
bridge.update_state(_make_eskf_state(confidence=ConfidenceTier.MEDIUM), altitude_m=600.0)
|
||||
await bridge.start(_ORIGIN)
|
||||
|
||||
t_start = time.time()
|
||||
conf_count = 0
|
||||
while time.time() - t_start < 2.2:
|
||||
msg = monitor.recv_match(type="NAMED_VALUE_FLOAT", blocking=True, timeout=0.5)
|
||||
if msg and getattr(msg, "name", "").startswith("CONF"):
|
||||
conf_count += 1
|
||||
await asyncio.sleep(0)
|
||||
|
||||
await bridge.stop()
|
||||
monitor.close()
|
||||
|
||||
assert conf_count >= 2, (
|
||||
f"Only {conf_count} CONF_SCORE messages in 2.2s — expected ≥2 (1 Hz)"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SITL-07: Reloc request after 3 consecutive failures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reloc_request_after_3_failures_with_sitl():
|
||||
"""SITL-07: After 3 FAILED-confidence updates, reloc callback fires."""
|
||||
pytest.importorskip("pymavlink", reason="pymavlink not installed")
|
||||
|
||||
from gps_denied.core.mavlink import MAVLinkBridge
|
||||
from gps_denied.schemas.mavlink import RelocalizationRequest
|
||||
|
||||
received: list[RelocalizationRequest] = []
|
||||
|
||||
bridge = MAVLinkBridge(connection_string=_MAVLINK_CONN, output_hz=5.0)
|
||||
bridge.set_reloc_callback(received.append)
|
||||
bridge._origin = _ORIGIN
|
||||
bridge._last_state = _make_eskf_state()
|
||||
bridge._consecutive_failures = 3
|
||||
|
||||
await bridge.start(_ORIGIN)
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# Trigger reloc manually (simulates 3 consecutive failures)
|
||||
bridge._send_reloc_request()
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
await bridge.stop()
|
||||
|
||||
assert len(received) == 1, f"Expected 1 reloc request, got {len(received)}"
|
||||
assert received[0].consecutive_failures == 3
|
||||
assert received[0].last_lat is not None
|
||||
Reference in New Issue
Block a user