mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 09:51:13 +00:00
3acc7f33dd
AZ-270: composition root with strategy registry, tier-gated lookup, topo-order construction, all-or-nothing teardown, StrategyNotLinkedError payload. AZ-272: orjson-backed FdrRecord serialise/parse with forward-compat for unknown payload + top-level fields and canonical overrun-record shape. AZ-279: pyproj-backed WGS84/ECEF/ENU + OSM slippy-map tile math with WgsConversionError for shape/range/zoom guards. AZ-281: strict EngineFilenameSchema build/parse/matches_host with anchored regex + enum validation; round-trip identity by construction. AZ-283: dtype-preserving (fp16/fp32) single + batch L2 normaliser with zero-norm safety and descriptor_metric() source-of-truth. pyproject.toml pins pyproj>=3.6 and orjson>=3.9 (named-backend deps per the AZ-272 / AZ-279 contracts). New DTOs LatLonAlt + BoundingBox and EngineCacheKey + HostCapabilities land in _types/ to back the helper contracts. 203 unit tests pass (64 new). Review verdict: PASS_WITH_WARNINGS; findings are perf-NFR deferrals + dep amendment + minor docstring polish. Co-authored-by: Cursor <cursoragent@cursor.com>
123 lines
4.3 KiB
Python
123 lines
4.3 KiB
Python
"""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)
|