AZ-270: composition root with strategy registry, tier-gated lookup, topo-order construction, all-or-nothing teardown, StrategyNotLinkedError payload. AZ-272: orjson-backed FdrRecord serialise/parse with forward-compat for unknown payload + top-level fields and canonical overrun-record shape. AZ-279: pyproj-backed WGS84/ECEF/ENU + OSM slippy-map tile math with WgsConversionError for shape/range/zoom guards. AZ-281: strict EngineFilenameSchema build/parse/matches_host with anchored regex + enum validation; round-trip identity by construction. AZ-283: dtype-preserving (fp16/fp32) single + batch L2 normaliser with zero-norm safety and descriptor_metric() source-of-truth. pyproject.toml pins pyproj>=3.6 and orjson>=3.9 (named-backend deps per the AZ-272 / AZ-279 contracts). New DTOs LatLonAlt + BoundingBox and EngineCacheKey + HostCapabilities land in _types/ to back the helper contracts. 203 unit tests pass (64 new). Review verdict: PASS_WITH_WARNINGS; findings are perf-NFR deferrals + dep amendment + minor docstring polish. Co-authored-by: Cursor <cursoragent@cursor.com>
8.8 KiB
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}.jpglayout. - A future datum or geoid change becomes a 7-place coordinated edit instead of a single helper update.
Outcome
- A single
helpers.wgs_convertermodule 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)) raiseWgsConversionErrorrather 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. WgsConversionErrorexception 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-deploymentCameraCalibration. - The
LatLonAlt/BoundingBoxDTOs 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.
WgsConversionErroris 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 LatLonAlts |
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.mdv1.0.0. - Layer 1 Foundation only.
pyprojis the single geodesy backend; pinned inpyproject.tomlat 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_enuon 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
Invariantssection 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
pyprojminor version changes the WGS84 ellipsoid parameters; all conversions shift by sub-metre amounts, breaking the round-trip ACs. - Mitigation:
pyprojis 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 matchingsatellite-provider's on-disk layout. - Allowed external stubs: none —
pyprojis 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-providercompatibility); 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.