diff --git a/src/gps_denied/core/rotation.py b/src/gps_denied/core/rotation.py index 327782b..b4dfe9d 100644 --- a/src/gps_denied/core/rotation.py +++ b/src/gps_denied/core/rotation.py @@ -1,5 +1,6 @@ """Image Rotation Manager (Component F06).""" +import dataclasses import math from abc import ABC, abstractmethod from datetime import datetime @@ -77,8 +78,13 @@ class ImageRotationManager: if result.matched: precise_angle = self.calculate_precise_angle(result.homography, float(angle)) - result.precise_angle = precise_angle - result.initial_angle = float(angle) + # RotationResult is now a frozen dataclass (ARCH-02 / Plan 01-01); + # use `dataclasses.replace` instead of attribute assignment. + result = dataclasses.replace( + result, + precise_angle=precise_angle, + initial_angle=float(angle), + ) self.update_heading(flight_id, frame_id, precise_angle, timestamp) return result diff --git a/src/gps_denied/hot_types/vo_estimate.py b/src/gps_denied/hot_types/vo_estimate.py index b4656ba..1d64eff 100644 --- a/src/gps_denied/hot_types/vo_estimate.py +++ b/src/gps_denied/hot_types/vo_estimate.py @@ -38,7 +38,15 @@ class Matches: @dataclass(slots=True, frozen=True, eq=False) class RelativePose: - """Relative pose between two frames.""" + """Relative pose between two frames. + + Note: `covariance` is included as an optional 6×6 SE(3) uncertainty + matrix. The legacy Pydantic model did not declare this field but + silently accepted `covariance=...` kwargs (Pydantic v2 default + `extra="ignore"` behavior). Several stage1 tests rely on that + construction signature; declaring the field here preserves the + contract under the dataclass migration without editing tests. + """ translation: np.ndarray # (3,) rotation: np.ndarray # (3, 3) @@ -48,6 +56,7 @@ class RelativePose: tracking_good: bool scale_ambiguous: bool = True chunk_id: Optional[str] = None + covariance: Optional[np.ndarray] = None # (6, 6) SE(3) covariance — optional @dataclass(slots=True, frozen=True, eq=False) diff --git a/src/gps_denied/schemas/eskf.py b/src/gps_denied/schemas/eskf.py index b6091aa..b4bb30b 100644 --- a/src/gps_denied/schemas/eskf.py +++ b/src/gps_denied/schemas/eskf.py @@ -1,9 +1,16 @@ -"""Error-State Kalman Filter schemas.""" +"""Error-State Kalman Filter schemas. + +Phase 1 shim — hot-path types `IMUSample` (legacy: `IMUMeasurement`) and +`ESKFState` live in `gps_denied.hot_types`. `ConfidenceTier` (enum) and +`ESKFConfig` (Pydantic config) stay here as boundary types. + +`ConfidenceTier` is defined BEFORE the hot_types re-imports because +`hot_types.eskf_state` imports `ConfidenceTier` from this module — load +order matters to avoid a circular import. +""" from enum import Enum -from typing import Optional -import numpy as np from pydantic import BaseModel @@ -15,15 +22,6 @@ class ConfidenceTier(str, Enum): FAILED = "FAILED" # 3+ consecutive total failures -class IMUMeasurement(BaseModel): - """Single IMU reading from flight controller.""" - model_config = {"arbitrary_types_allowed": True} - - accel: np.ndarray # (3,) m/s^2 in body frame - gyro: np.ndarray # (3,) rad/s in body frame - timestamp: float # seconds since epoch - - class ESKFConfig(BaseModel): """ESKF tuning parameters.""" @@ -55,17 +53,22 @@ class ESKFConfig(BaseModel): mahalanobis_threshold: float = 16.27 # chi2(3, 0.99999) ≈ 5-sigma gate -class ESKFState(BaseModel): - """Full ESKF nominal state snapshot.""" - model_config = {"arbitrary_types_allowed": True} +# Hot-path types — re-exported from gps_denied.hot_types (Plan 01-01). +# Tests and existing consumers continue to import from this path; the +# underlying type changed from a Pydantic BaseModel to a frozen dataclass. +# These imports MUST come AFTER `ConfidenceTier` is defined above — +# `hot_types.eskf_state` imports `ConfidenceTier` from this module. +from gps_denied.hot_types.eskf_state import ESKFState # noqa: E402, F401 +from gps_denied.hot_types.imu_sample import IMUSample # noqa: E402, F401 - position: np.ndarray # (3,) ENU meters from origin (East, North, Up) - velocity: np.ndarray # (3,) ENU m/s - quaternion: np.ndarray # (4,) [w, x, y, z] body-to-ENU - accel_bias: np.ndarray # (3,) m/s^2 - gyro_bias: np.ndarray # (3,) rad/s - covariance: np.ndarray # (15, 15) - timestamp: float # seconds since epoch - confidence: ConfidenceTier - last_satellite_time: Optional[float] = None - last_vo_time: Optional[float] = None +# Legacy alias preserved until Phase 2 test taxonomy reshuffle. +IMUMeasurement = IMUSample + + +__all__ = [ + "ConfidenceTier", + "ESKFConfig", + "ESKFState", + "IMUMeasurement", + "IMUSample", +] diff --git a/src/gps_denied/schemas/metric.py b/src/gps_denied/schemas/metric.py index 53e2a69..20b4269 100644 --- a/src/gps_denied/schemas/metric.py +++ b/src/gps_denied/schemas/metric.py @@ -1,46 +1,17 @@ -"""Metric Refinement schemas (Component F09).""" +"""Metric Refinement schemas (Component F09). +Phase 1 shim — hot-path types `AlignmentResult`, `ChunkAlignmentResult`, +`Sim3Transform` live in `gps_denied.hot_types.alignment_result`. +`LiteSAMConfig` (config) stays here as a Pydantic boundary type. +""" -import numpy as np from pydantic import BaseModel -from gps_denied.schemas import GPSPoint - - -class AlignmentResult(BaseModel): - """Result of aligning a UAV image to a single satellite tile.""" - model_config = {"arbitrary_types_allowed": True} - - matched: bool - homography: np.ndarray # (3, 3) - gps_center: GPSPoint - confidence: float - inlier_count: int - total_correspondences: int - reprojection_error: float # Mean error in pixels - - -class Sim3Transform(BaseModel): - """Sim(3) transformation: scale, rotation, translation.""" - model_config = {"arbitrary_types_allowed": True} - - translation: np.ndarray # (3,) - rotation: np.ndarray # (3, 3) rotation matrix - scale: float - - -class ChunkAlignmentResult(BaseModel): - """Result of aligning a chunk array of UAV images to a satellite tile.""" - model_config = {"arbitrary_types_allowed": True} - - matched: bool - chunk_id: str - chunk_center_gps: GPSPoint - rotation_angle: float - confidence: float - inlier_count: int - transform: Sim3Transform - reprojection_error: float +from gps_denied.hot_types.alignment_result import ( # noqa: F401 + AlignmentResult, + ChunkAlignmentResult, + Sim3Transform, +) class LiteSAMConfig(BaseModel): @@ -51,3 +22,11 @@ class LiteSAMConfig(BaseModel): max_reprojection_error: float = 2.0 # pixels multi_scale_levels: int = 3 chunk_min_inliers: int = 30 + + +__all__ = [ + "AlignmentResult", + "ChunkAlignmentResult", + "LiteSAMConfig", + "Sim3Transform", +] diff --git a/src/gps_denied/schemas/rotation.py b/src/gps_denied/schemas/rotation.py index 25ad421..3ca040d 100644 --- a/src/gps_denied/schemas/rotation.py +++ b/src/gps_denied/schemas/rotation.py @@ -1,24 +1,16 @@ -"""Rotation schemas (Component F06).""" +"""Rotation schemas (Component F06). + +Phase 1 shim — hot-path `RotationResult` lives in +`gps_denied.hot_types.rotation_result`. `HeadingHistory` (mutable +bookkeeping) and `RotationConfig` (config) stay here as Pydantic. +""" from datetime import datetime from typing import Optional -import numpy as np from pydantic import BaseModel - -class RotationResult(BaseModel): - """Result of a rotation sweep alignment.""" - matched: bool - initial_angle: float - precise_angle: float - confidence: float - # We will exclude np.ndarray from BaseModel to avoid validation issues, - # but store it as an attribute if needed or use arbitrary_types_allowed. - - model_config = {"arbitrary_types_allowed": True} - homography: Optional[np.ndarray] = None - inlier_count: int = 0 +from gps_denied.hot_types.rotation_result import RotationResult # noqa: F401 class HeadingHistory(BaseModel): @@ -36,3 +28,10 @@ class RotationConfig(BaseModel): sharp_turn_threshold: float = 45.0 confidence_threshold: float = 0.7 history_size: int = 10 + + +__all__ = [ + "HeadingHistory", + "RotationConfig", + "RotationResult", +] diff --git a/src/gps_denied/schemas/satellite.py b/src/gps_denied/schemas/satellite.py index a6eb049..648ab60 100644 --- a/src/gps_denied/schemas/satellite.py +++ b/src/gps_denied/schemas/satellite.py @@ -1,22 +1,17 @@ -"""Satellite domain schemas.""" +"""Satellite domain schemas. -from pydantic import BaseModel +Phase 1 shim — `TileCoords`, `TileBounds`, and the Phase-3 placeholder +`SatelliteAnchor` live in `gps_denied.hot_types.satellite_anchor`. +""" -from gps_denied.schemas import GPSPoint +from gps_denied.hot_types.satellite_anchor import ( # noqa: F401 + SatelliteAnchor, + TileBounds, + TileCoords, +) - -class TileCoords(BaseModel): - """Web Mercator tile coordinates.""" - x: int - y: int - zoom: int - - -class TileBounds(BaseModel): - """GPS boundaries of a tile.""" - nw: GPSPoint - ne: GPSPoint - sw: GPSPoint - se: GPSPoint - center: GPSPoint - gsd: float # Ground Sampling Distance (meters/pixel) +__all__ = [ + "SatelliteAnchor", + "TileBounds", + "TileCoords", +] diff --git a/src/gps_denied/schemas/vo.py b/src/gps_denied/schemas/vo.py index 4bc5d19..aed7b70 100644 --- a/src/gps_denied/schemas/vo.py +++ b/src/gps_denied/schemas/vo.py @@ -1,49 +1,21 @@ -"""Sequential Visual Odometry schemas (Component F07).""" +"""Sequential Visual Odometry schemas (Component F07). -from typing import Optional +Phase 1 shim — `Features`, `Matches`, `RelativePose`, `Motion` and the +ARCH-02 alias `VOEstimate` live in `gps_denied.hot_types.vo_estimate`. +""" -import numpy as np -from pydantic import BaseModel +from gps_denied.hot_types.vo_estimate import ( # noqa: F401 + Features, + Matches, + Motion, + RelativePose, + VOEstimate, +) - -class Features(BaseModel): - """Extracted image features (e.g., from SuperPoint).""" - model_config = {"arbitrary_types_allowed": True} - - keypoints: np.ndarray # (N, 2) - descriptors: np.ndarray # (N, 256) - scores: np.ndarray # (N,) - - -class Matches(BaseModel): - """Matches between two sets of features (e.g., from LightGlue).""" - model_config = {"arbitrary_types_allowed": True} - - matches: np.ndarray # (M, 2) - scores: np.ndarray # (M,) - keypoints1: np.ndarray # (M, 2) - keypoints2: np.ndarray # (M, 2) - - -class RelativePose(BaseModel): - """Relative pose between two frames.""" - model_config = {"arbitrary_types_allowed": True} - - translation: np.ndarray # (3,) - rotation: np.ndarray # (3, 3) - confidence: float - inlier_count: int - total_matches: int - tracking_good: bool - scale_ambiguous: bool = True - chunk_id: Optional[str] = None - - -class Motion(BaseModel): - """Motion estimate from OpenCV.""" - model_config = {"arbitrary_types_allowed": True} - - translation: np.ndarray # (3,) unit vector - rotation: np.ndarray # (3, 3) rotation matrix - inliers: np.ndarray # Boolean mask of inliers - inlier_count: int +__all__ = [ + "Features", + "Matches", + "Motion", + "RelativePose", + "VOEstimate", +]