Files
Oleksandr Bezdieniezhnykh 880eabcb3f Decompose Step 6 snapshot: 140 task specs + contract docs
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>
2026-05-11 00:39:48 +03:00

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_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