refactor(01-01): convert hot-path schemas/*.py to hot_types re-export shims

- schemas/eskf.py: keep ConfidenceTier + ESKFConfig; re-export IMUSample
  and ESKFState from hot_types (define ConfidenceTier BEFORE the
  hot_types imports to avoid circular import — eskf_state.py imports
  ConfidenceTier from this module). Legacy alias IMUMeasurement = IMUSample.
- schemas/vo.py: re-export Features, Matches, RelativePose, Motion,
  VOEstimate from hot_types.vo_estimate.
- schemas/satellite.py: re-export TileCoords, TileBounds, SatelliteAnchor.
- schemas/metric.py: keep LiteSAMConfig; re-export AlignmentResult,
  ChunkAlignmentResult, Sim3Transform.
- schemas/rotation.py: keep HeadingHistory + RotationConfig; re-export
  RotationResult.

Auto-fixes (Rules 1 + 3) needed to keep the 216-test floor green:
- core/rotation.py: refactor try_rotation_steps to use
  dataclasses.replace instead of attribute assignment on RotationResult
  (Rule 1 — frozen dataclass forbids mutation; Pydantic silently allowed
  it). PATTERNS.md §6.1 already flagged Pose mutation but missed this site.
- hot_types/vo_estimate.py: add Optional `covariance: np.ndarray` field
  to RelativePose (Rule 3 — five test sites construct RelativePose with
  `covariance=np.eye(6)`; Pydantic v2 silently accepted the extra kwarg
  via default `extra="ignore"`. Declaring the field preserves the
  construction contract under the dataclass migration without editing
  tests).

Verification: pytest tests/ -q --ignore=tests/e2e → 216 passed, 8 skipped
(matches baseline). Accuracy bench (23 tests) passes.
This commit is contained in:
Yuzviak
2026-05-10 22:47:56 +03:00
parent b86ec90066
commit f67c5f3cd0
7 changed files with 110 additions and 147 deletions
+8 -2
View File
@@ -1,5 +1,6 @@
"""Image Rotation Manager (Component F06).""" """Image Rotation Manager (Component F06)."""
import dataclasses
import math import math
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from datetime import datetime from datetime import datetime
@@ -77,8 +78,13 @@ class ImageRotationManager:
if result.matched: if result.matched:
precise_angle = self.calculate_precise_angle(result.homography, float(angle)) precise_angle = self.calculate_precise_angle(result.homography, float(angle))
result.precise_angle = precise_angle # RotationResult is now a frozen dataclass (ARCH-02 / Plan 01-01);
result.initial_angle = float(angle) # 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) self.update_heading(flight_id, frame_id, precise_angle, timestamp)
return result return result
+10 -1
View File
@@ -38,7 +38,15 @@ class Matches:
@dataclass(slots=True, frozen=True, eq=False) @dataclass(slots=True, frozen=True, eq=False)
class RelativePose: 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,) translation: np.ndarray # (3,)
rotation: np.ndarray # (3, 3) rotation: np.ndarray # (3, 3)
@@ -48,6 +56,7 @@ class RelativePose:
tracking_good: bool tracking_good: bool
scale_ambiguous: bool = True scale_ambiguous: bool = True
chunk_id: Optional[str] = None chunk_id: Optional[str] = None
covariance: Optional[np.ndarray] = None # (6, 6) SE(3) covariance — optional
@dataclass(slots=True, frozen=True, eq=False) @dataclass(slots=True, frozen=True, eq=False)
+28 -25
View File
@@ -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 enum import Enum
from typing import Optional
import numpy as np
from pydantic import BaseModel from pydantic import BaseModel
@@ -15,15 +22,6 @@ class ConfidenceTier(str, Enum):
FAILED = "FAILED" # 3+ consecutive total failures 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): class ESKFConfig(BaseModel):
"""ESKF tuning parameters.""" """ESKF tuning parameters."""
@@ -55,17 +53,22 @@ class ESKFConfig(BaseModel):
mahalanobis_threshold: float = 16.27 # chi2(3, 0.99999) ≈ 5-sigma gate mahalanobis_threshold: float = 16.27 # chi2(3, 0.99999) ≈ 5-sigma gate
class ESKFState(BaseModel): # Hot-path types — re-exported from gps_denied.hot_types (Plan 01-01).
"""Full ESKF nominal state snapshot.""" # Tests and existing consumers continue to import from this path; the
model_config = {"arbitrary_types_allowed": True} # 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) # Legacy alias preserved until Phase 2 test taxonomy reshuffle.
velocity: np.ndarray # (3,) ENU m/s IMUMeasurement = IMUSample
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 __all__ = [
covariance: np.ndarray # (15, 15) "ConfidenceTier",
timestamp: float # seconds since epoch "ESKFConfig",
confidence: ConfidenceTier "ESKFState",
last_satellite_time: Optional[float] = None "IMUMeasurement",
last_vo_time: Optional[float] = None "IMUSample",
]
+18 -39
View File
@@ -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 pydantic import BaseModel
from gps_denied.schemas import GPSPoint from gps_denied.hot_types.alignment_result import ( # noqa: F401
AlignmentResult,
ChunkAlignmentResult,
class AlignmentResult(BaseModel): Sim3Transform,
"""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
class LiteSAMConfig(BaseModel): class LiteSAMConfig(BaseModel):
@@ -51,3 +22,11 @@ class LiteSAMConfig(BaseModel):
max_reprojection_error: float = 2.0 # pixels max_reprojection_error: float = 2.0 # pixels
multi_scale_levels: int = 3 multi_scale_levels: int = 3
chunk_min_inliers: int = 30 chunk_min_inliers: int = 30
__all__ = [
"AlignmentResult",
"ChunkAlignmentResult",
"LiteSAMConfig",
"Sim3Transform",
]
+14 -15
View File
@@ -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 datetime import datetime
from typing import Optional from typing import Optional
import numpy as np
from pydantic import BaseModel from pydantic import BaseModel
from gps_denied.hot_types.rotation_result import RotationResult # noqa: F401
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
class HeadingHistory(BaseModel): class HeadingHistory(BaseModel):
@@ -36,3 +28,10 @@ class RotationConfig(BaseModel):
sharp_turn_threshold: float = 45.0 sharp_turn_threshold: float = 45.0
confidence_threshold: float = 0.7 confidence_threshold: float = 0.7
history_size: int = 10 history_size: int = 10
__all__ = [
"HeadingHistory",
"RotationConfig",
"RotationResult",
]
+14 -19
View File
@@ -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,
)
__all__ = [
class TileCoords(BaseModel): "SatelliteAnchor",
"""Web Mercator tile coordinates.""" "TileBounds",
x: int "TileCoords",
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)
+18 -46
View File
@@ -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 gps_denied.hot_types.vo_estimate import ( # noqa: F401
from pydantic import BaseModel Features,
Matches,
Motion,
RelativePose,
VOEstimate,
)
__all__ = [
class Features(BaseModel): "Features",
"""Extracted image features (e.g., from SuperPoint).""" "Matches",
model_config = {"arbitrary_types_allowed": True} "Motion",
"RelativePose",
keypoints: np.ndarray # (N, 2) "VOEstimate",
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