Closes out greenfield Step 6 (Decompose) for all 14 components (C1-C13 + cross-cutting helpers/replay). Covers tasks AZ-266..AZ-446 plus the _dependencies_table.md and component contract documents. State file updated to greenfield Step 7 (Implement), not_started. Co-authored-by: Cursor <cursoragent@cursor.com>
6.9 KiB
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
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_xymatches OSM /satellite-provider's on-disk{zoom}/{x}/{y}.jpglayout. Latitude is clamped to the Web-Mercator-valid range[-85.0511, 85.0511]; values outside raiseWgsConversionError. - ENU sign convention:
latlonalt_to_local_enureturns(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)) ≈ pwithinatol=1e-6metres (lat/lon to ~1 m, alt to ~1 cm) forpwithin 100 km oforigin. Beyond 100 km the tangent-plane approximation degrades — the contract documents this limit. - Zoom-level dependence:
tile_xy_to_latlon_boundsandlatlon_to_tile_xyare sensitive tozoom; callers MUST pass the right zoom for the tile in question (typicallyzoomLevelfromTileMetadata). - No upward imports (Layer 1): the module imports ONLY from
_types,pyproj, numpy, and stdlib. NOgps_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_utilsplus the per-deploymentCameraCalibration.
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 |