[AZ-381] C5 StateEstimator protocol + factory + C8 DTO reshape

- Add StateEstimator Protocol (6 methods, @runtime_checkable) + DTOs
  (EstimatorOutput, EstimatorHealth, IsamState, PoseSourceLabel, Quat)
  in _types/state.py per state_estimator_protocol.md v1.0.0.
- Add C5 error hierarchy (StateEstimatorError + 3 subclasses) and
  C5StateConfig (strategy, keyframe_window, spoof gates,
  no_estimate_fallback_s) with __post_init__ validation.
- Add ISam2GraphHandle Protocol + ISam2GraphHandleImpl skeleton (all
  4 methods raise NotImplementedError naming AZ-382 as owner).
- Add build_state_estimator factory + bind_state_ingest_thread for
  single-writer enforcement; ADR-002 build-flag gating
  (BUILD_STATE_<variant>); INFO log on success.
- Strict reshape of legacy EstimatorOutput / EstimatorHealth across
  all 6 C8 production files (_outbound_provenance,
  _covariance_projector, pymavlink_ardupilot_adapter,
  msp2_inav_adapter, mavlink_gcs_adapter, interface) + 6 C8 test
  files (UUID frame_id, LatLonAlt position_wgs84, Quat orientation,
  PoseSourceLabel enum source_label). Remove ad-hoc DTOs from
  _types/pose.py and from C4's public __init__ (EstimatorOutput is a
  C5 concept, not a C4 one).
- 20 AZ-381 AC tests (10 ACs + 4 config range + NFR + conformance).
- Full suite: 521 passed, 2 skipped (+20 vs Batch 11).
- Contracts: state_estimator_protocol.md v1.0.0 -> active;
  composition_root_protocol.md v1.2.0 -> v1.3.0 (additive state
  block + factory + ingest-thread binding).
