Files
gps-denied-onboard/src/gps_denied/core/satellite.py
T
Yuzviak 78dcf7b4e7 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>
2026-04-02 18:27:35 +03:00

288 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Satellite Data Manager (Component F04).
SAT-01: Reads pre-loaded tiles from a local z/x/y directory (no live HTTP during flight).
SAT-02: Tile selection uses ESKF position ± 3σ_horizontal to define search area.
"""
import hashlib
import logging
import math
import os
from concurrent.futures import ThreadPoolExecutor
import cv2
import numpy as np
from gps_denied.schemas import GPSPoint
from gps_denied.schemas.satellite import TileBounds, TileCoords
from gps_denied.utils import mercator
class SatelliteDataManager:
"""Manages satellite tiles from a local pre-loaded directory.
Directory layout (SAT-01):
{tile_dir}/{zoom}/{x}/{y}.png — standard Web Mercator slippy-map layout
No live HTTP requests are made during flight. A separate offline tooling step
downloads and stores tiles before the mission.
"""
_logger = logging.getLogger(__name__)
def __init__(
self,
tile_dir: str = ".satellite_tiles",
cache_dir: str = ".satellite_cache",
max_size_gb: float = 10.0,
):
self.tile_dir = tile_dir
self.thread_pool = ThreadPoolExecutor(max_workers=4)
# In-memory LRU for hot tiles (avoids repeated disk reads)
self._mem_cache: dict[str, np.ndarray] = {}
self._mem_cache_max = 256
# SHA-256 manifest for tile integrity (якщо файл існує)
self._manifest: dict[str, str] = self._load_manifest()
# ------------------------------------------------------------------
# SAT-01: Local tile reads (no HTTP)
# ------------------------------------------------------------------
def _load_manifest(self) -> dict[str, str]:
"""Завантажити SHA-256 manifest з tile_dir/manifest.sha256."""
path = os.path.join(self.tile_dir, "manifest.sha256")
if not os.path.isfile(path):
return {}
manifest: dict[str, str] = {}
with open(path) as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
parts = line.split(maxsplit=1)
if len(parts) == 2:
manifest[parts[1].strip()] = parts[0].strip()
return manifest
def _verify_tile_integrity(self, rel_path: str, file_path: str) -> bool:
"""Перевірити SHA-256 тайла проти manifest (якщо manifest існує)."""
if not self._manifest:
return True # без manifest — пропускаємо
expected = self._manifest.get(rel_path)
if expected is None:
return True # тайл не в manifest — OK
sha = hashlib.sha256()
with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
sha.update(chunk)
actual = sha.hexdigest()
if actual != expected:
self._logger.warning("Tile integrity failed: %s (exp %s, got %s)",
rel_path, expected[:12], actual[:12])
return False
return True
def load_local_tile(self, tile_coords: TileCoords) -> np.ndarray | None:
"""Load a tile image from the local pre-loaded directory.
Expected path: {tile_dir}/{zoom}/{x}/{y}.png
Returns None if the file does not exist.
"""
key = f"{tile_coords.zoom}/{tile_coords.x}/{tile_coords.y}"
if key in self._mem_cache:
return self._mem_cache[key]
rel_path = f"{tile_coords.zoom}/{tile_coords.x}/{tile_coords.y}.png"
path = os.path.join(self.tile_dir, rel_path)
if not os.path.isfile(path):
return None
if not self._verify_tile_integrity(rel_path, path):
return None # тайл пошкоджений
img = cv2.imread(path, cv2.IMREAD_COLOR)
if img is None:
return None
# LRU eviction: drop oldest if full
if len(self._mem_cache) >= self._mem_cache_max:
oldest = next(iter(self._mem_cache))
del self._mem_cache[oldest]
self._mem_cache[key] = img
return img
def save_local_tile(self, tile_coords: TileCoords, image: np.ndarray) -> bool:
"""Persist a tile to the local directory (used by offline pre-fetch tooling)."""
path = os.path.join(self.tile_dir, str(tile_coords.zoom),
str(tile_coords.x), f"{tile_coords.y}.png")
os.makedirs(os.path.dirname(path), exist_ok=True)
ok, encoded = cv2.imencode(".png", image)
if not ok:
return False
with open(path, "wb") as f:
f.write(encoded.tobytes())
key = f"{tile_coords.zoom}/{tile_coords.x}/{tile_coords.y}"
self._mem_cache[key] = image
return True
# ------------------------------------------------------------------
# SAT-02: Tile selection for ESKF position ± 3σ_horizontal
# ------------------------------------------------------------------
@staticmethod
def _meters_to_degrees(meters: float, lat: float) -> tuple[float, float]:
"""Convert a radius in metres to (Δlat°, Δlon°) at the given latitude."""
delta_lat = meters / 111_320.0
delta_lon = meters / (111_320.0 * math.cos(math.radians(lat)))
return delta_lat, delta_lon
def select_tiles_for_eskf_position(
self, gps: GPSPoint, sigma_h_m: float, zoom: int
) -> list[TileCoords]:
"""Return all tile coords covering the ESKF position ± 3σ_horizontal area.
Args:
gps: ESKF best-estimate position.
sigma_h_m: 1-σ horizontal uncertainty in metres (from ESKF covariance).
zoom: Web Mercator zoom level (18 recommended ≈ 0.6 m/px).
"""
radius_m = 3.0 * sigma_h_m
dlat, dlon = self._meters_to_degrees(radius_m, gps.lat)
# Bounding box corners
lat_min, lat_max = gps.lat - dlat, gps.lat + dlat
lon_min, lon_max = gps.lon - dlon, gps.lon + dlon
# Convert corners to tile coords
tc_nw = mercator.latlon_to_tile(lat_max, lon_min, zoom)
tc_se = mercator.latlon_to_tile(lat_min, lon_max, zoom)
tiles: list[TileCoords] = []
for x in range(tc_nw.x, tc_se.x + 1):
for y in range(tc_nw.y, tc_se.y + 1):
tiles.append(TileCoords(x=x, y=y, zoom=zoom))
return tiles
def assemble_mosaic(
self,
tile_list: list[tuple[TileCoords, np.ndarray]],
target_size: int = 512,
) -> tuple[np.ndarray, TileBounds] | None:
"""Assemble a list of (TileCoords, image) pairs into a single mosaic.
Returns (mosaic_image, combined_bounds) or None if tile_list is empty.
The mosaic is resized to (target_size × target_size) for the matcher.
"""
if not tile_list:
return None
xs = [tc.x for tc, _ in tile_list]
ys = [tc.y for tc, _ in tile_list]
zoom = tile_list[0][0].zoom
x_min, x_max = min(xs), max(xs)
y_min, y_max = min(ys), max(ys)
cols = x_max - x_min + 1
rows = y_max - y_min + 1
# Determine single-tile pixel size from first image
sample = tile_list[0][1]
th, tw = sample.shape[:2]
canvas = np.zeros((rows * th, cols * tw, 3), dtype=np.uint8)
for tc, img in tile_list:
col = tc.x - x_min
row = tc.y - y_min
h, w = img.shape[:2]
canvas[row * th: row * th + h, col * tw: col * tw + w] = img
mosaic = cv2.resize(canvas, (target_size, target_size), interpolation=cv2.INTER_AREA)
# Compute combined GPS bounds
nw_bounds = mercator.compute_tile_bounds(TileCoords(x=x_min, y=y_min, zoom=zoom))
se_bounds = mercator.compute_tile_bounds(TileCoords(x=x_max, y=y_max, zoom=zoom))
combined = TileBounds(
nw=nw_bounds.nw,
ne=GPSPoint(lat=nw_bounds.nw.lat, lon=se_bounds.se.lon),
sw=GPSPoint(lat=se_bounds.se.lat, lon=nw_bounds.nw.lon),
se=se_bounds.se,
center=GPSPoint(
lat=(nw_bounds.nw.lat + se_bounds.se.lat) / 2,
lon=(nw_bounds.nw.lon + se_bounds.se.lon) / 2,
),
gsd=nw_bounds.gsd,
)
return mosaic, combined
def fetch_tiles_for_position(
self, gps: GPSPoint, sigma_h_m: float, zoom: int
) -> tuple[np.ndarray, TileBounds] | None:
"""High-level helper: select tiles + load + assemble mosaic.
Returns (mosaic, bounds) or None if no local tiles are available.
"""
coords = self.select_tiles_for_eskf_position(gps, sigma_h_m, zoom)
loaded: list[tuple[TileCoords, np.ndarray]] = []
for tc in coords:
img = self.load_local_tile(tc)
if img is not None:
loaded.append((tc, img))
return self.assemble_mosaic(loaded) if loaded else None
# ------------------------------------------------------------------
# Cache helpers (backward-compat, also used for warm-path caching)
# ------------------------------------------------------------------
def cache_tile(self, flight_id: str, tile_coords: TileCoords, tile_data: np.ndarray) -> bool:
"""Cache a tile image in memory (used by tests and offline tools)."""
key = f"{tile_coords.zoom}/{tile_coords.x}/{tile_coords.y}"
self._mem_cache[key] = tile_data
return True
def get_cached_tile(self, flight_id: str, tile_coords: TileCoords) -> np.ndarray | None:
"""Retrieve a cached tile from memory."""
key = f"{tile_coords.zoom}/{tile_coords.x}/{tile_coords.y}"
return self._mem_cache.get(key)
# ------------------------------------------------------------------
# Tile math helpers
# ------------------------------------------------------------------
def get_tile_grid(self, center: TileCoords, grid_size: int) -> list[TileCoords]:
"""Return grid_size tiles centered on center."""
if grid_size == 1:
return [center]
side = int(grid_size ** 0.5)
half = side // 2
coords: list[TileCoords] = []
for dy in range(-half, half + 1):
for dx in range(-half, half + 1):
coords.append(TileCoords(x=center.x + dx, y=center.y + dy, zoom=center.zoom))
if grid_size == 4:
coords = []
for dy in range(2):
for dx in range(2):
coords.append(TileCoords(x=center.x + dx, y=center.y + dy, zoom=center.zoom))
return coords[:grid_size]
def expand_search_grid(self, center: TileCoords, current_size: int, new_size: int) -> list[TileCoords]:
"""Return only the NEW tiles when expanding from current_size to new_size grid."""
old_set = {(c.x, c.y) for c in self.get_tile_grid(center, current_size)}
return [c for c in self.get_tile_grid(center, new_size) if (c.x, c.y) not in old_set]
def compute_tile_coords(self, lat: float, lon: float, zoom: int) -> TileCoords:
return mercator.latlon_to_tile(lat, lon, zoom)
def compute_tile_bounds(self, tile_coords: TileCoords) -> TileBounds:
return mercator.compute_tile_bounds(tile_coords)
def clear_flight_cache(self, flight_id: str) -> bool:
"""Clear in-memory cache (flight scoping is tile-key-based)."""
self._mem_cache.clear()
return True