mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 08:21:13 +00:00
[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:
@@ -0,0 +1,133 @@
|
||||
# Batch Report — Cycle 1 · Batch 3
|
||||
|
||||
**Tasks shipped**: AZ-270, AZ-272, AZ-279, AZ-281, AZ-283
|
||||
**Date**: 2026-05-11
|
||||
**Branch**: dev
|
||||
**Review verdict**: PASS_WITH_WARNINGS — see `reviews/batch_03_review.md`
|
||||
|
||||
## Tasks
|
||||
|
||||
| ID | Title | Owner Layer | Outcome |
|
||||
|----|-------|-------------|---------|
|
||||
| AZ-270 | Composition Root | cross-cutting / `runtime_root.py` | Implemented — strategy registry, tier gating, topological-order construction, all-or-nothing teardown, `StrategyNotLinkedError` payload |
|
||||
| AZ-272 | FdrRecord Schema | cross-cutting / `fdr_client/records.py` | Implemented — `orjson`-backed `serialise`/`parse`, forward-compat for unknown payload + top-level fields, overrun-payload contract |
|
||||
| AZ-279 | WgsConverter | Layer 1 / `helpers/wgs_converter.py` | Implemented — `pyproj`-backed ECEF/ENU with hand-rolled OSM slippy-map tile math + `WgsConversionError` for shape / range / zoom guards |
|
||||
| AZ-281 | EngineFilenameSchema | Layer 1 / `helpers/engine_filename_schema.py` | Implemented — strict `build`/`parse`/`matches_host` with anchored regex + enum validation |
|
||||
| AZ-283 | DescriptorNormaliser | Layer 1 / `helpers/descriptor_normaliser.py` | Implemented — dtype-preserving single + batch L2 with zero-norm safety + `descriptor_metric()` source of truth |
|
||||
|
||||
## Files Changed
|
||||
|
||||
```
|
||||
Modified:
|
||||
pyproject.toml (+4 lines: pyproj, orjson pins)
|
||||
src/gps_denied_onboard/runtime_root.py (composition root impl)
|
||||
src/gps_denied_onboard/fdr_client/records.py (full schema + serialiser)
|
||||
src/gps_denied_onboard/fdr_client/__init__.py (re-exports)
|
||||
src/gps_denied_onboard/helpers/__init__.py (re-exports)
|
||||
src/gps_denied_onboard/helpers/wgs_converter.py (pyproj-backed converter)
|
||||
src/gps_denied_onboard/helpers/engine_filename_schema.py (build/parse/matches_host)
|
||||
src/gps_denied_onboard/helpers/descriptor_normaliser.py (L2 + zero-safe + dtype-preserving)
|
||||
src/gps_denied_onboard/_types/manifests.py (EngineCacheKey, HostCapabilities)
|
||||
tests/unit/test_runtime_root_env_gate.py (updated to Config-typed compose_root)
|
||||
|
||||
Added:
|
||||
src/gps_denied_onboard/_types/geo.py (LatLonAlt, BoundingBox)
|
||||
tests/unit/test_az270_compose_root.py
|
||||
tests/unit/test_az272_fdr_record_schema.py
|
||||
tests/unit/test_az279_wgs_converter.py
|
||||
tests/unit/test_az281_engine_filename_schema.py
|
||||
tests/unit/test_az283_descriptor_normaliser.py
|
||||
_docs/03_implementation/reviews/batch_03_review.md
|
||||
```
|
||||
|
||||
## Test Results
|
||||
|
||||
```
|
||||
$ pytest tests/unit -q --timeout=30
|
||||
203 passed, 2 skipped in 2.60s
|
||||
```
|
||||
|
||||
Skips are environment-gated (cmake configure, actionlint), both verified
|
||||
in CI.
|
||||
|
||||
### AC Coverage
|
||||
|
||||
| Task | ACs declared | ACs covered locally | Tier-2 deferred |
|
||||
|------|--------------|---------------------|-----------------|
|
||||
| AZ-270 | 6 + 2 NFR | 6 ACs + NFR-reliability (all-or-nothing) | NFR-perf (compose_root ≤ 750 ms on Jetson) |
|
||||
| AZ-272 | 6 + 2 NFR | 6 ACs + NFR-reliability + invariant inline-blob | NFR-perf (serialise p99 ≤ 20 µs, parse p99 ≤ 50 µs on Jetson) |
|
||||
| AZ-279 | 9 + 2 NFR | 9 ACs + determinism | NFR-perf (≤ 200 µs each on Jetson) |
|
||||
| AZ-281 | 11 + 2 NFR | 11 ACs | NFR-perf (≤ 50 µs each on Jetson) |
|
||||
| AZ-283 | 12 + 2 NFR | 12 ACs | NFR-perf-vector (≤ 50 µs / D=512) + NFR-perf-batch (≤ 5 ms / N=1000, D=512) |
|
||||
|
||||
Tier-2 perf budgets are owned by AZ-428..AZ-431; same deferral as
|
||||
batch 2. The batch-3 modules expose stable APIs so those tasks plug in
|
||||
without further code changes here.
|
||||
|
||||
## Dependency Pins
|
||||
|
||||
This batch amended `pyproject.toml` with two new runtime pins required
|
||||
by AZ-272 and AZ-279 contracts:
|
||||
|
||||
- `pyproj>=3.6,<4.0` — WGS84 geodesy backend per AZ-279 contract
|
||||
- `orjson>=3.9,<4.0` — FDR wire format per AZ-272 contract
|
||||
|
||||
Both names appear verbatim in the upstream contract documents and were
|
||||
the named-backend constraint. AZ-263 left these unpinned; the pins are
|
||||
added by the first batch that needs them — same pattern as batch 2.
|
||||
|
||||
## Adjacent Hygiene
|
||||
|
||||
This batch added four new DTOs to `_types/` because the AZ-279 / AZ-281
|
||||
contracts explicitly import them from `_types`:
|
||||
|
||||
- `_types/geo.py` (new file): `LatLonAlt`, `BoundingBox`
|
||||
- `_types/manifests.py`: `EngineCacheKey`, `HostCapabilities`
|
||||
|
||||
AZ-263 left these out; this batch closes the gap. No other helper or
|
||||
component depends on them yet, so the additions are forward-compatible.
|
||||
|
||||
The `runtime_root.py` signature change (`compose_root()` → `compose_root(config)`)
|
||||
forced an update to `tests/unit/test_runtime_root_env_gate.py` (now passes
|
||||
`Config()` so the env-var fail-fast still fires). AC-8 of AZ-263 is
|
||||
intact.
|
||||
|
||||
## Architecture Compliance
|
||||
|
||||
- `helpers/wgs_converter.py` imports only `numpy`, `pyproj`, `_types.geo`,
|
||||
and stdlib. AC-9 AST scan verifies no `components.*` imports.
|
||||
- `helpers/engine_filename_schema.py` imports only `re`, `_types.manifests`,
|
||||
and stdlib. AC-11 AST scan verifies.
|
||||
- `helpers/descriptor_normaliser.py` imports only `numpy` + stdlib.
|
||||
AC-12 AST scan verifies.
|
||||
- `fdr_client/records.py` imports only `orjson` + stdlib + the
|
||||
`dataclasses` decorator. No upward imports.
|
||||
- `runtime_root.py` imports from `gps_denied_onboard.config` only (the
|
||||
composition root is permitted to consume the config loader). The AC-6
|
||||
AST scan over every file under `components/` confirms no
|
||||
cross-component imports exist (the registry is the only sanctioned
|
||||
cross-component reference path).
|
||||
|
||||
No new circular imports.
|
||||
|
||||
## Review Findings Summary
|
||||
|
||||
Verdict: **PASS_WITH_WARNINGS**. Four Low-severity findings:
|
||||
|
||||
1. Two engine-cache types coexist (`EngineCacheEntry` vs `EngineCacheKey`)
|
||||
— recommend a docstring sentence on each.
|
||||
2. NFR-perf microbenchmarks deferred to Tier-2 perf suite.
|
||||
3. `pyproject.toml` dep amendment (pyproj + orjson) — by the consumer
|
||||
batch as designed.
|
||||
4. AC-6 architecture lint lives in tests; future `importlinter` upgrade
|
||||
recommended once concrete components ship.
|
||||
|
||||
Full details in `reviews/batch_03_review.md`.
|
||||
|
||||
## Tracker Transitions
|
||||
|
||||
- AZ-270: To Do → In Progress → (batch close) In Testing
|
||||
- AZ-272: To Do → In Progress → (batch close) In Testing
|
||||
- AZ-279: To Do → In Progress → (batch close) In Testing
|
||||
- AZ-281: To Do → In Progress → (batch close) In Testing
|
||||
- AZ-283: To Do → In Progress → (batch close) In Testing
|
||||
@@ -0,0 +1,331 @@
|
||||
# Code Review Report
|
||||
|
||||
**Batch**: 3
|
||||
**Tasks**: AZ-270 (Composition Root), AZ-272 (FdrRecord Schema), AZ-279 (WgsConverter), AZ-281 (EngineFilenameSchema), AZ-283 (DescriptorNormaliser)
|
||||
**Date**: 2026-05-11
|
||||
**Verdict**: PASS_WITH_WARNINGS
|
||||
|
||||
## Scope
|
||||
|
||||
Batch 3 closes the E-CC-CONF epic (AZ-270 ships the real composition root
|
||||
with strategy registry and tier gates) and kicks off E-CC-FDR-CLIENT
|
||||
(AZ-272 ships the wire-format schema). Three Layer-1 helpers — WGS
|
||||
converter, engine-filename schema, descriptor normaliser — land in the
|
||||
same batch because each is a small, contract-frozen unit that dozens of
|
||||
downstream component tasks gate on.
|
||||
|
||||
## Phase 1: Context Loading
|
||||
|
||||
Read:
|
||||
|
||||
- `_docs/02_tasks/todo/AZ-270_compose_root.md` (6 ACs + 2 NFRs)
|
||||
- `_docs/02_tasks/todo/AZ-272_fdr_record_schema.md` (6 ACs + 2 NFRs)
|
||||
- `_docs/02_tasks/todo/AZ-279_wgs_converter.md` (9 ACs + 2 NFRs)
|
||||
- `_docs/02_tasks/todo/AZ-281_engine_filename_schema.md` (11 ACs + 2 NFRs)
|
||||
- `_docs/02_tasks/todo/AZ-283_descriptor_normaliser.md` (12 ACs + 2 NFRs)
|
||||
- Five contracts under `_docs/02_document/contracts/`
|
||||
- `_docs/02_document/module-layout.md` (`runtime_root.py` ownership, helper
|
||||
layer envelope, Layer-1 import rules)
|
||||
|
||||
Ownership envelopes resolved per `module-layout.md`:
|
||||
|
||||
- AZ-270 owns `src/gps_denied_onboard/runtime_root.py` (replaces the
|
||||
AZ-263 stub) + `tests/unit/test_az270_compose_root.py` + adjusts the
|
||||
existing `tests/unit/test_runtime_root_env_gate.py` for the new
|
||||
`compose_root(config)` signature
|
||||
- AZ-272 owns `src/gps_denied_onboard/fdr_client/records.py` +
|
||||
`src/gps_denied_onboard/fdr_client/__init__.py` re-exports +
|
||||
`tests/unit/test_az272_fdr_record_schema.py`
|
||||
- AZ-279 owns `src/gps_denied_onboard/helpers/wgs_converter.py` +
|
||||
`tests/unit/test_az279_wgs_converter.py`
|
||||
- AZ-281 owns `src/gps_denied_onboard/helpers/engine_filename_schema.py` +
|
||||
`tests/unit/test_az281_engine_filename_schema.py`
|
||||
- AZ-283 owns `src/gps_denied_onboard/helpers/descriptor_normaliser.py` +
|
||||
`tests/unit/test_az283_descriptor_normaliser.py`
|
||||
|
||||
Adjacent hygiene this batch (per `coderule.mdc` scope-discipline rule):
|
||||
|
||||
- New DTOs in `src/gps_denied_onboard/_types/geo.py` (`LatLonAlt`,
|
||||
`BoundingBox`) and added `EngineCacheKey` + `HostCapabilities` to
|
||||
`src/gps_denied_onboard/_types/manifests.py`. The AZ-279 and AZ-281
|
||||
contracts explicitly import these from `_types`; AZ-263 left them
|
||||
un-declared, so this batch closes the gap.
|
||||
- `pyproject.toml` amended with two new pinned dependencies (`pyproj`
|
||||
for AZ-279, `orjson` for AZ-272). Both names appear verbatim in the
|
||||
upstream contract documents.
|
||||
|
||||
## Phase 2: Spec Compliance
|
||||
|
||||
### AZ-270 — Composition Root
|
||||
|
||||
| AC | Verification |
|
||||
|----|--------------|
|
||||
| AC-1 Default deployment composes | `test_ac1_default_deployment_composes` builds a 3-component graph and asserts every slot is populated |
|
||||
| AC-2 Strategy/build-flag mismatch rejected | `test_ac2_strategy_not_linked_raises_with_payload` asserts `StrategyNotLinkedError(strategy_name, component_slug, available_strategies)` payload matches |
|
||||
| AC-3 Operator-side excludes airborne | `test_ac3_operator_excludes_airborne_only` registers a strategy with `tier="airborne"`, asserts the same strategy in `compose_operator` raises |
|
||||
| AC-4 Reachability proof | `test_ac4_runtime_root_smoke_exit_zero` runs `compose_root(Config())` with no component blocks and asserts a fully-formed `RuntimeRoot` |
|
||||
| AC-5 Construction order respects deps | `test_ac5_construction_order_respects_dependencies` registers in reverse order, asserts the topo pass orders dependents after dependencies |
|
||||
| AC-6 Single import point enforced | `test_ac6_only_compose_root_imports_concrete_strategies` AST-walks every file under `components/` and asserts no cross-component imports |
|
||||
| NFR-reliability all-or-nothing | `test_nfr_reliability_partial_construction_closed_on_failure` injects a failing factory, asserts every prior `_Closable` had `close()` called |
|
||||
|
||||
The implementation provides a `_Registration` dataclass + global
|
||||
`_STRATEGY_REGISTRY` keyed by `(component_slug, strategy_name)`, a
|
||||
`register_strategy()` entrypoint (the only sanctioned write-path), a
|
||||
`clear_strategy_registry()` helper for test isolation, a Kahn-style
|
||||
topo-sort over the `depends_on` graph, and a `_close_partial_instances`
|
||||
best-effort teardown hook.
|
||||
|
||||
### AZ-272 — FdrRecord Schema
|
||||
|
||||
| AC | Verification |
|
||||
|----|--------------|
|
||||
| AC-1 Every kind round-trips | `test_ac1_roundtrip_every_known_kind` parametrised over all 10 v1.0.0 kinds with kind-specific payload fixtures |
|
||||
| AC-2 Forward-compat payload | `test_ac2_forward_compatible_unknown_payload_field_preserved` (and `_ac2b_unknown_top_level_field_preserved` for the top-level bucket) |
|
||||
| AC-3 Unknown future kind opaque | `test_ac3_unknown_future_kind_returned_opaquely` |
|
||||
| AC-4 Missing/non-int schema_version | `test_ac4_missing_schema_version_raises` + `test_ac4_non_integer_schema_version_raises` |
|
||||
| AC-5 Overrun shape | `test_ac5_overrun_missing_dropped_count_rejected_on_parse` + `test_ac5_overrun_zero_dropped_count_rejected_on_serialise` |
|
||||
| AC-6 Producer ID required | `test_ac6_empty_producer_id_rejected_on_serialise` |
|
||||
| Invariant inline-blob cap | `test_nfr_oversized_inline_blob_rejected` |
|
||||
| Pure determinism | `test_nfr_serialise_is_pure_byte_identical` |
|
||||
|
||||
Tier-2 perf NFR (`serialise` p99 ≤ 20 µs; `parse` p99 ≤ 50 µs) is
|
||||
deferred to the Tier-2 perf suite — same pattern as batch-2 NFR-perf.
|
||||
|
||||
### AZ-279 — WgsConverter
|
||||
|
||||
| AC | Verification |
|
||||
|----|--------------|
|
||||
| AC-1 ECEF round-trip | `test_ac1_ecef_roundtrip` over 5 globally-distributed samples within `atol=1e-9` deg + `1e-6` m |
|
||||
| AC-2 ENU 10 km round-trip | `test_ac2_enu_roundtrip_within_10_km` asserts horizontal residual < 1 m, vertical < 1 cm |
|
||||
| AC-3 Slippy-map z18 round-trip | `test_ac3_slippy_map_tile_roundtrip_z18_contains_input` pinned to `(153295, 88392)` per OSM convention |
|
||||
| AC-4 Lat range guard | `test_ac4_web_mercator_latitude_range_guard` |
|
||||
| AC-5 Zoom range guard | `test_ac5_zoom_range_guard` |
|
||||
| AC-6 Tile-xy range guard | `test_ac6_tile_xy_range_guard` |
|
||||
| AC-7 ECEF shape contract | `test_ac7_ecef_shape_contract` |
|
||||
| AC-8 Determinism | `test_ac8_determinism_byte_equal_outputs` (`tobytes()` equality) |
|
||||
| AC-9 No upward imports | `test_ac9_no_upward_imports_to_components` (AST scan) |
|
||||
|
||||
Web-Mercator max-lat constant is `arctan(sinh(pi))` ≈ 85.0511287798066°
|
||||
(matches the OSM-documented constant). `_enu_to_ecef_rotation`
|
||||
implements the canonical local-tangent-plane basis at
|
||||
`(lat, lon)`; ENU sign convention is `(east, north, up)`.
|
||||
|
||||
### AZ-281 — EngineFilenameSchema
|
||||
|
||||
| AC | Verification |
|
||||
|----|--------------|
|
||||
| AC-1 Reference example | `test_ac1_reference_example_builds_exact_string` |
|
||||
| AC-2 Round-trip identity | `test_ac2_roundtrip_identity_over_10_random_tuples` (seeded `random.Random(2026)`) |
|
||||
| AC-3 Host-match exact | `test_ac3_matches_host_exact_match` |
|
||||
| AC-4 Host-mismatch no exception | `test_ac4_matches_host_tuple_mismatch_returns_false` (sm + trt variants) |
|
||||
| AC-5 Precision enum strictness | `test_ac5_precision_enum_strictness` |
|
||||
| AC-6 Model char set | `test_ac6_model_name_character_set_rejection` |
|
||||
| AC-7 Reserved separator | `test_ac7_reserved_separator_collision_rejected` |
|
||||
| AC-8 Version format | `test_ac8_three_segment_version_rejected` |
|
||||
| AC-9 Parse malformed | `test_ac9_parse_rejects_malformed_filename` |
|
||||
| AC-10 `.engine` suffix | `test_ac10_parse_requires_engine_suffix` |
|
||||
| AC-11 No upward imports | `test_ac11_no_upward_imports_to_components` (AST scan) |
|
||||
|
||||
The implementation backs everything on a single anchored regex
|
||||
`_FILENAME_RE` plus explicit `_validate_*` helpers for the producer
|
||||
path. Round-trip identity holds by construction because `parse` extracts
|
||||
the same five fields the regex consumed.
|
||||
|
||||
### AZ-283 — DescriptorNormaliser
|
||||
|
||||
| AC | Verification |
|
||||
|----|--------------|
|
||||
| AC-1 Unit-vector example | `test_ac1_unit_vector_example` |
|
||||
| AC-2 Batch normalisation | `test_ac2_batch_normalisation` |
|
||||
| AC-3 fp16 dtype preserved | `test_ac3_fp16_dtype_preservation` |
|
||||
| AC-4 fp32 dtype preserved | `test_ac4_fp32_dtype_preservation` |
|
||||
| AC-5 Zero-vector safe | `test_ac5_zero_vector_handling` + `_ac5b_zero_row_in_batch_remains_zero` |
|
||||
| AC-6 fp32 idempotence | `test_ac6_idempotence_fp32` (`tobytes()` equality) |
|
||||
| AC-7 fp16 idempotence | `test_ac7_idempotence_fp16_within_half_precision_tol` |
|
||||
| AC-8 No in-place mutation | `test_ac8_no_in_place_mutation` |
|
||||
| AC-9 Metric source-of-truth | `test_ac9_metric_is_inner_product_exact_string` |
|
||||
| AC-10 fp64 rejected | `test_ac10_float64_dtype_rejected` |
|
||||
| AC-11 Shape contract | `test_ac11_shape_contract_single_rejects_2d` + `_batch_rejects_1d` |
|
||||
| AC-12 No upward imports | `test_ac12_no_upward_imports_to_components` (AST scan) |
|
||||
|
||||
The implementation routes through `float32` internally for norm
|
||||
stability, then casts back to the caller dtype (no silent up-cast). The
|
||||
batch path uses `np.where(norms == 0.0, 1.0, norms)` to avoid
|
||||
division-by-zero without branching per row.
|
||||
|
||||
No Spec-Gap findings.
|
||||
|
||||
## Phase 3: Code Quality
|
||||
|
||||
- **SRP** — each module owns exactly one concern. `runtime_root.py` is
|
||||
marginally larger (registry + topo + compose + entrypoint) but every
|
||||
internal function has a sharp name; nothing leaks responsibility into
|
||||
generic "candidate"/"data" helpers.
|
||||
- **Error handling** — every public surface raises one typed exception:
|
||||
`StrategyNotLinkedError`, `FdrSchemaError`, `WgsConversionError`,
|
||||
`EngineFilenameSchemaError`, `DescriptorNormaliserError`. Library
|
||||
errors (`orjson.JSONDecodeError`, `pyproj.ProjError`-tier exceptions)
|
||||
are wrapped at the public boundary.
|
||||
- **Naming** — public symbols match the contract files verbatim.
|
||||
`RuntimeRoot.construction_order` is an additive field the contract
|
||||
permits but does not mandate; it's the observable used by AC-5 tests.
|
||||
- **Complexity** — no function exceeds 50 lines; the busiest function is
|
||||
`_compose` at ~30 lines.
|
||||
- **DRY** — shared helpers (`_enu_to_ecef_rotation`,
|
||||
`_validate_envelope_outgoing`, `_validate_overrun_payload`) are
|
||||
module-private; no duplication across helpers.
|
||||
- **Test quality** — every AC has a directly-mapped test that asserts
|
||||
the contractually-named behaviour. AST-based import scans for the
|
||||
"no upward imports" invariant rather than string matches.
|
||||
- **Dead code** — the previous AZ-263 `compose_root` stub returned a
|
||||
hollow `RuntimeRoot(binary, profile)`; replaced verbatim. The previous
|
||||
`runtime_root.py` had a `dataclass` import that is no longer needed at
|
||||
module level — confirmed it is still used (the new file imports it
|
||||
via `dataclass` for `RuntimeRoot`/`OperatorRoot`).
|
||||
|
||||
## Phase 4: Security Quick-Scan
|
||||
|
||||
- **No SQL string interpolation** anywhere in the batch.
|
||||
- **No `shell=True` / `eval` / `exec`**.
|
||||
- **No hardcoded secrets** — operator/airborne env-var lists are
|
||||
identifiers only.
|
||||
- **No insecure deserialization** — `orjson.loads` is the production
|
||||
decoder; type-validated before any field is used. YAML loading is in
|
||||
the AZ-269 path (out of scope here) and uses `yaml.safe_load`.
|
||||
- **`_FILENAME_RE` is anchored** with `^...$`; no ReDoS surface (the
|
||||
pattern is linear-time on input length).
|
||||
- **`atomic_write` is delegated** to the AZ-280 helper (`Sha256Sidecar`);
|
||||
this batch does not introduce new disk-write code paths.
|
||||
|
||||
No security findings.
|
||||
|
||||
## Phase 5: Performance Scan
|
||||
|
||||
- `serialise` / `parse` go through `orjson` (C-extension); a single
|
||||
allocation per record. No O(n²) loops.
|
||||
- `WgsConverter._ECEF_FROM_LLA` and `_LLA_FROM_ECEF` are module-level
|
||||
cached `pyproj.Transformer` instances; no per-call transformer setup
|
||||
cost.
|
||||
- `DescriptorNormaliser.l2_normalise_batch` uses NumPy vectorised
|
||||
`np.linalg.norm(..., axis=1, keepdims=True)`; no Python-level row
|
||||
iteration.
|
||||
- `_topo_order` is Kahn-style DFS at O(V+E) in the strategy graph
|
||||
(sub-msec for any realistic component count).
|
||||
|
||||
NFR microbenchmarks (AZ-272 serialise / parse latency; AZ-279 per-helper
|
||||
latency; AZ-283 per-vector + batch latency) need Tier-2 hardware; same
|
||||
deferral pattern as batch 2.
|
||||
|
||||
## Phase 6: Cross-Task Consistency
|
||||
|
||||
- `fdr_client.records` imports `orjson` only; no upward imports.
|
||||
- `helpers/wgs_converter.py` imports `pyproj`, `numpy`, `_types.geo`
|
||||
only; no component imports.
|
||||
- `helpers/engine_filename_schema.py` imports `re` and `_types.manifests`
|
||||
only.
|
||||
- `helpers/descriptor_normaliser.py` imports `numpy` only.
|
||||
- `runtime_root.py` imports `config` (allowed — it's the consumer) and
|
||||
no `components.*` modules (because no concrete components exist yet;
|
||||
AC-6 enforces this going forward).
|
||||
- The `_close_partial_instances` cleanup hook uses `getattr(inst,
|
||||
"close", None)` so it does not require components to implement a
|
||||
particular interface — they opt in by exposing `.close()`.
|
||||
|
||||
No cross-task consistency findings.
|
||||
|
||||
## Phase 7: Architecture Compliance
|
||||
|
||||
Per `module-layout.md`:
|
||||
|
||||
- `helpers/*` (Layer 1): allowed imports are `_types`, stdlib, and named
|
||||
external deps (pyproj, numpy, orjson). PASS for AZ-279 / AZ-281 /
|
||||
AZ-283.
|
||||
- `runtime_root.py` (composition root): allowed to import concrete
|
||||
strategies from `components.*` — but none exist yet, so this
|
||||
permission is unused.
|
||||
- `fdr_client/records.py` (Layer 0 / cross-cutting): allowed imports are
|
||||
stdlib + `orjson`. PASS.
|
||||
- The strategy registry is global state inside `runtime_root.py`. This
|
||||
is intentional and matches the ADR-009 "interface-first DI"
|
||||
prescription — the registry is filled by bootstrap modules, then
|
||||
consumed by `compose_*`. Tests reset it via the
|
||||
`clear_strategy_registry()` fixture.
|
||||
|
||||
No new circular imports. The import graph
|
||||
`runtime_root → config → (stdlib + pyyaml)` and
|
||||
`fdr_client.records → (stdlib + orjson)` are both acyclic.
|
||||
|
||||
No Architecture findings.
|
||||
|
||||
## Findings
|
||||
|
||||
| # | Severity | Category | File:Line | Title |
|
||||
|---|----------|----------|-----------|-------|
|
||||
| 1 | Low | Maintainability | _types/manifests.py | Two engine-cache types coexist (`EngineCacheEntry` and `EngineCacheKey`) |
|
||||
| 2 | Low | Performance | tests/unit/test_az272*.py + test_az279*.py + test_az283*.py | NFR-perf microbenchmarks deferred to Tier-2 |
|
||||
| 3 | Low | Scope | pyproject.toml | Batch 3 added `pyproj` + `orjson` deps that AZ-263 had not pinned |
|
||||
| 4 | Low | Architecture | src/gps_denied_onboard/runtime_root.py + tests | AC-6 architecture lint relies on a tests-only AST scan |
|
||||
|
||||
### Finding Details
|
||||
|
||||
**F1: Two engine-cache types coexist** (Low / Maintainability)
|
||||
|
||||
- Location: `src/gps_denied_onboard/_types/manifests.py`
|
||||
- Description: `EngineCacheEntry` (AZ-263 stub, carries `engine_path`,
|
||||
`content_hash`, `int8_calibration_path`) and `EngineCacheKey` (AZ-281,
|
||||
the five-tuple filename key) sit side by side. They serve different
|
||||
purposes — a `Key` is the parsed filename tuple, an `Entry` is the
|
||||
cache row that also tracks a content hash. C10's Manifest will need
|
||||
both. Worth a follow-up doc note in `_types/manifests.py` so the next
|
||||
reader doesn't think one supersedes the other.
|
||||
- Suggestion: capture a short docstring sentence on each, calling out
|
||||
the other.
|
||||
- Task: AZ-281
|
||||
|
||||
**F2: NFR-perf microbenchmarks deferred** (Low / Performance)
|
||||
|
||||
- Location: AZ-272 / AZ-279 / AZ-283 perf NFRs
|
||||
- Description: Same pattern as batch 2. Tier-2 hardware-pinned budgets
|
||||
(serialise/parse latency, per-helper p99, batch p99) cannot be
|
||||
validated locally; AZ-428..AZ-431 own the Tier-2 perf suite.
|
||||
- Suggestion: add corresponding `tests/perf/` files when AZ-428..AZ-431
|
||||
lands.
|
||||
- Task: AZ-272, AZ-279, AZ-283
|
||||
|
||||
**F3: pyproject.toml dep amendment** (Low / Scope)
|
||||
|
||||
- Location: `pyproject.toml::dependencies`
|
||||
- Description: Batch 3 added `pyproj>=3.6,<4.0` and `orjson>=3.9,<4.0`.
|
||||
Both are named-backend deps the upstream contract documents call out.
|
||||
Same justification as batch 2's `gtsam` / `atomicwrites` amendment.
|
||||
- Suggestion: none — recorded so the AZ-263 implementation report and
|
||||
the Product-Implementation-Completeness audit reflect the batch-3 dep
|
||||
addition.
|
||||
- Task: AZ-272, AZ-279
|
||||
|
||||
**F4: AC-6 architecture lint lives in tests** (Low / Architecture)
|
||||
|
||||
- Location: `tests/unit/test_az270_compose_root.py::test_ac6_only_compose_root_imports_concrete_strategies`
|
||||
- Description: The "only compose_root imports concrete strategies"
|
||||
invariant is enforced by an AST scan inside the unit suite. A future
|
||||
CI lane that runs `importlinter` would catch the same thing earlier.
|
||||
For v1.0.0 the unit-test gate is sufficient (and necessary — without
|
||||
any concrete strategies yet, there's no need for a separate tool).
|
||||
- Suggestion: when the first concrete component strategy is wired in,
|
||||
consider adding an `importlinter.cfg` so the check runs at lint time
|
||||
too.
|
||||
- Task: AZ-270
|
||||
|
||||
## Verdict
|
||||
|
||||
**PASS_WITH_WARNINGS**. Four Low-severity findings, all informational
|
||||
follow-ups (per-NFR deferrals, dep amendment, docstring polish, and a
|
||||
future lint-tier upgrade). Per the Auto-Fix Gate matrix, Low findings
|
||||
continue to commit without escalation.
|
||||
|
||||
## Test Run Summary
|
||||
|
||||
- **Local**: 203 passed, 2 skipped (cmake configure, actionlint — both
|
||||
CI-gated). Includes 64 new batch-3 tests.
|
||||
- **Coverage**: every AC across all five tasks has at least one
|
||||
corresponding test. NFR-perf budgets are deferred to Tier-2.
|
||||
@@ -8,7 +8,7 @@ status: in_progress
|
||||
sub_step:
|
||||
phase: 14
|
||||
name: loop-next-batch
|
||||
detail: "batch 2 of N committed"
|
||||
detail: "batch 3 of N committed"
|
||||
retry_count: 0
|
||||
cycle: 1
|
||||
tracker: jira
|
||||
|
||||
@@ -29,6 +29,10 @@ dependencies = [
|
||||
"gtsam>=4.2,<5.0",
|
||||
# Atomic-rename backend for helpers.sha256_sidecar (D-C10-3, AZ-280).
|
||||
"atomicwrites>=1.4,<2.0",
|
||||
# WGS84 geodesy backend for helpers.wgs_converter (AZ-264 / AZ-279).
|
||||
"pyproj>=3.6,<4.0",
|
||||
# FDR wire format for fdr_client.records (E-CC-FDR-CLIENT / AZ-272).
|
||||
"orjson>=3.9,<4.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Geographic DTOs shared by every component that crosses the WGS / ENU / tile-pixel
|
||||
boundary.
|
||||
|
||||
Consumed by `helpers.wgs_converter` (AZ-279), C4 / C5 / C6 / C8 / C10 / C11 / C12.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LatLonAlt:
|
||||
"""A WGS84 geographic position. ``alt_m`` is height above the WGS84 ellipsoid."""
|
||||
|
||||
lat_deg: float
|
||||
lon_deg: float
|
||||
alt_m: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BoundingBox:
|
||||
"""An axis-aligned lat/lon bounding box.
|
||||
|
||||
The slippy-map tile bounds returned by ``WgsConverter.tile_xy_to_latlon_bounds``
|
||||
use this shape. Latitude axis grows northward; longitude axis grows eastward.
|
||||
"""
|
||||
|
||||
min_lat_deg: float
|
||||
min_lon_deg: float
|
||||
max_lat_deg: float
|
||||
max_lon_deg: float
|
||||
|
||||
def contains(self, lat_deg: float, lon_deg: float) -> bool:
|
||||
return (
|
||||
self.min_lat_deg <= lat_deg <= self.max_lat_deg
|
||||
and self.min_lon_deg <= lon_deg <= self.max_lon_deg
|
||||
)
|
||||
@@ -30,3 +30,34 @@ class EngineCacheEntry:
|
||||
precision: str
|
||||
content_hash: str
|
||||
int8_calibration_path: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EngineCacheKey:
|
||||
"""Parsed tuple of a self-describing `.engine` filename (D-C10-7, AZ-281).
|
||||
|
||||
Filename schema: ``{model}__sm{SM}_jp{JP_dotted}_trt{TRT_dotted}_{precision}.engine``.
|
||||
The dotted-version strings (``jetpack``, ``trt``) are stored verbatim so the
|
||||
round-trip ``parse(build(*args)) == EngineCacheKey(*args)`` invariant holds.
|
||||
"""
|
||||
|
||||
model_name: str
|
||||
sm: int
|
||||
jetpack: str
|
||||
trt: str
|
||||
precision: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HostCapabilities:
|
||||
"""Host-side TensorRT capability tuple consulted by AZ-281's ``matches_host``.
|
||||
|
||||
Captures the same five-tuple the engine filename encodes; ``matches_host`` is
|
||||
true iff every element matches exactly. Precision and ``model_name`` are
|
||||
excluded from the comparison: the predicate is host-vs-binary, not
|
||||
engine-vs-engine.
|
||||
"""
|
||||
|
||||
sm: int
|
||||
jetpack: str
|
||||
trt: str
|
||||
|
||||
@@ -5,6 +5,27 @@ Producer-side API used by every component. Consumer-side writer lives in
|
||||
"""
|
||||
|
||||
from gps_denied_onboard.fdr_client.client import FdrClient
|
||||
from gps_denied_onboard.fdr_client.records import FdrRecord
|
||||
from gps_denied_onboard.fdr_client.records import (
|
||||
CURRENT_SCHEMA_VERSION,
|
||||
KNOWN_KINDS,
|
||||
MAX_INLINE_BLOB_BYTES,
|
||||
OVERRUN_KIND,
|
||||
OVERRUN_PRODUCER_ID,
|
||||
FdrRecord,
|
||||
FdrSchemaError,
|
||||
parse,
|
||||
serialise,
|
||||
)
|
||||
|
||||
__all__ = ["FdrClient", "FdrRecord"]
|
||||
__all__ = [
|
||||
"CURRENT_SCHEMA_VERSION",
|
||||
"KNOWN_KINDS",
|
||||
"MAX_INLINE_BLOB_BYTES",
|
||||
"OVERRUN_KIND",
|
||||
"OVERRUN_PRODUCER_ID",
|
||||
"FdrClient",
|
||||
"FdrRecord",
|
||||
"FdrSchemaError",
|
||||
"parse",
|
||||
"serialise",
|
||||
]
|
||||
|
||||
@@ -1,25 +1,226 @@
|
||||
"""FDR record schema — STUB.
|
||||
"""FDR record schema + versioned (de)serialiser (AZ-272 / E-CC-FDR-CLIENT).
|
||||
|
||||
Concrete schema (estimates / IMU / MAVLink / health / tile / thumbnail discriminated
|
||||
record types) is owned by AZ-272. Bootstrap declares the umbrella DTO so every
|
||||
producer can import it.
|
||||
Public surface frozen by
|
||||
`_docs/02_document/contracts/shared_fdr_client/fdr_record_schema.md` v1.0.0.
|
||||
|
||||
The library backing ``serialise`` / ``parse`` (``orjson``) is pinned in
|
||||
``pyproject.toml`` and intentionally hidden from the public API — callers
|
||||
trade ``FdrRecord <-> bytes`` only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from typing import Any, Final
|
||||
|
||||
import orjson
|
||||
|
||||
__all__ = [
|
||||
"CURRENT_SCHEMA_VERSION",
|
||||
"KNOWN_KINDS",
|
||||
"MAX_INLINE_BLOB_BYTES",
|
||||
"OVERRUN_KIND",
|
||||
"OVERRUN_PRODUCER_ID",
|
||||
"FdrRecord",
|
||||
"FdrSchemaError",
|
||||
"parse",
|
||||
"serialise",
|
||||
]
|
||||
|
||||
CURRENT_SCHEMA_VERSION: Final[int] = 1
|
||||
|
||||
OVERRUN_KIND: Final[str] = "overrun"
|
||||
OVERRUN_PRODUCER_ID: Final[str] = "shared.fdr_client"
|
||||
|
||||
# Per-kind allowed payload keys. The parser uses this to route unknown future
|
||||
# fields into ``payload["extra"]`` (forward-compat AC-2). Unknown ``kind`` values
|
||||
# bypass the table and are returned opaquely (AC-3).
|
||||
KNOWN_PAYLOAD_KEYS: Final[dict[str, frozenset[str]]] = {
|
||||
"log": frozenset({"level", "component", "frame_id", "kind", "msg", "kv", "exc"}),
|
||||
"vio.tick": frozenset(
|
||||
{"frame_id", "R", "t", "P", "last_anchor_age_ms", "mre_px", "imu_bias_norm"}
|
||||
),
|
||||
"state.tick": frozenset({"frame_id", "fused_pose", "covariance_2x2", "estimator_label"}),
|
||||
"tile_match": frozenset({"frame_id", "tile_id", "score", "match_count", "ransac_inliers"}),
|
||||
"overrun": frozenset({"producer_id", "dropped_count"}),
|
||||
"segment_rollover": frozenset({"old_segment", "new_segment", "total_bytes_after"}),
|
||||
"failed_tile_thumbnail": frozenset({"frame_id", "tile_id", "jpeg_bytes_b64"}),
|
||||
"mid_flight_tile_snapshot": frozenset({"snapshot_path", "captured_at"}),
|
||||
"flight_header": frozenset({"flight_id", "started_at", "schema_version", "build_info"}),
|
||||
"flight_footer": frozenset({"flight_id", "ended_at", "records_written", "records_dropped"}),
|
||||
}
|
||||
|
||||
KNOWN_KINDS: Final[frozenset[str]] = frozenset(KNOWN_PAYLOAD_KEYS.keys())
|
||||
|
||||
# Inline binary blob cap; bigger payloads must reference a sidecar path.
|
||||
MAX_INLINE_BLOB_BYTES: Final[int] = 4 * 1024
|
||||
|
||||
|
||||
class FdrSchemaError(ValueError):
|
||||
"""Raised on schema-violation in ``serialise`` / ``parse`` (AZ-272).
|
||||
|
||||
The ONLY exception type either function raises on schema-violation; orjson
|
||||
/ library-specific errors are wrapped at the public boundary.
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FdrRecord:
|
||||
"""A single FDR record (record-type discriminator + payload).
|
||||
"""Frozen FDR record envelope per contract v1.0.0."""
|
||||
|
||||
The full discriminated-union of record types is defined by AZ-272.
|
||||
"""
|
||||
|
||||
record_type: str
|
||||
timestamp: datetime
|
||||
producer: str
|
||||
schema_version: int
|
||||
ts: str
|
||||
producer_id: str
|
||||
kind: str
|
||||
payload: dict[str, Any] = field(default_factory=dict)
|
||||
extra: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
_ENVELOPE_REQUIRED: Final[tuple[str, ...]] = (
|
||||
"schema_version",
|
||||
"ts",
|
||||
"producer_id",
|
||||
"kind",
|
||||
"payload",
|
||||
)
|
||||
|
||||
|
||||
def _validate_envelope_outgoing(record: FdrRecord) -> None:
|
||||
if not isinstance(record.schema_version, int) or isinstance(record.schema_version, bool):
|
||||
raise FdrSchemaError(
|
||||
f"FdrRecord.schema_version must be a non-bool integer; got {record.schema_version!r}"
|
||||
)
|
||||
if record.schema_version < 1:
|
||||
raise FdrSchemaError(f"FdrRecord.schema_version must be >= 1; got {record.schema_version}")
|
||||
if not isinstance(record.ts, str) or not record.ts:
|
||||
raise FdrSchemaError(f"FdrRecord.ts must be a non-empty string; got {record.ts!r}")
|
||||
if not isinstance(record.producer_id, str) or not record.producer_id:
|
||||
raise FdrSchemaError(
|
||||
f"FdrRecord.producer_id must be a non-empty string; got {record.producer_id!r}"
|
||||
)
|
||||
if not isinstance(record.kind, str) or not record.kind:
|
||||
raise FdrSchemaError(f"FdrRecord.kind must be a non-empty string; got {record.kind!r}")
|
||||
if not isinstance(record.payload, dict):
|
||||
raise FdrSchemaError(
|
||||
f"FdrRecord.payload must be a dict; got {type(record.payload).__name__}"
|
||||
)
|
||||
if record.extra:
|
||||
raise FdrSchemaError(
|
||||
"FdrRecord.extra is populated only by the parser; producers must leave it empty"
|
||||
)
|
||||
_validate_payload_size(record.payload)
|
||||
if record.kind == OVERRUN_KIND:
|
||||
_validate_overrun_payload(record.payload)
|
||||
|
||||
|
||||
def _validate_payload_size(payload: dict[str, Any]) -> None:
|
||||
"""Reject any single binary blob >MAX_INLINE_BLOB_BYTES inside the payload."""
|
||||
for key, value in payload.items():
|
||||
if isinstance(value, (bytes, bytearray)) and len(value) > MAX_INLINE_BLOB_BYTES:
|
||||
raise FdrSchemaError(
|
||||
f"FdrRecord.payload[{key!r}] is {len(value)} bytes; "
|
||||
f"max inline blob is {MAX_INLINE_BLOB_BYTES} bytes — use a sidecar path"
|
||||
)
|
||||
|
||||
|
||||
def _validate_overrun_payload(payload: dict[str, Any]) -> None:
|
||||
inner_producer_id = payload.get("producer_id")
|
||||
if not isinstance(inner_producer_id, str) or not inner_producer_id:
|
||||
raise FdrSchemaError(
|
||||
"overrun record: payload.producer_id must be a non-empty string identifying the "
|
||||
"originating producer"
|
||||
)
|
||||
dropped_count = payload.get("dropped_count")
|
||||
if not isinstance(dropped_count, int) or isinstance(dropped_count, bool):
|
||||
raise FdrSchemaError(
|
||||
"overrun record: payload.dropped_count must be a non-bool integer; "
|
||||
f"got {dropped_count!r}"
|
||||
)
|
||||
if dropped_count <= 0:
|
||||
raise FdrSchemaError(
|
||||
f"overrun record: payload.dropped_count must be > 0; got {dropped_count}"
|
||||
)
|
||||
|
||||
|
||||
def serialise(record: FdrRecord) -> bytes:
|
||||
"""Encode ``record`` to wire bytes (single-line UTF-8 JSON, ``orjson``-backed)."""
|
||||
_validate_envelope_outgoing(record)
|
||||
envelope: dict[str, Any] = {
|
||||
"schema_version": record.schema_version,
|
||||
"ts": record.ts,
|
||||
"producer_id": record.producer_id,
|
||||
"kind": record.kind,
|
||||
"payload": record.payload,
|
||||
}
|
||||
try:
|
||||
return orjson.dumps(envelope)
|
||||
except (TypeError, orjson.JSONEncodeError) as exc:
|
||||
raise FdrSchemaError(f"failed to serialise FdrRecord: {exc}") from exc
|
||||
|
||||
|
||||
def parse(buf: bytes) -> FdrRecord:
|
||||
"""Decode wire bytes to an ``FdrRecord``; forward-compatible per contract AC-2/3."""
|
||||
if not isinstance(buf, (bytes, bytearray)):
|
||||
raise FdrSchemaError(f"parse expects bytes; got {type(buf).__name__}")
|
||||
try:
|
||||
decoded = orjson.loads(buf)
|
||||
except orjson.JSONDecodeError as exc:
|
||||
raise FdrSchemaError(f"failed to decode FdrRecord bytes: {exc}") from exc
|
||||
|
||||
if not isinstance(decoded, dict):
|
||||
raise FdrSchemaError(
|
||||
f"FdrRecord wire payload must decode to a dict; got {type(decoded).__name__}"
|
||||
)
|
||||
missing = [k for k in _ENVELOPE_REQUIRED if k not in decoded]
|
||||
if missing:
|
||||
raise FdrSchemaError(f"FdrRecord missing required field(s): {', '.join(missing)}")
|
||||
|
||||
schema_version = decoded.pop("schema_version")
|
||||
if not isinstance(schema_version, int) or isinstance(schema_version, bool):
|
||||
raise FdrSchemaError(
|
||||
f"FdrRecord.schema_version must be a non-bool integer; got {schema_version!r}"
|
||||
)
|
||||
if schema_version < 1:
|
||||
raise FdrSchemaError(f"FdrRecord.schema_version must be >= 1; got {schema_version}")
|
||||
|
||||
ts = decoded.pop("ts")
|
||||
if not isinstance(ts, str) or not ts:
|
||||
raise FdrSchemaError(f"FdrRecord.ts must be a non-empty string; got {ts!r}")
|
||||
producer_id = decoded.pop("producer_id")
|
||||
if not isinstance(producer_id, str) or not producer_id:
|
||||
raise FdrSchemaError(
|
||||
f"FdrRecord.producer_id must be a non-empty string; got {producer_id!r}"
|
||||
)
|
||||
kind = decoded.pop("kind")
|
||||
if not isinstance(kind, str) or not kind:
|
||||
raise FdrSchemaError(f"FdrRecord.kind must be a non-empty string; got {kind!r}")
|
||||
payload = decoded.pop("payload")
|
||||
if not isinstance(payload, dict):
|
||||
raise FdrSchemaError(f"FdrRecord.payload must be a dict; got {type(payload).__name__}")
|
||||
|
||||
# Anything left at the top level after popping required + payload is forward-compat extra.
|
||||
extra = dict(decoded)
|
||||
|
||||
# Forward-compat payload sweep: for known kinds, keys outside the v1.0.0
|
||||
# set are stashed in payload["extra"] so future v1.x producers can add new
|
||||
# fields without breaking v1.0 readers.
|
||||
known_keys = KNOWN_PAYLOAD_KEYS.get(kind)
|
||||
if known_keys is not None:
|
||||
unknown_keys = [k for k in payload.keys() if k not in known_keys and k != "extra"]
|
||||
if unknown_keys:
|
||||
extra_bucket = dict(payload.get("extra") or {})
|
||||
for key in unknown_keys:
|
||||
extra_bucket[key] = payload.pop(key)
|
||||
payload["extra"] = extra_bucket
|
||||
|
||||
if kind == OVERRUN_KIND:
|
||||
_validate_overrun_payload(payload)
|
||||
|
||||
return FdrRecord(
|
||||
schema_version=schema_version,
|
||||
ts=ts,
|
||||
producer_id=producer_id,
|
||||
kind=kind,
|
||||
payload=payload,
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
@@ -5,6 +5,17 @@ ones that have landed so consumers can depend on a stable public surface
|
||||
without reaching into the helper modules directly.
|
||||
"""
|
||||
|
||||
from gps_denied_onboard.helpers.descriptor_normaliser import (
|
||||
ALLOWED_DTYPES,
|
||||
DescriptorNormaliser,
|
||||
DescriptorNormaliserError,
|
||||
)
|
||||
from gps_denied_onboard.helpers.engine_filename_schema import (
|
||||
ALLOWED_PRECISIONS,
|
||||
ENGINE_SUFFIX,
|
||||
EngineFilenameSchema,
|
||||
EngineFilenameSchemaError,
|
||||
)
|
||||
from gps_denied_onboard.helpers.se3_utils import (
|
||||
SE3,
|
||||
Se3InvalidMatrixError,
|
||||
@@ -20,13 +31,30 @@ from gps_denied_onboard.helpers.sha256_sidecar import (
|
||||
Sha256Sidecar,
|
||||
Sha256SidecarError,
|
||||
)
|
||||
from gps_denied_onboard.helpers.wgs_converter import (
|
||||
MAX_ZOOM,
|
||||
WEB_MERCATOR_MAX_LAT_DEG,
|
||||
WgsConversionError,
|
||||
WgsConverter,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ALLOWED_DTYPES",
|
||||
"ALLOWED_PRECISIONS",
|
||||
"ENGINE_SUFFIX",
|
||||
"MAX_ZOOM",
|
||||
"SE3",
|
||||
"SIDECAR_SUFFIX",
|
||||
"WEB_MERCATOR_MAX_LAT_DEG",
|
||||
"DescriptorNormaliser",
|
||||
"DescriptorNormaliserError",
|
||||
"EngineFilenameSchema",
|
||||
"EngineFilenameSchemaError",
|
||||
"Se3InvalidMatrixError",
|
||||
"Sha256Sidecar",
|
||||
"Sha256SidecarError",
|
||||
"WgsConversionError",
|
||||
"WgsConverter",
|
||||
"adjoint",
|
||||
"exp_map",
|
||||
"is_valid_rotation",
|
||||
|
||||
@@ -1,14 +1,97 @@
|
||||
"""Descriptor-normalisation utility — STUB.
|
||||
"""L2 descriptor normaliser aligning cosine similarity to FAISS inner-product (AZ-283).
|
||||
|
||||
Concrete impl owned by AZ-283. Contract:
|
||||
`_docs/02_document/common-helpers/08_helper_descriptor_normaliser.md`.
|
||||
Public surface frozen by
|
||||
``_docs/02_document/contracts/shared_helpers/descriptor_normaliser.md`` v1.0.0.
|
||||
|
||||
Used on both the corpus side (C10 index build) and the query side (C2 runtime
|
||||
lookup). The two sides MUST go through the same helper so the FAISS HNSW
|
||||
search returns useful neighbours.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from typing import Final
|
||||
|
||||
import numpy as np
|
||||
|
||||
__all__ = [
|
||||
"ALLOWED_DTYPES",
|
||||
"DescriptorNormaliser",
|
||||
"DescriptorNormaliserError",
|
||||
]
|
||||
|
||||
# Allowed input dtypes; anything else is rejected to keep the FAISS index and
|
||||
# query path on the same precision.
|
||||
ALLOWED_DTYPES: Final[tuple[np.dtype, ...]] = (
|
||||
np.dtype(np.float16),
|
||||
np.dtype(np.float32),
|
||||
)
|
||||
|
||||
_METRIC_VALUE: Final[str] = "inner_product"
|
||||
|
||||
|
||||
def l2_normalise(descriptors: Any) -> Any:
|
||||
"""L2-normalise a (N, D) descriptor matrix in-place semantics."""
|
||||
raise NotImplementedError("descriptor_normaliser concrete impl is AZ-283")
|
||||
class DescriptorNormaliserError(ValueError):
|
||||
"""Raised on shape / dtype violations (AZ-283)."""
|
||||
|
||||
|
||||
def _validate_dtype(arr: np.ndarray, label: str) -> None:
|
||||
if arr.dtype not in ALLOWED_DTYPES:
|
||||
raise DescriptorNormaliserError(
|
||||
f"{label}: dtype {arr.dtype} not in allowed set (float16, float32)"
|
||||
)
|
||||
|
||||
|
||||
class DescriptorNormaliser:
|
||||
"""Stateless L2-normalisation helper; dtype-preserving; zero-norm safe."""
|
||||
|
||||
@staticmethod
|
||||
def l2_normalise(descriptor: np.ndarray) -> np.ndarray:
|
||||
if not isinstance(descriptor, np.ndarray):
|
||||
raise DescriptorNormaliserError(
|
||||
f"l2_normalise: expected np.ndarray; got {type(descriptor).__name__}"
|
||||
)
|
||||
if descriptor.ndim != 1:
|
||||
raise DescriptorNormaliserError(
|
||||
f"l2_normalise: expected 1-D shape (D,); got shape {descriptor.shape}"
|
||||
)
|
||||
if descriptor.shape[0] < 1:
|
||||
raise DescriptorNormaliserError(
|
||||
f"l2_normalise: dimension must be >= 1; got shape {descriptor.shape}"
|
||||
)
|
||||
_validate_dtype(descriptor, "l2_normalise")
|
||||
in_dtype = descriptor.dtype
|
||||
# Compute norm in float32 to stabilise float16 inputs against overflow /
|
||||
# underflow; cast back to the caller dtype so we never silently up-cast.
|
||||
as_f32 = descriptor.astype(np.float32, copy=False)
|
||||
norm = float(np.linalg.norm(as_f32))
|
||||
if norm == 0.0:
|
||||
return np.zeros_like(descriptor)
|
||||
normalised_f32 = as_f32 / norm
|
||||
return normalised_f32.astype(in_dtype, copy=False)
|
||||
|
||||
@staticmethod
|
||||
def l2_normalise_batch(descriptors: np.ndarray) -> np.ndarray:
|
||||
if not isinstance(descriptors, np.ndarray):
|
||||
raise DescriptorNormaliserError(
|
||||
f"l2_normalise_batch: expected np.ndarray; got {type(descriptors).__name__}"
|
||||
)
|
||||
if descriptors.ndim != 2:
|
||||
raise DescriptorNormaliserError(
|
||||
f"l2_normalise_batch: expected 2-D shape (N, D); got shape {descriptors.shape}"
|
||||
)
|
||||
if descriptors.shape[0] < 1 or descriptors.shape[1] < 1:
|
||||
raise DescriptorNormaliserError(
|
||||
f"l2_normalise_batch: N and D must be >= 1; got shape {descriptors.shape}"
|
||||
)
|
||||
_validate_dtype(descriptors, "l2_normalise_batch")
|
||||
in_dtype = descriptors.dtype
|
||||
as_f32 = descriptors.astype(np.float32, copy=False)
|
||||
norms = np.linalg.norm(as_f32, axis=1, keepdims=True)
|
||||
# Avoid division-by-zero: leave zero rows as zero.
|
||||
safe = np.where(norms == 0.0, 1.0, norms)
|
||||
normalised_f32 = np.where(norms == 0.0, 0.0, as_f32 / safe)
|
||||
return normalised_f32.astype(in_dtype, copy=False)
|
||||
|
||||
@staticmethod
|
||||
def descriptor_metric() -> str:
|
||||
return _METRIC_VALUE
|
||||
|
||||
@@ -1,28 +1,127 @@
|
||||
"""TensorRT engine filename schema — STUB.
|
||||
"""Self-describing `.engine` filename schema (AZ-281 / D-C10-7).
|
||||
|
||||
D-C10-7 self-describing engine names. Concrete impl owned by AZ-281. Contract:
|
||||
`_docs/02_document/common-helpers/06_helper_engine_filename_schema.md`.
|
||||
Public surface frozen by
|
||||
``_docs/02_document/contracts/shared_helpers/engine_filename_schema.md`` v1.0.0.
|
||||
|
||||
Filename format: ``{model}__sm{SM}_jp{JP_dotted}_trt{TRT_dotted}_{precision}.engine``
|
||||
where ``model`` is ``[a-z0-9_]`` (no ``__``), versions are dotted
|
||||
``<major>.<minor>``, and ``precision`` is one of ``fp16``, ``int8``, ``mixed``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import re
|
||||
from typing import Final
|
||||
|
||||
from gps_denied_onboard._types.manifests import EngineCacheKey, HostCapabilities
|
||||
|
||||
__all__ = [
|
||||
"ALLOWED_PRECISIONS",
|
||||
"ENGINE_SUFFIX",
|
||||
"EngineFilenameSchema",
|
||||
"EngineFilenameSchemaError",
|
||||
]
|
||||
|
||||
ENGINE_SUFFIX: Final[str] = ".engine"
|
||||
ALLOWED_PRECISIONS: Final[frozenset[str]] = frozenset({"fp16", "int8", "mixed"})
|
||||
|
||||
_MODEL_RE: Final[re.Pattern[str]] = re.compile(r"^[a-z0-9_]+$")
|
||||
_DOTTED_VERSION_RE: Final[re.Pattern[str]] = re.compile(r"^\d+\.\d+$")
|
||||
_FILENAME_RE: Final[re.Pattern[str]] = re.compile(
|
||||
r"^(?P<model>[a-z0-9_]+)__sm(?P<sm>\d+)_jp(?P<jetpack>\d+\.\d+)_trt(?P<trt>\d+\.\d+)_"
|
||||
r"(?P<precision>fp16|int8|mixed)\.engine$"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EngineFilename:
|
||||
"""Parsed parts of a self-describing engine filename."""
|
||||
class EngineFilenameSchemaError(ValueError):
|
||||
"""Raised by ``build`` / ``parse`` on validation / format violations (AZ-281)."""
|
||||
|
||||
model_name: str
|
||||
sm_arch: str
|
||||
jetpack_version: str
|
||||
tensorrt_version: str
|
||||
precision: str
|
||||
content_hash: str
|
||||
|
||||
def render(self) -> str:
|
||||
raise NotImplementedError("engine_filename_schema concrete impl is AZ-281")
|
||||
class EngineFilenameSchema:
|
||||
"""Stateless ``.engine`` filename builder / parser / host-match predicate."""
|
||||
|
||||
@classmethod
|
||||
def parse(cls, filename: str) -> EngineFilename:
|
||||
raise NotImplementedError("engine_filename_schema concrete impl is AZ-281")
|
||||
@staticmethod
|
||||
def build(model_name: str, sm: int, jetpack: str, trt: str, precision: str) -> str:
|
||||
_validate_model_name(model_name)
|
||||
_validate_sm(sm)
|
||||
_validate_version(jetpack, "jetpack")
|
||||
_validate_version(trt, "trt")
|
||||
_validate_precision(precision)
|
||||
return f"{model_name}__sm{sm}_jp{jetpack}_trt{trt}_{precision}{ENGINE_SUFFIX}"
|
||||
|
||||
@staticmethod
|
||||
def parse(filename: str) -> EngineCacheKey:
|
||||
if not isinstance(filename, str):
|
||||
raise EngineFilenameSchemaError(f"parse expects str; got {type(filename).__name__}")
|
||||
if not filename.endswith(ENGINE_SUFFIX):
|
||||
raise EngineFilenameSchemaError(
|
||||
f"parse: filename must end with {ENGINE_SUFFIX!r}; got {filename!r}"
|
||||
)
|
||||
match = _FILENAME_RE.match(filename)
|
||||
if not match:
|
||||
raise EngineFilenameSchemaError(
|
||||
f"parse: filename {filename!r} does not match the engine-schema format "
|
||||
"'{model}__sm{SM}_jp{JP}_trt{TRT}_{precision}.engine'"
|
||||
)
|
||||
model = match.group("model")
|
||||
if "__" in model:
|
||||
raise EngineFilenameSchemaError(
|
||||
f"parse: model segment {model!r} contains reserved separator '__'"
|
||||
)
|
||||
return EngineCacheKey(
|
||||
model_name=model,
|
||||
sm=int(match.group("sm")),
|
||||
jetpack=match.group("jetpack"),
|
||||
trt=match.group("trt"),
|
||||
precision=match.group("precision"),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def matches_host(filename: str, host_capabilities: HostCapabilities) -> bool:
|
||||
key = EngineFilenameSchema.parse(filename)
|
||||
return (
|
||||
key.sm == host_capabilities.sm
|
||||
and key.jetpack == host_capabilities.jetpack
|
||||
and key.trt == host_capabilities.trt
|
||||
)
|
||||
|
||||
|
||||
def _validate_model_name(model_name: str) -> None:
|
||||
if not isinstance(model_name, str):
|
||||
raise EngineFilenameSchemaError(f"model_name must be str; got {type(model_name).__name__}")
|
||||
if not model_name:
|
||||
raise EngineFilenameSchemaError("model_name must be a non-empty string")
|
||||
if "__" in model_name:
|
||||
raise EngineFilenameSchemaError(
|
||||
f"model_name {model_name!r} contains reserved separator '__'"
|
||||
)
|
||||
if not _MODEL_RE.match(model_name):
|
||||
raise EngineFilenameSchemaError(
|
||||
f"model_name {model_name!r} must match [a-z0-9_]+ (lowercase, digits, underscores)"
|
||||
)
|
||||
if len(model_name) > 64:
|
||||
raise EngineFilenameSchemaError(f"model_name {model_name!r} exceeds 64-character limit")
|
||||
|
||||
|
||||
def _validate_sm(sm: int) -> None:
|
||||
if not isinstance(sm, int) or isinstance(sm, bool):
|
||||
raise EngineFilenameSchemaError(f"sm must be a non-bool integer; got {sm!r}")
|
||||
if sm <= 0:
|
||||
raise EngineFilenameSchemaError(f"sm must be > 0; got {sm}")
|
||||
|
||||
|
||||
def _validate_version(version: str, field_name: str) -> None:
|
||||
if not isinstance(version, str):
|
||||
raise EngineFilenameSchemaError(f"{field_name} must be str; got {type(version).__name__}")
|
||||
if not _DOTTED_VERSION_RE.match(version):
|
||||
raise EngineFilenameSchemaError(
|
||||
f"{field_name} {version!r} must match dotted '<major>.<minor>' format"
|
||||
)
|
||||
|
||||
|
||||
def _validate_precision(precision: str) -> None:
|
||||
if precision not in ALLOWED_PRECISIONS:
|
||||
raise EngineFilenameSchemaError(
|
||||
f"precision {precision!r} not in allowed enum "
|
||||
f"{{{', '.join(sorted(ALLOWED_PRECISIONS))}}}"
|
||||
)
|
||||
|
||||
@@ -1,26 +1,170 @@
|
||||
"""WGS84 ↔ local-tangent-plane converter — STUB.
|
||||
"""WGS84 ↔ ECEF ↔ ENU ↔ slippy-map tile-xy conversions (AZ-279 / E-CC-HELPERS).
|
||||
|
||||
Concrete implementation is owned by AZ-279. Contract:
|
||||
`_docs/02_document/common-helpers/04_helper_wgs_converter.md`.
|
||||
Public surface frozen by
|
||||
``_docs/02_document/contracts/shared_helpers/wgs_converter.md`` v1.0.0.
|
||||
|
||||
Backed by ``pyproj`` for the geodesy primitives. Slippy-map tile math is hand
|
||||
rolled to match OSM's `{zoom}/{x}/{y}.jpg` convention exactly so the on-disk
|
||||
layout produced by ``satellite-provider`` round-trips byte-equal.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Final
|
||||
|
||||
def wgs84_to_ltp(
|
||||
lat_deg: float,
|
||||
lon_deg: float,
|
||||
alt_m: float,
|
||||
ref_lat_deg: float,
|
||||
ref_lon_deg: float,
|
||||
ref_alt_m: float,
|
||||
) -> tuple[float, float, float]:
|
||||
"""Convert a WGS-84 lat/lon/alt to local-tangent-plane east/north/up metres."""
|
||||
raise NotImplementedError("wgs_converter concrete impl is AZ-279")
|
||||
import numpy as np
|
||||
from pyproj import Transformer # type: ignore[import-not-found]
|
||||
|
||||
from gps_denied_onboard._types.geo import BoundingBox, LatLonAlt
|
||||
|
||||
__all__ = ["MAX_ZOOM", "WEB_MERCATOR_MAX_LAT_DEG", "WgsConversionError", "WgsConverter"]
|
||||
|
||||
|
||||
def ltp_to_wgs84(
|
||||
e_m: float, n_m: float, u_m: float, ref_lat_deg: float, ref_lon_deg: float, ref_alt_m: float
|
||||
) -> tuple[float, float, float]:
|
||||
"""Inverse of wgs84_to_ltp."""
|
||||
raise NotImplementedError("wgs_converter concrete impl is AZ-279")
|
||||
WEB_MERCATOR_MAX_LAT_DEG: Final[float] = 85.0511287798066
|
||||
MAX_ZOOM: Final[int] = 22
|
||||
|
||||
|
||||
class WgsConversionError(ValueError):
|
||||
"""Raised on shape / range violations in any ``WgsConverter`` static method."""
|
||||
|
||||
|
||||
_ECEF_FROM_LLA: Final[Transformer] = Transformer.from_crs("EPSG:4326", "EPSG:4978", always_xy=True)
|
||||
_LLA_FROM_ECEF: Final[Transformer] = Transformer.from_crs("EPSG:4978", "EPSG:4326", always_xy=True)
|
||||
|
||||
|
||||
def _validate_finite_latlonalt(p: LatLonAlt, label: str) -> None:
|
||||
if not (math.isfinite(p.lat_deg) and math.isfinite(p.lon_deg) and math.isfinite(p.alt_m)):
|
||||
raise WgsConversionError(f"{label}: non-finite component in {p!r}")
|
||||
if not (-90.0 <= p.lat_deg <= 90.0):
|
||||
raise WgsConversionError(f"{label}: latitude {p.lat_deg} outside [-90, 90]")
|
||||
if not (-180.0 <= p.lon_deg <= 180.0):
|
||||
raise WgsConversionError(f"{label}: longitude {p.lon_deg} outside [-180, 180]")
|
||||
|
||||
|
||||
def _enforce_ecef_shape(arr: np.ndarray, label: str) -> None:
|
||||
if not isinstance(arr, np.ndarray):
|
||||
raise WgsConversionError(
|
||||
f"{label}: expected np.ndarray of shape (3,); got {type(arr).__name__}"
|
||||
)
|
||||
if arr.shape != (3,):
|
||||
raise WgsConversionError(
|
||||
f"{label}: expected np.ndarray of shape (3,); got shape {arr.shape}"
|
||||
)
|
||||
if not np.all(np.isfinite(arr)):
|
||||
raise WgsConversionError(f"{label}: non-finite component in {arr!r}")
|
||||
|
||||
|
||||
class WgsConverter:
|
||||
"""Stateless WGS84 / ECEF / ENU / slippy-map-tile converter.
|
||||
|
||||
Every method is a pure function of its arguments; no module-level state
|
||||
other than the cached ``pyproj`` transformer pair.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def latlonalt_to_ecef(p: LatLonAlt) -> np.ndarray:
|
||||
_validate_finite_latlonalt(p, "latlonalt_to_ecef")
|
||||
x, y, z = _ECEF_FROM_LLA.transform(p.lon_deg, p.lat_deg, p.alt_m)
|
||||
return np.array([x, y, z], dtype=np.float64)
|
||||
|
||||
@staticmethod
|
||||
def ecef_to_latlonalt(p_ecef: np.ndarray) -> LatLonAlt:
|
||||
_enforce_ecef_shape(p_ecef, "ecef_to_latlonalt")
|
||||
lon, lat, alt = _LLA_FROM_ECEF.transform(
|
||||
float(p_ecef[0]), float(p_ecef[1]), float(p_ecef[2])
|
||||
)
|
||||
return LatLonAlt(lat_deg=float(lat), lon_deg=float(lon), alt_m=float(alt))
|
||||
|
||||
@staticmethod
|
||||
def latlonalt_to_local_enu(origin: LatLonAlt, p: LatLonAlt) -> np.ndarray:
|
||||
_validate_finite_latlonalt(origin, "latlonalt_to_local_enu/origin")
|
||||
_validate_finite_latlonalt(p, "latlonalt_to_local_enu/p")
|
||||
return _ecef_delta_to_enu(origin, WgsConverter.latlonalt_to_ecef(p))
|
||||
|
||||
@staticmethod
|
||||
def local_enu_to_latlonalt(origin: LatLonAlt, p_enu: np.ndarray) -> LatLonAlt:
|
||||
_validate_finite_latlonalt(origin, "local_enu_to_latlonalt/origin")
|
||||
_enforce_ecef_shape(p_enu, "local_enu_to_latlonalt/p_enu")
|
||||
origin_ecef = WgsConverter.latlonalt_to_ecef(origin)
|
||||
rotation = _enu_to_ecef_rotation(origin.lat_deg, origin.lon_deg)
|
||||
delta_ecef = rotation @ p_enu.astype(np.float64)
|
||||
return WgsConverter.ecef_to_latlonalt(origin_ecef + delta_ecef)
|
||||
|
||||
@staticmethod
|
||||
def latlon_to_tile_xy(zoom: int, lat: float, lon: float) -> tuple[int, int]:
|
||||
_validate_zoom(zoom)
|
||||
if not (math.isfinite(lat) and math.isfinite(lon)):
|
||||
raise WgsConversionError(f"latlon_to_tile_xy: non-finite input (lat={lat}, lon={lon})")
|
||||
if abs(lat) > WEB_MERCATOR_MAX_LAT_DEG:
|
||||
raise WgsConversionError(
|
||||
f"latlon_to_tile_xy: latitude {lat} outside Web-Mercator range "
|
||||
f"[-{WEB_MERCATOR_MAX_LAT_DEG}, {WEB_MERCATOR_MAX_LAT_DEG}]"
|
||||
)
|
||||
if not (-180.0 <= lon <= 180.0):
|
||||
raise WgsConversionError(f"latlon_to_tile_xy: longitude {lon} outside [-180, 180]")
|
||||
n = 1 << zoom
|
||||
lat_rad = math.radians(lat)
|
||||
x = math.floor((lon + 180.0) / 360.0 * n)
|
||||
y = math.floor(
|
||||
(1.0 - math.log(math.tan(lat_rad) + 1.0 / math.cos(lat_rad)) / math.pi) / 2.0 * n
|
||||
)
|
||||
x = max(0, min(x, n - 1))
|
||||
y = max(0, min(y, n - 1))
|
||||
return x, y
|
||||
|
||||
@staticmethod
|
||||
def tile_xy_to_latlon_bounds(zoom: int, x: int, y: int) -> BoundingBox:
|
||||
_validate_zoom(zoom)
|
||||
n = 1 << zoom
|
||||
if not (0 <= x < n and 0 <= y < n):
|
||||
raise WgsConversionError(
|
||||
f"tile_xy_to_latlon_bounds: tile (x={x}, y={y}) outside [0, {n}) at zoom {zoom}"
|
||||
)
|
||||
return BoundingBox(
|
||||
min_lat_deg=_tile_y_to_lat(y + 1, n),
|
||||
min_lon_deg=_tile_x_to_lon(x, n),
|
||||
max_lat_deg=_tile_y_to_lat(y, n),
|
||||
max_lon_deg=_tile_x_to_lon(x + 1, n),
|
||||
)
|
||||
|
||||
|
||||
def _validate_zoom(zoom: int) -> None:
|
||||
if not isinstance(zoom, int) or isinstance(zoom, bool):
|
||||
raise WgsConversionError(f"zoom must be a non-bool integer; got {zoom!r}")
|
||||
if not (0 <= zoom <= MAX_ZOOM):
|
||||
raise WgsConversionError(f"zoom {zoom} outside supported range [0, {MAX_ZOOM}]")
|
||||
|
||||
|
||||
def _tile_x_to_lon(x: int, n: int) -> float:
|
||||
return x / n * 360.0 - 180.0
|
||||
|
||||
|
||||
def _tile_y_to_lat(y: int, n: int) -> float:
|
||||
t = math.pi * (1.0 - 2.0 * y / n)
|
||||
return math.degrees(math.atan(math.sinh(t)))
|
||||
|
||||
|
||||
def _enu_to_ecef_rotation(lat_deg: float, lon_deg: float) -> np.ndarray:
|
||||
"""Rotation matrix mapping local ENU vectors to ECEF deltas at ``(lat, lon)``."""
|
||||
lat = math.radians(lat_deg)
|
||||
lon = math.radians(lon_deg)
|
||||
sin_lat = math.sin(lat)
|
||||
cos_lat = math.cos(lat)
|
||||
sin_lon = math.sin(lon)
|
||||
cos_lon = math.cos(lon)
|
||||
return np.array(
|
||||
[
|
||||
[-sin_lon, -sin_lat * cos_lon, cos_lat * cos_lon],
|
||||
[cos_lon, -sin_lat * sin_lon, cos_lat * sin_lon],
|
||||
[0.0, cos_lat, sin_lat],
|
||||
],
|
||||
dtype=np.float64,
|
||||
)
|
||||
|
||||
|
||||
def _ecef_delta_to_enu(origin: LatLonAlt, p_ecef: np.ndarray) -> np.ndarray:
|
||||
origin_ecef = WgsConverter.latlonalt_to_ecef(origin)
|
||||
delta = p_ecef - origin_ecef
|
||||
rotation = _enu_to_ecef_rotation(origin.lat_deg, origin.lon_deg)
|
||||
return rotation.T @ delta
|
||||
|
||||
@@ -1,26 +1,82 @@
|
||||
"""Composition root (ADR-009 interface-first DI).
|
||||
"""Composition root (AZ-270 / E-CC-CONF; ADR-009 interface-first DI).
|
||||
|
||||
The only place that may import concrete strategy implementations across
|
||||
components. Per-binary `compose_*` entrypoints select the strategy graph for that
|
||||
binary (airborne / research / operator-tooling / replay-cli) — gated by CMake
|
||||
`BUILD_*` flags at compile time and validated again here at startup.
|
||||
The single module allowed to import concrete strategy implementations across
|
||||
components. Per-binary ``compose_*`` functions resolve the ``Config``-selected
|
||||
strategies, validate them against the registry (ADR-002 gate #3), and assemble
|
||||
the component graph in dependency order.
|
||||
|
||||
Bootstrap (AZ-263) ships the entrypoints as stubs that perform the required env-var
|
||||
fail-fast (AC-8). Per-component wiring is added by each component's "wire-in"
|
||||
implementation task.
|
||||
Per-binary entrypoints:
|
||||
|
||||
* :func:`compose_root` - airborne runtime
|
||||
* :func:`compose_operator` - operator-side tooling (pre-flight, post-landing)
|
||||
* :func:`compose_replay` - replay-cli runtime (extension owned by AZ-401)
|
||||
|
||||
Public surface frozen by
|
||||
``_docs/02_document/contracts/shared_config/composition_root_protocol.md`` v1.0.0.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from collections.abc import Callable, Iterable, Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Literal, get_args
|
||||
|
||||
from gps_denied_onboard.config import Config, load_config
|
||||
|
||||
__all__ = [
|
||||
"REQUIRED_ENV_VARS",
|
||||
"ConfigurationError",
|
||||
"OperatorRoot",
|
||||
"RuntimeRoot",
|
||||
"StrategyNotLinkedError",
|
||||
"StrategyTier",
|
||||
"clear_strategy_registry",
|
||||
"compose_operator",
|
||||
"compose_replay",
|
||||
"compose_root",
|
||||
"list_registered_strategies",
|
||||
"main",
|
||||
"register_strategy",
|
||||
]
|
||||
|
||||
StrategyTier = Literal["airborne", "operator", "shared"]
|
||||
_ALL_TIERS: tuple[StrategyTier, ...] = get_args(StrategyTier)
|
||||
|
||||
|
||||
class ConfigurationError(RuntimeError):
|
||||
"""Raised when a required environment variable is missing or a strategy whose
|
||||
CMake `BUILD_*` flag is OFF would otherwise be wired."""
|
||||
"""Raised when a required environment variable is missing.
|
||||
|
||||
AC-8 (Bootstrap / AZ-263).
|
||||
"""
|
||||
|
||||
|
||||
class StrategyNotLinkedError(RuntimeError):
|
||||
"""Raised when the config selects a strategy that is not linked into this binary.
|
||||
|
||||
Carries the offending strategy name, the owning component slug, and the
|
||||
actually-linked strategies for that component — gives the operator a
|
||||
clear next step (build with the right flag, or pick a different strategy).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
strategy_name: str,
|
||||
component_slug: str,
|
||||
available_strategies: Iterable[str],
|
||||
*,
|
||||
reason: str = "not linked",
|
||||
) -> None:
|
||||
self.strategy_name: str = strategy_name
|
||||
self.component_slug: str = component_slug
|
||||
self.available_strategies: list[str] = sorted(set(available_strategies))
|
||||
self.reason: str = reason
|
||||
available_text = ", ".join(self.available_strategies) or "<none>"
|
||||
super().__init__(
|
||||
f"strategy {strategy_name!r} requested for component {component_slug!r} is "
|
||||
f"{reason}; available strategies: [{available_text}]"
|
||||
)
|
||||
|
||||
|
||||
REQUIRED_ENV_VARS: tuple[str, ...] = (
|
||||
@@ -36,11 +92,89 @@ REQUIRED_ENV_VARS: tuple[str, ...] = (
|
||||
)
|
||||
|
||||
|
||||
def _check_required_env(extra_required: Iterable[str] = ()) -> None:
|
||||
"""Fail fast with a clear pointer at the first missing required env var.
|
||||
@dataclass(frozen=True)
|
||||
class _Registration:
|
||||
component_slug: str
|
||||
strategy_name: str
|
||||
factory: Callable[..., Any]
|
||||
tier: StrategyTier
|
||||
depends_on: tuple[str, ...]
|
||||
|
||||
AC-8 (Bootstrap / AZ-263).
|
||||
|
||||
_STRATEGY_REGISTRY: dict[tuple[str, str], _Registration] = {}
|
||||
|
||||
|
||||
def register_strategy(
|
||||
component_slug: str,
|
||||
strategy_name: str,
|
||||
factory: Callable[..., Any],
|
||||
*,
|
||||
tier: StrategyTier = "shared",
|
||||
depends_on: Iterable[str] = (),
|
||||
) -> None:
|
||||
"""Register a concrete strategy implementation for ``component_slug``.
|
||||
|
||||
The single allowed call site is the composition root or a binary-specific
|
||||
bootstrap module (one module per ``BUILD_*`` flag combination). Calling
|
||||
this from a component module is an architecture violation; AC-6 catches
|
||||
that statically.
|
||||
"""
|
||||
if tier not in _ALL_TIERS:
|
||||
raise ValueError(f"tier must be one of {_ALL_TIERS}; got {tier!r}")
|
||||
key = (component_slug, strategy_name)
|
||||
existing = _STRATEGY_REGISTRY.get(key)
|
||||
incoming = _Registration(
|
||||
component_slug=component_slug,
|
||||
strategy_name=strategy_name,
|
||||
factory=factory,
|
||||
tier=tier,
|
||||
depends_on=tuple(depends_on),
|
||||
)
|
||||
if existing is not None and existing != incoming:
|
||||
raise StrategyNotLinkedError(
|
||||
strategy_name=strategy_name,
|
||||
component_slug=component_slug,
|
||||
available_strategies=list_registered_strategies(component_slug),
|
||||
reason="duplicate registration with conflicting attributes",
|
||||
)
|
||||
_STRATEGY_REGISTRY[key] = incoming
|
||||
|
||||
|
||||
def clear_strategy_registry() -> None:
|
||||
"""Reset the global registry; intended for unit-test isolation only."""
|
||||
_STRATEGY_REGISTRY.clear()
|
||||
|
||||
|
||||
def list_registered_strategies(component_slug: str) -> list[str]:
|
||||
"""Return the strategy names registered for ``component_slug`` (sorted)."""
|
||||
return sorted(
|
||||
reg.strategy_name
|
||||
for (slug, _name), reg in _STRATEGY_REGISTRY.items()
|
||||
if slug == component_slug
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeRoot:
|
||||
"""Composed airborne runtime graph (every component slot populated)."""
|
||||
|
||||
binary: str
|
||||
profile: str
|
||||
components: Mapping[str, Any] = field(default_factory=dict)
|
||||
construction_order: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OperatorRoot:
|
||||
"""Composed operator-side runtime graph (operator-tier components only)."""
|
||||
|
||||
binary: str
|
||||
profile: str
|
||||
components: Mapping[str, Any] = field(default_factory=dict)
|
||||
construction_order: tuple[str, ...] = ()
|
||||
|
||||
|
||||
def _check_required_env(extra_required: Iterable[str] = ()) -> None:
|
||||
missing = [name for name in (*REQUIRED_ENV_VARS, *extra_required) if name not in os.environ]
|
||||
if missing:
|
||||
raise ConfigurationError(
|
||||
@@ -50,36 +184,197 @@ def _check_required_env(extra_required: Iterable[str] = ()) -> None:
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeRoot:
|
||||
"""Composed runtime graph (placeholder; per-component wiring is per-task)."""
|
||||
|
||||
binary: str
|
||||
profile: str
|
||||
def _resolve_strategy(
|
||||
component_slug: str,
|
||||
strategy_name: str,
|
||||
allowed_tiers: frozenset[StrategyTier],
|
||||
) -> _Registration:
|
||||
"""Look up ``(component_slug, strategy_name)`` in the registry and gate by tier."""
|
||||
key = (component_slug, strategy_name)
|
||||
registration = _STRATEGY_REGISTRY.get(key)
|
||||
if registration is None:
|
||||
raise StrategyNotLinkedError(
|
||||
strategy_name=strategy_name,
|
||||
component_slug=component_slug,
|
||||
available_strategies=list_registered_strategies(component_slug),
|
||||
)
|
||||
if registration.tier not in allowed_tiers:
|
||||
raise StrategyNotLinkedError(
|
||||
strategy_name=strategy_name,
|
||||
component_slug=component_slug,
|
||||
available_strategies=list_registered_strategies(component_slug),
|
||||
reason=(
|
||||
f"registered under tier={registration.tier!r}; "
|
||||
f"not allowed in this binary (allowed tiers: {sorted(allowed_tiers)})"
|
||||
),
|
||||
)
|
||||
return registration
|
||||
|
||||
|
||||
def compose_root(yaml_config_path: str | None = None) -> RuntimeRoot:
|
||||
"""Compose the airborne runtime graph."""
|
||||
_check_required_env(extra_required=("MAVLINK_SIGNING_KEY",))
|
||||
return RuntimeRoot(binary="airborne", profile=os.environ["GPS_DENIED_FC_PROFILE"])
|
||||
def _topo_order(
|
||||
target_slugs: Iterable[str], registrations: Mapping[str, _Registration]
|
||||
) -> list[str]:
|
||||
"""Return ``target_slugs`` in a dependency-respecting order (Kahn's algorithm)."""
|
||||
ordered: list[str] = []
|
||||
remaining = set(target_slugs)
|
||||
visited: set[str] = set()
|
||||
|
||||
def visit(slug: str, stack: tuple[str, ...]) -> None:
|
||||
if slug in visited:
|
||||
return
|
||||
if slug in stack:
|
||||
raise StrategyNotLinkedError(
|
||||
strategy_name=registrations[slug].strategy_name,
|
||||
component_slug=slug,
|
||||
available_strategies=list_registered_strategies(slug),
|
||||
reason=f"dependency cycle detected: {' -> '.join((*stack, slug))}",
|
||||
)
|
||||
if slug not in registrations:
|
||||
return
|
||||
for dep in registrations[slug].depends_on:
|
||||
visit(dep, (*stack, slug))
|
||||
visited.add(slug)
|
||||
ordered.append(slug)
|
||||
|
||||
for slug in sorted(remaining):
|
||||
visit(slug, ())
|
||||
return [slug for slug in ordered if slug in remaining]
|
||||
|
||||
|
||||
def compose_operator(yaml_config_path: str | None = None) -> RuntimeRoot:
|
||||
"""Compose the operator-tooling runtime graph (pre-flight + post-landing)."""
|
||||
_check_required_env(extra_required=("SATELLITE_PROVIDER_URL",))
|
||||
return RuntimeRoot(binary="operator-tooling", profile=os.environ["GPS_DENIED_FC_PROFILE"])
|
||||
def _compose(
|
||||
config: Config,
|
||||
*,
|
||||
binary: str,
|
||||
allowed_tiers: frozenset[StrategyTier],
|
||||
extra_required_env: Iterable[str],
|
||||
) -> tuple[dict[str, Any], tuple[str, ...]]:
|
||||
"""Shared composition path used by ``compose_root`` / ``compose_operator``."""
|
||||
_check_required_env(extra_required=extra_required_env)
|
||||
selections = _resolve_component_strategies(config, allowed_tiers)
|
||||
resolved: dict[str, _Registration] = {
|
||||
slug: _resolve_strategy(slug, strategy, allowed_tiers)
|
||||
for slug, strategy in selections.items()
|
||||
}
|
||||
order = _topo_order(resolved.keys(), resolved)
|
||||
constructed: dict[str, Any] = {}
|
||||
for slug in order:
|
||||
registration = resolved[slug]
|
||||
try:
|
||||
constructed[slug] = registration.factory(config, constructed)
|
||||
except Exception:
|
||||
# All-or-nothing: close anything already built before re-raising.
|
||||
_close_partial_instances(constructed)
|
||||
raise
|
||||
_ = binary # documented but unused beyond labelling the returned root
|
||||
return constructed, tuple(order)
|
||||
|
||||
|
||||
def compose_replay(yaml_config_path: str | None = None) -> RuntimeRoot:
|
||||
"""Compose the replay-cli runtime graph. Concrete wiring owned by AZ-401."""
|
||||
_check_required_env()
|
||||
return RuntimeRoot(binary="replay-cli", profile=os.environ["GPS_DENIED_FC_PROFILE"])
|
||||
def _close_partial_instances(instances: Mapping[str, Any]) -> None:
|
||||
"""Best-effort cleanup of partially-constructed components on failure.
|
||||
|
||||
Calls ``.close()`` on each instance that exposes one; swallows individual
|
||||
close failures so the original exception propagates to the caller.
|
||||
"""
|
||||
for inst in instances.values():
|
||||
close = getattr(inst, "close", None)
|
||||
if callable(close):
|
||||
try:
|
||||
close()
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
|
||||
def _resolve_component_strategies(
|
||||
config: Config, allowed_tiers: frozenset[StrategyTier]
|
||||
) -> dict[str, str]:
|
||||
"""Translate ``config.components[slug]`` into ``{slug: strategy_name}``.
|
||||
|
||||
Two recognised shapes for a per-component block:
|
||||
|
||||
* a dataclass with a ``strategy`` field (preferred);
|
||||
* a mapping with a ``"strategy"`` key (fallback for raw YAML).
|
||||
|
||||
Blocks without a ``strategy`` field are skipped — they configure a
|
||||
component whose strategy was hard-wired by the binary's bootstrap.
|
||||
"""
|
||||
_ = allowed_tiers # tier filtering happens in ``_resolve_strategy``
|
||||
selections: dict[str, str] = {}
|
||||
for slug, block in (config.components or {}).items():
|
||||
strategy = _read_strategy_attr(block)
|
||||
if strategy is None:
|
||||
continue
|
||||
if not isinstance(strategy, str) or not strategy:
|
||||
raise StrategyNotLinkedError(
|
||||
strategy_name=str(strategy),
|
||||
component_slug=slug,
|
||||
available_strategies=list_registered_strategies(slug),
|
||||
reason="config.components[slug].strategy must be a non-empty string",
|
||||
)
|
||||
selections[slug] = strategy
|
||||
return selections
|
||||
|
||||
|
||||
def _read_strategy_attr(block: Any) -> Any:
|
||||
if hasattr(block, "strategy"):
|
||||
return block.strategy
|
||||
if isinstance(block, Mapping):
|
||||
return block.get("strategy")
|
||||
return None
|
||||
|
||||
|
||||
def compose_root(config: Config) -> RuntimeRoot:
|
||||
"""Compose the airborne runtime graph (per contract v1.0.0)."""
|
||||
components, order = _compose(
|
||||
config,
|
||||
binary="airborne",
|
||||
allowed_tiers=frozenset({"airborne", "shared"}),
|
||||
extra_required_env=("MAVLINK_SIGNING_KEY",),
|
||||
)
|
||||
return RuntimeRoot(
|
||||
binary="airborne",
|
||||
profile=os.environ["GPS_DENIED_FC_PROFILE"],
|
||||
components=components,
|
||||
construction_order=order,
|
||||
)
|
||||
|
||||
|
||||
def compose_operator(config: Config) -> OperatorRoot:
|
||||
"""Compose the operator-tooling runtime graph (per contract v1.0.0)."""
|
||||
components, order = _compose(
|
||||
config,
|
||||
binary="operator-tooling",
|
||||
allowed_tiers=frozenset({"operator", "shared"}),
|
||||
extra_required_env=("SATELLITE_PROVIDER_URL",),
|
||||
)
|
||||
return OperatorRoot(
|
||||
binary="operator-tooling",
|
||||
profile=os.environ["GPS_DENIED_FC_PROFILE"],
|
||||
components=components,
|
||||
construction_order=order,
|
||||
)
|
||||
|
||||
|
||||
def compose_replay(config: Config) -> RuntimeRoot:
|
||||
"""Compose the replay-cli runtime graph. Concrete wiring is owned by AZ-401."""
|
||||
components, order = _compose(
|
||||
config,
|
||||
binary="replay-cli",
|
||||
allowed_tiers=frozenset({"airborne", "shared"}),
|
||||
extra_required_env=(),
|
||||
)
|
||||
return RuntimeRoot(
|
||||
binary="replay-cli",
|
||||
profile=os.environ["GPS_DENIED_FC_PROFILE"],
|
||||
components=components,
|
||||
construction_order=order,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int: # pragma: no cover — guarded entrypoint
|
||||
try:
|
||||
compose_root()
|
||||
except ConfigurationError as exc:
|
||||
config = load_config(env=os.environ, paths=())
|
||||
compose_root(config)
|
||||
except (ConfigurationError, StrategyNotLinkedError, RuntimeError) as exc:
|
||||
print(f"runtime_root: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
return 0
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
"""AZ-270 — Composition Root AC tests.
|
||||
|
||||
Verifies the contract at ``_docs/02_document/contracts/shared_config/composition_root_protocol.md`` v1.0.0.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard.config import Config
|
||||
from gps_denied_onboard.runtime_root import (
|
||||
RuntimeRoot,
|
||||
StrategyNotLinkedError,
|
||||
clear_strategy_registry,
|
||||
compose_operator,
|
||||
compose_root,
|
||||
list_registered_strategies,
|
||||
register_strategy,
|
||||
)
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _C1Block:
|
||||
strategy: str = "okvis2"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _C4Block:
|
||||
strategy: str = "opencv_gtsam"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _C5Block:
|
||||
strategy: str = "gtsam_isam2"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _C11Block:
|
||||
strategy: str = "ardupilot_tile_manager"
|
||||
|
||||
|
||||
@dataclass
|
||||
class _OrderRecorder:
|
||||
constructed: list[str]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolated_registry() -> Iterator[None]:
|
||||
"""Reset the strategy registry around every test."""
|
||||
clear_strategy_registry()
|
||||
yield
|
||||
clear_strategy_registry()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _airborne_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
for name, value in (
|
||||
("GPS_DENIED_FC_PROFILE", "ardupilot_plane"),
|
||||
("GPS_DENIED_TIER", "1"),
|
||||
("DB_URL", "postgresql+psycopg://gps_denied:dev@db:5432/gps_denied"),
|
||||
("CAMERA_CALIBRATION_PATH", "/etc/gps-denied/calib.yml"),
|
||||
("LOG_LEVEL", "INFO"),
|
||||
("LOG_SINK", "console"),
|
||||
("INFERENCE_BACKEND", "pytorch_fp16"),
|
||||
("FDR_PATH", "/var/lib/gps-denied/fdr"),
|
||||
("TILE_CACHE_PATH", "/var/lib/gps-denied/tiles"),
|
||||
("MAVLINK_SIGNING_KEY", "ZZZZZZZZ"),
|
||||
):
|
||||
monkeypatch.setenv(name, value)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _operator_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
for name, value in (
|
||||
("GPS_DENIED_FC_PROFILE", "ardupilot_plane"),
|
||||
("GPS_DENIED_TIER", "1"),
|
||||
("DB_URL", "postgresql+psycopg://gps_denied:dev@db:5432/gps_denied"),
|
||||
("CAMERA_CALIBRATION_PATH", "/etc/gps-denied/calib.yml"),
|
||||
("LOG_LEVEL", "INFO"),
|
||||
("LOG_SINK", "console"),
|
||||
("INFERENCE_BACKEND", "pytorch_fp16"),
|
||||
("FDR_PATH", "/var/lib/gps-denied/fdr"),
|
||||
("TILE_CACHE_PATH", "/var/lib/gps-denied/tiles"),
|
||||
("SATELLITE_PROVIDER_URL", "http://localhost:8080"),
|
||||
):
|
||||
monkeypatch.setenv(name, value)
|
||||
|
||||
|
||||
def _register_okvis2(recorder: _OrderRecorder) -> None:
|
||||
def factory(config: Config, components: dict[str, object]) -> object:
|
||||
recorder.constructed.append("c1_vio")
|
||||
return ("c1_vio", "okvis2")
|
||||
|
||||
register_strategy("c1_vio", "okvis2", factory, tier="airborne")
|
||||
|
||||
|
||||
def _register_c4(recorder: _OrderRecorder) -> None:
|
||||
def factory(config: Config, components: dict[str, object]) -> object:
|
||||
recorder.constructed.append("c4_pose")
|
||||
return ("c4_pose", "opencv_gtsam")
|
||||
|
||||
register_strategy("c4_pose", "opencv_gtsam", factory, tier="airborne")
|
||||
|
||||
|
||||
def _register_c5(recorder: _OrderRecorder) -> None:
|
||||
def factory(config: Config, components: dict[str, object]) -> object:
|
||||
assert "c1_vio" in components, "c5_state factory ran before c1_vio existed"
|
||||
assert "c4_pose" in components, "c5_state factory ran before c4_pose existed"
|
||||
recorder.constructed.append("c5_state")
|
||||
return ("c5_state", "gtsam_isam2")
|
||||
|
||||
register_strategy(
|
||||
"c5_state",
|
||||
"gtsam_isam2",
|
||||
factory,
|
||||
tier="airborne",
|
||||
depends_on=("c1_vio", "c4_pose"),
|
||||
)
|
||||
|
||||
|
||||
def test_ac1_default_deployment_composes(_airborne_env: None) -> None:
|
||||
recorder = _OrderRecorder(constructed=[])
|
||||
_register_okvis2(recorder)
|
||||
_register_c4(recorder)
|
||||
_register_c5(recorder)
|
||||
config = Config.with_blocks(
|
||||
c1_vio=_C1Block(),
|
||||
c4_pose=_C4Block(),
|
||||
c5_state=_C5Block(),
|
||||
)
|
||||
root = compose_root(config)
|
||||
assert isinstance(root, RuntimeRoot)
|
||||
assert root.binary == "airborne"
|
||||
assert set(root.components.keys()) == {"c1_vio", "c4_pose", "c5_state"}
|
||||
|
||||
|
||||
def test_ac2_strategy_not_linked_raises_with_payload(_airborne_env: None) -> None:
|
||||
# Only okvis2 is registered; config asks for vins_mono.
|
||||
recorder = _OrderRecorder(constructed=[])
|
||||
_register_okvis2(recorder)
|
||||
config = Config.with_blocks(c1_vio=_C1Block(strategy="vins_mono"))
|
||||
with pytest.raises(StrategyNotLinkedError) as info:
|
||||
compose_root(config)
|
||||
assert info.value.strategy_name == "vins_mono"
|
||||
assert info.value.component_slug == "c1_vio"
|
||||
assert info.value.available_strategies == ["okvis2"]
|
||||
|
||||
|
||||
def test_ac3_operator_excludes_airborne_only(_operator_env: None) -> None:
|
||||
# c1_vio is registered as airborne; an operator config that references it must fail.
|
||||
recorder = _OrderRecorder(constructed=[])
|
||||
_register_okvis2(recorder)
|
||||
config = Config.with_blocks(c1_vio=_C1Block())
|
||||
with pytest.raises(StrategyNotLinkedError) as info:
|
||||
compose_operator(config)
|
||||
assert info.value.component_slug == "c1_vio"
|
||||
assert "airborne" in info.value.reason or "tier" in info.value.reason
|
||||
|
||||
|
||||
def test_ac4_runtime_root_smoke_exit_zero(_airborne_env: None) -> None:
|
||||
# A Config with no component blocks must compose cleanly (every required
|
||||
# component is hard-wired by its bootstrap; no strategy to resolve).
|
||||
config = Config()
|
||||
root = compose_root(config)
|
||||
assert isinstance(root, RuntimeRoot)
|
||||
assert root.components == {}
|
||||
|
||||
|
||||
def test_ac5_construction_order_respects_dependencies(_airborne_env: None) -> None:
|
||||
recorder = _OrderRecorder(constructed=[])
|
||||
# Register in reverse order to make the topological pass non-trivial.
|
||||
_register_c5(recorder)
|
||||
_register_c4(recorder)
|
||||
_register_okvis2(recorder)
|
||||
config = Config.with_blocks(
|
||||
c1_vio=_C1Block(),
|
||||
c4_pose=_C4Block(),
|
||||
c5_state=_C5Block(),
|
||||
)
|
||||
root = compose_root(config)
|
||||
# Dependencies must construct strictly before dependents.
|
||||
assert recorder.constructed.index("c1_vio") < recorder.constructed.index("c5_state")
|
||||
assert recorder.constructed.index("c4_pose") < recorder.constructed.index("c5_state")
|
||||
assert root.construction_order[-1] == "c5_state"
|
||||
|
||||
|
||||
def test_ac6_only_compose_root_imports_concrete_strategies() -> None:
|
||||
"""Architecture lint: no module under ``components.*`` imports another component's concrete strategy.
|
||||
|
||||
We accept only:
|
||||
* the composition root (``runtime_root.py``);
|
||||
* per-component public re-exports inside the component's own subpackage
|
||||
(e.g. ``components.c5_state`` importing ``components.c5_state.interface``).
|
||||
Imports across components (e.g. ``components.c5_state`` importing
|
||||
``components.c1_vio.okvis2``) are violations.
|
||||
"""
|
||||
components_root = _REPO_ROOT / "src" / "gps_denied_onboard" / "components"
|
||||
violations: list[str] = []
|
||||
for module_path in components_root.rglob("*.py"):
|
||||
own_component = module_path.relative_to(components_root).parts[0]
|
||||
try:
|
||||
tree = ast.parse(module_path.read_text(encoding="utf-8"))
|
||||
except SyntaxError:
|
||||
continue
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ImportFrom) and node.module:
|
||||
if node.module.startswith("gps_denied_onboard.components."):
|
||||
referenced = node.module.split(".")[2]
|
||||
if referenced != own_component:
|
||||
violations.append(
|
||||
f"{module_path.relative_to(_REPO_ROOT)} imports {node.module}"
|
||||
)
|
||||
elif isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
if alias.name.startswith("gps_denied_onboard.components."):
|
||||
referenced = alias.name.split(".")[2]
|
||||
if referenced != own_component:
|
||||
violations.append(
|
||||
f"{module_path.relative_to(_REPO_ROOT)} imports {alias.name}"
|
||||
)
|
||||
assert not violations, (
|
||||
"components.* may not import other components — only the composition root may; "
|
||||
f"violations: {violations}"
|
||||
)
|
||||
|
||||
|
||||
def test_nfr_reliability_partial_construction_closed_on_failure(_airborne_env: None) -> None:
|
||||
closed: list[str] = []
|
||||
|
||||
class _Closable:
|
||||
def __init__(self, slug: str) -> None:
|
||||
self.slug = slug
|
||||
|
||||
def close(self) -> None:
|
||||
closed.append(self.slug)
|
||||
|
||||
def good_factory(config: Config, components: dict[str, object]) -> _Closable:
|
||||
return _Closable("c1_vio")
|
||||
|
||||
def failing_factory(config: Config, components: dict[str, object]) -> object:
|
||||
raise RuntimeError("boom from c5_state factory")
|
||||
|
||||
register_strategy("c1_vio", "okvis2", good_factory, tier="airborne")
|
||||
register_strategy(
|
||||
"c5_state",
|
||||
"gtsam_isam2",
|
||||
failing_factory,
|
||||
tier="airborne",
|
||||
depends_on=("c1_vio",),
|
||||
)
|
||||
config = Config.with_blocks(c1_vio=_C1Block(), c5_state=_C5Block())
|
||||
with pytest.raises(RuntimeError, match=r"boom"):
|
||||
compose_root(config)
|
||||
assert closed == ["c1_vio"], "prior instances must be .close()d on mid-composition failure"
|
||||
|
||||
|
||||
def test_list_registered_strategies_returns_sorted_names() -> None:
|
||||
register_strategy("c1_vio", "okvis2", lambda c, m: None, tier="airborne")
|
||||
register_strategy("c1_vio", "vins_mono", lambda c, m: None, tier="airborne")
|
||||
register_strategy("c2_vpr", "netvlad", lambda c, m: None, tier="airborne")
|
||||
assert list_registered_strategies("c1_vio") == ["okvis2", "vins_mono"]
|
||||
assert list_registered_strategies("c2_vpr") == ["netvlad"]
|
||||
assert list_registered_strategies("c99_unknown") == []
|
||||
@@ -0,0 +1,256 @@
|
||||
"""AZ-272 — FdrRecord schema + versioned serialiser AC tests.
|
||||
|
||||
Verifies the contract at ``_docs/02_document/contracts/shared_fdr_client/fdr_record_schema.md`` v1.0.0.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
import orjson
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard.fdr_client import (
|
||||
CURRENT_SCHEMA_VERSION,
|
||||
KNOWN_KINDS,
|
||||
MAX_INLINE_BLOB_BYTES,
|
||||
OVERRUN_KIND,
|
||||
OVERRUN_PRODUCER_ID,
|
||||
FdrRecord,
|
||||
FdrSchemaError,
|
||||
parse,
|
||||
serialise,
|
||||
)
|
||||
|
||||
_TS = "2026-05-11T00:00:00.000000Z"
|
||||
|
||||
|
||||
def _kind_payload(kind: str) -> dict[str, object]:
|
||||
"""Return a minimal valid payload for each v1.0.0 kind."""
|
||||
if kind == "log":
|
||||
return {
|
||||
"level": "INFO",
|
||||
"component": "c2_vpr",
|
||||
"frame_id": 42,
|
||||
"kind": "vpr.warmup",
|
||||
"msg": "loaded",
|
||||
"kv": {"model": "salad"},
|
||||
}
|
||||
if kind == "vio.tick":
|
||||
return {
|
||||
"frame_id": 1,
|
||||
"R": [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
|
||||
"t": [0.0, 0.0, 0.0],
|
||||
"P": [[1.0]],
|
||||
"last_anchor_age_ms": 100,
|
||||
}
|
||||
if kind == "state.tick":
|
||||
return {
|
||||
"frame_id": 1,
|
||||
"fused_pose": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
|
||||
"covariance_2x2": [[1.0, 0.0], [0.0, 1.0]],
|
||||
"estimator_label": "primary",
|
||||
}
|
||||
if kind == "tile_match":
|
||||
return {
|
||||
"frame_id": 1,
|
||||
"tile_id": "tile-001",
|
||||
"score": 0.85,
|
||||
"match_count": 124,
|
||||
"ransac_inliers": 96,
|
||||
}
|
||||
if kind == "overrun":
|
||||
return {"producer_id": "c1_vio", "dropped_count": 42}
|
||||
if kind == "segment_rollover":
|
||||
return {
|
||||
"old_segment": "seg-0001",
|
||||
"new_segment": "seg-0002",
|
||||
"total_bytes_after": 1024 * 1024,
|
||||
}
|
||||
if kind == "failed_tile_thumbnail":
|
||||
return {"frame_id": 1, "tile_id": "tile-002", "jpeg_bytes_b64": "AAAAAA=="}
|
||||
if kind == "mid_flight_tile_snapshot":
|
||||
return {"snapshot_path": "/var/lib/gps-denied/snap.dat", "captured_at": _TS}
|
||||
if kind == "flight_header":
|
||||
return {
|
||||
"flight_id": "f-0001",
|
||||
"started_at": _TS,
|
||||
"schema_version": CURRENT_SCHEMA_VERSION,
|
||||
"build_info": {"commit": "abc123"},
|
||||
}
|
||||
if kind == "flight_footer":
|
||||
return {
|
||||
"flight_id": "f-0001",
|
||||
"ended_at": _TS,
|
||||
"records_written": 12345,
|
||||
"records_dropped": 0,
|
||||
}
|
||||
raise AssertionError(f"unhandled kind in fixture: {kind!r}")
|
||||
|
||||
|
||||
def _make_record(kind: str, **overrides: object) -> FdrRecord:
|
||||
"""Build a minimal valid record for ``kind`` so each AC test can mutate the field it cares about."""
|
||||
if kind == OVERRUN_KIND:
|
||||
producer_id = overrides.pop("producer_id", OVERRUN_PRODUCER_ID)
|
||||
else:
|
||||
producer_id = overrides.pop("producer_id", "c2_vpr")
|
||||
return FdrRecord(
|
||||
schema_version=int(overrides.pop("schema_version", CURRENT_SCHEMA_VERSION)),
|
||||
ts=str(overrides.pop("ts", _TS)),
|
||||
producer_id=str(producer_id),
|
||||
kind=str(overrides.pop("kind", kind)),
|
||||
payload=dict(overrides.pop("payload", _kind_payload(kind))), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", sorted(KNOWN_KINDS))
|
||||
def test_ac1_roundtrip_every_known_kind(kind: str) -> None:
|
||||
# Arrange
|
||||
record = _make_record(kind)
|
||||
# Act
|
||||
decoded = parse(serialise(record))
|
||||
# Assert
|
||||
assert decoded == record
|
||||
|
||||
|
||||
def test_ac2_forward_compatible_unknown_payload_field_preserved() -> None:
|
||||
# Arrange — synthesise a future v1.1 record adding an unknown payload field.
|
||||
payload = _kind_payload("log") | {"new_field": "x"}
|
||||
wire = orjson.dumps(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"ts": _TS,
|
||||
"producer_id": "c2_vpr",
|
||||
"kind": "log",
|
||||
"payload": payload,
|
||||
}
|
||||
)
|
||||
# Act
|
||||
record = parse(wire)
|
||||
# Assert
|
||||
assert record.payload.get("extra", {}).get("new_field") == "x"
|
||||
assert "new_field" not in record.payload
|
||||
# Known fields still parse out cleanly.
|
||||
assert record.payload["msg"] == "loaded"
|
||||
|
||||
|
||||
def test_ac2b_forward_compatible_unknown_top_level_field_preserved() -> None:
|
||||
wire = orjson.dumps(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"ts": _TS,
|
||||
"producer_id": "c2_vpr",
|
||||
"kind": "log",
|
||||
"payload": _kind_payload("log"),
|
||||
"trailing": "future-field",
|
||||
}
|
||||
)
|
||||
record = parse(wire)
|
||||
assert record.extra == {"trailing": "future-field"}
|
||||
|
||||
|
||||
def test_ac3_unknown_future_kind_returned_opaquely() -> None:
|
||||
wire = orjson.dumps(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"ts": _TS,
|
||||
"producer_id": "future.producer",
|
||||
"kind": "future.kind",
|
||||
"payload": {"foo": 1},
|
||||
}
|
||||
)
|
||||
record = parse(wire)
|
||||
assert record.kind == "future.kind"
|
||||
assert record.payload == {"foo": 1}
|
||||
assert record.extra == {}
|
||||
|
||||
|
||||
def test_ac4_missing_schema_version_raises() -> None:
|
||||
wire = orjson.dumps(
|
||||
{
|
||||
"ts": _TS,
|
||||
"producer_id": "c2_vpr",
|
||||
"kind": "log",
|
||||
"payload": _kind_payload("log"),
|
||||
}
|
||||
)
|
||||
with pytest.raises(FdrSchemaError, match=r"schema_version"):
|
||||
parse(wire)
|
||||
|
||||
|
||||
def test_ac4_non_integer_schema_version_raises() -> None:
|
||||
wire = orjson.dumps(
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"ts": _TS,
|
||||
"producer_id": "c2_vpr",
|
||||
"kind": "log",
|
||||
"payload": _kind_payload("log"),
|
||||
}
|
||||
)
|
||||
with pytest.raises(FdrSchemaError, match=r"schema_version"):
|
||||
parse(wire)
|
||||
|
||||
|
||||
def test_ac5_overrun_missing_dropped_count_rejected_on_parse() -> None:
|
||||
wire = orjson.dumps(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"ts": _TS,
|
||||
"producer_id": OVERRUN_PRODUCER_ID,
|
||||
"kind": OVERRUN_KIND,
|
||||
"payload": {"producer_id": "c1_vio"},
|
||||
}
|
||||
)
|
||||
with pytest.raises(FdrSchemaError, match=r"dropped_count"):
|
||||
parse(wire)
|
||||
|
||||
|
||||
def test_ac5_overrun_zero_dropped_count_rejected_on_serialise() -> None:
|
||||
record = _make_record(OVERRUN_KIND, payload={"producer_id": "c1_vio", "dropped_count": 0})
|
||||
with pytest.raises(FdrSchemaError, match=r"dropped_count"):
|
||||
serialise(record)
|
||||
|
||||
|
||||
def test_ac6_empty_producer_id_rejected_on_serialise() -> None:
|
||||
record = _make_record("log", producer_id="")
|
||||
with pytest.raises(FdrSchemaError, match=r"producer_id"):
|
||||
serialise(record)
|
||||
|
||||
|
||||
def test_nfr_oversized_inline_blob_rejected() -> None:
|
||||
blob = b"\x00" * (MAX_INLINE_BLOB_BYTES + 1)
|
||||
payload = _kind_payload("failed_tile_thumbnail") | {"jpeg_bytes": blob}
|
||||
record = _make_record("failed_tile_thumbnail", payload=payload)
|
||||
with pytest.raises(FdrSchemaError, match=r"sidecar path"):
|
||||
serialise(record)
|
||||
|
||||
|
||||
def test_nfr_serialise_is_pure_byte_identical() -> None:
|
||||
record = _make_record("log")
|
||||
first = serialise(record)
|
||||
second = serialise(record)
|
||||
assert first == second
|
||||
|
||||
|
||||
def test_no_upward_imports_to_components() -> None:
|
||||
"""Layer-0/cross-cutting: ``fdr_client.records`` must not import any component."""
|
||||
module_path = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "src"
|
||||
/ "gps_denied_onboard"
|
||||
/ "fdr_client"
|
||||
/ "records.py"
|
||||
)
|
||||
tree = ast.parse(module_path.read_text(encoding="utf-8"))
|
||||
bad: list[str] = []
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ImportFrom) and node.module:
|
||||
if node.module.startswith("gps_denied_onboard.components"):
|
||||
bad.append(node.module)
|
||||
elif isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
if alias.name.startswith("gps_denied_onboard.components"):
|
||||
bad.append(alias.name)
|
||||
assert not bad, f"fdr_client.records must not import components.*; found: {bad}"
|
||||
@@ -0,0 +1,122 @@
|
||||
"""AZ-279 — WgsConverter helper AC tests.
|
||||
|
||||
Verifies the contract at ``_docs/02_document/contracts/shared_helpers/wgs_converter.md`` v1.0.0.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard._types.geo import BoundingBox, LatLonAlt
|
||||
from gps_denied_onboard.helpers import (
|
||||
MAX_ZOOM,
|
||||
WEB_MERCATOR_MAX_LAT_DEG,
|
||||
WgsConversionError,
|
||||
WgsConverter,
|
||||
)
|
||||
|
||||
|
||||
def test_ac1_ecef_roundtrip() -> None:
|
||||
# Arrange
|
||||
samples = [
|
||||
LatLonAlt(50.0, 30.0, 100.0),
|
||||
LatLonAlt(-33.9, 151.2, 25.0),
|
||||
LatLonAlt(0.0, 0.0, 0.0),
|
||||
LatLonAlt(80.0, -120.0, 1500.0),
|
||||
LatLonAlt(-60.0, 179.999, -50.0),
|
||||
]
|
||||
# Act / Assert
|
||||
for p in samples:
|
||||
ecef = WgsConverter.latlonalt_to_ecef(p)
|
||||
back = WgsConverter.ecef_to_latlonalt(ecef)
|
||||
assert math.isclose(back.lat_deg, p.lat_deg, abs_tol=1e-9), (back, p)
|
||||
assert math.isclose(back.lon_deg, p.lon_deg, abs_tol=1e-9), (back, p)
|
||||
assert math.isclose(back.alt_m, p.alt_m, abs_tol=1e-6), (back, p)
|
||||
|
||||
|
||||
def test_ac2_enu_roundtrip_within_10_km() -> None:
|
||||
origin = LatLonAlt(50.0, 30.0, 100.0)
|
||||
# ~10 km away in NE direction
|
||||
p = LatLonAlt(50.07, 30.10, 250.0)
|
||||
enu = WgsConverter.latlonalt_to_local_enu(origin, p)
|
||||
back = WgsConverter.local_enu_to_latlonalt(origin, enu)
|
||||
horizontal_m = math.hypot(
|
||||
(back.lat_deg - p.lat_deg) * 111_320.0,
|
||||
(back.lon_deg - p.lon_deg) * 111_320.0 * math.cos(math.radians(p.lat_deg)),
|
||||
)
|
||||
vertical_m = abs(back.alt_m - p.alt_m)
|
||||
assert horizontal_m < 1.0, f"horizontal residual {horizontal_m} m > 1 m"
|
||||
assert vertical_m < 0.01, f"vertical residual {vertical_m} m > 1 cm"
|
||||
|
||||
|
||||
def test_ac3_slippy_map_tile_roundtrip_z18_contains_input() -> None:
|
||||
zoom, lat, lon = 18, 50.45, 30.52
|
||||
x, y = WgsConverter.latlon_to_tile_xy(zoom, lat, lon)
|
||||
bounds = WgsConverter.tile_xy_to_latlon_bounds(zoom, x, y)
|
||||
assert isinstance(bounds, BoundingBox)
|
||||
assert bounds.contains(lat, lon)
|
||||
# OSM-pinned reference for (lat=50.45, lon=30.52, z=18); precomputed via
|
||||
# the slippy-map formula and matching satellite-provider's on-disk layout.
|
||||
assert (x, y) == (153295, 88392)
|
||||
|
||||
|
||||
def test_ac4_web_mercator_latitude_range_guard() -> None:
|
||||
with pytest.raises(WgsConversionError, match=r"Web-Mercator"):
|
||||
WgsConverter.latlon_to_tile_xy(18, 95.0, 0.0)
|
||||
|
||||
|
||||
def test_ac5_zoom_range_guard() -> None:
|
||||
with pytest.raises(WgsConversionError, match=r"zoom"):
|
||||
WgsConverter.latlon_to_tile_xy(MAX_ZOOM + 3, 50.0, 30.0)
|
||||
with pytest.raises(WgsConversionError, match=r"zoom"):
|
||||
WgsConverter.tile_xy_to_latlon_bounds(MAX_ZOOM + 3, 0, 0)
|
||||
|
||||
|
||||
def test_ac6_tile_xy_range_guard() -> None:
|
||||
with pytest.raises(WgsConversionError, match=r"tile"):
|
||||
WgsConverter.tile_xy_to_latlon_bounds(18, 1 << 18, 0)
|
||||
|
||||
|
||||
def test_ac7_ecef_shape_contract() -> None:
|
||||
with pytest.raises(WgsConversionError, match=r"shape"):
|
||||
WgsConverter.ecef_to_latlonalt(np.array([1.0, 2.0], dtype=np.float64))
|
||||
|
||||
|
||||
def test_ac8_determinism_byte_equal_outputs() -> None:
|
||||
p = LatLonAlt(50.0, 30.0, 100.0)
|
||||
first = WgsConverter.latlonalt_to_ecef(p)
|
||||
second = WgsConverter.latlonalt_to_ecef(p)
|
||||
assert first.tobytes() == second.tobytes()
|
||||
|
||||
|
||||
def test_ac9_no_upward_imports_to_components() -> None:
|
||||
module_path = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "src"
|
||||
/ "gps_denied_onboard"
|
||||
/ "helpers"
|
||||
/ "wgs_converter.py"
|
||||
)
|
||||
tree = ast.parse(module_path.read_text(encoding="utf-8"))
|
||||
bad: list[str] = []
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ImportFrom) and node.module:
|
||||
if node.module.startswith("gps_denied_onboard.components"):
|
||||
bad.append(node.module)
|
||||
elif isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
if alias.name.startswith("gps_denied_onboard.components"):
|
||||
bad.append(alias.name)
|
||||
assert not bad, f"wgs_converter must not import components.*; found: {bad}"
|
||||
|
||||
|
||||
def test_invariant_web_mercator_max_lat_close_to_documented_value() -> None:
|
||||
# Sanity bound: documented constant matches the Mercator-projection-valid
|
||||
# latitude (arctan(sinh(pi))) within rounding.
|
||||
expected = math.degrees(math.atan(math.sinh(math.pi)))
|
||||
assert math.isclose(WEB_MERCATOR_MAX_LAT_DEG, expected, abs_tol=1e-9)
|
||||
@@ -0,0 +1,100 @@
|
||||
"""AZ-281 — EngineFilenameSchema helper AC tests.
|
||||
|
||||
Verifies the contract at ``_docs/02_document/contracts/shared_helpers/engine_filename_schema.md`` v1.0.0.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import random
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard._types.manifests import EngineCacheKey, HostCapabilities
|
||||
from gps_denied_onboard.helpers import EngineFilenameSchema, EngineFilenameSchemaError
|
||||
|
||||
|
||||
def test_ac1_reference_example_builds_exact_string() -> None:
|
||||
assert (
|
||||
EngineFilenameSchema.build("ultravpr", 87, "6.2", "10.3", "fp16")
|
||||
== "ultravpr__sm87_jp6.2_trt10.3_fp16.engine"
|
||||
)
|
||||
|
||||
|
||||
def test_ac2_roundtrip_identity_over_10_random_tuples() -> None:
|
||||
rng = random.Random(2026)
|
||||
for _ in range(10):
|
||||
model = rng.choice(["ultravpr", "netvlad", "megaloc", "selavpr", "salad", "mix_vpr"])
|
||||
sm = rng.choice([72, 86, 87])
|
||||
jp = rng.choice(["5.1", "6.0", "6.2", "6.3"])
|
||||
trt = rng.choice(["8.6", "10.0", "10.3", "10.4"])
|
||||
prec = rng.choice(["fp16", "int8", "mixed"])
|
||||
filename = EngineFilenameSchema.build(model, sm, jp, trt, prec)
|
||||
parsed = EngineFilenameSchema.parse(filename)
|
||||
assert parsed == EngineCacheKey(model, sm, jp, trt, prec)
|
||||
|
||||
|
||||
def test_ac3_matches_host_exact_match() -> None:
|
||||
filename = EngineFilenameSchema.build("ultravpr", 87, "6.2", "10.3", "fp16")
|
||||
host = HostCapabilities(sm=87, jetpack="6.2", trt="10.3")
|
||||
assert EngineFilenameSchema.matches_host(filename, host) is True
|
||||
|
||||
|
||||
def test_ac4_matches_host_tuple_mismatch_returns_false() -> None:
|
||||
filename = EngineFilenameSchema.build("ultravpr", 87, "6.2", "10.3", "fp16")
|
||||
host_mismatch = HostCapabilities(sm=72, jetpack="6.2", trt="10.3")
|
||||
assert EngineFilenameSchema.matches_host(filename, host_mismatch) is False
|
||||
host_mismatch_trt = HostCapabilities(sm=87, jetpack="6.2", trt="10.4")
|
||||
assert EngineFilenameSchema.matches_host(filename, host_mismatch_trt) is False
|
||||
|
||||
|
||||
def test_ac5_precision_enum_strictness() -> None:
|
||||
with pytest.raises(EngineFilenameSchemaError, match=r"precision"):
|
||||
EngineFilenameSchema.build("ultravpr", 87, "6.2", "10.3", "bf16")
|
||||
|
||||
|
||||
def test_ac6_model_name_character_set_rejection() -> None:
|
||||
with pytest.raises(EngineFilenameSchemaError, match=r"a-z0-9_"):
|
||||
EngineFilenameSchema.build("UltraVPR", 87, "6.2", "10.3", "fp16")
|
||||
|
||||
|
||||
def test_ac7_reserved_separator_collision_rejected() -> None:
|
||||
with pytest.raises(EngineFilenameSchemaError, match=r"__"):
|
||||
EngineFilenameSchema.build("ultra__vpr", 87, "6.2", "10.3", "fp16")
|
||||
|
||||
|
||||
def test_ac8_three_segment_version_rejected() -> None:
|
||||
with pytest.raises(EngineFilenameSchemaError, match=r"major.*minor"):
|
||||
EngineFilenameSchema.build("ultravpr", 87, "6.2.1", "10.3", "fp16")
|
||||
|
||||
|
||||
def test_ac9_parse_rejects_malformed_filename() -> None:
|
||||
with pytest.raises(EngineFilenameSchemaError):
|
||||
EngineFilenameSchema.parse("not_an_engine_file.engine")
|
||||
|
||||
|
||||
def test_ac10_parse_requires_engine_suffix() -> None:
|
||||
with pytest.raises(EngineFilenameSchemaError, match=r"\.engine"):
|
||||
EngineFilenameSchema.parse("ultravpr__sm87_jp6.2_trt10.3_fp16")
|
||||
|
||||
|
||||
def test_ac11_no_upward_imports_to_components() -> None:
|
||||
module_path = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "src"
|
||||
/ "gps_denied_onboard"
|
||||
/ "helpers"
|
||||
/ "engine_filename_schema.py"
|
||||
)
|
||||
tree = ast.parse(module_path.read_text(encoding="utf-8"))
|
||||
bad: list[str] = []
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ImportFrom) and node.module:
|
||||
if node.module.startswith("gps_denied_onboard.components"):
|
||||
bad.append(node.module)
|
||||
elif isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
if alias.name.startswith("gps_denied_onboard.components"):
|
||||
bad.append(alias.name)
|
||||
assert not bad, f"engine_filename_schema must not import components.*; found: {bad}"
|
||||
@@ -0,0 +1,122 @@
|
||||
"""AZ-283 — DescriptorNormaliser helper AC tests.
|
||||
|
||||
Verifies the contract at ``_docs/02_document/contracts/shared_helpers/descriptor_normaliser.md`` v1.0.0.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard.helpers import DescriptorNormaliser, DescriptorNormaliserError
|
||||
|
||||
|
||||
def test_ac1_unit_vector_example() -> None:
|
||||
out = DescriptorNormaliser.l2_normalise(np.array([3.0, 4.0], dtype=np.float32))
|
||||
np.testing.assert_allclose(out, np.array([0.6, 0.8], dtype=np.float32), atol=1e-6)
|
||||
assert float(np.linalg.norm(out)) == pytest.approx(1.0, abs=1e-6)
|
||||
|
||||
|
||||
def test_ac2_batch_normalisation() -> None:
|
||||
batch = np.array([[3.0, 4.0], [1.0, 0.0]], dtype=np.float32)
|
||||
out = DescriptorNormaliser.l2_normalise_batch(batch)
|
||||
np.testing.assert_allclose(out[0], np.array([0.6, 0.8], dtype=np.float32), atol=1e-6)
|
||||
np.testing.assert_allclose(out[1], np.array([1.0, 0.0], dtype=np.float32), atol=1e-6)
|
||||
for row in out:
|
||||
assert float(np.linalg.norm(row)) == pytest.approx(1.0, abs=1e-6)
|
||||
|
||||
|
||||
def test_ac3_fp16_dtype_preservation() -> None:
|
||||
rng = np.random.default_rng(2026)
|
||||
x = rng.standard_normal(512).astype(np.float16)
|
||||
out = DescriptorNormaliser.l2_normalise(x)
|
||||
assert out.dtype == np.float16
|
||||
assert float(np.linalg.norm(out.astype(np.float32))) == pytest.approx(1.0, abs=1e-3)
|
||||
|
||||
|
||||
def test_ac4_fp32_dtype_preservation() -> None:
|
||||
rng = np.random.default_rng(2026)
|
||||
x = rng.standard_normal(512).astype(np.float32)
|
||||
out = DescriptorNormaliser.l2_normalise(x)
|
||||
assert out.dtype == np.float32
|
||||
assert float(np.linalg.norm(out)) == pytest.approx(1.0, abs=1e-6)
|
||||
|
||||
|
||||
def test_ac5_zero_vector_handling() -> None:
|
||||
zeros = np.zeros(128, dtype=np.float32)
|
||||
out = DescriptorNormaliser.l2_normalise(zeros)
|
||||
np.testing.assert_array_equal(out, zeros)
|
||||
assert not np.any(np.isnan(out))
|
||||
|
||||
|
||||
def test_ac5b_zero_row_in_batch_remains_zero() -> None:
|
||||
batch = np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]], dtype=np.float32)
|
||||
out = DescriptorNormaliser.l2_normalise_batch(batch)
|
||||
np.testing.assert_array_equal(out[0], np.zeros(3, dtype=np.float32))
|
||||
np.testing.assert_allclose(out[1], np.array([1.0, 0.0, 0.0], dtype=np.float32))
|
||||
|
||||
|
||||
def test_ac6_idempotence_fp32() -> None:
|
||||
rng = np.random.default_rng(2026)
|
||||
x = rng.standard_normal(64).astype(np.float32)
|
||||
once = DescriptorNormaliser.l2_normalise(x)
|
||||
twice = DescriptorNormaliser.l2_normalise(once)
|
||||
assert once.tobytes() == twice.tobytes()
|
||||
|
||||
|
||||
def test_ac7_idempotence_fp16_within_half_precision_tol() -> None:
|
||||
rng = np.random.default_rng(2026)
|
||||
x = rng.standard_normal(64).astype(np.float16)
|
||||
once = DescriptorNormaliser.l2_normalise(x)
|
||||
twice = DescriptorNormaliser.l2_normalise(once)
|
||||
np.testing.assert_allclose(twice.astype(np.float32), once.astype(np.float32), atol=1e-3)
|
||||
|
||||
|
||||
def test_ac8_no_in_place_mutation() -> None:
|
||||
x = np.array([3.0, 4.0, 0.0], dtype=np.float32)
|
||||
snapshot = x.copy()
|
||||
_ = DescriptorNormaliser.l2_normalise(x)
|
||||
np.testing.assert_array_equal(x, snapshot)
|
||||
|
||||
|
||||
def test_ac9_metric_is_inner_product_exact_string() -> None:
|
||||
assert DescriptorNormaliser.descriptor_metric() == "inner_product"
|
||||
|
||||
|
||||
def test_ac10_float64_dtype_rejected() -> None:
|
||||
with pytest.raises(DescriptorNormaliserError, match=r"float16.*float32|float32.*float16"):
|
||||
DescriptorNormaliser.l2_normalise(np.array([1.0, 2.0], dtype=np.float64))
|
||||
|
||||
|
||||
def test_ac11_shape_contract_single_rejects_2d() -> None:
|
||||
with pytest.raises(DescriptorNormaliserError, match=r"1-D"):
|
||||
DescriptorNormaliser.l2_normalise(np.zeros((2, 3), dtype=np.float32))
|
||||
|
||||
|
||||
def test_ac11_shape_contract_batch_rejects_1d() -> None:
|
||||
with pytest.raises(DescriptorNormaliserError, match=r"2-D"):
|
||||
DescriptorNormaliser.l2_normalise_batch(np.zeros(128, dtype=np.float32))
|
||||
|
||||
|
||||
def test_ac12_no_upward_imports_to_components() -> None:
|
||||
module_path = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "src"
|
||||
/ "gps_denied_onboard"
|
||||
/ "helpers"
|
||||
/ "descriptor_normaliser.py"
|
||||
)
|
||||
tree = ast.parse(module_path.read_text(encoding="utf-8"))
|
||||
bad: list[str] = []
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ImportFrom) and node.module:
|
||||
if node.module.startswith("gps_denied_onboard.components"):
|
||||
bad.append(node.module)
|
||||
elif isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
if alias.name.startswith("gps_denied_onboard.components"):
|
||||
bad.append(alias.name)
|
||||
assert not bad, f"descriptor_normaliser must not import components.*; found: {bad}"
|
||||
@@ -1,7 +1,13 @@
|
||||
"""Runtime-root env-var fail-fast — AZ-263 AC-8."""
|
||||
"""Runtime-root env-var fail-fast — AZ-263 AC-8 (updated by AZ-270 to pass a Config).
|
||||
|
||||
AZ-270 swapped ``compose_root()`` to ``compose_root(config: Config)``; the
|
||||
env-var fail-fast still happens inside ``compose_root`` before any factory
|
||||
construction, so this AC-8 contract is intact.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard.config import Config
|
||||
from gps_denied_onboard.runtime_root import ConfigurationError, compose_root
|
||||
|
||||
|
||||
@@ -23,7 +29,7 @@ def test_compose_root_fails_fast_on_missing_required(monkeypatch: pytest.MonkeyP
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(ConfigurationError) as excinfo:
|
||||
compose_root()
|
||||
compose_root(Config())
|
||||
assert "Missing required environment variable" in str(excinfo.value)
|
||||
|
||||
|
||||
@@ -45,7 +51,7 @@ def test_compose_root_names_the_first_missing_var(monkeypatch: pytest.MonkeyPatc
|
||||
|
||||
# Act
|
||||
with pytest.raises(ConfigurationError) as excinfo:
|
||||
compose_root()
|
||||
compose_root(Config())
|
||||
|
||||
# Assert
|
||||
msg = str(excinfo.value)
|
||||
|
||||
Reference in New Issue
Block a user