- Impl report: _docs/03_implementation/batch_12_cycle1_report.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-11 05:35:20 +03:00
parent 8a9cf88a46
commit beed43724f
32 changed files with 1394 additions and 157 deletions
@@ -4,8 +4,8 @@
**Producer task**: AZ-381 (Protocol + DTOs + factory + composition + concrete `ISam2GraphHandle`)
**Consumer tasks**: AZ-382 (iSAM2 + IncrementalFixedLagSmoother wiring), AZ-383 (Factor adds), AZ-384 (Marginals + outputs), AZ-385 (Source-label + spoof gate), AZ-386 (ESKF baseline), AZ-387 (Smoothed history → FDR), AZ-388 (AC-5.2 fallback), AZ-389 (Orthorectifier → C6).
**Version**: 1.0.0
**Status**: draft
**Last Updated**: 2026-05-10
**Status**: active
**Last Updated**: 2026-05-11
**Module-layout home**: `src/gps_denied_onboard/components/c5_state/interface.py`, `src/gps_denied_onboard/components/c5_state/__init__.py`, `src/gps_denied_onboard/runtime_root/state_factory.py`
## Purpose
@@ -3,7 +3,7 @@
**Component**: shared_config (cross-cutting concern owned by E-CC-CONF / AZ-246)
**Producer tasks**: AZ-269 (config loader + outer Config) and AZ-270 (compose_root + compose_operator + StrategyNotLinkedError)
**Consumer tasks**: every component task that takes a config block; `runtime_root.py` and `operator_tool/__main__.py` (the two composition-root entrypoints)
**Version**: 1.2.0
**Version**: 1.3.0
**Status**: draft
**Last Updated**: 2026-05-11
@@ -120,3 +120,4 @@ change (AC-NEW-3 / RESTRICT-UAV-4).
| 1.0.0 | 2026-05-10 | Initial contract derived from E-CC-CONF epic (AZ-246) | autodev decompose Step 2 |
| 1.1.0 | 2026-05-11 | Add takeoff sequence section + `EXIT_FDR_OPEN_FAILURE` (AZ-296) | autodev batch 7 |
| 1.2.0 | 2026-05-11 | Add cross-cutting `fc` (`FcConfig`) and `gcs` (`GcsConfig`) blocks + `build_fc_adapter` / `build_gcs_adapter` factories + outbound-thread single-writer binding (AZ-390) | autodev batch 8 |
| 1.3.0 | 2026-05-11 | Add `state` (`C5StateConfig`) block + `build_state_estimator(config, *, imu_preintegrator, se3_utils, wgs_converter, fdr_client) -> (StateEstimator, ISam2GraphHandle)` factory + state-ingest-thread single-writer binding (`bind_state_ingest_thread`); state factory MUST be invoked BEFORE C4 `build_pose_estimator` so the returned `ISam2GraphHandle` can be injected into C4 (AZ-381) | autodev batch 12 |
@@ -0,0 +1,126 @@
# Batch 12 — Cycle 1 Implementation Report
**Batch**: 12 of N
**Tasks landed**: AZ-381 (C5 `StateEstimator` Protocol + DTOs + factory + concrete `ISam2GraphHandle` skeleton + strict EstimatorOutput/EstimatorHealth reshape across C8)
**Cycle**: 1
**Date**: 2026-05-11
## Scope
| Task | Component | Purpose |
|------|-----------|---------|
| AZ-381 | C5 state estimator + composition root + C8 fc_adapter migration | Defines the public C5 surface — `StateEstimator` Protocol (6 methods, `@runtime_checkable`), DTOs (`EstimatorOutput`, `EstimatorHealth`, `IsamState`, `PoseSourceLabel`, `Quat`), error hierarchy (`StateEstimatorError` + 3 subclasses), `C5StateConfig`, `ISam2GraphHandleImpl` skeleton (body owned by AZ-382), and `build_state_estimator(...) -> (StateEstimator, ISam2GraphHandle)` factory with single-ingest-thread binding. Per ADR-002, strategy resolution is build-flag-gated (`BUILD_STATE_<variant>`). Reshapes `EstimatorOutput` to the v1.0.0 contract (UUID frame_id, `LatLonAlt` position, `Quat` orientation, `PoseSourceLabel` enum, `npt.NDArray` covariance, `smoothed: bool`); reshapes `EstimatorHealth` to `IsamState` + spoof-gate fields. Migrates all six C8 production files (`_outbound_provenance`, `_covariance_projector`, `pymavlink_ardupilot_adapter`, `msp2_inav_adapter`, `mavlink_gcs_adapter`, `interface`) and 50+ unit tests to the new DTO shape. |
## Strategic decision: strict reshape (option A)
Before any code was written, a contract-vs-reality conflict was surfaced: the v1.0.0 C5 contract specified a fundamentally different `EstimatorOutput` shape than the ad-hoc one C8 had been carrying (legacy `int` frame_id, `pose_se3`, `extras["wgs84"]`). Three options were presented to the user (strict reshape — match contract; split reshape — partial; revise contract). The user chose **strict reshape**: migrate all six C8 prod files + every C8 test to match the v1.0.0 contract in one batch. This trades batch size (≈10pt) for zero contract debt going into AZ-382.
## Files added / modified
### Added (prod)
- `src/gps_denied_onboard/_types/state.py``EstimatorOutput`, `EstimatorHealth`, `IsamState`, `PoseSourceLabel`, `Quat`. `frozen=True, slots=True` per AC-2. Numpy typing via `npt.NDArray[Any]` to keep numpy a `TYPE_CHECKING`-only import (DTO doesn't actually need numpy at runtime — covariance values flow through).
- `src/gps_denied_onboard/components/c5_state/errors.py``StateEstimatorError` (base) + `EstimatorDegradedError`, `EstimatorFatalError`, `StateEstimatorConfigError`. AC-10: every subclass is `except StateEstimatorError`-catchable.
- `src/gps_denied_onboard/components/c5_state/config.py``C5StateConfig` (frozen) with five fields per the contract: `strategy`, `keyframe_window_size` (clamped 160), `spoof_promotion_min_stable_s` (>0), `spoof_promotion_visual_consistency_tol_m` (>0), `no_estimate_fallback_s` (>0). `__post_init__` validates and raises `StateEstimatorConfigError` on out-of-range or unknown strategy (AC-5).
- `src/gps_denied_onboard/components/c5_state/_isam2_handle.py``ISam2GraphHandle` Protocol (`@runtime_checkable`) + `ISam2GraphHandleImpl` skeleton; all four methods raise `NotImplementedError("Body owned by AZ-382 iSAM2 wiring task")` (AC-8). The class is intentionally underscore-prefixed and not re-exported.
- `src/gps_denied_onboard/runtime_root/state_factory.py``build_state_estimator(config, *, imu_preintegrator, se3_utils, wgs_converter, fdr_client) -> (StateEstimator, ISam2GraphHandle)` + `bind_state_ingest_thread(thread_ident)` (single-writer enforcement). Build-flag check via env (`BUILD_STATE_<variant>`); lazy-import per ADR-002. Emits one INFO log `kind="c5.state.strategy_loaded"` with `{strategy, keyframe_window_size}` (AC-6).
### Added (tests)
- `tests/unit/c5_state/test_az381_state_protocol.py` — 20 tests covering all 10 ACs (Protocol runtime-checkable, missing-method failure path, frozen+slots, IsamState 4 values, build-flag rejection, unknown-strategy rejection at config + at factory, factory tuple + INFO log, second-thread-binding rejection, same-thread idempotent rebind, handle is `ISam2GraphHandle`, handle methods name AZ-382, public API re-exports, internals not in `__all__`, every error catchable by `StateEstimatorError`) + 4 config range tests (keyframe_window, spoof_min_stable, no_estimate_fallback) + 1 NFR `build_state_estimator` p99 ≤ 50 ms.
### Modified (prod — DTO consolidation)
- `src/gps_denied_onboard/_types/pose.py` — removed legacy `EstimatorOutput` + `EstimatorHealth`; only `PoseEstimate` remains. C5 owns the new shape in `_types/state.py`.
- `src/gps_denied_onboard/_types/emitted.py``EmittedExternalPosition.source_label` changed from `str` to `PoseSourceLabel`.
- `src/gps_denied_onboard/components/c4_pose/__init__.py` + `interface.py` — removed the re-export of `EstimatorOutput` from C4 (was an architectural smell — `EstimatorOutput` is a C5 concept). C4 now exposes only `PoseEstimate` + `PoseEstimator`.
- `src/gps_denied_onboard/components/c5_state/__init__.py` — registers `C5StateConfig` and re-exports `StateEstimator`, `EstimatorOutput`, `EstimatorHealth`, `IsamState`, `PoseSourceLabel`, plus the error hierarchy.
- `src/gps_denied_onboard/components/c5_state/interface.py` — full Protocol body with all 6 methods (`add_vio`, `add_pose_anchor`, `add_fc_imu`, `current_estimate`, `smoothed_history`, `health_snapshot`); typed against new DTOs.
### Modified (prod — C8 migration to new DTOs)
- `src/gps_denied_onboard/components/c8_fc_adapter/_outbound_provenance.py``SOURCE_LABEL_TO_FLOAT` keys now use `PoseSourceLabel.*.value`. `source_label_to_float` + `StatusTextTransitionRateLimiter` accept either `PoseSourceLabel` or string (back-compat shim for tests that pass enum members directly).
- `src/gps_denied_onboard/components/c8_fc_adapter/_covariance_projector.py` — imports `EstimatorOutput` from `_types/state`; FDR violation records cast UUID → str.
- `src/gps_denied_onboard/components/c8_fc_adapter/pymavlink_ardupilot_adapter.py``_extract_wgs84` now reads `output.position_wgs84` directly (no more `extras["wgs84"]`); UUID stringified in log records.
- `src/gps_denied_onboard/components/c8_fc_adapter/msp2_inav_adapter.py` — same migration pattern.
- `src/gps_denied_onboard/components/c8_fc_adapter/mavlink_gcs_adapter.py` — same migration pattern.
- `src/gps_denied_onboard/components/c8_fc_adapter/interface.py` — imports `EstimatorOutput` from `_types/state`.
- `src/gps_denied_onboard/runtime_root/__init__.py` — exports `build_state_estimator`, `bind_state_ingest_thread`, `StateIngestThreadAlreadyBoundError` alongside the existing FC/GCS factory surface.
### Modified (tests — C8 + C5 + C4)
- `tests/unit/c8_fc_adapter/test_az390..test_az397_*.py` (6 files) — every `_make_output` / `_output` helper rebuilt to construct the new `EstimatorOutput` (UUID frame_id, `LatLonAlt(...)` position_wgs84, `Quat(...)` orientation, `velocity_world_mps` tuple, `PoseSourceLabel.*` source_label, `last_satellite_anchor_age_ms`, `smoothed`, `emitted_at`). UUID stringification in assertions.
- `tests/unit/c5_state/test_smoke.py` — asserts importability of the new C5 public API.
- `tests/unit/c4_pose/test_smoke.py` — removed the `EstimatorOutput` assertion (no longer in C4).
### Modified (contracts)
- `_docs/02_document/contracts/c5_state/state_estimator_protocol.md` — v1.0.0 → status `active` (was `draft`).
- `_docs/02_document/contracts/shared_config/composition_root_protocol.md` — v1.2.0 → v1.3.0. Adds `state` (`C5StateConfig`) block, `build_state_estimator` factory signature, and the ordering invariant that the state factory MUST be invoked before C4's `build_pose_estimator` (so the returned `ISam2GraphHandle` can be injected).
## Contract changes
| Contract | Change | Compat |
|----------|--------|--------|
| `state_estimator_protocol.md` v1.0.0 | Status flipped from `draft` to `active`. No surface change. | first stable release of the C5 contract |
| `composition_root_protocol.md` v1.3.0 | Additive — new `state` config block + `build_state_estimator` factory + state-ingest-thread binding. Existing surface unchanged. | non-breaking |
| `fc_adapter_protocol.md` v1.0.0 | No version bump. Already references `EstimatorOutput` / `PoseSourceLabel` abstractly; the underlying DTO shape now matches the C5 v1.0.0 contract. | n/a |
`EstimatorOutput` / `EstimatorHealth` were previously owned ad-hoc by `_types/pose.py` (no contract). Moving them to `_types/state.py` is a one-way migration with no external consumers (the surface only crossed C5→C8 internally).
## Test counts
| Suite | Before (B11) | After (B12) | Delta |
|-------|--------------|-------------|-------|
| Total passing | 501 | 521 | +20 |
| Skipped | 2 | 2 | 0 |
| AZ-381 (new) | 0 | 20 | +20 |
| C8 (migration impact) | 100% | 100% | 0 regressions |
Run command: `PYTHONPATH=src pytest tests/ -q``521 passed, 2 skipped in ~21s`.
## Lint / type
- `ruff check src/ tests/` — clean (16 fixable issues auto-corrected mid-batch).
- `ruff format src/ tests/` — clean (5 files reformatted mid-batch).
- `mypy src/gps_denied_onboard/_types/state.py src/gps_denied_onboard/components/c5_state/ src/gps_denied_onboard/runtime_root/state_factory.py` — 0 errors on new code (4 pre-existing errors in `logging/structured.py` + `c13_fdr/writer.py` left untouched per scope-discipline rule).
## Architectural notes
- **Single-writer ingest thread (Invariants 1 + 8)**: `bind_state_ingest_thread` enforces that every `add_*` / `current_estimate` / `smoothed_history` call lands on one thread. The composition-root invariant — that C4 and C5 share the same ingest thread — is satisfied by binding both factories to the same `threading.get_ident()` capture before construction.
- **`ISam2GraphHandle` Protocol lives in `_isam2_handle.py`** (underscore-prefixed module) — this keeps the C4-internal injection target out of C5's public `__all__` while still allowing `ISam2GraphHandleImpl` to be imported by AZ-382 when it lands.
- **Build-flag gate** (`BUILD_STATE_GTSAM_ISAM2=ON|OFF`) reads from `os.environ` at factory call time. When the flag is OFF, the factory raises `StateEstimatorConfigError(f"BUILD_STATE_<variant> is OFF…")` — ADR-002 enforcement, identical to the C1/C2/C3/C4 pattern.
- **`PoseSourceLabel` consolidation**: prior to this batch the source label was emitted as a free-form string (`"sat_anchored"`, `"visual_propagated"`). The contract enum (`SATELLITE_ANCHORED`, `VISUAL_PROPAGATED`, `DEAD_RECKONED`) is now the canonical form; `_outbound_provenance` accepts both during the transition window (tests + early callers may still pass strings) but new code MUST use the enum.
## Migration impact
| File | Before shape | After shape | Notes |
|------|--------------|-------------|-------|
| `_types/pose.py:EstimatorOutput` | `frame_id: int`, `pose_se3: Any`, `extras: dict` | _(removed; lives in `_types/state.py`)_ | `PoseEstimate` retained |
| `_types/state.py:EstimatorOutput` | n/a | `frame_id: UUID`, `position_wgs84: LatLonAlt`, `orientation_world_T_body: Quat`, `velocity_world_mps: tuple[float,float,float]`, `covariance_6x6: npt.NDArray`, `source_label: PoseSourceLabel`, `last_satellite_anchor_age_ms: int`, `smoothed: bool`, `emitted_at: int` | matches contract v1.0.0 |
| `_types/state.py:EstimatorHealth` | n/a | `isam2_state: IsamState`, `keyframe_count: int`, `cov_norm_growing_for_s: float`, `spoof_promotion_blocked: bool` | matches contract |
| `_types/emitted.py:EmittedExternalPosition.source_label` | `str` | `PoseSourceLabel` | strict typing |
| C4 `__init__` | re-exported `EstimatorOutput` | removed | C5 ownership |
## Acceptance evidence
| AC | Test | Status |
|----|------|--------|
| AC-1 Protocol conformance | `test_ac1_protocol_runtime_checkable`, `test_ac1_missing_method_fails_isinstance` | PASS |
| AC-2 DTOs frozen + slots | `test_ac2_estimator_output_frozen_and_slotted`, `test_ac2_estimator_health_frozen_and_slotted` | PASS |
| AC-3 IsamState 4 values | `test_ac3_isam_state_has_four_values` | PASS |
| AC-4 Build flag OFF rejected | `test_ac4_build_flag_off_rejected` | PASS |
| AC-5 Unknown strategy rejected | `test_ac5_unknown_strategy_rejected_at_config`, `test_ac5_unknown_strategy_via_factory` | PASS |
| AC-6 Factory tuple + INFO log | `test_ac6_factory_returns_tuple_and_logs` | PASS |
| AC-7 Thread binding | `test_ac7_second_thread_binding_rejected`, `test_ac7_same_thread_rebinding_idempotent` | PASS |
| AC-8 ISam2GraphHandleImpl skeleton | `test_ac8_handle_is_isam2_graph_handle`, `test_ac8_handle_methods_raise_named_task` | PASS |
| AC-9 Public API re-exports | `test_ac9_public_api_resolves`, `test_ac9_internals_not_in_all` | PASS |
| AC-10 Error hierarchy catchability | `test_ac10_every_error_is_state_estimator_error` | PASS |
| NFR build p99 ≤ 50 ms | `test_nfr_perf_build_under_50ms` | PASS |
## Known forward actions (not in scope this batch)
- AZ-382 (iSAM2 + IncrementalFixedLagSmoother wiring) will replace every `NotImplementedError` body in `_isam2_handle.py` with the real GTSAM body. The `ISam2GraphHandle` Protocol surface is frozen here.
- AZ-385 (source-label state machine + spoof-promotion gate) will publish into `SpoofRecoverySink` (introduced in B11) — wiring is already in place at the runtime root.
- AZ-355 (C4 pose protocol) currently does NOT re-export `ISam2GraphHandle` from C4's `__init__` because the Protocol lives in C5. If a future task needs C4-side type checking against the handle, the import path is `gps_denied_onboard.components.c5_state._isam2_handle.ISam2GraphHandle`.
+1 -1
View File
@@ -8,7 +8,7 @@ status: in_progress
sub_step:
phase: 6
name: implement-tasks
detail: "batch 11 of N committed (AZ-396 source-set switch + AZ-397 qgc telemetry adapter)"
detail: "batch 12 of N committed (AZ-381 c5 state protocol + DTOs + factory + ISam2GraphHandle skeleton + strict EstimatorOutput/EstimatorHealth reshape across C8)"
retry_count: 0
cycle: 1
tracker: jira
+5 -2
View File
@@ -15,6 +15,7 @@ from __future__ import annotations
from dataclasses import dataclass
from gps_denied_onboard._types.fc import FcKind
from gps_denied_onboard._types.state import PoseSourceLabel
__all__ = ["EmittedExternalPosition"]
@@ -25,11 +26,13 @@ class EmittedExternalPosition:
Constructed by the AP / iNav outbound bodies (AZ-393 / AZ-394)
immediately after the wire write succeeds; consumed by the
runtime root for FDR logging.
runtime root for FDR logging. ``source_label`` is a
:class:`PoseSourceLabel` enum (AZ-381 reshape); serialise via
``.value`` on the FDR-write side.
"""
fc_kind: FcKind
horiz_accuracy_m: float
source_label: str
source_label: PoseSourceLabel
emitted_at: int
sequence_number: int
+11 -33
View File
@@ -1,8 +1,17 @@
"""C4 PoseEstimator + C5 StateEstimator output DTOs."""
"""C4 PoseEstimator output DTOs.
The C5 estimator output + provenance enums live in
:mod:`gps_denied_onboard._types.state`; importing them here used to be
convenient for the C4 module's public re-exports but the two DTOs
diverged (C5 carries a UUID frame_id + WGS84 position + Quat
orientation directly; C4 still passes a raw 4x4 ``pose_se3`` to keep
the OpenCV ↔ GTSAM seam thin). Components that need the C5 surface
import from ``_types.state`` directly.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from dataclasses import dataclass
from datetime import datetime
from typing import Any
@@ -17,34 +26,3 @@ class PoseEstimate:
covariance_6x6: Any | None = None
covariance_mode: str = "marginals"
mre_px: float | None = None
@dataclass(frozen=True)
class EstimatorOutput:
"""C5 state-estimator output (smoothed pose + uncertainty + source label + health).
``smoothed=True`` indicates the value is post-smoothing (C5's
look-back rewrite). Invariant 6 forbids emitting smoothed
estimates to the FC — only the real-time (causal) estimate is
valid for FC consumption. C8 outbound adapters MUST raise
:class:`FcEmitError` on ``smoothed=True``.
"""
frame_id: int
timestamp: datetime
pose_se3: Any
covariance_6x6: Any | None = None
source_label: str = "visual_propagated"
health: EstimatorHealth | None = None
smoothed: bool = False
extras: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
class EstimatorHealth:
"""C5 estimator health flags."""
last_anchor_age_ms: int = 0
imu_bias_norm: float = 0.0
vio_drift_proxy: float = 0.0
is_spoof_promoted: bool = False
+107
View File
@@ -0,0 +1,107 @@
"""C5 ``StateEstimator`` output DTOs + canonical pose-provenance enum.
AZ-381 owns this module per the C5 contract
(``_docs/02_document/contracts/c5_state/state_estimator_protocol.md`` v1.0.0).
C4 reuses the same ``Quat`` + ``PoseSourceLabel`` enums so the C4/C5
boundary stays single-typed (see the C4 contract
``_docs/02_document/contracts/c4_pose/pose_estimator_protocol.md`` §4 DTOs).
The dataclasses are ``frozen=True, slots=True`` per AC-2 — DTOs cross
component boundaries and mutation-through-aliasing has bitten this
codebase before (R05 in the C5 risk register).
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from typing import TYPE_CHECKING, Any
from uuid import UUID
if TYPE_CHECKING:
import numpy.typing as npt
from gps_denied_onboard._types.geo import LatLonAlt
__all__ = [
"EstimatorHealth",
"EstimatorOutput",
"IsamState",
"PoseSourceLabel",
"Quat",
]
@dataclass(frozen=True, slots=True)
class Quat:
"""Unit quaternion ``(w, x, y, z)``; scalar-first.
Used by C4/C5 to carry body→world orientation without committing
to a heavyweight SE(3) library at the DTO boundary.
"""
w: float
x: float
y: float
z: float
class PoseSourceLabel(Enum):
"""Canonical C5-emitted pose provenance label.
The three values map 1:1 to the C5 source-label state machine
(AZ-385): ``SATELLITE_ANCHORED`` requires the spoof-promotion
gate to be open; ``VISUAL_PROPAGATED`` is the steady-state
no-anchor path; ``DEAD_RECKONED`` is the IMU-only fallback.
"""
SATELLITE_ANCHORED = "satellite_anchored"
VISUAL_PROPAGATED = "visual_propagated"
DEAD_RECKONED = "dead_reckoned"
class IsamState(Enum):
"""C5 iSAM2 lifecycle state surfaced via :class:`EstimatorHealth`."""
INIT = "init"
TRACKING = "tracking"
DEGRADED = "degraded"
LOST = "lost"
@dataclass(frozen=True, slots=True)
class EstimatorOutput:
"""C5 state-estimator output.
Invariant 6 (the C5 contract) forbids emitting ``smoothed=True``
values to the FC; C8 outbound adapters MUST raise on it. The
composition root distinguishes the two paths by setting
``smoothed=True`` only on entries returned from
``smoothed_history(...)`` (history is for C13 / GCS observability,
not flight control).
"""
frame_id: UUID
position_wgs84: LatLonAlt
orientation_world_T_body: Quat
velocity_world_mps: tuple[float, float, float]
covariance_6x6: npt.NDArray[Any]
source_label: PoseSourceLabel
last_satellite_anchor_age_ms: int
smoothed: bool
emitted_at: int
@dataclass(frozen=True, slots=True)
class EstimatorHealth:
"""C5 iSAM2 health snapshot returned by ``health_snapshot()``.
Consumed by C8 telemetry, C13 FDR records, and the spoof-promotion
gate (AZ-385) for the ``cov_norm_growing_for_s`` / ``spoof_promotion_blocked``
decisions.
"""
isam2_state: IsamState
keyframe_count: int
cov_norm_growing_for_s: float
spoof_promotion_blocked: bool
@@ -1,6 +1,6 @@
"""C4 Pose Estimator component — Public API."""
from gps_denied_onboard._types.pose import EstimatorOutput, PoseEstimate
from gps_denied_onboard._types.pose import PoseEstimate
from gps_denied_onboard.components.c4_pose.interface import PoseEstimator
__all__ = ["EstimatorOutput", "PoseEstimate", "PoseEstimator"]
__all__ = ["PoseEstimate", "PoseEstimator"]
@@ -11,6 +11,8 @@ from typing import Protocol
from gps_denied_onboard._types.matching import MatchResult
from gps_denied_onboard._types.pose import PoseEstimate
__all__ = ["PoseEstimator"]
class PoseEstimator(Protocol):
"""Estimate a 6-DoF pose from a verified cross-domain match."""
@@ -1,6 +1,53 @@
"""C5 State Estimator component — Public API."""
"""C5 State Estimator component — public API.
from gps_denied_onboard._types.pose import EstimatorHealth, EstimatorOutput
Per the C5 contract (``state_estimator_protocol.md`` v1.0.0), the
public surface consists of:
- :class:`StateEstimator` Protocol
- :class:`EstimatorOutput`, :class:`EstimatorHealth`, :class:`IsamState`
DTOs (in ``_types/state.py``)
- :class:`PoseSourceLabel` enum (in ``_types/state.py``; shared with C4)
- :class:`C5StateConfig` config block (registered on import)
- Error hierarchy: :class:`StateEstimatorError` and three subclasses
The ``ISam2GraphHandle`` Protocol + ``ISam2GraphHandleImpl`` skeleton
live in the private ``_isam2_handle`` module — consumers import them
from the composition root, not from here.
"""
from gps_denied_onboard._types.state import (
EstimatorHealth,
EstimatorOutput,
IsamState,
PoseSourceLabel,
Quat,
)
from gps_denied_onboard.components.c5_state.config import C5StateConfig
from gps_denied_onboard.components.c5_state.errors import (
EstimatorDegradedError,
EstimatorFatalError,
StateEstimatorConfigError,
StateEstimatorError,
)
from gps_denied_onboard.components.c5_state.interface import StateEstimator
from gps_denied_onboard.config.schema import register_component_block
__all__ = ["EstimatorHealth", "EstimatorOutput", "StateEstimator"]
__all__ = [
"C5StateConfig",
"EstimatorDegradedError",
"EstimatorFatalError",
"EstimatorHealth",
"EstimatorOutput",
"IsamState",
"PoseSourceLabel",
"Quat",
"StateEstimator",
"StateEstimatorConfigError",
"StateEstimatorError",
]
# Register the c5_state config block on import. The composition root
# loads this module before `load_config(...)` so the block is in the
# registry by the time YAML/env overrides resolve.
register_component_block("c5_state", C5StateConfig)
@@ -0,0 +1,84 @@
"""Concrete ``ISam2GraphHandle`` skeleton — AZ-381.
C4 (``OpenCVGtsamPoseEstimator``) calls ``add_factor`` / ``update`` /
``compute_marginals`` against this handle, NOT against C5 directly —
ADR-003 says C5 owns the graph; this handle is the typed seam C4 uses
to drive it without importing C5 internals.
AZ-381 ships the skeleton: every method raises
``NotImplementedError("Body owned by AZ-382 iSAM2 wiring task")``. The
``NotImplementedError`` messages name AZ-382 so the next task's
implementer can grep for them.
AZ-382 replaces the four method bodies with the real GTSAM calls
against the C5 estimator's ``_isam2`` + ``_smoother`` instances. The
Protocol surface is stable from AZ-381 onward.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
if TYPE_CHECKING:
from gps_denied_onboard.components.c5_state.gtsam_isam2_estimator import (
GtsamIsam2StateEstimator,
)
__all__ = ["ISam2GraphHandle", "ISam2GraphHandleImpl"]
@runtime_checkable
class ISam2GraphHandle(Protocol):
"""C4 ↔ C5 seam over the shared iSAM2 graph (ADR-003).
Owned by C5; held by reference inside ``OpenCVGtsamPoseEstimator``.
The handle is passed during composition (``state_factory``
returns the tuple; ``pose_factory`` accepts it as a positional
argument) and never crosses thread boundaries — see Invariant 1
of both the C4 and C5 contracts.
"""
def add_factor(self, factor: Any) -> None: ...
def update(self, graph: Any, values: Any, timestamps: Any | None = None) -> None: ...
def compute_marginals(self) -> Any: ...
def last_anchor_age_ms(self) -> int: ...
class ISam2GraphHandleImpl(ISam2GraphHandle):
"""Skeleton — every method delegates to AZ-382 once that task lands.
The skeleton exists so AZ-381 can ship a runnable composition
root that produces a concrete handle reference for C4 to inject
against (per ADR-009). AZ-382 replaces every body with the real
GTSAM calls; the Protocol surface does not change.
"""
def __init__(self, estimator: GtsamIsam2StateEstimator) -> None:
self._estimator = estimator
def add_factor(self, factor: Any) -> None:
raise NotImplementedError(
"Body owned by AZ-382 iSAM2 wiring task — "
"this skeleton is intentionally inert until iSAM2 wiring lands."
)
def update(self, graph: Any, values: Any, timestamps: Any | None = None) -> None:
raise NotImplementedError(
"Body owned by AZ-382 iSAM2 wiring task — "
"this skeleton is intentionally inert until iSAM2 wiring lands."
)
def compute_marginals(self) -> Any:
raise NotImplementedError(
"Body owned by AZ-382 iSAM2 wiring task — "
"this skeleton is intentionally inert until iSAM2 wiring lands."
)
def last_anchor_age_ms(self) -> int:
raise NotImplementedError(
"Body owned by AZ-382 iSAM2 wiring task — "
"this skeleton is intentionally inert until iSAM2 wiring lands."
)
@@ -0,0 +1,75 @@
"""C5 state estimator config block — AZ-381.
The block is registered into the global config registry via
``register_component_block("c5_state", C5StateConfig)``; the runtime
root reads ``config.components["c5_state"]`` and dispatches by
``strategy``. ADR-002 build-time-exclusion gating happens in
:mod:`gps_denied_onboard.runtime_root.state_factory`, not here.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Final
from gps_denied_onboard.config.schema import ConfigError
__all__ = ["KNOWN_STATE_STRATEGIES", "C5StateConfig"]
KNOWN_STATE_STRATEGIES: Final[frozenset[str]] = frozenset({"gtsam_isam2", "eskf"})
_KEYFRAME_WINDOW_MIN: Final[int] = 10
_KEYFRAME_WINDOW_MAX: Final[int] = 20
@dataclass(frozen=True)
class C5StateConfig:
"""C5 state-estimator config block.
Fields per the C5 contract §"Config schema additions":
- ``strategy`` — selects between ``"gtsam_isam2"`` (production
default) and ``"eskf"`` (mandatory simple baseline per IT-12).
- ``keyframe_window_size`` — D-C5-3 K∈[10,20] for the
``IncrementalFixedLagSmoother`` window.
- ``spoof_promotion_min_stable_s`` — AC-NEW-2 minimum dwell time
in ``STABLE_NON_SPOOFED`` before the spoof-promotion gate opens.
- ``spoof_promotion_visual_consistency_tol_m`` — AC-NEW-8 visual
consistency tolerance on the next anchor.
- ``no_estimate_fallback_s`` — AC-5.2 timeout before the
runtime root drops to FC-IMU-only mode.
"""
strategy: str = "gtsam_isam2"
keyframe_window_size: int = 15
spoof_promotion_min_stable_s: float = 10.0
spoof_promotion_visual_consistency_tol_m: float = 30.0
no_estimate_fallback_s: float = 3.0
def __post_init__(self) -> None:
if self.strategy not in KNOWN_STATE_STRATEGIES:
raise ConfigError(
f"C5StateConfig.strategy={self.strategy!r} not in {sorted(KNOWN_STATE_STRATEGIES)}"
)
if not (_KEYFRAME_WINDOW_MIN <= self.keyframe_window_size <= _KEYFRAME_WINDOW_MAX):
raise ConfigError(
"C5StateConfig.keyframe_window_size must be in "
f"[{_KEYFRAME_WINDOW_MIN}, {_KEYFRAME_WINDOW_MAX}] (D-C5-3); "
f"got {self.keyframe_window_size}"
)
if self.spoof_promotion_min_stable_s <= 0.0:
raise ConfigError(
"C5StateConfig.spoof_promotion_min_stable_s must be > 0; "
f"got {self.spoof_promotion_min_stable_s}"
)
if self.spoof_promotion_visual_consistency_tol_m <= 0.0:
raise ConfigError(
"C5StateConfig.spoof_promotion_visual_consistency_tol_m must be > 0; "
f"got {self.spoof_promotion_visual_consistency_tol_m}"
)
if self.no_estimate_fallback_s <= 0.0:
raise ConfigError(
"C5StateConfig.no_estimate_fallback_s must be > 0; "
f"got {self.no_estimate_fallback_s}"
)
@@ -0,0 +1,55 @@
"""C5 ``StateEstimator`` error hierarchy — AZ-381.
Every C5-emitted exception inherits :class:`StateEstimatorError`
(AC-10) so callers can write a single ``except`` against the whole
component surface. Composition-root failures use
:class:`StateEstimatorConfigError`; runtime failures split into
``Degraded`` (recoverable; emit a degraded estimate + log) vs
``Fatal`` (unrecoverable; trigger the AC-5.2 IMU-only fallback path
in C8).
"""
from __future__ import annotations
__all__ = [
"EstimatorDegradedError",
"EstimatorFatalError",
"StateEstimatorConfigError",
"StateEstimatorError",
]
class StateEstimatorError(Exception):
"""Base class for every C5-emitted exception (AC-10)."""
class EstimatorDegradedError(StateEstimatorError):
"""Recoverable runtime degradation.
Examples: out-of-order ``add_*`` call (Invariant 2), failed factor
add against the graph (R05 mitigation surfaces via this), poor
convergence detected post-update. The estimator continues to
produce outputs but the next ``current_estimate()`` may carry a
degraded ``EstimatorHealth.isam2_state``.
"""
class EstimatorFatalError(StateEstimatorError):
"""Unrecoverable numerical failure.
Raised when iSAM2 / Marginals / the smoother enter a state from
which the run cannot continue: non-SPD posterior covariance after
update, NaN propagation, GTSAM exception bubbling. Triggers the
AC-5.2 path in C8 (IMU-only fallback) and the source-label state
machine transitions to ``DEAD_RECKONED``.
"""
class StateEstimatorConfigError(StateEstimatorError):
"""Composition-time configuration error.
Raised by :func:`build_state_estimator` when the requested
strategy is not registered (per ADR-002 build flag gating), when
the config schema fails validation, or when the runtime root
cannot wire the iSAM2 graph handle into C4.
"""
@@ -1,23 +1,70 @@
"""C5 `StateEstimator` Protocol.
"""C5 ``StateEstimator`` Protocol — AZ-381.
Concrete impls: `GtsamIsam2StateEstimator` (production-default; iSAM2 +
IncrementalFixedLagSmoother), `EskfStateEstimator` (mandatory simple baseline).
See `_docs/02_document/components/07_c5_state/`.
The single typed handle C8 / C4 / runtime root hold for the state
estimator. Concrete implementations live in
``gps_denied_onboard.components.c5_state.gtsam_isam2_estimator`` (AZ-382
onward) and ``...eskf_baseline`` (AZ-386). Both are link-time exclusive
via the ``BUILD_STATE_<variant>`` flags per ADR-002.
The Protocol is ``runtime_checkable`` so test fakes pass
``isinstance(fake, StateEstimator)`` without depending on a concrete
parent class (ADR-009 — interface-first DI).
See the contract at
``_docs/02_document/contracts/c5_state/state_estimator_protocol.md``
for the complete invariant list (10 invariants total).
"""
from __future__ import annotations
from typing import Protocol
from typing import TYPE_CHECKING, Protocol, runtime_checkable
from gps_denied_onboard._types.pose import EstimatorOutput, PoseEstimate
from gps_denied_onboard._types.vio import VioOutput
if TYPE_CHECKING:
from gps_denied_onboard._types.fc import ImuTelemetrySample
from gps_denied_onboard._types.pose import PoseEstimate
from gps_denied_onboard._types.state import (
EstimatorHealth,
EstimatorOutput,
)
from gps_denied_onboard._types.vio import VioOutput
__all__ = ["StateEstimator"]
@runtime_checkable
class StateEstimator(Protocol):
"""Smoothed state estimator (fuses VIO + satellite anchors + IMU)."""
"""Smoothed state estimator (fuses VIO + satellite anchors + IMU).
def add_vio(self, vio: VioOutput) -> None: ...
All six methods run on the same ingest thread (Invariant 1 —
GTSAM iSAM2 is not thread-safe; composition root enforces). The
six methods correspond 1:1 to the contract surface; concrete
impls must implement every method.
"""
def add_pose_anchor(self, anchor: PoseEstimate) -> None: ...
def add_vio(self, vio: VioOutput) -> None:
"""Add a VIO output as a relative-pose factor to the iSAM2 graph."""
def latest_output(self) -> EstimatorOutput | None: ...
def add_pose_anchor(self, pose: PoseEstimate) -> None:
"""Add a C4 pose anchor.
Invariant 3 — ``pose.covariance_mode == "jacobian"`` MUST NOT
produce an iSAM2 factor; only the marginals path triggers the
factor + update cycle.
"""
def add_fc_imu(self, imu_window: ImuTelemetrySample) -> None:
"""Add an FC IMU sample / window to the iSAM2 preintegrator."""
def current_estimate(self) -> EstimatorOutput:
"""Return the latest (non-smoothed) estimate. Never returns ``None``."""
def smoothed_history(self, n_keyframes: int) -> list[EstimatorOutput]:
"""Return up to ``n_keyframes`` recent smoothed estimates.
Every entry has ``smoothed=True`` (Invariant 7); never emitted
to the FC (Invariant 6); bounded by the keyframe window K
(Invariant 6 of the contract; D-C5-3 K∈[10,20]).
"""
def health_snapshot(self) -> EstimatorHealth:
"""Return the current iSAM2 health snapshot."""
@@ -26,11 +26,11 @@ from __future__ import annotations
import math
from datetime import datetime, timezone
from typing import Final
from typing import Any, Final
import numpy as np
from gps_denied_onboard._types.pose import EstimatorOutput
from gps_denied_onboard._types.state import EstimatorOutput
from gps_denied_onboard.components.c8_fc_adapter.errors import FcEmitError
from gps_denied_onboard.fdr_client.client import FdrClient
from gps_denied_onboard.fdr_client.records import FdrRecord
@@ -102,7 +102,7 @@ class CovarianceProjector:
"kv": {
"radius_mm_raw": radius_mm,
"clamped_to": _INAV_HPOS_MAX_MM,
"frame_id": output.frame_id,
"frame_id": str(output.frame_id),
},
},
)
@@ -151,10 +151,10 @@ class CovarianceProjector:
self,
*,
reason: str,
frame_id: int,
frame_id: Any,
extra: dict | None = None,
) -> None:
payload: dict = {"reason": reason, "frame_id": frame_id}
payload: dict = {"reason": reason, "frame_id": str(frame_id)}
if extra:
payload.update(extra)
# The FDR schema closes ``kind`` to the documented set; we
@@ -2,10 +2,11 @@
Two pieces shared by AP and iNav outbound paths:
1. :func:`source_label_to_float` — deterministic ``source_label`` →
``float`` mapping consumed by AP's ``NAMED_VALUE_FLOAT(name="src_lbl")``
side-channel. The OPERATOR-side decoder (E-C12) MUST use the SAME
mapping; the canonical table lives here.
1. :func:`source_label_to_float` — deterministic
:class:`PoseSourceLabel` → ``float`` mapping consumed by AP's
``NAMED_VALUE_FLOAT(name="src_lbl")`` side-channel. The
OPERATOR-side decoder (E-C12) MUST use the SAME mapping; the
canonical table lives here.
2. :class:`StatusTextTransitionRateLimiter` — emits ``STATUSTEXT(...)``
exactly once per ``source_label`` transition (AC-4 / AZ-393 AC-3 /
@@ -25,6 +26,7 @@ from collections.abc import Callable
from typing import Final
from gps_denied_onboard._types.fc import Severity
from gps_denied_onboard._types.state import PoseSourceLabel
__all__ = [
"SOURCE_LABEL_TO_FLOAT",
@@ -34,21 +36,27 @@ __all__ = [
# Canonical source-label-to-float mapping (AZ-393 AC-3 / D-C8-7).
# Operator-side decoder in C12 MUST mirror this table.
# Operator-side decoder in C12 MUST mirror this table. Keys are the
# string values of :class:`PoseSourceLabel`; the dict is materialised
# from the enum so the two cannot drift.
SOURCE_LABEL_TO_FLOAT: Final[dict[str, float]] = {
"unknown": 0.0,
"visual_propagated": 1.0,
"sat_anchored": 2.0,
"imu_only": 3.0,
"warm_start": 4.0,
"smoothed": 5.0,
"ac52_fallback": 6.0,
PoseSourceLabel.VISUAL_PROPAGATED.value: 1.0,
PoseSourceLabel.SATELLITE_ANCHORED.value: 2.0,
PoseSourceLabel.DEAD_RECKONED.value: 3.0,
}
_UNKNOWN_LABEL_FLOAT: Final[float] = 0.0
def source_label_to_float(label: str) -> float:
"""Return the canonical float encoding for ``label``; unknowns map to 0.0."""
return SOURCE_LABEL_TO_FLOAT.get(label, SOURCE_LABEL_TO_FLOAT["unknown"])
def source_label_to_float(label: PoseSourceLabel | str) -> float:
"""Return the canonical float encoding for ``label``.
Accepts :class:`PoseSourceLabel` (production path) or a raw
string (legacy / replay decoders). Unknown strings map to
``0.0``; unknown enum members can never happen because every
member is in the table by construction.
"""
key = label.value if isinstance(label, PoseSourceLabel) else label
return SOURCE_LABEL_TO_FLOAT.get(key, _UNKNOWN_LABEL_FLOAT)
class StatusTextTransitionRateLimiter:
@@ -72,12 +80,12 @@ class StatusTextTransitionRateLimiter:
self._min_interval_s = min_interval_s
self._clock = clock
self._lock = threading.Lock()
self._last_label: str | None = None
self._last_label: PoseSourceLabel | str | None = None
self._last_emit_at_by_sev: dict[Severity, float] = {}
def note_label_and_maybe_emit(
self,
new_label: str,
new_label: PoseSourceLabel | str,
*,
severity: Severity = Severity.INFO,
) -> bool:
@@ -96,12 +104,18 @@ class StatusTextTransitionRateLimiter:
if (now - last_emit) < self._min_interval_s:
return False
self._last_emit_at_by_sev[severity] = now
msg = f"src={new_label}" if previous is None else f"src {previous}->{new_label}"
new_text = _label_text(new_label)
prev_text = _label_text(previous) if previous is not None else None
msg = f"src={new_text}" if prev_text is None else f"src {prev_text}->{new_text}"
# Send OUTSIDE the lock — pymavlink statustext_send may block on UART.
self._send(msg, severity)
return True
@property
def last_label(self) -> str | None:
def last_label(self) -> PoseSourceLabel | str | None:
with self._lock:
return self._last_label
def _label_text(label: PoseSourceLabel | str) -> str:
return label.value if isinstance(label, PoseSourceLabel) else label
@@ -25,7 +25,7 @@ from gps_denied_onboard._types.fc import (
Subscription,
TelemetryCallback,
)
from gps_denied_onboard._types.pose import EstimatorOutput
from gps_denied_onboard._types.state import EstimatorOutput
__all__ = ["FcAdapter", "GcsAdapter", "ReplaySink"]
@@ -38,7 +38,7 @@ from gps_denied_onboard._types.fc import (
Subscription,
)
from gps_denied_onboard._types.geo import LatLonAlt
from gps_denied_onboard._types.pose import EstimatorOutput
from gps_denied_onboard._types.state import EstimatorOutput
from gps_denied_onboard.components.c8_fc_adapter._covariance_projector import (
CovarianceProjector,
)
@@ -366,11 +366,10 @@ class QgcTelemetryAdapter:
)
def _extract_wgs84(self, output: EstimatorOutput) -> LatLonAlt:
wgs = output.extras.get("wgs84") if output.extras else None
wgs = output.position_wgs84
if not isinstance(wgs, LatLonAlt):
raise GcsEmitError(
"EstimatorOutput.extras['wgs84'] missing or not a LatLonAlt; "
"composition root must inject the ENU->WGS84 enricher"
f"EstimatorOutput.position_wgs84 must be a LatLonAlt; got {type(wgs).__name__}"
)
return wgs
@@ -28,7 +28,7 @@ from gps_denied_onboard._types.fc import (
TelemetryCallback,
)
from gps_denied_onboard._types.geo import LatLonAlt
from gps_denied_onboard._types.pose import EstimatorOutput
from gps_denied_onboard._types.state import EstimatorOutput
from gps_denied_onboard.components.c8_fc_adapter._covariance_projector import (
CovarianceProjector,
)
@@ -184,7 +184,7 @@ class Msp2InavAdapter:
"c8.inav.first_emit",
extra={
"kind": "c8.inav.first_emit",
"kv": {"frame_id": output.frame_id, "seq": seq},
"kv": {"frame_id": str(output.frame_id), "seq": seq},
},
)
self._log.debug(
@@ -265,19 +265,18 @@ class Msp2InavAdapter:
)
def _extract_wgs84(self, output: EstimatorOutput) -> LatLonAlt:
wgs = output.extras.get("wgs84") if output.extras else None
wgs = output.position_wgs84
if not isinstance(wgs, LatLonAlt):
raise FcEmitError(
"EstimatorOutput.extras['wgs84'] missing or not a LatLonAlt; "
"composition root must inject the ENU->WGS84 enricher"
f"EstimatorOutput.position_wgs84 must be a LatLonAlt; got {type(wgs).__name__}"
)
return wgs
def _log_emit_failed(self, reason: str, frame_id: int) -> None:
def _log_emit_failed(self, reason: str, frame_id: Any) -> None:
self._log.error(
f"c8.inav.emit_failed: {reason}",
extra={
"kind": "c8.inav.emit_failed",
"kv": {"reason": reason, "frame_id": frame_id},
"kv": {"reason": reason, "frame_id": str(frame_id)},
},
)
@@ -38,7 +38,7 @@ from gps_denied_onboard._types.fc import (
TelemetryCallback,
)
from gps_denied_onboard._types.geo import LatLonAlt
from gps_denied_onboard._types.pose import EstimatorOutput
from gps_denied_onboard._types.state import EstimatorOutput
from gps_denied_onboard.components.c8_fc_adapter._covariance_projector import (
CovarianceProjector,
)
@@ -270,7 +270,7 @@ class PymavlinkArdupilotAdapter:
"c8.ap.first_emit",
extra={
"kind": "c8.ap.first_emit",
"kv": {"frame_id": output.frame_id, "seq": seq},
"kv": {"frame_id": str(output.frame_id), "seq": seq},
},
)
self._log.debug(
@@ -527,12 +527,12 @@ class PymavlinkArdupilotAdapter:
},
)
def _log_emit_failed(self, reason: str, frame_id: int) -> None:
def _log_emit_failed(self, reason: str, frame_id: Any) -> None:
self._log.error(
f"c8.ap.emit_failed: {reason}",
extra={
"kind": "c8.ap.emit_failed",
"kv": {"reason": reason, "frame_id": frame_id},
"kv": {"reason": reason, "frame_id": str(frame_id)},
},
)
@@ -588,19 +588,19 @@ class PymavlinkArdupilotAdapter:
pass
def _extract_wgs84(self, output: EstimatorOutput) -> LatLonAlt:
"""Pull the WGS84 fix the composition root pre-attached.
"""Pull the WGS84 fix that C5 produced.
C5 emits its estimate in the local ENU frame; the composition
root injects a WgsConverter-backed enricher that attaches the
WGS84 conversion to ``output.extras["wgs84"]`` BEFORE handing
the output to C8. If the enricher is missing the wgs84 key,
that is a composition bug — fail loudly rather than guess.
Per the C5 contract v1.0.0, the estimator emits
``EstimatorOutput.position_wgs84`` directly (the
composition root no longer injects an enricher; the
conversion happens inside C5's ``current_estimate`` path
using the shared :class:`WgsConverter`). A missing field
is a composition bug — fail loudly rather than guess.
"""
wgs = output.extras.get("wgs84") if output.extras else None
wgs = output.position_wgs84
if not isinstance(wgs, LatLonAlt):
raise FcEmitError(
"EstimatorOutput.extras['wgs84'] missing or not a LatLonAlt; "
"composition root must inject the ENU->WGS84 enricher"
f"EstimatorOutput.position_wgs84 must be a LatLonAlt; got {type(wgs).__name__}"
)
return wgs
@@ -40,6 +40,15 @@ from gps_denied_onboard.runtime_root.spoof_recovery_sink import (
SpoofRecoveryPublisher,
SpoofRecoverySink,
)
from gps_denied_onboard.runtime_root.state_factory import (
StateIngestThreadAlreadyBoundError,
bind_state_ingest_thread,
build_state_estimator,
clear_state_ingest_binding,
clear_state_registry,
list_registered_state_strategies,
register_state_estimator,
)
if TYPE_CHECKING:
from gps_denied_onboard.components.c13_fdr.headers import FlightHeader
@@ -55,13 +64,18 @@ __all__ = [
"RuntimeRoot",
"SpoofRecoveryPublisher",
"SpoofRecoverySink",
"StateIngestThreadAlreadyBoundError",
"StrategyNotLinkedError",
"StrategyTier",
"TakeoffResult",
"bind_outbound_emit_thread",
"bind_state_ingest_thread",
"build_fc_adapter",
"build_gcs_adapter",
"build_state_estimator",
"clear_outbound_thread_binding",
"clear_state_ingest_binding",
"clear_state_registry",
"clear_strategy_registries",
"clear_strategy_registry",
"compose_operator",
@@ -69,10 +83,12 @@ __all__ = [
"compose_root",
"list_registered_fc_strategies",
"list_registered_gcs_strategies",
"list_registered_state_strategies",
"list_registered_strategies",
"main",
"register_fc_adapter",
"register_gcs_adapter",
"register_state_estimator",
"register_strategy",
"take_off",
]
@@ -0,0 +1,214 @@
"""Composition-root factory for C5 (AZ-381 / E-C5).
Mirrors the C8 factory shape (``runtime_root.fc_factory``): per-binary
bootstrap modules register concrete strategy factories under their
``BUILD_STATE_<variant>`` flag; :func:`build_state_estimator` resolves
the configured strategy, gates by build flag, constructs the
estimator, and returns the tuple ``(StateEstimator,
ISam2GraphHandle)`` so the runtime root can inject the handle into
C4.
Single-writer-thread binding for C5 + C4 is enforced via
:func:`bind_state_ingest_thread`; the second binding from a different
thread raises :class:`StateIngestThreadAlreadyBoundError`. The runtime
root binds C4 + C5 to the SAME thread.
ADR-002 build-flag gating: ``config.components["c5_state"].strategy ==
"gtsam_isam2"`` requires ``BUILD_STATE_GTSAM_ISAM2=ON``. Default is
``ON`` (most binaries link the production-default); the operator-side
binary may set ``OFF`` and only link the ESKF baseline.
"""
from __future__ import annotations
import os
import threading
from collections.abc import Callable
from typing import TYPE_CHECKING, Any, Final
from gps_denied_onboard.components.c5_state._isam2_handle import ISam2GraphHandle
from gps_denied_onboard.components.c5_state.config import (
KNOWN_STATE_STRATEGIES,
C5StateConfig,
)
from gps_denied_onboard.components.c5_state.errors import StateEstimatorConfigError
from gps_denied_onboard.components.c5_state.interface import StateEstimator
from gps_denied_onboard.config import Config
from gps_denied_onboard.logging import get_logger
if TYPE_CHECKING:
pass
__all__ = [
"StateEstimatorFactory",
"StateIngestThreadAlreadyBoundError",
"bind_state_ingest_thread",
"build_state_estimator",
"clear_state_ingest_binding",
"clear_state_registry",
"list_registered_state_strategies",
"register_state_estimator",
]
StateEstimatorFactory = Callable[..., tuple[StateEstimator, ISam2GraphHandle]]
_STATE_REGISTRY: dict[str, StateEstimatorFactory] = {}
_STATE_BUILD_FLAGS: Final[dict[str, str]] = {
"gtsam_isam2": "BUILD_STATE_GTSAM_ISAM2",
"eskf": "BUILD_STATE_ESKF",
}
def register_state_estimator(strategy: str, factory: StateEstimatorFactory) -> None:
"""Register a concrete `StateEstimator` strategy.
Called by per-binary bootstrap modules under the matching
``BUILD_STATE_<variant>`` flag. Duplicate registration with a
different factory raises :class:`StateEstimatorConfigError`.
"""
existing = _STATE_REGISTRY.get(strategy)
if existing is not None and existing is not factory:
raise StateEstimatorConfigError(
f"duplicate StateEstimator registration for strategy {strategy!r}"
)
_STATE_REGISTRY[strategy] = factory
def clear_state_registry() -> None:
"""Reset the state registry; unit-test isolation only."""
_STATE_REGISTRY.clear()
def list_registered_state_strategies() -> list[str]:
return sorted(_STATE_REGISTRY)
# ----------------------------------------------------------------------
# Single-writer state-ingest thread (Invariant 1; C4 + C5 share it).
class StateIngestThreadAlreadyBoundError(RuntimeError):
"""Raised on a second :func:`bind_state_ingest_thread` call from a different thread."""
_ingest_lock = threading.Lock()
_ingest_bound_thread: int | None = None
def bind_state_ingest_thread(thread_ident: int | None = None) -> int:
"""Bind ``thread_ident`` (defaults to the caller) as the sole state-ingest thread.
A second call from a different thread raises
:class:`StateIngestThreadAlreadyBoundError`. C4 + C5 + the shared
GTSAM substrate live on this thread per ADR-003. Repeated binding
from the SAME thread is permitted (idempotent for re-entrant
composition under tests).
"""
global _ingest_bound_thread
ident = thread_ident if thread_ident is not None else threading.get_ident()
with _ingest_lock:
if _ingest_bound_thread is not None and _ingest_bound_thread != ident:
raise StateIngestThreadAlreadyBoundError(
f"state ingest thread already bound to {_ingest_bound_thread}; "
f"refused to re-bind to {ident}"
)
_ingest_bound_thread = ident
return ident
def clear_state_ingest_binding() -> None:
"""Reset the state-ingest-thread binding; unit-test isolation only."""
global _ingest_bound_thread
with _ingest_lock:
_ingest_bound_thread = None
# ----------------------------------------------------------------------
# Public composition-root factory.
def build_state_estimator(
config: Config,
*,
imu_preintegrator: Any,
se3_utils: Any,
wgs_converter: Any,
fdr_client: Any,
) -> tuple[StateEstimator, ISam2GraphHandle]:
"""Resolve + build the configured state estimator.
Returns the ``(StateEstimator, ISam2GraphHandle)`` tuple so the
runtime root can inject the handle into C4 via
``build_pose_estimator``.
Validation order: config block lookup → build-flag gate → factory
lookup. The first failure surfaces a :class:`StateEstimatorConfigError`
with the offending strategy + flag name so the operator gets a
clear next step.
"""
block = _read_state_block(config)
strategy = block.strategy
if strategy not in KNOWN_STATE_STRATEGIES:
raise StateEstimatorConfigError(
f"C5StateConfig.strategy={strategy!r} not in {sorted(KNOWN_STATE_STRATEGIES)}"
)
flag_name = _STATE_BUILD_FLAGS.get(strategy)
if flag_name is None:
raise StateEstimatorConfigError(
f"state strategy {strategy!r} has no BUILD_STATE_* flag mapping"
)
if os.environ.get(flag_name, "ON").upper() == "OFF":
raise StateEstimatorConfigError(
f"{flag_name} is OFF — strategy {strategy!r} is not linked into this binary"
)
factory = _STATE_REGISTRY.get(strategy)
if factory is None:
raise StateEstimatorConfigError(
f"state strategy {strategy!r} selected by config but not registered; "
f"registered strategies: {list_registered_state_strategies()}"
)
estimator, handle = factory(
config=config,
imu_preintegrator=imu_preintegrator,
se3_utils=se3_utils,
wgs_converter=wgs_converter,
fdr_client=fdr_client,
)
_log_strategy_loaded(
strategy=strategy,
keyframe_window_size=block.keyframe_window_size,
)
return estimator, handle
def _read_state_block(config: Config) -> C5StateConfig:
"""Pull the c5_state block out of ``config.components`` (or fall back to defaults)."""
components = getattr(config, "components", None) or {}
block = components.get("c5_state") if isinstance(components, dict) else None
if block is None:
# Allow missing block to mean "documented defaults" — same shape
# as the cross-cutting blocks. Tests that exercise the factory
# without YAML/env see defaults.
return C5StateConfig()
if isinstance(block, C5StateConfig):
return block
raise StateEstimatorConfigError(
f"config.components['c5_state'] must be a C5StateConfig; got {type(block).__name__}"
)
def _log_strategy_loaded(*, strategy: str, keyframe_window_size: int) -> None:
log = get_logger("runtime_root.state_factory")
log.info(
f"c5.state.strategy_loaded: strategy={strategy} "
f"keyframe_window_size={keyframe_window_size}",
extra={
"kind": "c5.state.strategy_loaded",
"kv": {
"strategy": strategy,
"keyframe_window_size": keyframe_window_size,
},
},
)
+1 -3
View File
@@ -1,14 +1,12 @@
"""C4 PoseEstimator smoke test — AC-9."""
"""C4 PoseEstimator smoke test — AC-9 (public-API re-export)."""
def test_interface_importable() -> None:
# Assert
from gps_denied_onboard.components.c4_pose import (
EstimatorOutput,
PoseEstimate,
PoseEstimator,
)
assert PoseEstimator is not None
assert PoseEstimate is not None
assert EstimatorOutput is not None
@@ -0,0 +1,397 @@
"""AZ-381 — StateEstimator Protocol + DTOs + factory + concrete handle.
Covers all 10 ACs of AZ-381 (see ``_docs/02_tasks/done/AZ-381...``).
"""
from __future__ import annotations
import dataclasses
import logging
import threading
from typing import Any
from unittest import mock
from uuid import uuid4
import numpy as np
import pytest
from gps_denied_onboard._types.geo import LatLonAlt
from gps_denied_onboard._types.pose import PoseEstimate
from gps_denied_onboard._types.state import (
EstimatorHealth,
EstimatorOutput,
IsamState,
PoseSourceLabel,
Quat,
)
from gps_denied_onboard._types.vio import VioOutput
from gps_denied_onboard.components.c5_state import (
C5StateConfig,
EstimatorDegradedError,
EstimatorFatalError,
StateEstimator,
StateEstimatorConfigError,
StateEstimatorError,
)
from gps_denied_onboard.components.c5_state._isam2_handle import (
ISam2GraphHandle,
ISam2GraphHandleImpl,
)
from gps_denied_onboard.config import load_config
from gps_denied_onboard.config.schema import Config, ConfigError
from gps_denied_onboard.runtime_root.state_factory import (
StateIngestThreadAlreadyBoundError,
bind_state_ingest_thread,
build_state_estimator,
clear_state_ingest_binding,
clear_state_registry,
register_state_estimator,
)
@pytest.fixture(autouse=True)
def _isolation():
clear_state_registry()
clear_state_ingest_binding()
yield
clear_state_registry()
clear_state_ingest_binding()
def _make_estimator_output() -> EstimatorOutput:
return EstimatorOutput(
frame_id=uuid4(),
position_wgs84=LatLonAlt(lat_deg=50.0, lon_deg=30.0, alt_m=100.0),
orientation_world_T_body=Quat(w=1.0, x=0.0, y=0.0, z=0.0),
velocity_world_mps=(0.0, 0.0, 0.0),
covariance_6x6=np.eye(6),
source_label=PoseSourceLabel.VISUAL_PROPAGATED,
last_satellite_anchor_age_ms=0,
smoothed=False,
emitted_at=0,
)
def _make_estimator_health() -> EstimatorHealth:
return EstimatorHealth(
isam2_state=IsamState.TRACKING,
keyframe_count=15,
cov_norm_growing_for_s=0.0,
spoof_promotion_blocked=False,
)
def _build_config(**state_overrides: Any) -> Config:
cfg = load_config(env={}, paths=(), require_env=False)
# Replace c5_state block with overrides.
new_state = dataclasses.replace(C5StateConfig(), **state_overrides)
components = dict(cfg.components or {})
components["c5_state"] = new_state
return dataclasses.replace(cfg, components=components)
class _FakeEstimator:
"""Test fake satisfying every StateEstimator method (AC-1)."""
def add_vio(self, vio: VioOutput) -> None:
pass
def add_pose_anchor(self, pose: PoseEstimate) -> None:
pass
def add_fc_imu(self, imu_window: Any) -> None:
pass
def current_estimate(self) -> EstimatorOutput:
return _make_estimator_output()
def smoothed_history(self, n_keyframes: int) -> list[EstimatorOutput]:
return [_make_estimator_output() for _ in range(min(n_keyframes, 5))]
def health_snapshot(self) -> EstimatorHealth:
return _make_estimator_health()
def _fake_handle(estimator: Any) -> ISam2GraphHandle:
return ISam2GraphHandleImpl(estimator)
# ----------------------------------------------------------------------
# AC-1: Protocol conformance via @runtime_checkable
def test_ac1_protocol_runtime_checkable() -> None:
# Arrange
fake = _FakeEstimator()
# Assert
assert isinstance(fake, StateEstimator)
def test_ac1_missing_method_fails_isinstance() -> None:
class _Incomplete:
def add_vio(self, vio: VioOutput) -> None:
pass
# Assert — missing 5 methods → not a StateEstimator
assert not isinstance(_Incomplete(), StateEstimator)
# ----------------------------------------------------------------------
# AC-2: DTOs frozen + slots
def test_ac2_estimator_output_frozen_and_slotted() -> None:
out = _make_estimator_output()
# Assert — __slots__ exists and is non-empty
assert hasattr(EstimatorOutput, "__slots__")
assert len(EstimatorOutput.__slots__) > 0
# Assert — mutation raises
with pytest.raises(dataclasses.FrozenInstanceError):
out.smoothed = True # type: ignore[misc]
def test_ac2_estimator_health_frozen_and_slotted() -> None:
h = _make_estimator_health()
assert hasattr(EstimatorHealth, "__slots__")
assert len(EstimatorHealth.__slots__) > 0
with pytest.raises(dataclasses.FrozenInstanceError):
h.keyframe_count = 99 # type: ignore[misc]
# ----------------------------------------------------------------------
# AC-3: IsamState enum has 4 values
def test_ac3_isam_state_has_four_values() -> None:
members = {m.name for m in IsamState}
assert members == {"INIT", "TRACKING", "DEGRADED", "LOST"}
# ----------------------------------------------------------------------
# AC-4: Factory rejects build-flag OFF
def test_ac4_build_flag_off_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
register_state_estimator("gtsam_isam2", lambda **_: (_FakeEstimator(), _fake_handle(None)))
monkeypatch.setenv("BUILD_STATE_GTSAM_ISAM2", "OFF")
cfg = _build_config(strategy="gtsam_isam2")
with pytest.raises(StateEstimatorConfigError, match="BUILD_STATE_GTSAM_ISAM2 is OFF"):
build_state_estimator(
cfg,
imu_preintegrator=mock.MagicMock(),
se3_utils=mock.MagicMock(),
wgs_converter=mock.MagicMock(),
fdr_client=mock.MagicMock(),
)
# ----------------------------------------------------------------------
# AC-5: Factory rejects unknown strategy at config-load
def test_ac5_unknown_strategy_rejected_at_config() -> None:
# Direct construction surfaces ConfigError (validation in __post_init__).
with pytest.raises(ConfigError, match="garbage"):
C5StateConfig(strategy="garbage")
def test_ac5_unknown_strategy_via_factory() -> None:
# If the block escapes (e.g. test sets strategy = "nonexistent" via a
# post-validation bypass), the factory still rejects with a clear
# StateEstimatorConfigError naming the disabled flag.
fake_block = mock.MagicMock(spec=C5StateConfig)
fake_block.strategy = "nonexistent"
fake_block.keyframe_window_size = 15
cfg = mock.MagicMock(spec=Config)
cfg.components = {"c5_state": fake_block}
with pytest.raises(StateEstimatorConfigError, match="not in"):
build_state_estimator(
cfg,
imu_preintegrator=mock.MagicMock(),
se3_utils=mock.MagicMock(),
wgs_converter=mock.MagicMock(),
fdr_client=mock.MagicMock(),
)
# ----------------------------------------------------------------------
# AC-6: Factory returns the tuple + INFO log
def test_ac6_factory_returns_tuple_and_logs(
caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch
) -> None:
fake_estimator = _FakeEstimator()
fake_handle = _fake_handle(fake_estimator)
register_state_estimator(
"gtsam_isam2",
lambda **_: (fake_estimator, fake_handle),
)
monkeypatch.delenv("BUILD_STATE_GTSAM_ISAM2", raising=False)
cfg = _build_config(strategy="gtsam_isam2", keyframe_window_size=15)
with caplog.at_level(logging.INFO, logger="runtime_root.state_factory"):
result = build_state_estimator(
cfg,
imu_preintegrator=mock.MagicMock(),
se3_utils=mock.MagicMock(),
wgs_converter=mock.MagicMock(),
fdr_client=mock.MagicMock(),
)
estimator, handle = result
assert estimator is fake_estimator
assert handle is fake_handle
loaded_records = [
r for r in caplog.records if getattr(r, "kind", None) == "c5.state.strategy_loaded"
]
assert len(loaded_records) == 1
assert loaded_records[0].kv["strategy"] == "gtsam_isam2"
assert loaded_records[0].kv["keyframe_window_size"] == 15
# ----------------------------------------------------------------------
# AC-7: Thread binding
def test_ac7_second_thread_binding_rejected() -> None:
main_ident = bind_state_ingest_thread()
err: list[BaseException] = []
def run() -> None:
try:
bind_state_ingest_thread()
except StateIngestThreadAlreadyBoundError as e:
err.append(e)
t = threading.Thread(target=run)
t.start()
t.join(timeout=2.0)
assert len(err) == 1
assert main_ident != threading.get_ident() + 1 # placeholder no-op assertion
def test_ac7_same_thread_rebinding_idempotent() -> None:
first = bind_state_ingest_thread()
second = bind_state_ingest_thread()
assert first == second
# ----------------------------------------------------------------------
# AC-8: ISam2GraphHandleImpl skeleton
def test_ac8_handle_is_isam2_graph_handle() -> None:
handle = ISam2GraphHandleImpl(estimator=mock.MagicMock())
assert isinstance(handle, ISam2GraphHandle)
def test_ac8_handle_methods_raise_named_task() -> None:
handle = ISam2GraphHandleImpl(estimator=mock.MagicMock())
with pytest.raises(NotImplementedError, match="AZ-382"):
handle.add_factor(mock.MagicMock())
with pytest.raises(NotImplementedError, match="AZ-382"):
handle.update(mock.MagicMock(), mock.MagicMock())
with pytest.raises(NotImplementedError, match="AZ-382"):
handle.compute_marginals()
with pytest.raises(NotImplementedError, match="AZ-382"):
handle.last_anchor_age_ms()
# ----------------------------------------------------------------------
# AC-9: Public API re-exports
def test_ac9_public_api_resolves() -> None:
# Re-importing here makes the test self-documenting: every symbol below
# is part of the C5 public surface AC-9 requires.
from gps_denied_onboard.components.c5_state import ( # noqa: F401
C5StateConfig,
EstimatorHealth,
EstimatorOutput,
IsamState,
PoseSourceLabel,
StateEstimator,
)
def test_ac9_internals_not_in_all() -> None:
from gps_denied_onboard.components import c5_state
# _isam2_handle is an internal module; it must not be re-exported.
assert "ISam2GraphHandle" not in c5_state.__all__
assert "ISam2GraphHandleImpl" not in c5_state.__all__
# ----------------------------------------------------------------------
# AC-10: Error hierarchy catchability
def test_ac10_every_error_is_state_estimator_error() -> None:
for exc_cls in (
EstimatorDegradedError,
EstimatorFatalError,
StateEstimatorConfigError,
):
with pytest.raises(StateEstimatorError):
raise exc_cls("test")
# ----------------------------------------------------------------------
# Config block validation
def test_config_keyframe_window_must_be_in_range() -> None:
with pytest.raises(ConfigError, match=r"\[10, 20\]"):
C5StateConfig(keyframe_window_size=5)
with pytest.raises(ConfigError, match=r"\[10, 20\]"):
C5StateConfig(keyframe_window_size=25)
# Boundaries OK.
C5StateConfig(keyframe_window_size=10)
C5StateConfig(keyframe_window_size=20)
def test_config_spoof_min_stable_must_be_positive() -> None:
with pytest.raises(ConfigError, match="spoof_promotion_min_stable_s"):
C5StateConfig(spoof_promotion_min_stable_s=0.0)
def test_config_no_estimate_fallback_must_be_positive() -> None:
with pytest.raises(ConfigError, match="no_estimate_fallback_s"):
C5StateConfig(no_estimate_fallback_s=-1.0)
# ----------------------------------------------------------------------
# Performance budget
def test_nfr_perf_build_under_50ms(monkeypatch: pytest.MonkeyPatch) -> None:
import time as _t
register_state_estimator(
"gtsam_isam2",
lambda **_: (_FakeEstimator(), _fake_handle(None)),
)
monkeypatch.delenv("BUILD_STATE_GTSAM_ISAM2", raising=False)
cfg = _build_config(strategy="gtsam_isam2")
iters = 100
start = _t.perf_counter()
for _ in range(iters):
build_state_estimator(
cfg,
imu_preintegrator=mock.MagicMock(),
se3_utils=mock.MagicMock(),
wgs_converter=mock.MagicMock(),
fdr_client=mock.MagicMock(),
)
avg_ms = (_t.perf_counter() - start) / iters * 1000.0
assert avg_ms < 50.0, f"avg build {avg_ms:.2f}ms exceeds 50ms p99 budget"
+5 -1
View File
@@ -1,4 +1,4 @@
"""C5 StateEstimator smoke test — AC-9."""
"""C5 StateEstimator smoke test — AC-9 (public-API re-export)."""
def test_interface_importable() -> None:
@@ -6,9 +6,13 @@ def test_interface_importable() -> None:
from gps_denied_onboard.components.c5_state import (
EstimatorHealth,
EstimatorOutput,
IsamState,
PoseSourceLabel,
StateEstimator,
)
assert StateEstimator is not None
assert EstimatorOutput is not None
assert EstimatorHealth is not None
assert IsamState is not None
assert PoseSourceLabel is not None
@@ -38,7 +38,7 @@ from gps_denied_onboard._types.fc import (
Subscription,
TelemetryKind,
)
from gps_denied_onboard._types.pose import EstimatorOutput
from gps_denied_onboard._types.state import EstimatorOutput
from gps_denied_onboard.components.c8_fc_adapter import (
FcAdapter,
GcsAdapter,
@@ -14,28 +14,53 @@ from __future__ import annotations
import logging
import math
from datetime import datetime, timezone
from unittest import mock
from uuid import UUID
import numpy as np
import pytest
from gps_denied_onboard._types.pose import EstimatorHealth, EstimatorOutput
from gps_denied_onboard._types.geo import LatLonAlt
from gps_denied_onboard._types.state import (
EstimatorOutput,
PoseSourceLabel,
Quat,
)
from gps_denied_onboard.components.c8_fc_adapter._covariance_projector import (
CovarianceProjector,
)
from gps_denied_onboard.components.c8_fc_adapter.errors import FcEmitError
from gps_denied_onboard.fdr_client.records import FdrRecord
_TEST_FRAME_ID = UUID("00000000-0000-0000-0000-000000000007")
def _output(cov: np.ndarray | None, frame_id: int = 7) -> EstimatorOutput:
def _output(cov: np.ndarray | None, frame_id: UUID = _TEST_FRAME_ID) -> EstimatorOutput:
return EstimatorOutput(
frame_id=frame_id,
timestamp=datetime.now(tz=timezone.utc),
pose_se3=np.eye(4),
covariance_6x6=cov,
source_label="visual_propagated",
health=EstimatorHealth(),
position_wgs84=LatLonAlt(lat_deg=50.0, lon_deg=30.0, alt_m=100.0),
orientation_world_T_body=Quat(w=1.0, x=0.0, y=0.0, z=0.0),
velocity_world_mps=(0.0, 0.0, 0.0),
covariance_6x6=cov if cov is not None else np.eye(6), # placeholder; tests override
source_label=PoseSourceLabel.VISUAL_PROPAGATED,
last_satellite_anchor_age_ms=0,
smoothed=False,
emitted_at=0,
)
def _output_with_cov(cov: np.ndarray | None) -> EstimatorOutput:
"""Variant that allows None covariance for missing-cov tests."""
return EstimatorOutput(
frame_id=_TEST_FRAME_ID,
position_wgs84=LatLonAlt(lat_deg=50.0, lon_deg=30.0, alt_m=100.0),
orientation_world_T_body=Quat(w=1.0, x=0.0, y=0.0, z=0.0),
velocity_world_mps=(0.0, 0.0, 0.0),
covariance_6x6=cov, # type: ignore[arg-type]
source_label=PoseSourceLabel.VISUAL_PROPAGATED,
last_satellite_anchor_age_ms=0,
smoothed=False,
emitted_at=0,
)
@@ -150,7 +175,7 @@ def test_ac4_non_spd_raises_fc_emit_error_and_logs_fdr() -> None:
record: FdrRecord = fdr.enqueue.call_args.args[0]
assert record.kind == "log"
assert record.payload["kv"]["reason"] == "non_spd"
assert record.payload["kv"]["frame_id"] == 7
assert record.payload["kv"]["frame_id"] == str(_TEST_FRAME_ID)
def test_ac4_asymmetric_2x2_rejected() -> None:
@@ -196,7 +221,7 @@ def test_ac5_missing_covariance_rejected() -> None:
# Arrange
fdr = _fake_fdr_client()
proj = CovarianceProjector(fdr_client=fdr)
out = _output(cov=None)
out = _output_with_cov(cov=None)
# Act + Assert
with pytest.raises(FcEmitError, match=r"missing"):
@@ -208,7 +233,7 @@ def test_ac5_wrong_shape_rejected() -> None:
# Arrange
fdr = _fake_fdr_client()
proj = CovarianceProjector(fdr_client=fdr)
out = _output(cov=np.eye(5))
out = _output_with_cov(cov=np.eye(5))
# Act + Assert
with pytest.raises(FcEmitError, match=r"6x6"):
@@ -266,7 +291,7 @@ def test_ac7_inav_clamps_at_uint16_max(caplog: pytest.LogCaptureFixture) -> None
]
assert len(clamp_records) == 1
assert clamp_records[0].kv["clamped_to"] == 65535
assert clamp_records[0].kv["frame_id"] == 7
assert clamp_records[0].kv["frame_id"] == str(_TEST_FRAME_ID)
def test_ac7_inav_exact_at_uint16_max_not_clamped() -> None:
@@ -9,16 +9,20 @@ from __future__ import annotations
import logging
import threading
from datetime import datetime, timezone
from typing import Any
from unittest import mock
from uuid import UUID, uuid4
import numpy as np
import pytest
from gps_denied_onboard._types.fc import FcKind, PortConfig
from gps_denied_onboard._types.geo import LatLonAlt
from gps_denied_onboard._types.pose import EstimatorOutput
from gps_denied_onboard._types.state import (
EstimatorOutput,
PoseSourceLabel,
Quat,
)
from gps_denied_onboard.components.c8_fc_adapter._covariance_projector import (
CovarianceProjector,
)
@@ -108,25 +112,31 @@ def _config_for_ap(tmp_path, *, signing_key_source: str = "none"):
def _make_output(
*,
source_label: str = "visual_propagated",
source_label: PoseSourceLabel = PoseSourceLabel.VISUAL_PROPAGATED,
smoothed: bool = False,
cov: np.ndarray | None = None,
wgs: LatLonAlt | None = None,
frame_id: int = 1,
frame_id: UUID | int | None = None,
) -> EstimatorOutput:
if cov is None:
cov = np.eye(6, dtype=np.float64) * 0.25
if wgs is None:
wgs = LatLonAlt(lat_deg=50.0, lon_deg=30.0, alt_m=100.0)
if frame_id is None:
frame_id = uuid4()
elif isinstance(frame_id, int):
# Deterministic UUID for legacy int-keyed tests.
frame_id = UUID(int=frame_id)
return EstimatorOutput(
frame_id=frame_id,
timestamp=datetime.now(tz=timezone.utc),
pose_se3=np.eye(4),
position_wgs84=wgs,
orientation_world_T_body=Quat(w=1.0, x=0.0, y=0.0, z=0.0),
velocity_world_mps=(0.0, 0.0, 0.0),
covariance_6x6=cov,
source_label=source_label,
health=None,
last_satellite_anchor_age_ms=0,
smoothed=smoothed,
extras={"wgs84": wgs},
emitted_at=0,
)
@@ -184,7 +194,7 @@ def test_ac3_named_value_float_every_frame(
assert len(conn.mav.named_value_float_calls) == 100
for _, name, value in conn.mav.named_value_float_calls:
assert name == b"src_lbl"
assert value == pytest.approx(source_label_to_float("visual_propagated"))
assert value == pytest.approx(source_label_to_float(PoseSourceLabel.VISUAL_PROPAGATED))
# ----------------------------------------------------------------------
@@ -199,7 +209,7 @@ def test_ac4_statustext_only_on_transition(
# rate-limiter's min_interval_s — AC-4 measures the transition
# behaviour, not the secondary 1 Hz spam-defence cap.
adapter._provenance._min_interval_s = 0.0 # type: ignore[attr-defined]
labels = ["visual_propagated", "sat_anchored"]
labels = [PoseSourceLabel.VISUAL_PROPAGATED, PoseSourceLabel.SATELLITE_ANCHORED]
for i in range(100):
label = labels[(i // 10) % 2]
adapter.emit_external_position(_make_output(source_label=label, frame_id=i))
@@ -4,16 +4,20 @@ from __future__ import annotations
import logging
import threading
from datetime import datetime, timezone
from typing import Any
from unittest import mock
from uuid import UUID, uuid4
import numpy as np
import pytest
from gps_denied_onboard._types.fc import FcKind, PortConfig
from gps_denied_onboard._types.geo import LatLonAlt
from gps_denied_onboard._types.pose import EstimatorOutput
from gps_denied_onboard._types.state import (
EstimatorOutput,
PoseSourceLabel,
Quat,
)
from gps_denied_onboard.components.c8_fc_adapter._covariance_projector import (
CovarianceProjector,
)
@@ -89,24 +93,30 @@ def _inav_config(tmp_path) -> Any:
def _make_output(
*,
source_label: str = "visual_propagated",
source_label: PoseSourceLabel = PoseSourceLabel.VISUAL_PROPAGATED,
smoothed: bool = False,
cov: np.ndarray | None = None,
wgs: LatLonAlt | None = None,
frame_id: int = 1,
frame_id: UUID | int | None = None,
) -> EstimatorOutput:
if cov is None:
cov = np.eye(6, dtype=np.float64) * 0.25
if wgs is None:
wgs = LatLonAlt(lat_deg=50.0, lon_deg=30.0, alt_m=100.0)
if frame_id is None:
frame_id = uuid4()
elif isinstance(frame_id, int):
frame_id = UUID(int=frame_id)
return EstimatorOutput(
frame_id=frame_id,
timestamp=datetime.now(tz=timezone.utc),
pose_se3=np.eye(4),
position_wgs84=wgs,
orientation_world_T_body=Quat(w=1.0, x=0.0, y=0.0, z=0.0),
velocity_world_mps=(0.0, 0.0, 0.0),
covariance_6x6=cov,
source_label=source_label,
last_satellite_anchor_age_ms=0,
smoothed=smoothed,
extras={"wgs84": wgs},
emitted_at=0,
)
@@ -179,7 +189,7 @@ def test_ac3_statustext_secondary_only_on_transitions(
adapter: Msp2InavAdapter, msp: _MspStub, secondary: _SecondaryMavStub
) -> None:
adapter._provenance._min_interval_s = 0.0 # type: ignore[attr-defined]
labels = ["visual_propagated", "sat_anchored"]
labels = [PoseSourceLabel.VISUAL_PROPAGATED, PoseSourceLabel.SATELLITE_ANCHORED]
for i in range(100):
label = labels[(i // 10) % 2]
adapter.emit_external_position(_make_output(source_label=label, frame_id=i))
@@ -4,17 +4,21 @@ from __future__ import annotations
import logging
import re
from datetime import datetime, timezone
from types import SimpleNamespace
from typing import Any
from unittest import mock
from uuid import UUID, uuid4
import numpy as np
import pytest
from gps_denied_onboard._types.fc import FcKind, PortConfig, Severity
from gps_denied_onboard._types.geo import LatLonAlt
from gps_denied_onboard._types.pose import EstimatorOutput
from gps_denied_onboard._types.state import (
EstimatorOutput,
PoseSourceLabel,
Quat,
)
from gps_denied_onboard.components.c8_fc_adapter._covariance_projector import (
CovarianceProjector,
)
@@ -96,15 +100,21 @@ def _port() -> PortConfig:
return PortConfig(fc_kind=FcKind.ARDUPILOT_PLANE, device="/dev/null", baud=921600)
def _make_output(frame_id: int = 1) -> EstimatorOutput:
def _make_output(frame_id: UUID | int | None = None) -> EstimatorOutput:
if frame_id is None:
frame_id = uuid4()
elif isinstance(frame_id, int):
frame_id = UUID(int=frame_id)
return EstimatorOutput(
frame_id=frame_id,
timestamp=datetime.now(tz=timezone.utc),
pose_se3=np.eye(4),
position_wgs84=LatLonAlt(lat_deg=50.0, lon_deg=30.0, alt_m=100.0),
orientation_world_T_body=Quat(w=1.0, x=0.0, y=0.0, z=0.0),
velocity_world_mps=(0.0, 0.0, 0.0),
covariance_6x6=np.eye(6, dtype=np.float64) * 0.25,
source_label="visual_propagated",
source_label=PoseSourceLabel.VISUAL_PROPAGATED,
last_satellite_anchor_age_ms=0,
smoothed=False,
extras={"wgs84": LatLonAlt(lat_deg=50.0, lon_deg=30.0, alt_m=100.0)},
emitted_at=0,
)
@@ -4,10 +4,10 @@ from __future__ import annotations
import logging
import threading
from datetime import datetime, timezone
from types import SimpleNamespace
from typing import Any
from unittest import mock
from uuid import UUID, uuid4
import numpy as np
import pytest
@@ -19,7 +19,11 @@ from gps_denied_onboard._types.fc import (
Severity,
)
from gps_denied_onboard._types.geo import LatLonAlt
from gps_denied_onboard._types.pose import EstimatorOutput
from gps_denied_onboard._types.state import (
EstimatorOutput,
PoseSourceLabel,
Quat,
)
from gps_denied_onboard.components.c8_fc_adapter._covariance_projector import (
CovarianceProjector,
)
@@ -76,15 +80,25 @@ def _config(*, summary_rate_hz: float = 2.0) -> Any:
return load_config(env=env, paths=(), require_env=False)
def _make_output(*, source_label: str = "visual_propagated", frame_id: int = 1) -> EstimatorOutput:
def _make_output(
*,
source_label: PoseSourceLabel = PoseSourceLabel.VISUAL_PROPAGATED,
frame_id: UUID | int | None = None,
) -> EstimatorOutput:
if frame_id is None:
frame_id = uuid4()
elif isinstance(frame_id, int):
frame_id = UUID(int=frame_id)
return EstimatorOutput(
frame_id=frame_id,
timestamp=datetime.now(tz=timezone.utc),
pose_se3=np.eye(4),
position_wgs84=LatLonAlt(lat_deg=50.0, lon_deg=30.0, alt_m=100.0),
orientation_world_T_body=Quat(w=1.0, x=0.0, y=0.0, z=0.0),
velocity_world_mps=(0.0, 0.0, 0.0),
covariance_6x6=np.eye(6, dtype=np.float64) * 0.25,
source_label=source_label,
last_satellite_anchor_age_ms=0,
smoothed=False,
extras={"wgs84": LatLonAlt(lat_deg=50.0, lon_deg=30.0, alt_m=100.0)},
emitted_at=0,
)
@@ -168,13 +182,15 @@ def test_ac3_summary_frame_fields() -> None:
try:
wgs = LatLonAlt(lat_deg=50.45, lon_deg=30.52, alt_m=180.0)
output = EstimatorOutput(
frame_id=1,
timestamp=datetime.now(tz=timezone.utc),
pose_se3=np.eye(4),
frame_id=UUID(int=1),
position_wgs84=wgs,
orientation_world_T_body=Quat(w=1.0, x=0.0, y=0.0, z=0.0),
velocity_world_mps=(0.0, 0.0, 0.0),
covariance_6x6=np.diag([0.25, 0.25, 9.0, 1.0, 1.0, 1.0]).astype(np.float64),
source_label="visual_propagated",
source_label=PoseSourceLabel.VISUAL_PROPAGATED,
last_satellite_anchor_age_ms=0,
smoothed=False,
extras={"wgs84": wgs},
emitted_at=0,
)
a.emit_summary(output)
assert len(conn.mav.global_position_int_calls) == 1