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>
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); § 2PoseEstimatorinterface +PoseEstimateDTO; § 5 error handling; § 9 logging._docs/02_document/module-layout.md—c4_posePer-Component Mapping; joint native ownership note (C5 ownscpp/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.md—MatchResultshape 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.pydefining thePoseEstimatorProtocol withestimate+current_covariance_mode.src/gps_denied_onboard/components/c4_pose/__init__.pyre-exportingPoseEstimator,PoseEstimate,EstimatorOutput(per module-layoutc4_posePublic API row),CovarianceMode,PoseSourceLabel.src/gps_denied_onboard/_types/pose.pydefining the frozen + slotted dataclassesLatLonAlt,Quat,PoseEstimate, plus theCovarianceModeandPoseSourceLabelenums.src/gps_denied_onboard/components/c4_pose/errors.pydefiningPoseEstimatorError,PnpFailureError, andCovarianceDegradedWarning(the latter as aWarningsubclass).src/gps_denied_onboard/runtime_root/pose_factory.pyexportingbuild_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 viaimportlib.import_module(...)for symmetry with other component factories.- Composition-root
compose_rootextension: invokebuild_pose_estimatorAFTERRansacFilter,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. ISam2GraphHandleProtocol stub atsrc/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
PoseEstimatorProtocol withestimate+current_covariance_mode. - The five DTOs / enums in
_types/pose.py. - The error hierarchy (note:
CovarianceDegradedWarningis aWarning, NOTException). - The composition-root factory.
- Config schema extension.
- The
ISam2GraphHandleProtocol 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 (
CovarianceDegradedWarningIS-AWarning, NOTException).
Excluded
- The
OpenCVGtsamPoseEstimatorconcrete implementation — owned by the Marginals task (TBD). - The Jacobian fallback path + thermal switch — owned by the Hybrid task (TBD).
- The GTSAM
Marginalsfactor 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
ThermalStatesource — 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_estimatorp99 ≤ 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).
CovarianceDegradedWarningsemantics codified — callers MUST usewarnings.catch_warningsif 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_checkableMUST be used on bothPoseEstimatorandISam2GraphHandle.- DTOs MUST be
frozen=True, slots=True. CovarianceDegradedWarningMUST be aWarningsubclass (NOTException). 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:
PoseEstimatorProtocol +PoseEstimateDTO +ISam2GraphHandleProtocol 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,FakeSE3Utilsfor tests. - Unacceptable substitutes: making
CovarianceDegradedWarninganExceptionsubclass (would change warning semantics for all callers); skipping theISam2GraphHandlestub (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.