Files
gps-denied-onboard/_docs/02_tasks/done/AZ-355_c4_pose_protocol.md
T
Oleksandr Bezdieniezhnykh db27e25630 [AZ-355] C4 PoseEstimator Protocol + factory + DTOs + composition
Land the foundational C4 surface AZ-358 (Marginals) and AZ-361
(Hybrid) build on top of:

- PoseEstimator Protocol (@runtime_checkable): estimate(...) +
  current_covariance_mode().
- Error hierarchy: PoseEstimatorError, PnpFailureError,
  PoseEstimatorConfigError; CovarianceDegradedWarning as a Warning
  subclass (warnings.warn path, not raised).
- ISam2GraphHandle Protocol stub (READ-ONLY view, get_pose_key only)
  decoupled from C5's concrete ISam2GraphHandleImpl.
- C4PoseConfig (frozen dataclass) + register on c4_pose import.
- runtime_root/pose_factory.build_pose_estimator with lazy-import
  fallback; INFO log c4.pose.strategy_loaded; shares ingest-thread
  binding with C5 per ADR-003.

DTO restructuring (cross-cutting): retire the legacy raw-4x4
PoseEstimate(int frame_id, datetime timestamp, pose_se3, ...) and
ship the contract shape PoseEstimate(UUID, LatLonAlt, Quat,
np.ndarray, CovarianceMode, PoseSourceLabel,
last_satellite_anchor_age_ms, emitted_at). C5 add_pose_anchor in
both gtsam_isam2 + eskf_baseline migrated in lockstep via
WGS84->ENU + Quat->R helpers; test fixtures updated. VIO output
stays on the raw shape until AZ-331 (C1 protocol) lands.

LatLonAlt upgraded to slots=True per AC-2. ThermalState stub added
to _types/thermal.py so the Protocol typechecks pre-AZ-302.

Tests: 25 new in tests/unit/c4_pose/test_az355_pose_protocol.py
covering AC-1..AC-10 + factory wiring + config validation; full
repo: 685 passed, 2 pre-existing CI-only skips.

Jira transition deferred: MCP "Not connected"; leftover entry in
_docs/_process_leftovers/2026-05-11_jira_transition_az355_deferred.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 10:32:14 +03:00

11 KiB

C4 PoseEstimator Protocol + Factory + DTOs + Composition

Task: AZ-355_c4_pose_protocol Name: C4 PoseEstimator Protocol + Factory + DTOs + Composition Description: Define the public PoseEstimator Protocol (PEP 544 @runtime_checkable), the C4 DTOs (PoseEstimate, LatLonAlt, Quat, CovarianceMode enum, PoseSourceLabel enum), the error hierarchy (PoseEstimatorError, PnpFailureError, CovarianceDegradedWarning — note: a Warning subclass NOT an Exception), and the composition-root factory build_pose_estimator(config, ransac_filter, wgs_converter, se3_utils, isam2_graph_handle) -> PoseEstimator. The shared RansacFilter (AZ-282), WgsConverter (AZ-279), and SE3Utils (AZ-277) helpers are constructor-injected. The C5 iSAM2 graph handle is constructor-injected from the runtime root (ADR-003 shared substrate; C4 NEVER owns the graph). This task delivers the foundational scaffolding the Marginals (TBD AZ-?) and Hybrid (TBD AZ-?) tasks depend on; no PnP / GTSAM / Jacobian implementation is in scope here. Complexity: 3 points Dependencies: AZ-263_initial_structure, AZ-269_config_loader, AZ-270_compose_root, AZ-282_ransac_filter, AZ-279_wgs_converter, AZ-277_se3_utils, AZ-266_log_module Component: c4_pose (epic AZ-259 / E-C4) Tracker: AZ-355 Epic: AZ-259 (E-C4)

Document Dependencies

  • _docs/02_document/contracts/c4_pose/pose_estimator_protocol.md — the public contract this task implements.
  • _docs/02_document/components/06_c4_pose/description.md — § 1 architectural pattern (single concrete impl behind a Protocol); § 2 PoseEstimator interface + PoseEstimate DTO; § 5 error handling; § 9 logging.
  • _docs/02_document/module-layout.mdc4_pose Per-Component Mapping; joint native ownership note (C5 owns cpp/gtsam_bindings/; C4 imports READ-ONLY).
  • _docs/02_document/architecture.md — ADR-001, ADR-003 (shared GTSAM substrate), ADR-006 (Jacobian fallback acceptance), ADR-009.
  • _docs/02_document/contracts/c3_5_adhop/conditional_refiner_protocol.mdMatchResult shape consumed at input.

Problem

