# 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.
