# WgsConverter Helper Module **Task**: AZ-279_wgs_converter **Name**: WgsConverter Helper **Description**: Implement the shared `WgsConverter` helper for WGS84 ↔ local-tangent-plane (ENU) ↔ tile-pixel coordinate conversions, backed by `pyproj`. Used by C4, C5, C6, C8, C10, C11, and C12 — every component that crosses the geographic-vs-local-frame boundary. Stateless static-only design (per `coderule.mdc`); slippy-map tile convention matches `satellite-provider`'s on-disk layout. **Complexity**: 2 points **Dependencies**: AZ-263_initial_structure **Component**: shared.helpers.wgs_converter (cross-cutting; epic AZ-264 / E-CC-HELPERS) **Tracker**: AZ-279 **Epic**: AZ-264 (E-CC-HELPERS) ### Document Dependencies - `_docs/02_document/contracts/shared_helpers/wgs_converter.md` — frozen public interface this task produces. - `_docs/02_document/common-helpers/04_helper_wgs_converter.md` — design rationale and consumer mapping. ## Problem Seven components (C4, C5, C6, C8, C10, C11, C12) need to cross the geographic-vs-local-frame boundary: - C4 compares pose-in-WGS to pose-in-ENU; C5 initialises iSAM2 from a WGS origin. - C6's tile bbox queries map between lat/lon and tile-pixel `(zoom, x, y)`. - C8 encodes pose for FC emission; C10 / C11 resolve operator-entered bboxes to tile lists; C12 takes the operator's bbox input. Without a shared helper: - Each component re-derives the WGS84 → ECEF → ENU pipeline; sign conventions (ENU vs NED) drift; altitude treatment (ellipsoidal vs orthometric) diverges. - Tile-xy conversions go through OSM-style math in some places and Mercator-projection in others, breaking on-disk compatibility with `satellite-provider`'s `{zoom}/{x}/{y}.jpg` layout. - A future datum or geoid change becomes a 7-place coordinated edit instead of a single helper update. ## Outcome - A single `helpers.wgs_converter` module is the only place that performs WGS84 / ECEF / ENU / tile-xy conversions across the codebase. Component imports go through the helper. - All conversions are pure static functions: same input → byte-equal output (deep-equal numpy / `LatLonAlt`). - ENU sign convention is locked to `(east, north, up)` and documented; consumers cannot drift to NED accidentally. - Slippy-map tile convention matches `satellite-provider`'s on-disk layout — the contract test pins the `(zoom=18, lat=50.45, lon=30.52) → (x, y)` round-trip against a known-good fixture. - Out-of-range inputs (zoom > 22, lat outside Web-Mercator-valid range, ECEF shape mismatch, tile-xy out of `[0, 2^zoom)`) raise `WgsConversionError` rather than silently producing garbage. ## Scope ### Included - Static methods on `WgsConverter`: `latlonalt_to_ecef`, `ecef_to_latlonalt`, `latlonalt_to_local_enu`, `local_enu_to_latlonalt`, `latlon_to_tile_xy`, `tile_xy_to_latlon_bounds`. - `WgsConversionError` exception type. - Public interface contract published at `_docs/02_document/contracts/shared_helpers/wgs_converter.md`. ### Excluded - 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; the contract documents that altitude is ellipsoidal. - Vincenty / great-circle distance helpers — out of scope. - Body-frame ↔ ECEF rotation transforms — `helpers.se3_utils` + per-deployment `CameraCalibration`. - The `LatLonAlt` / `BoundingBox` DTOs themselves — owned by `_types/` (AZ-263). ## Acceptance Criteria **AC-1: ECEF round-trip** Given `p = LatLonAlt(50.0, 30.0, 100.0)` When `ecef_to_latlonalt(latlonalt_to_ecef(p))` runs Then the returned `LatLonAlt` matches `p` within `atol=1e-9` deg lat/lon and `1e-6` m altitude **AC-2: ENU round-trip within 10 km** Given an `origin` and a `p` ~10 km away When `local_enu_to_latlonalt(origin, latlonalt_to_local_enu(origin, p))` runs Then the returned `LatLonAlt` matches `p` within 1 m horizontal + 1 cm vertical **AC-3: Slippy-map tile round-trip at z18** Given `(zoom=18, lat=50.45, lon=30.52)` When `tile_xy_to_latlon_bounds(zoom, *latlon_to_tile_xy(zoom, lat, lon))` runs Then the returned bounding box contains the input lat/lon AND the `(x, y)` matches the OSM-pinned fixture for the same coordinates **AC-4: Web-Mercator latitude range guard** Given `lat = 95.0` passed to `latlon_to_tile_xy` When the call runs Then `WgsConversionError` is raised mentioning the Web-Mercator-valid range `[-85.0511, 85.0511]` **AC-5: Zoom range guard** Given `zoom = 25` When `latlon_to_tile_xy` or `tile_xy_to_latlon_bounds` runs Then `WgsConversionError` is raised mentioning the supported zoom range `[0, 22]` **AC-6: Tile-xy range guard** Given `(zoom=18, x=2^18, y=0)` When `tile_xy_to_latlon_bounds` runs Then `WgsConversionError` is raised mentioning the valid `(x, y)` range `[0, 2^zoom)` **AC-7: ECEF shape contract** Given an array of shape `(2,)` passed to `ecef_to_latlonalt` When the call runs Then `WgsConversionError` is raised mentioning the expected shape `(3,)` **AC-8: Determinism** Given the same input When any helper function is called twice Then both outputs are byte-equal **AC-9: No upward imports (Layer 1 invariant)** Given the helper module When a static-import check runs Then it imports ONLY from `_types`, `pyproj`, numpy, and stdlib — no `gps_denied_onboard.components.*` imports anywhere ## Non-Functional Requirements **Performance** - No specific latency budget per `_docs/02_document/common-helpers/04_helper_wgs_converter.md` (consumers are pre-flight / post-landing). Each function p99 ≤ 200 µs on Tier-2 as a sanity bound. **Reliability** - Pure deterministic; same input → byte-equal output. - `WgsConversionError` is the ONLY exception type the public surface raises on shape / range violations. `pyproj`'s lower-level exceptions MUST be wrapped. ## Unit Tests | AC Ref | What to Test | Required Outcome | |--------|-------------|-----------------| | AC-1 | ECEF round-trip on 100 random valid `LatLonAlt`s | all match within `atol=1e-9` deg + `1e-6` m | | AC-2 | ENU round-trip on 100 origin/point pairs within 10 km | all match within 1 m + 1 cm | | AC-3 | Slippy-map round-trip at z18 with OSM-pinned fixture | `(x, y)` matches fixture; bounds contain input | | AC-4 | `latlon_to_tile_xy(18, 95.0, 0.0)` | `WgsConversionError`; mentions Web-Mercator range | | AC-5 | `latlon_to_tile_xy(25, 0, 0)` | `WgsConversionError`; mentions zoom range | | AC-6 | `tile_xy_to_latlon_bounds(18, 2**18, 0)` | `WgsConversionError`; mentions tile-xy range | | AC-7 | `ecef_to_latlonalt(np.zeros(2))` | `WgsConversionError`; mentions shape `(3,)` | | AC-8 | each helper called twice with same input | byte-equal outputs | | AC-9 | importlinter / grep gate | no `components.*` imports | | NFR-perf | microbench each helper (10k iterations on Tier-2 fixture) | p99 ≤ 200 µs each | ## Constraints - Public surface frozen by `_docs/02_document/contracts/shared_helpers/wgs_converter.md` v1.0.0. - Layer 1 Foundation only. - `pyproj` is the single geodesy backend; pinned in `pyproject.toml` at AZ-263 / E-BOOT. - Static-only design satisfies `coderule.mdc` ("only use static methods for pure self-contained computations") — every operation is a pure mathematical function of its arguments. - No new dependency beyond what AZ-263 / E-BOOT pinned. ## Risks & Mitigation **Risk 1: Tangent-plane approximation degrades silently beyond 100 km** - *Risk*: A consumer (e.g., C12 operator tooling with a continent-scale bbox) calls `latlonalt_to_local_enu` on a point 500 km from origin; the helper returns a result with O(1 km) error; consumer uses it as ground truth. - *Mitigation*: The contract `Invariants` section documents the 100 km validity range. Consumers that need wider range explicitly chain ECEF↔ENU through a closer origin. **Risk 2: Datum drift if `pyproj` upgrades silently change WGS84 parameters** - *Risk*: A future `pyproj` minor version changes the WGS84 ellipsoid parameters; all conversions shift by sub-metre amounts, breaking the round-trip ACs. - *Mitigation*: `pyproj` is pinned at AZ-263; round-trip ACs are the canary that detects drift on dependency upgrade. ## Runtime Completeness - **Named capability**: WGS84 ↔ ECEF ↔ ENU ↔ tile-xy conversions via `pyproj` (architecture / E-CC-HELPERS / `04_helper_wgs_converter.md`). - **Production code that must exist**: real `pyproj`-backed conversions; real slippy-map tile math matching `satellite-provider`'s on-disk layout. - **Allowed external stubs**: none — `pyproj` is the production runtime. - **Unacceptable substitutes**: hand-rolled flat-earth ENU approximation (silently breaks AC-2 beyond a few km); custom Mercator tile math that drifts from OSM convention (breaks `satellite-provider` compatibility); skipping out-of-range guards (silent garbage for high latitudes). ## Contract This task produces the contract at `_docs/02_document/contracts/shared_helpers/wgs_converter.md`. Consumers MUST read that file — not this task spec — to discover the interface.