Without this task, the C4 PnP/Marginals consumers (TBD) and the downstream C5 StateEstimator (AZ-260) would each invent their own ad-hoc interface for the pose-estimator boundary, breaking ADR-001 (Strategy + composition root) and ADR-009 (interface-first DI). The DTO surface (PoseEstimate, CovarianceMode, PoseSourceLabel) is also consumed by C5 + C8 + C13 — defining it once in _types/pose.py prevents drift across consumers. The error hierarchy choice (CovarianceDegradedWarning as a Warning subclass, NOT Exception) is also non-obvious and needs to be codified before any caller writes a try/except around estimate(...) and accidentally swallows the warning.

Outcome

  • src/gps_denied_onboard/components/c4_pose/interface.py defining the PoseEstimator Protocol with estimate + current_covariance_mode.
  • src/gps_denied_onboard/components/c4_pose/__init__.py re-exporting PoseEstimator, PoseEstimate, EstimatorOutput (per module-layout c4_pose Public API row), CovarianceMode, PoseSourceLabel.
  • src/gps_denied_onboard/_types/pose.py defining the frozen + slotted dataclasses LatLonAlt, Quat, PoseEstimate, plus the CovarianceMode and PoseSourceLabel enums.
  • src/gps_denied_onboard/components/c4_pose/errors.py defining PoseEstimatorError, PnpFailureError, and CovarianceDegradedWarning (the latter as a Warning subclass).
  • src/gps_denied_onboard/runtime_root/pose_factory.py exporting build_pose_estimator(config, ransac_filter, wgs_converter, se3_utils, isam2_graph_handle) -> PoseEstimator. Single-strategy resolution table ("opencv_gtsam" only). Lazy-imports the concrete module via importlib.import_module(...) for symmetry with other component factories.
  • Composition-root compose_root extension: invoke build_pose_estimator AFTER RansacFilter, WgsConverter, SE3Utils, and the C5 iSAM2 graph handle are constructed; bind the result to the SAME ingest thread as C5.
  • Config schema extension to AZ-269: config.pose.strategy (default "opencv_gtsam"), config.pose.ransac_iterations (default 200), config.pose.ransac_reprojection_threshold_px (default 4.0), config.pose.thermal_throttle_threshold_celsius (default 75.0; informational).
  • INFO log on every successful build_pose_estimator: kind="c4.pose.strategy_loaded" with strategy name + thresholds.
  • ISam2GraphHandle Protocol stub at src/gps_denied_onboard/components/c4_pose/_isam2_handle.py (READ-ONLY view; allows C5 to provide a duck-typed handle without prematurely defining C5's graph internals). Documents the ONE method C4 needs: get_pose_key(frame_id) -> int.

Scope

Included

  • The PoseEstimator Protocol with estimate + current_covariance_mode.
  • The five DTOs / enums in _types/pose.py.
  • The error hierarchy (note: CovarianceDegradedWarning is a Warning, NOT Exception).
  • The composition-root factory.
  • Config schema extension.
  • The ISam2GraphHandle Protocol stub (consumed-side surface only; concrete impl owned by E-C5 / AZ-260).
  • Composition-root wiring path.
  • Unit tests covering Protocol conformance, DTO immutability + slots, factory rejection on unknown strategy, factory acceptance, INFO log emission, error-hierarchy distinction (CovarianceDegradedWarning IS-A Warning, NOT Exception).

Excluded

  • The OpenCVGtsamPoseEstimator concrete implementation — owned by the Marginals task (TBD).
  • The Jacobian fallback path + thermal switch — owned by the Hybrid task (TBD).
  • The GTSAM Marginals factor add — owned by the Marginals task.
  • The C5 iSAM2 graph implementation — owned by AZ-260.
  • C4-IT-01..04 + C4-PT-01 — deferred to E-BBT (AZ-262).
  • The C7 ThermalState source — owned by AZ-302.

Acceptance Criteria

AC-1: Protocol conformance — runtime_checkable A FakePoseEstimator test double implementing both methods passes isinstance; missing-method fakes fail.

AC-2: DTOs are frozen + slots LatLonAlt, Quat, PoseEstimate are frozen=True, slots=True. Mutation raises FrozenInstanceError. __slots__ non-empty.

AC-3: Enums have the documented values CovarianceMode has exactly MARGINALS and JACOBIAN (string-valued). PoseSourceLabel has exactly SATELLITE_ANCHORED, VISUAL_PROPAGATED, DEAD_RECKONED.

AC-4: CovarianceDegradedWarning IS-A Warning, NOT Exception issubclass(CovarianceDegradedWarning, Warning) is True; issubclass(CovarianceDegradedWarning, Exception) is False (in Python's hierarchy Warning is NOT an Exception subclass at the catch-by-default level — try/except Exception does NOT catch warnings emitted via warnings.warn). Test verifies that a try/except Exception around warnings.warn(CovarianceDegradedWarning(...)) does NOT catch the warning.

AC-5: PnpFailureError IS-A Exception issubclass(PnpFailureError, PoseEstimatorError) AND issubclass(PnpFailureError, Exception) both True.

AC-6: Factory rejects unknown strategy config.pose.strategy = "garbage"PoseEstimatorConfigError raised; ERROR log emitted.

AC-7: Factory accepts "opencv_gtsam" and emits INFO log Successful construction; ONE INFO log kind="c4.pose.strategy_loaded" with structured fields.

AC-8: Public API surface — __init__.py re-exports from gps_denied_onboard.components.c4_pose import PoseEstimator, PoseEstimate, CovarianceMode, PoseSourceLabel resolves; _isam2_handle and internal classes NOT in __all__.

AC-9: Strategy bound to single ingest thread (same thread as C5) Composition root binds C4 + C5 to the same thread; binding C4 to a different thread raises RuntimeError.

AC-10: ISam2GraphHandle Protocol stub conforms to runtime_checkable A test double implementing get_pose_key(frame_id) -> int passes isinstance(fake, ISam2GraphHandle).

Non-Functional Requirements

Performance

  • build_pose_estimator p99 ≤ 50 ms.

Compatibility

  • Protocol method-signature changes are MAJOR; DTO field additions are MINOR.

Reliability

  • Single-thread invariant enforced at composition-root binding (AC-9).
  • CovarianceDegradedWarning semantics codified — callers MUST use warnings.catch_warnings if they need to programmatically observe warnings.

Unit Tests

AC Ref What to Test Required Outcome
AC-1 Protocol conformance Fake passes; partial fake fails
AC-2 DTO immutability + slots FrozenInstanceError; non-empty __slots__
AC-3 Enum values Documented values present
AC-4 Warning vs Exception hierarchy try/except Exception does NOT catch the warning
AC-5 PnpFailureError IS-A Exception try/except Exception catches
AC-6 Unknown-strategy rejection PoseEstimatorConfigError; ERROR log
AC-7 Successful factory load INFO log with structured fields
AC-8 Public API re-exports Public names resolve; internals not
AC-9 Single-thread binding Second binding (different thread) raises RuntimeError
AC-10 ISam2GraphHandle Protocol Fake passes

Constraints

  • @runtime_checkable MUST be used on both PoseEstimator and ISam2GraphHandle.
  • DTOs MUST be frozen=True, slots=True.
  • CovarianceDegradedWarning MUST be a Warning subclass (NOT Exception). Documents R10 acceptance.
  • The factory does NOT instantiate RansacFilter, WgsConverter, SE3Utils, or the iSAM2 graph handle — runtime root constructs ONCE and passes references.
  • Single-thread binding with C5 is enforced at composition root; ADR-003 shared GTSAM substrate is non-thread-safe.

Risks & Mitigation

Risk 1: ISam2GraphHandle Protocol stub couples C4 prematurely to C5

  • Mitigation: the stub defines ONLY get_pose_key(frame_id) -> int — the minimal surface C4 needs to attach factors. C5 (AZ-260) implements the concrete handle; if C5's graph design changes, the stub may grow but the Protocol surface stays stable as long as C4's needs don't change.

Risk 2: CovarianceDegradedWarning semantics confuse callers expecting an exception-like flow

  • Mitigation: documented in the contract (Invariant 9) AND codified in AC-4. Description.md § 5 also states explicitly "NOT a fatal condition".

Risk 3: Composition root needs to construct C4 and C5 in lockstep (chicken-and-egg)

  • Mitigation: ADR-003 documents this. The composition root constructs the iSAM2 graph FIRST (C5), then C4 (passing the handle). The Protocol task creates the stub Protocol so C5 can be implemented in parallel without C4 implementations being ready.

Runtime Completeness

  • Named capability: PoseEstimator Protocol + PoseEstimate DTO + ISam2GraphHandle Protocol stub + composition-root factory.
  • Production code that must exist: real Protocol + real DTOs + real error hierarchy + real factory + real config schema extension + real composition-root wiring path that binds C4 to the same thread as C5.
  • Allowed external stubs: FakePoseEstimator, FakeISam2GraphHandle, FakeRansacFilter, FakeWgsConverter, FakeSE3Utils for tests.
  • Unacceptable substitutes: making CovarianceDegradedWarning an Exception subclass (would change warning semantics for all callers); skipping the ISam2GraphHandle stub (would force C4 implementations to import C5's concrete graph type → cycle).

Contract

This task produces/implements the contract at _docs/02_document/contracts/c4_pose/pose_estimator_protocol.md. Consumers MUST read that file — not this task spec — to discover the interface.