[AZ-270] [AZ-272] [AZ-279] [AZ-281] [AZ-283] Compose root + FDR schema + 3 Layer-1 helpers

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>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-11 02:03:36 +03:00
parent 8e71f6c002
commit 3acc7f33dd
24 changed files with 2381 additions and 97 deletions
+153
View File
@@ -0,0 +1,153 @@
# 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.