# Contract: wgs_converter **Component**: shared_helpers / `helpers.wgs_converter` (cross-cutting concern owned by E-CC-HELPERS / AZ-264) **Producer task**: AZ-279 — `_docs/02_tasks/todo/AZ-279_wgs_converter.md` **Consumer tasks**: every C4 pose-estimation task that compares pose-in-WGS to pose-in-ENU; every C5 state-estimator task that initialises the iSAM2 graph from a WGS origin; every C6 task that maps a tile bbox to lat/lon; every C8 task that encodes pose for FC emission; every C10 / C11 task that resolves a bbox to a tile-id list; every C12 task where the operator enters a bbox **Version**: 1.0.0 **Status**: draft **Last Updated**: 2026-05-10 ## Purpose Centralise WGS84 ↔ local-tangent-plane (ENU) ↔ tile-pixel coordinate conversions. Required by every component that interacts with geographic positions. Per `_docs/02_document/common-helpers/04_helper_wgs_converter.md`. Backed by `pyproj` for the geodesy primitives; tile_xy math uses the standard slippy-map convention so it matches `satellite-provider`'s on-disk layout. ## Shape ### For function / method APIs ```python class WgsConverter: @staticmethod def latlonalt_to_ecef(p: LatLonAlt) -> np.ndarray: ... # shape (3,) @staticmethod def ecef_to_latlonalt(p_ecef: np.ndarray) -> LatLonAlt: ... @staticmethod def latlonalt_to_local_enu(origin: LatLonAlt, p: LatLonAlt) -> np.ndarray: ... # shape (3,) @staticmethod def local_enu_to_latlonalt(origin: LatLonAlt, p_enu: np.ndarray) -> LatLonAlt: ... @staticmethod def latlon_to_tile_xy(zoom: int, lat: float, lon: float) -> tuple[int, int]: ... @staticmethod def tile_xy_to_latlon_bounds(zoom: int, x: int, y: int) -> BoundingBox: ... ``` | Name | Signature | Throws / Errors | Blocking? | |------|-----------|-----------------|-----------| | `latlonalt_to_ecef` | `(LatLonAlt) -> np.ndarray (3,)` | `WgsConversionError` if lat / lon / alt are out of range | sync, pure | | `ecef_to_latlonalt` | `(np.ndarray (3,)) -> LatLonAlt` | `WgsConversionError` on shape mismatch | sync, pure | | `latlonalt_to_local_enu` | `(origin, p) -> np.ndarray (3,)` | `WgsConversionError` on origin / point validation | sync, pure | | `local_enu_to_latlonalt` | `(origin, p_enu) -> LatLonAlt` | `WgsConversionError` on origin / shape | sync, pure | | `latlon_to_tile_xy` | `(zoom, lat, lon) -> (int, int)` | `WgsConversionError` if zoom < 0 or > 22, lat out of `[-85.0511, 85.0511]`, lon out of `[-180, 180]` | sync, pure | | `tile_xy_to_latlon_bounds` | `(zoom, x, y) -> BoundingBox` | `WgsConversionError` if `x` or `y` out of `[0, 2^zoom)` | sync, pure | `LatLonAlt` and `BoundingBox` are imported from `gps_denied_onboard._types`. Numpy arrays use `dtype=float64`. `WgsConversionError` is the only exception type the public surface raises. ## Invariants - **Stateless**: no module-level state; static methods only. The static-only design satisfies the coderule.mdc constraint ("only use static methods for pure self-contained computations") because every operation is a pure mathematical function of its arguments. - **WGS84 ellipsoid only**: all conversions use the WGS84 ellipsoid; no datum-shift logic. If a future deployment needs alternative datum support, switch to an instance-based factory then. - **Slippy-map tile convention**: `latlon_to_tile_xy` matches OSM / `satellite-provider`'s on-disk `{zoom}/{x}/{y}.jpg` layout. Latitude is clamped to the Web-Mercator-valid range `[-85.0511, 85.0511]`; values outside raise `WgsConversionError`. - **ENU sign convention**: `latlonalt_to_local_enu` returns `(east, north, up)` in metres. Origin altitude IS used (height above ellipsoid); zero altitude is NOT silently substituted. - **Round-trip identity**: `local_enu_to_latlonalt(origin, latlonalt_to_local_enu(origin, p)) ≈ p` within `atol=1e-6` metres (lat/lon to ~1 m, alt to ~1 cm) for `p` within 100 km of `origin`. Beyond 100 km the tangent-plane approximation degrades — the contract documents this limit. - **Zoom-level dependence**: `tile_xy_to_latlon_bounds` and `latlon_to_tile_xy` are sensitive to `zoom`; callers MUST pass the right zoom for the tile in question (typically `zoomLevel` from `TileMetadata`). - **No upward imports** (Layer 1): the module imports ONLY from `_types`, `pyproj`, numpy, and stdlib. NO `gps_denied_onboard.components.*` imports. ## Non-Goals - Datum-shift logic / non-WGS84 datums — out of scope for v1.0.0. - UTM / MGRS conversions — out of scope. - Geoid-height corrections (orthometric vs. ellipsoidal altitude) — out of scope; consumers using altitude do so under the ellipsoid convention or apply geoid correction themselves. - Vincenty / great-circle distance helpers — out of scope. - Coordinate transforms involving rotation (body-frame ↔ ECEF) — owned by `helpers.se3_utils` plus the per-deployment `CameraCalibration`. ## Versioning Rules - **Breaking changes** (function renamed/removed, signature changed, ENU sign convention flipped, return shape changed) require a new major version + a deprecation pass through C4, C5, C6, C8, C10, C11, C12. - **Non-breaking additions** (new helper function, new optional kwarg with safe default) require a minor version bump. - Adding a new datum is a major version (the static-only design assumes WGS84). ## Test Cases | Case | Input | Expected | Notes | |------|-------|----------|-------| | valid-roundtrip-ecef | `LatLonAlt(50.0, 30.0, 100.0)` | `ecef_to_latlonalt(latlonalt_to_ecef(p))` matches `p` within `atol=1e-9 deg, 1e-6 m` | Round-trip happy path | | valid-roundtrip-enu | origin + point ~10 km away | `local_enu_to_latlonalt(origin, latlonalt_to_local_enu(origin, p))` matches `p` within 1 m horizontal + 1 cm vertical | ENU round-trip | | valid-tile-roundtrip-z18 | `(zoom=18, lat=50.45, lon=30.52)` | `latlon_to_tile_xy` returns valid `(x, y)`; `tile_xy_to_latlon_bounds(zoom, x, y)` contains the input lat/lon | Slippy-map convention | | valid-tile-bounds-z18 | `(zoom=18, x=148000, y=89400)` | bounds returned with non-zero area; corners at expected slippy-map lat/lon | Tile bounds | | invalid-lat-out-of-range | lat = 95.0 in `latlon_to_tile_xy` | `WgsConversionError` mentions Web-Mercator latitude range | Slippy-map invariant | | invalid-zoom-too-high | zoom = 25 | `WgsConversionError` mentions zoom range `[0, 22]` | Zoom guard | | invalid-tile-xy-out-of-range | `(zoom=18, x=2^18, y=0)` | `WgsConversionError` mentions tile-xy range | Tile-xy guard | | invalid-shape | `ecef_to_latlonalt(np.array([1.0, 2.0]))` (shape (2,)) | `WgsConversionError` mentions expected shape (3,) | Shape contract | | no-upward-imports | static import scan | only `_types`, `pyproj`, numpy, stdlib | Layer 1 invariant | | determinism | same input through any function twice | byte-equal outputs | Pure-function determinism | ## Change Log | Version | Date | Change | Author | |---------|------|--------|--------| | 1.0.0 | 2026-05-10 | Initial contract derived from `_docs/02_document/common-helpers/04_helper_wgs_converter.md` | autodev decompose Step 2 |