# Common Helper — `WgsConverter`

## Purpose

WGS84 ↔ local tangent-plane (ENU/NED) ↔ tile pixel-coordinate conversions. Required by every component that interacts with geographic positions — from C4's pose estimation, through C5's state graph, through C6's tile-bounding-box queries, through C8's per-FC encoding, through C10's bbox provisioning, through C12's operator UX.

## Used By

- C4 — Pose Estimation.
- C5 — State Estimator.
- C6 — Tile Cache + Spatial Index (bbox queries).
- C8 — FC Adapter (per-FC encoding of LatLonAlt → MAVLink/MSP2).
- C10 — Pre-flight Cache Provisioning (bbox → tile-id list).
- C12 — Operator Pre-flight Tooling (operator-entered bbox).

## Interface (sketch)

```
class WgsConverter:
    @staticmethod
    def latlonalt_to_ecef(p: LatLonAlt) -> Vector3
    @staticmethod
    def ecef_to_latlonalt(p: Vector3) -> LatLonAlt
    @staticmethod
    def latlonalt_to_local_enu(origin: LatLonAlt, p: LatLonAlt) -> Vector3
    @staticmethod
    def local_enu_to_latlonalt(origin: LatLonAlt, p_enu: Vector3) -> 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
```

## Implementation Notes

- Stateless; pure functions.
- Backed by `pyproj` for the geodesy primitives; tile_xy math uses the standard slippy-map convention (matches `satellite-provider`'s on-disk layout).
- All conversions use WGS84 ellipsoid; no datum-shift complexity.

## Caveats

- The static-only design satisfies the coderule.mdc constraint ("only use static methods for pure self-contained computations"). If a future deployment needs alternative datum support, switch to an instance-based factory then.
- Tile-coordinate math is zoom-level-sensitive; callers MUST pass the right zoom level for the tile in question (typically zoomLevel from `TileMetadata`).

## Cycle-1 operational reality

The shipped surface in `src/gps_denied_onboard/helpers/wgs_converter.py` (AZ-279, extended by AZ-490) is the canonical entry point for every geodesy hop in the system. Stateless and `pyproj`-backed (`EPSG:4326 ↔ EPSG:4978`), with module-level `Transformer` instances cached on import.

- **Public constants** — `WEB_MERCATOR_MAX_LAT_DEG = 85.0511287798066` (the slippy-map cutoff; outside this band, `latlon_to_tile_xy` raises) and `MAX_ZOOM = 22` (slippy-map upper bound; exposed so callers can validate operator input without hard-coding the limit).
- **`WgsConversionError`** — single public exception type (subclasses `ValueError`). Raised on: non-finite `lat/lon/alt`; latitude/longitude out of WGS-84 range; non-`ndarray` or wrong-shape ECEF input; non-`float64` ECEF input; `zoom` not a non-bool `int` or out of `[0, MAX_ZOOM]`; tile `(x, y)` out of `[0, 2**zoom)`; latitude outside the Web-Mercator band for `latlon_to_tile_xy`.
- **ECEF arrays are `np.ndarray` of shape `(3,)` and `dtype=float64`** — the Interface sketch above uses "Vector3" as a placeholder. `latlonalt_to_ecef` returns a freshly-allocated array; `ecef_to_latlonalt` and `local_enu_to_latlonalt` validate input shape/dtype and raise `WgsConversionError` on mismatch.
- **`horizontal_distance_m(a: LatLonAlt, b: LatLonAlt) -> float`** — new method added in AZ-490 for C5's `set_takeoff_origin` bounded-delta gate. Computes the geodesic horizontal distance in metres via the same ECEF transformer used by `latlonalt_to_local_enu`: convert `b` into the local-ENU frame anchored at `a`, then `hypot(east, north)`. Altitude is ignored (flat-distance on the WGS-84 ellipsoid, NOT a 3-D distance). Accuracy ≤ sub-mm vs. Vincenty for separations ≤ a few km — the bounded-delta gate operates at ≤ ~1 km, so AZ-490's "geodesic horizontal distance" AC is satisfied.
- **Slippy-map tile math** — hand-rolled (NOT `pyproj`) to match OSM's `{zoom}/{x}/{y}.jpg` convention byte-equal so files produced by `satellite-provider` round-trip exactly. `latlon_to_tile_xy` clamps the output into `[0, n-1]` after `floor` — out-of-band latitude is rejected before this clamp via the Web-Mercator range check. `tile_xy_to_latlon_bounds` returns a `BoundingBox(min_lat_deg, min_lon_deg, max_lat_deg, max_lon_deg)` matching the tile's outer extent.
- **`pyproj` import** — `from pyproj import Transformer` is tagged `# type: ignore[import-not-found]` because `pyproj` ships type stubs in a separate package; the project pin does not add the stubs. Don't drop the ignore comment in mypy passes.

### Cycle-1 task lineage

- AZ-279 — initial helper, contract producer (`latlonalt_to_*`, `*_to_latlonalt`, `latlon_to_tile_xy`, `tile_xy_to_latlon_bounds`).
- AZ-490 — `horizontal_distance_m` addition for C5's takeoff-origin bounded-delta gate. Contract minor revision (v1.0.0 → v1.1.0) is queued for the next contracts-folder sweep.
