mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 23:21:13 +00:00
[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>
This commit is contained in:
@@ -372,9 +372,9 @@ class EskfStateEstimator(StateEstimator):
|
||||
has no graph to throttle); it integrates every anchor as a
|
||||
regular measurement.
|
||||
"""
|
||||
ts_ns = _datetime_to_ns(pose.timestamp)
|
||||
ts_ns = int(pose.emitted_at)
|
||||
self._guard_timestamp(ts_ns, source="pose_anchor")
|
||||
meas_pose = _pose_se3_to_array(pose.pose_se3)
|
||||
meas_pose = self._pose_estimate_to_matrix(pose)
|
||||
|
||||
# Both modes are treated identically by the ESKF — the
|
||||
# JACOBIAN exclusion is iSAM2-graph-specific. AC-4.
|
||||
@@ -641,6 +641,29 @@ class EskfStateEstimator(StateEstimator):
|
||||
)
|
||||
return WgsConverter.local_enu_to_latlonalt(origin, enu)
|
||||
|
||||
def _pose_estimate_to_matrix(self, pose: PoseEstimate) -> np.ndarray:
|
||||
"""Convert a C4 :class:`PoseEstimate` to a 4x4 homogeneous matrix.
|
||||
|
||||
WGS84 → ENU via the injected origin (matches the iSAM2
|
||||
estimator's helper); ``Quat`` → 3x3 rotation via the local
|
||||
``_quat_to_rot`` helper applied to a freshly-built quaternion.
|
||||
"""
|
||||
origin = self._enu_origin if self._enu_origin is not None else _DEFAULT_ENU_ORIGIN
|
||||
try:
|
||||
enu = WgsConverter.latlonalt_to_local_enu(origin, pose.position_wgs84)
|
||||
except Exception as exc:
|
||||
raise EstimatorDegradedError(
|
||||
f"eskf add_pose_anchor: WGS84→ENU failed for frame {pose.frame_id}: {exc}"
|
||||
) from exc
|
||||
q = pose.orientation_world_T_body
|
||||
rotation = _quat_to_rot(
|
||||
np.array([float(q.w), float(q.x), float(q.y), float(q.z)], dtype=np.float64)
|
||||
)
|
||||
matrix = np.eye(4, dtype=np.float64)
|
||||
matrix[:3, :3] = rotation
|
||||
matrix[:3, 3] = enu
|
||||
return matrix
|
||||
|
||||
def _compute_last_anchor_age_ms(self, now_ns: int) -> int:
|
||||
if self._last_anchor_ns == 0:
|
||||
return now_ns // 1_000_000
|
||||
|
||||
@@ -476,11 +476,11 @@ class GtsamIsam2StateEstimator(StateEstimator):
|
||||
gate (AZ-385) and ``last_anchor_age_ms`` see a recent anchor.
|
||||
"""
|
||||
handle = self._require_handle()
|
||||
ts_ns = _datetime_to_ns(pose.timestamp)
|
||||
ts_ns = int(pose.emitted_at)
|
||||
self._guard_timestamp(ts_ns, source="pose_anchor")
|
||||
|
||||
pose_key = self.key_for_frame(pose.frame_id)
|
||||
mode = (pose.covariance_mode or "marginals").lower()
|
||||
mode = pose.covariance_mode.value
|
||||
|
||||
# Both paths update the anchor freshness sentinel. The C5
|
||||
# contract documents this — even the throttled JACOBIAN path
|
||||
@@ -488,7 +488,7 @@ class GtsamIsam2StateEstimator(StateEstimator):
|
||||
self._last_anchor_ns = time.monotonic_ns()
|
||||
|
||||
if mode == "marginals":
|
||||
gtsam_pose = _pose_se3_to_gtsam(pose.pose_se3)
|
||||
gtsam_pose = _pose_se3_to_gtsam(self._pose_estimate_to_matrix(pose))
|
||||
noise = _build_pose_noise(pose.covariance_6x6)
|
||||
factor = gtsam.PriorFactorPose3(pose_key, gtsam_pose, noise)
|
||||
try:
|
||||
@@ -1063,6 +1063,26 @@ class GtsamIsam2StateEstimator(StateEstimator):
|
||||
except Exception as exc:
|
||||
raise EstimatorFatalError(f"_enu_pose_to_wgs84 failed: {exc}") from exc
|
||||
|
||||
def _pose_estimate_to_matrix(self, pose: PoseEstimate) -> np.ndarray:
|
||||
"""Convert a C4 :class:`PoseEstimate` to a 4x4 homogeneous-transform matrix.
|
||||
|
||||
WGS84 → ENU via the injected origin; ``Quat`` → 3x3 rotation
|
||||
via :func:`_quat_dto_to_rot`. The result is what GTSAM's
|
||||
``Pose3`` constructor consumes.
|
||||
"""
|
||||
origin = self._enu_origin if self._enu_origin is not None else _DEFAULT_ENU_ORIGIN
|
||||
try:
|
||||
enu = WgsConverter.latlonalt_to_local_enu(origin, pose.position_wgs84)
|
||||
except Exception as exc:
|
||||
raise EstimatorDegradedError(
|
||||
f"add_pose_anchor: WGS84→ENU failed for frame {pose.frame_id}: {exc}"
|
||||
) from exc
|
||||
rotation = _quat_dto_to_rot(pose.orientation_world_T_body)
|
||||
matrix = np.eye(4, dtype=np.float64)
|
||||
matrix[:3, :3] = rotation
|
||||
matrix[:3, 3] = enu
|
||||
return matrix
|
||||
|
||||
def _latest_velocity_or_zero(self) -> tuple[float, float, float]:
|
||||
"""Read the most-recent IMU keyframe's velocity or fall back to zero.
|
||||
|
||||
@@ -1258,6 +1278,19 @@ def _pose_se3_to_gtsam(pose_se3: Any) -> gtsam.Pose3:
|
||||
return gtsam.Pose3(arr)
|
||||
|
||||
|
||||
def _quat_dto_to_rot(q: Quat) -> np.ndarray:
|
||||
"""Convert a :class:`Quat` DTO (scalar-first, body→world) to a 3x3 rotation."""
|
||||
w, x, y, z = float(q.w), float(q.x), float(q.y), float(q.z)
|
||||
return np.array(
|
||||
[
|
||||
[1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)],
|
||||
[2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)],
|
||||
[2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)],
|
||||
],
|
||||
dtype=np.float64,
|
||||
)
|
||||
|
||||
|
||||
def _build_pose_noise(covariance: Any | None) -> gtsam.noiseModel.Base:
|
||||
"""Build a 6-DoF Gaussian noise model from a 6x6 covariance.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user