fix: post-audit — runtime bugs, functional gaps, docs, hardening

Phase A — Runtime bugs:
  - SSE: add push_event() method to SSEEventStreamer (was missing, masked by mocks)
  - MAVLink: satellites_visible=10 (was 0, triggers ArduPilot failsafe)
  - MAVLink: horiz_accuracy=sqrt(P[0,0]+P[1,1]) per spec (was sqrt(avg))
  - MAVLink: MEDIUM confidence → fix_type=3 per solution.md (was 2)

Phase B — Functional gaps:
  - handle_user_fix() injects operator GPS into ESKF with noise=500m
  - app.py uses create_vo_backend() factory (was hardcoded SequentialVO)
  - ESKF: Mahalanobis gating on satellite updates (rejects outliers >5σ)
  - ESKF: public accessors (position, quaternion, covariance, last_timestamp)
  - Processor: no more private ESKF field access

Phase C — Documentation:
  - README: correct API endpoints, CLI command, 40+ env vars documented
  - Dockerfile: ENV prefixes match pydantic-settings (DB_, SATELLITE_, MAVLINK_)
  - tech_stack.md marked ARCHIVED (contradicts solution.md)

Phase D — Hardening:
  - JWT auth middleware (AUTH_ENABLED=false default, verify_token on /flights)
  - TLS config env vars (AUTH_SSL_CERTFILE, AUTH_SSL_KEYFILE)
  - SHA-256 tile manifest verification in SatelliteDataManager
  - AuthConfig, ESKFSettings, MAVLinkConfig, SatelliteConfig in config.py

Also: conftest.py shared fixtures, download_tiles.py, convert_to_trt.py scripts,
config wiring into app.py lifespan, config-driven ESKF, calculate_precise_angle fix.

