"""AZ-279 — WgsConverter helper AC tests. Verifies the contract at ``_docs/02_document/contracts/shared_helpers/wgs_converter.md`` v1.0.0. """ from __future__ import annotations import ast import math from pathlib import Path import numpy as np import pytest from gps_denied_onboard._types.geo import BoundingBox, LatLonAlt from gps_denied_onboard.helpers import ( MAX_ZOOM, WEB_MERCATOR_MAX_LAT_DEG, WgsConversionError, WgsConverter, ) def test_ac1_ecef_roundtrip() -> None: # Arrange samples = [ LatLonAlt(50.0, 30.0, 100.0), LatLonAlt(-33.9, 151.2, 25.0), LatLonAlt(0.0, 0.0, 0.0), LatLonAlt(80.0, -120.0, 1500.0), LatLonAlt(-60.0, 179.999, -50.0), ] # Act / Assert for p in samples: ecef = WgsConverter.latlonalt_to_ecef(p) back = WgsConverter.ecef_to_latlonalt(ecef) assert math.isclose(back.lat_deg, p.lat_deg, abs_tol=1e-9), (back, p) assert math.isclose(back.lon_deg, p.lon_deg, abs_tol=1e-9), (back, p) assert math.isclose(back.alt_m, p.alt_m, abs_tol=1e-6), (back, p) def test_ac2_enu_roundtrip_within_10_km() -> None: origin = LatLonAlt(50.0, 30.0, 100.0) # ~10 km away in NE direction p = LatLonAlt(50.07, 30.10, 250.0) enu = WgsConverter.latlonalt_to_local_enu(origin, p) back = WgsConverter.local_enu_to_latlonalt(origin, enu) horizontal_m = math.hypot( (back.lat_deg - p.lat_deg) * 111_320.0, (back.lon_deg - p.lon_deg) * 111_320.0 * math.cos(math.radians(p.lat_deg)), ) vertical_m = abs(back.alt_m - p.alt_m) assert horizontal_m < 1.0, f"horizontal residual {horizontal_m} m > 1 m" assert vertical_m < 0.01, f"vertical residual {vertical_m} m > 1 cm" def test_ac3_slippy_map_tile_roundtrip_z18_contains_input() -> None: zoom, lat, lon = 18, 50.45, 30.52 x, y = WgsConverter.latlon_to_tile_xy(zoom, lat, lon) bounds = WgsConverter.tile_xy_to_latlon_bounds(zoom, x, y) assert isinstance(bounds, BoundingBox) assert bounds.contains(lat, lon) # OSM-pinned reference for (lat=50.45, lon=30.52, z=18); precomputed via # the slippy-map formula and matching satellite-provider's on-disk layout. assert (x, y) == (153295, 88392) def test_ac4_web_mercator_latitude_range_guard() -> None: with pytest.raises(WgsConversionError, match=r"Web-Mercator"): WgsConverter.latlon_to_tile_xy(18, 95.0, 0.0) def test_ac5_zoom_range_guard() -> None: with pytest.raises(WgsConversionError, match=r"zoom"): WgsConverter.latlon_to_tile_xy(MAX_ZOOM + 3, 50.0, 30.0) with pytest.raises(WgsConversionError, match=r"zoom"): WgsConverter.tile_xy_to_latlon_bounds(MAX_ZOOM + 3, 0, 0) def test_ac6_tile_xy_range_guard() -> None: with pytest.raises(WgsConversionError, match=r"tile"): WgsConverter.tile_xy_to_latlon_bounds(18, 1 << 18, 0) def test_ac7_ecef_shape_contract() -> None: with pytest.raises(WgsConversionError, match=r"shape"): WgsConverter.ecef_to_latlonalt(np.array([1.0, 2.0], dtype=np.float64)) def test_ac8_determinism_byte_equal_outputs() -> None: p = LatLonAlt(50.0, 30.0, 100.0) first = WgsConverter.latlonalt_to_ecef(p) second = WgsConverter.latlonalt_to_ecef(p) assert first.tobytes() == second.tobytes() def test_ac9_no_upward_imports_to_components() -> None: module_path = ( Path(__file__).resolve().parents[2] / "src" / "gps_denied_onboard" / "helpers" / "wgs_converter.py" ) tree = ast.parse(module_path.read_text(encoding="utf-8")) bad: list[str] = [] for node in ast.walk(tree): if isinstance(node, ast.ImportFrom) and node.module: if node.module.startswith("gps_denied_onboard.components"): bad.append(node.module) elif isinstance(node, ast.Import): for alias in node.names: if alias.name.startswith("gps_denied_onboard.components"): bad.append(alias.name) assert not bad, f"wgs_converter must not import components.*; found: {bad}" def test_invariant_web_mercator_max_lat_close_to_documented_value() -> None: # Sanity bound: documented constant matches the Mercator-projection-valid # latitude (arctan(sinh(pi))) within rounding. expected = math.degrees(math.atan(math.sinh(math.pi))) assert math.isclose(WEB_MERCATOR_MAX_LAT_DEG, expected, abs_tol=1e-9)