Files
gps-denied-onboard/tests/unit/test_az279_wgs_converter.py
T
Oleksandr Bezdieniezhnykh 3acc7f33dd [AZ-270] [AZ-272] [AZ-279] [AZ-281] [AZ-283] Compose root + FDR schema + 3 Layer-1 helpers
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>
2026-05-11 02:03:36 +03:00

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)