Tests: 196 passed / 8 skipped. Ruff clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yuzviak
2026-04-02 18:27:35 +03:00
parent d0009f012b
commit 78dcf7b4e7
22 changed files with 756 additions and 64 deletions
+3 -5
View File
@@ -23,19 +23,17 @@ Scenarios:
from __future__ import annotations
import argparse
import sys
import time
import numpy as np
# Ensure src/ is on the path when running from the repo root
import os
import sys
import time
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
from gps_denied.core.benchmark import AccuracyBenchmark, SyntheticTrajectory, SyntheticTrajectoryConfig
from gps_denied.schemas import GPSPoint
ORIGIN = GPSPoint(lat=49.0, lon=32.0)
_SEP = "" * 60
+170
View File
@@ -0,0 +1,170 @@
#!/usr/bin/env python3
"""TensorRT engine conversion script.
Converts ONNX models to TensorRT FP16 .engine files for Jetson deployment.
Wraps trtexec CLI (available on Jetson with JetPack installed).
Usage:
# Convert a single model
python scripts/convert_to_trt.py --onnx weights/litesam.onnx --output /opt/engines/litesam.engine
# Convert all known models from weights/ to engines/
python scripts/convert_to_trt.py --all --onnx-dir weights/ --engine-dir /opt/engines/
# Dry run (check trtexec availability)
python scripts/convert_to_trt.py --check
Requires:
- NVIDIA TensorRT (trtexec in PATH) — available on Jetson with JetPack
- NOT available on dev/CI machines — script exits cleanly with a message
"""
from __future__ import annotations
import argparse
import os
import shutil
import subprocess
import sys
# Known models and their expected ONNX filenames
_MODELS = {
"superpoint": "superpoint.onnx",
"lightglue": "lightglue.onnx",
"xfeat": "xfeat.onnx",
"dinov2": "dinov2.onnx",
"litesam": "litesam.onnx",
}
def find_trtexec() -> str | None:
"""Find trtexec binary in PATH or common Jetson locations."""
path = shutil.which("trtexec")
if path:
return path
# Common Jetson paths
for candidate in [
"/usr/src/tensorrt/bin/trtexec",
"/usr/local/cuda/bin/trtexec",
]:
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
return candidate
return None
def convert_onnx_to_engine(
onnx_path: str,
engine_path: str,
fp16: bool = True,
workspace_mb: int = 1024,
trtexec_path: str | None = None,
) -> bool:
"""Run trtexec to convert ONNX → TensorRT engine.
Returns True on success, False on failure.
"""
trtexec = trtexec_path or find_trtexec()
if not trtexec:
print("ERROR: trtexec not found. Install TensorRT or run on Jetson with JetPack.")
return False
if not os.path.isfile(onnx_path):
print(f"ERROR: ONNX file not found: {onnx_path}")
return False
os.makedirs(os.path.dirname(engine_path) or ".", exist_ok=True)
cmd = [
trtexec,
f"--onnx={onnx_path}",
f"--saveEngine={engine_path}",
f"--workspace={workspace_mb}",
]
if fp16:
cmd.append("--fp16")
print(f" Converting: {onnx_path}{engine_path}")
print(f" Command: {' '.join(cmd)}")
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
if result.returncode == 0:
size_mb = os.path.getsize(engine_path) / (1024 * 1024)
print(f" OK: {engine_path} ({size_mb:.1f} MB)")
return True
else:
print(f" FAIL (exit {result.returncode})")
if result.stderr:
print(f" stderr: {result.stderr[:500]}")
return False
except subprocess.TimeoutExpired:
print(" FAIL: trtexec timed out (>600s)")
return False
except FileNotFoundError:
print(f" FAIL: trtexec not found at {trtexec}")
return False
def main() -> int:
parser = argparse.ArgumentParser(description="Convert ONNX models to TensorRT FP16 engines")
parser.add_argument("--onnx", help="Input ONNX model path")
parser.add_argument("--output", help="Output .engine path")
parser.add_argument("--all", action="store_true", help="Convert all known models")
parser.add_argument("--onnx-dir", default="weights", help="Directory with ONNX files")
parser.add_argument("--engine-dir", default="/opt/engines", help="Output engine directory")
parser.add_argument("--no-fp16", action="store_true", help="Disable FP16 (use FP32)")
parser.add_argument("--workspace", type=int, default=1024, help="TRT workspace (MB)")
parser.add_argument("--check", action="store_true", help="Check trtexec availability only")
args = parser.parse_args()
# Check mode
if args.check:
trtexec = find_trtexec()
if trtexec:
print(f"trtexec found: {trtexec}")
return 0
else:
print("trtexec not found. Not on Jetson or TensorRT not installed.")
return 1
fp16 = not args.no_fp16
# Single model conversion
if args.onnx and args.output:
ok = convert_onnx_to_engine(args.onnx, args.output, fp16=fp16, workspace_mb=args.workspace)
return 0 if ok else 1
# Batch conversion
if args.all:
trtexec = find_trtexec()
if not trtexec:
print("trtexec not found. Conversion requires Jetson with JetPack installed.")
return 1
print(f"Converting all known models from {args.onnx_dir}/ → {args.engine_dir}/")
success = 0
fail = 0
for model_name, onnx_file in _MODELS.items():
onnx_path = os.path.join(args.onnx_dir, onnx_file)
engine_path = os.path.join(args.engine_dir, f"{model_name}.engine")
if not os.path.isfile(onnx_path):
print(f" SKIP {model_name}: {onnx_path} not found")
continue
ok = convert_onnx_to_engine(
onnx_path, engine_path,
fp16=fp16, workspace_mb=args.workspace, trtexec_path=trtexec,
)
if ok:
success += 1
else:
fail += 1
print(f"\nDone: {success} converted, {fail} failed")
return 1 if fail > 0 else 0
parser.print_help()
return 1
if __name__ == "__main__":
sys.exit(main())
+165
View File
@@ -0,0 +1,165 @@
#!/usr/bin/env python3
"""Satellite tile downloader for GPS-denied navigation.
Downloads Web Mercator satellite tiles for a given bounding box and stores
them in the standard z/x/y.png directory layout expected by SatelliteDataManager.
Usage:
# Dry run (count tiles only)
python scripts/download_tiles.py --lat-min 48.5 --lat-max 49.5 --lon-min 31.5 --lon-max 32.5 --dry-run
# Download from OpenStreetMap (default, no API key)
python scripts/download_tiles.py --lat-min 48.5 --lat-max 49.5 --lon-min 31.5 --lon-max 32.5
# Custom zoom and output dir
python scripts/download_tiles.py --lat-min 49.0 --lat-max 49.1 --lon-min 32.0 --lon-max 32.1 \
--zoom 18 --output .satellite_tiles
# Google Maps provider (requires API key)
python scripts/download_tiles.py --lat-min 49.0 --lat-max 49.1 --lon-min 32.0 --lon-max 32.1 \
--provider google --api-key YOUR_KEY
"""
from __future__ import annotations
import argparse
import asyncio
import os
import sys
import time
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
from gps_denied.schemas.satellite import TileCoords
from gps_denied.utils.mercator import latlon_to_tile
# Tile provider URL templates
_PROVIDERS = {
"osm": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
"google": (
"https://maps.googleapis.com/maps/api/staticmap"
"?center={lat},{lon}&zoom={z}&size=256x256&maptype=satellite&key={api_key}"
),
}
_USER_AGENT = "GPS-Denied-Onboard/1.0 (tile prefetch)"
def compute_tile_range(
lat_min: float, lat_max: float, lon_min: float, lon_max: float, zoom: int
) -> list[TileCoords]:
"""Compute all tile coordinates within a lat/lon bounding box."""
nw = latlon_to_tile(lat_max, lon_min, zoom) # NW corner
se = latlon_to_tile(lat_min, lon_max, zoom) # SE corner
tiles: list[TileCoords] = []
for x in range(nw.x, se.x + 1):
for y in range(nw.y, se.y + 1):
tiles.append(TileCoords(x=x, y=y, zoom=zoom))
return tiles
async def download_tiles(
tiles: list[TileCoords],
output_dir: str,
provider: str = "osm",
api_key: str = "",
max_concurrent: int = 4,
delay_s: float = 0.1,
) -> tuple[int, int]:
"""Download tiles concurrently. Returns (success_count, error_count)."""
try:
import httpx
except ImportError:
print("ERROR: httpx required. Install: pip install httpx")
return 0, len(tiles)
url_template = _PROVIDERS.get(provider, _PROVIDERS["osm"])
semaphore = asyncio.Semaphore(max_concurrent)
success = 0
errors = 0
async def fetch_one(client: httpx.AsyncClient, tc: TileCoords) -> bool:
nonlocal success, errors
out_path = os.path.join(output_dir, str(tc.zoom), str(tc.x), f"{tc.y}.png")
if os.path.isfile(out_path):
success += 1
return True
if provider == "google":
from gps_denied.utils.mercator import tile_to_latlon
center = tile_to_latlon(tc.x + 0.5, tc.y + 0.5, tc.zoom)
url = url_template.format(
z=tc.zoom, x=tc.x, y=tc.y,
lat=center.lat, lon=center.lon, api_key=api_key,
)
else:
url = url_template.format(z=tc.zoom, x=tc.x, y=tc.y)
async with semaphore:
try:
resp = await client.get(url)
resp.raise_for_status()
os.makedirs(os.path.dirname(out_path), exist_ok=True)
with open(out_path, "wb") as f:
f.write(resp.content)
success += 1
await asyncio.sleep(delay_s)
return True
except Exception as exc:
errors += 1
print(f" FAIL {tc.zoom}/{tc.x}/{tc.y}: {exc}")
return False
async with httpx.AsyncClient(
timeout=30.0,
headers={"User-Agent": _USER_AGENT},
follow_redirects=True,
) as client:
tasks = [fetch_one(client, tc) for tc in tiles]
await asyncio.gather(*tasks)
return success, errors
def main() -> int:
parser = argparse.ArgumentParser(description="Download satellite tiles for GPS-denied navigation")
parser.add_argument("--lat-min", type=float, required=True)
parser.add_argument("--lat-max", type=float, required=True)
parser.add_argument("--lon-min", type=float, required=True)
parser.add_argument("--lon-max", type=float, required=True)
parser.add_argument("--zoom", type=int, default=18)
parser.add_argument("--output", default=".satellite_tiles", help="Output directory")
parser.add_argument("--provider", choices=["osm", "google"], default="osm")
parser.add_argument("--api-key", default="", help="API key (for google provider)")
parser.add_argument("--max-concurrent", type=int, default=4)
parser.add_argument("--dry-run", action="store_true", help="Only count tiles, don't download")
args = parser.parse_args()
tiles = compute_tile_range(args.lat_min, args.lat_max, args.lon_min, args.lon_max, args.zoom)
# Estimate size: ~30KB per tile
est_mb = len(tiles) * 30 / 1024
print(f"Tiles: {len(tiles)} at zoom {args.zoom}")
print(f"Bounding box: ({args.lat_min}, {args.lon_min}) → ({args.lat_max}, {args.lon_max})")
print(f"Estimated size: ~{est_mb:.1f} MB")
print(f"Output: {args.output}")
if args.dry_run:
print("\n--dry-run: no tiles downloaded.")
return 0
t0 = time.time()
ok, err = asyncio.run(download_tiles(
tiles, args.output,
provider=args.provider, api_key=args.api_key,
max_concurrent=args.max_concurrent,
))
elapsed = time.time() - t0
print(f"\nDone in {elapsed:.1f}s: {ok} downloaded, {err} errors")
return 1 if err > 0 else 0
if __name__ == "__main__":
sys.exit(main())