docs(01-eskf-core): create phase plan — 3 plans in 2 waves

This commit is contained in:
Yuzviak
2026-04-01 22:11:57 +03:00
parent d2b431f17f
commit bf9bef19c8
5 changed files with 1212 additions and 2 deletions
+17
View File
@@ -1,2 +1,19 @@
# OS # OS
.DS_Store .DS_Store
# Python
__pycache__/
*.pyc
*.pyo
*.pyd
*.egg-info/
.venv/
dist/
build/
# Pytest
.pytest_cache/
# Env
.env
*.env
+6 -2
View File
@@ -26,7 +26,11 @@ The scaffold exists (~2800 lines): FastAPI service, all component ABCs, Pydantic
3. Satellite measurement update corrects absolute position and covariance tightens to satellite noise level 3. Satellite measurement update corrects absolute position and covariance tightens to satellite noise level
4. Confidence tier outputs HIGH when last satellite correction is recent and covariance is small, MEDIUM on VO-only, LOW on IMU-only — verified by unit tests 4. Confidence tier outputs HIGH when last satellite correction is recent and covariance is small, MEDIUM on VO-only, LOW on IMU-only — verified by unit tests
5. Full coordinate chain (pixel → camera ray → body → NED → WGS84) produces correct GPS coordinates for a known geometry test case; all FAKE Math stubs replaced 5. Full coordinate chain (pixel → camera ray → body → NED → WGS84) produces correct GPS coordinates for a known geometry test case; all FAKE Math stubs replaced
**Plans**: TBD **Plans**: 3 plans
Plans:
- [ ] 01-01-PLAN.md — ESKF core algorithm (schemas, 15-state filter, IMU prediction, VO/satellite updates, confidence tiers)
- [ ] 01-02-PLAN.md — Coordinate chain fix (replace fake math with real K matrix projection, ray-ground intersection)
- [ ] 01-03-PLAN.md — Unit tests for ESKF and coordinate chain (18+ ESKF tests, 10+ coordinate tests)
### Phase 2: Visual Odometry ### Phase 2: Visual Odometry
**Goal**: VO produces metric relative poses via cuVSLAM on Jetson and via OpenCV ORB on dev/CI, both satisfying the same interface — no more scale-ambiguous unit vectors **Goal**: VO produces metric relative poses via cuVSLAM on Jetson and via OpenCV ORB on dev/CI, both satisfying the same interface — no more scale-ambiguous unit vectors
@@ -101,7 +105,7 @@ The scaffold exists (~2800 lines): FastAPI service, all component ABCs, Pydantic
| Phase | Plans Complete | Status | Completed | | Phase | Plans Complete | Status | Completed |
|-------|----------------|--------|-----------| |-------|----------------|--------|-----------|
| 1. ESKF Core | 0/TBD | Not started | - | | 1. ESKF Core | 0/3 | Planned | - |
| 2. Visual Odometry | 0/TBD | Not started | - | | 2. Visual Odometry | 0/TBD | Not started | - |
| 3. Satellite Matching + GPR | 0/TBD | Not started | - | | 3. Satellite Matching + GPR | 0/TBD | Not started | - |
| 4. MAVLink I/O | 0/TBD | Not started | - | | 4. MAVLink I/O | 0/TBD | Not started | - |
+355
View File
@@ -0,0 +1,355 @@
---
phase: 01-eskf-core
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- src/gps_denied/core/eskf.py
- src/gps_denied/schemas/eskf.py
autonomous: true
requirements:
- ESKF-01
- ESKF-02
- ESKF-03
- ESKF-04
- ESKF-05
must_haves:
truths:
- "ESKF propagates nominal state (position, velocity, quaternion, biases) from IMU inputs and covariance grows between measurement updates"
- "VO measurement update reduces position uncertainty and innovation is within expected bounds"
- "Satellite measurement update corrects absolute position and covariance tightens to satellite noise level"
- "Confidence tier outputs HIGH when satellite correction is recent and covariance small, MEDIUM on VO-only, LOW on IMU-only"
- "ESKF initializes from a GPS position with high-uncertainty covariance"
artifacts:
- path: "src/gps_denied/core/eskf.py"
provides: "15-state ESKF implementation"
min_lines: 250
exports: ["ESKF"]
- path: "src/gps_denied/schemas/eskf.py"
provides: "ESKF data contracts"
exports: ["ESKFState", "ESKFConfig", "ConfidenceTier", "IMUMeasurement"]
key_links:
- from: "src/gps_denied/core/eskf.py"
to: "src/gps_denied/schemas/eskf.py"
via: "import ESKFState, ESKFConfig, ConfidenceTier, IMUMeasurement"
pattern: "from gps_denied\\.schemas\\.eskf import"
- from: "src/gps_denied/core/eskf.py"
to: "numpy"
via: "matrix math for F, Q, P, K, H"
pattern: "import numpy as np"
---
<objective>
Implement the 15-state Error-State Kalman Filter (ESKF) that fuses IMU, VO, and satellite measurements and outputs confidence-tiered position estimates.
Purpose: The ESKF is the central position estimator — without it, no GPS_INPUT messages can be generated, no IMU prediction can fill inter-frame gaps, and no confidence tier can drive fix_type.
Output: `src/gps_denied/core/eskf.py` (ESKF class) and `src/gps_denied/schemas/eskf.py` (data contracts).
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/codebase/CONVENTIONS.md
@_docs/01_solution/solution.md (lines 127-253 — ESKF spec, confidence tiers, GPS_INPUT fields)
@src/gps_denied/schemas/__init__.py
@src/gps_denied/schemas/vo.py
@src/gps_denied/core/coordinates.py
</context>
<tasks>
<task type="auto">
<name>Task 1: Create ESKF schema contracts</name>
<files>src/gps_denied/schemas/eskf.py</files>
<read_first>
- src/gps_denied/schemas/__init__.py (GPSPoint, CameraParameters — the pattern for Pydantic schemas in this project)
- src/gps_denied/schemas/vo.py (RelativePose — the VO output that ESKF consumes; note model_config for numpy)
- _docs/01_solution/solution.md (lines 127-253 for state vector, confidence tiers, GPS_INPUT mapping)
</read_first>
<action>
Create `src/gps_denied/schemas/eskf.py` with these exact types:
```python
"""Error-State Kalman Filter schemas."""
from enum import Enum
from typing import Optional
import numpy as np
from pydantic import BaseModel, Field
class ConfidenceTier(str, Enum):
"""ESKF confidence tier for GPS_INPUT fix_type mapping."""
HIGH = "HIGH" # Satellite match <30s ago, covariance < 400 m^2
MEDIUM = "MEDIUM" # VO tracking OK, no recent satellite match
LOW = "LOW" # IMU-only, cuVSLAM lost
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."""
# Process noise (from IMU datasheet — ICM-42688-P)
accel_noise_density: float = 6.86e-4 # 70 ug/sqrt(Hz) -> m/s^2/sqrt(Hz)
gyro_noise_density: float = 5.24e-5 # 3.0e-3 deg/s/sqrt(Hz) -> rad/s/sqrt(Hz)
accel_random_walk: float = 2.0e-3 # m/s^3/sqrt(Hz)
gyro_random_walk: float = 8.73e-7 # 5.0e-5 deg/s^2/sqrt(Hz) -> rad/s^2/sqrt(Hz)
# VO measurement noise
vo_position_noise: float = 0.3 # meters (cuVSLAM at 600m altitude)
# Satellite measurement noise range
sat_noise_min: float = 5.0 # meters (high-confidence RANSAC)
sat_noise_max: float = 20.0 # meters (low-confidence RANSAC)
# Confidence tier thresholds
satellite_max_age: float = 30.0 # seconds
covariance_high_threshold: float = 400.0 # m^2 (trace of position covariance)
# Initial covariance diagonals
init_pos_var: float = 100.0 # m^2
init_vel_var: float = 1.0 # (m/s)^2
init_att_var: float = 0.01 # rad^2
init_accel_bias_var: float = 0.01 # (m/s^2)^2
init_gyro_bias_var: float = 1e-6 # (rad/s)^2
class ESKFState(BaseModel):
"""Full ESKF nominal state snapshot."""
model_config = {"arbitrary_types_allowed": True}
position: np.ndarray # (3,) NED meters from origin
velocity: np.ndarray # (3,) NED m/s
quaternion: np.ndarray # (4,) [w, x, y, z] body-to-NED
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
```
Also update `src/gps_denied/schemas/__init__.py` to add re-exports:
Add these imports at the end of the file:
```python
from gps_denied.schemas.eskf import ConfidenceTier, ESKFConfig, ESKFState, IMUMeasurement
```
Per ESKF-01 (state vector), ESKF-04 (initialization config), ESKF-05 (confidence tiers).
</action>
<verify>
<automated>python -c "from gps_denied.schemas.eskf import ESKFState, ESKFConfig, ConfidenceTier, IMUMeasurement; print('OK')"</automated>
</verify>
<acceptance_criteria>
- src/gps_denied/schemas/eskf.py contains `class ConfidenceTier(str, Enum)` with members HIGH, MEDIUM, LOW, FAILED
- src/gps_denied/schemas/eskf.py contains `class IMUMeasurement(BaseModel)` with fields accel, gyro, timestamp
- src/gps_denied/schemas/eskf.py contains `class ESKFConfig(BaseModel)` with fields accel_noise_density, gyro_noise_density, satellite_max_age, covariance_high_threshold
- src/gps_denied/schemas/eskf.py contains `class ESKFState(BaseModel)` with fields position, velocity, quaternion, accel_bias, gyro_bias, covariance, timestamp, confidence
- ESKFState.quaternion comment documents [w, x, y, z] convention
- ESKFState.position comment documents NED meters from origin
- `python -c "from gps_denied.schemas.eskf import ESKFState, ESKFConfig, ConfidenceTier, IMUMeasurement"` exits 0
</acceptance_criteria>
<done>All four ESKF schema classes importable, fields match solution.md spec exactly, ConfidenceTier has all four tiers</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Implement ESKF core algorithm</name>
<files>src/gps_denied/core/eskf.py</files>
<read_first>
- src/gps_denied/schemas/eskf.py (just created — the contracts this implementation uses)
- src/gps_denied/core/coordinates.py (CoordinateTransformer — used for NED/WGS84 conversion, gps_to_enu, enu_to_gps methods)
- src/gps_denied/schemas/vo.py (RelativePose — the VO measurement input; fields: translation, rotation, confidence, scale_ambiguous)
- src/gps_denied/schemas/__init__.py (GPSPoint — used for initialization from GPS position)
- _docs/01_solution/solution.md (lines 127-178 for ESKF math, lines 244-253 for confidence tiers)
- src/gps_denied/core/vo.py (to understand interface pattern: ABC + concrete class in same module)
</read_first>
<behavior>
- Test: ESKF initialized with GPS position has state.position == gps_to_enu(gps), quaternion == [1,0,0,0], covariance diagonal matches config init vars
- Test: After predict() with zero IMU and dt=0.01, position unchanged, covariance[0:3,0:3] trace increases
- Test: After predict() with accel=[0,0,-9.81] (gravity in body NED) and dt=0.1, velocity change is near zero (gravity compensated)
- Test: VO update with zero innovation leaves state unchanged, covariance does not increase
- Test: VO update with 1m innovation in north reduces position covariance and shifts position toward measurement
- Test: Satellite update with known position reduces position covariance below pre-update level
- Test: Confidence is HIGH when last_satellite_time < 30s ago and position covariance trace < 400
- Test: Confidence is MEDIUM when last_vo_time is recent but no satellite
- Test: Confidence is LOW when only IMU (no VO, no satellite)
- Test: Confidence is FAILED when get_confidence called with consecutive_failures >= 3
</behavior>
<action>
Create `src/gps_denied/core/eskf.py` implementing the full 15-state Error-State Kalman Filter.
Module docstring: `"""Error-State Kalman Filter (ESKF) — 15-state sensor fusion (Component F17)."""`
Class `ESKF`:
**Constructor** `__init__(self, config: ESKFConfig | None = None)`:
- Store config (default ESKFConfig())
- Initialize: `_initialized: bool = False`, `_nominal_state: dict | None = None`, `_P: np.ndarray | None = None` (15x15 covariance), `_last_satellite_time: float | None = None`, `_last_vo_time: float | None = None`, `_last_timestamp: float | None = None`
**`initialize(self, position_ned: np.ndarray, timestamp: float, velocity: np.ndarray | None = None, quaternion: np.ndarray | None = None) -> None`**:
- Set nominal state: position=position_ned, velocity=velocity or zeros(3), quaternion=quaternion or [1,0,0,0] (identity), accel_bias=zeros(3), gyro_bias=zeros(3)
- Initialize P as 15x15 diagonal: [init_pos_var]*3 + [init_vel_var]*3 + [init_att_var]*3 + [init_accel_bias_var]*3 + [init_gyro_bias_var]*3
- Set _initialized=True, _last_timestamp=timestamp
**`initialize_from_gps(self, gps: GPSPoint, altitude: float, timestamp: float, coord_transformer: CoordinateTransformer, flight_id: str) -> None`**:
- Convert GPS to NED using coord_transformer.gps_to_enu(flight_id, gps) (note: existing code uses ENU, treat as [East, North, Up] -> convert to NED: [North, East, -Up] — BUT the existing codebase uses ENU throughout, so store as ENU to match CoordinateTransformer convention)
- IMPORTANT: The existing CoordinateTransformer uses ENU (East, North, Up). Store position in ENU to maintain compatibility. Document this in a comment.
- Call self.initialize(position_enu, timestamp)
Per ESKF-04: initialization from GLOBAL_POSITION_INT.
**`predict(self, imu: IMUMeasurement) -> None`** (per ESKF-01):
- Compute dt = imu.timestamp - self._last_timestamp. If dt <= 0 or dt > 1.0, skip.
- Extract corrected measurements: a_corrected = imu.accel - self._nominal_state["accel_bias"], w_corrected = imu.gyro - self._nominal_state["gyro_bias"]
- Get rotation matrix R from quaternion: R = _quat_to_rotation_matrix(self._nominal_state["quaternion"])
- Gravity vector g = np.array([0, 0, -9.81]) (in ENU, up is positive, gravity points down)
- Propagate nominal state:
- position += velocity * dt
- velocity += (R @ a_corrected + g) * dt
- quaternion = _quat_multiply(quaternion, _exp_quaternion(w_corrected * dt))
- quaternion = quaternion / np.linalg.norm(quaternion) (normalize)
- Biases unchanged (random walk modeled in Q)
- Build 15x15 state transition matrix F:
- F = I_15
- F[0:3, 3:6] = I_3 * dt (position depends on velocity)
- F[3:6, 6:9] = -R @ _skew_symmetric(a_corrected) * dt (velocity depends on attitude error)
- F[3:6, 9:12] = -R * dt (velocity depends on accel bias)
- F[6:9, 12:15] = -I_3 * dt (attitude depends on gyro bias)
- Build 15x15 process noise Q:
- Q = zeros(15, 15)
- Q[3:6, 3:6] = (config.accel_noise_density**2) * dt * I_3 (velocity noise from accel)
- Q[6:9, 6:9] = (config.gyro_noise_density**2) * dt * I_3 (attitude noise from gyro)
- Q[9:12, 9:12] = (config.accel_random_walk**2) * dt * I_3 (accel bias random walk)
- Q[12:15, 12:15] = (config.gyro_random_walk**2) * dt * I_3 (gyro bias random walk)
- Propagate covariance: P = F @ P @ F.T + Q
- Update _last_timestamp
**`update_vo(self, relative_position: np.ndarray, dt_vo: float) -> np.ndarray`** (per ESKF-02):
- Predicted relative position = self._nominal_state["velocity"] * dt_vo
- Innovation z = relative_position - predicted_relative_position (3,)
- H_vo = zeros(3, 15); H_vo[0:3, 3:6] = I_3 * dt_vo (maps velocity error to relative position)
- Alternative simpler H: H_vo[0:3, 0:3] = I_3 (direct position observation of relative displacement)
- Use the simpler form: H_vo observes the position error directly since relative_position is integrated
- R_vo = config.vo_position_noise**2 * I_3
- S = H_vo @ P @ H_vo.T + R_vo
- K = P @ H_vo.T @ np.linalg.inv(S)
- delta_x = K @ z (15,)
- Apply error-state correction to nominal state:
- position += delta_x[0:3]
- velocity += delta_x[3:6]
- quaternion = _quat_multiply(quaternion, _exp_quaternion(delta_x[6:9]))
- quaternion /= np.linalg.norm(quaternion)
- accel_bias += delta_x[9:12]
- gyro_bias += delta_x[12:15]
- Reset error state: P = (I_15 - K @ H_vo) @ P
- Update _last_vo_time = self._last_timestamp
- Return innovation z
**`update_satellite(self, position_enu: np.ndarray, noise_meters: float) -> np.ndarray`** (per ESKF-03):
- Innovation z = position_enu - self._nominal_state["position"] (3,)
- H_sat = zeros(3, 15); H_sat[0:3, 0:3] = I_3 (directly observes position)
- R_sat = noise_meters**2 * I_3
- S = H_sat @ P @ H_sat.T + R_sat
- K = P @ H_sat.T @ np.linalg.inv(S)
- delta_x = K @ z (15,)
- Apply same error-state correction as update_vo
- P = (I_15 - K @ H_sat) @ P
- Update _last_satellite_time = self._last_timestamp
- Return innovation z
**`get_confidence(self, consecutive_failures: int = 0) -> ConfidenceTier`** (per ESKF-05):
- If consecutive_failures >= 3: return FAILED
- current_time = self._last_timestamp
- pos_cov_trace = np.trace(self._P[0:3, 0:3])
- If _last_satellite_time is not None and (current_time - _last_satellite_time) < config.satellite_max_age and pos_cov_trace < config.covariance_high_threshold: return HIGH
- If _last_vo_time is not None and (current_time - _last_vo_time) < 5.0: return MEDIUM
- return LOW
**`get_state(self) -> ESKFState`**:
- Return ESKFState with all current nominal state fields, covariance=self._P.copy(), timestamp=self._last_timestamp, confidence=self.get_confidence()
**`@property initialized(self) -> bool`**: return self._initialized
**Helper functions** (module-level, private):
- `_quat_to_rotation_matrix(q: np.ndarray) -> np.ndarray`: Convert [w,x,y,z] quaternion to 3x3 rotation matrix using standard formula: R = (2w^2-1)I + 2(v*v^T) + 2w*skew(v) where v=[x,y,z]
- `_quat_multiply(q1: np.ndarray, q2: np.ndarray) -> np.ndarray`: Hamilton product [w,x,y,z] convention
- `_exp_quaternion(theta: np.ndarray) -> np.ndarray`: Convert rotation vector (3,) to quaternion: angle = ||theta||, if angle < 1e-10 return [1,0,0,0], else return [cos(angle/2), sin(angle/2)*theta/angle]
- `_skew_symmetric(v: np.ndarray) -> np.ndarray`: 3x3 skew-symmetric matrix from 3-vector
All functions use numpy only. No scipy dependency needed.
</action>
<verify>
<automated>python -c "
import numpy as np
from gps_denied.core.eskf import ESKF
from gps_denied.schemas.eskf import ESKFConfig, IMUMeasurement
eskf = ESKF()
eskf.initialize(np.zeros(3), 0.0)
assert eskf.initialized
imu = IMUMeasurement(accel=np.array([0., 0., 9.81]), gyro=np.zeros(3), timestamp=0.01)
eskf.predict(imu)
state = eskf.get_state()
assert state.position is not None
assert state.covariance.shape == (15, 15)
print('ESKF basic test OK')
"</automated>
</verify>
<acceptance_criteria>
- src/gps_denied/core/eskf.py contains `class ESKF:`
- src/gps_denied/core/eskf.py contains `def predict(self, imu: IMUMeasurement)` with F matrix construction and P = F @ P @ F.T + Q
- src/gps_denied/core/eskf.py contains `def update_vo(self, relative_position: np.ndarray` with Kalman gain K = P @ H.T @ inv(S)
- src/gps_denied/core/eskf.py contains `def update_satellite(self, position_enu: np.ndarray` with H_sat[0:3, 0:3] = I_3
- src/gps_denied/core/eskf.py contains `def get_confidence(self` returning ConfidenceTier with checks for satellite_max_age and covariance_high_threshold
- src/gps_denied/core/eskf.py contains `def initialize_from_gps(self, gps: GPSPoint`
- src/gps_denied/core/eskf.py contains `def _quat_to_rotation_matrix(`
- src/gps_denied/core/eskf.py contains `def _skew_symmetric(`
- Covariance matrix P is 15x15
- Error-state vector is 15-dimensional: delta_x[0:3] position, [3:6] velocity, [6:9] attitude, [9:12] accel_bias, [12:15] gyro_bias
- `python -c "from gps_denied.core.eskf import ESKF"` exits 0
</acceptance_criteria>
<done>ESKF class fully implements: IMU prediction with F/Q matrices and bias propagation (ESKF-01), VO measurement update with Kalman gain (ESKF-02), satellite measurement update with absolute position correction (ESKF-03), GPS initialization with high-uncertainty covariance (ESKF-04), and confidence tier computation (ESKF-05)</done>
</task>
</tasks>
<verification>
1. `python -c "from gps_denied.core.eskf import ESKF; from gps_denied.schemas.eskf import ESKFState, ESKFConfig, ConfidenceTier, IMUMeasurement; print('All imports OK')"`
2. `python -c "import numpy as np; from gps_denied.core.eskf import ESKF; e = ESKF(); e.initialize(np.zeros(3), 0.0); s = e.get_state(); assert s.covariance.shape == (15,15); print('State shape OK')"`
3. `ruff check src/gps_denied/core/eskf.py src/gps_denied/schemas/eskf.py` passes
</verification>
<success_criteria>
- ESKF class importable and instantiable with default config
- predict() propagates nominal state and grows covariance
- update_vo() applies Kalman correction from relative pose
- update_satellite() applies Kalman correction from absolute position
- get_confidence() returns correct tier based on timing and covariance
- initialize_from_gps() sets state from GPS coordinates
- All math uses numpy, no external filter library
</success_criteria>
<output>
After completion, create `.planning/phases/01-eskf-core/01-01-SUMMARY.md`
</output>
+389
View File
@@ -0,0 +1,389 @@
---
phase: 01-eskf-core
plan: 02
type: execute
wave: 1
depends_on: []
files_modified:
- src/gps_denied/core/coordinates.py
autonomous: true
requirements:
- ESKF-06
must_haves:
truths:
- "pixel_to_gps uses camera intrinsic matrix K to unproject pixel to camera ray, not fake 0.1m/pixel scaling"
- "Camera-to-body transform uses T_cam_body rotation (nadir-pointing camera mount)"
- "Body-to-NED rotation uses a quaternion parameter (from ESKF attitude), not hardcoded identity"
- "Ray-ground intersection at known altitude produces NED position, then converts to WGS84"
- "gps_to_pixel is the inverse: WGS84 -> NED -> body -> camera -> pixel"
- "transform_points applies a real 3x3 homography via cv2.perspectiveTransform"
artifacts:
- path: "src/gps_denied/core/coordinates.py"
provides: "Full coordinate chain: pixel -> camera ray -> body -> NED -> WGS84"
contains: "np.linalg.inv"
key_links:
- from: "src/gps_denied/core/coordinates.py"
to: "numpy"
via: "K matrix construction, quaternion rotation, ray-ground intersection"
pattern: "np\\.array"
- from: "src/gps_denied/core/coordinates.py"
to: "cv2"
via: "perspectiveTransform for homography application"
pattern: "cv2\\.perspectiveTransform"
---
<objective>
Replace all FAKE Math stubs in CoordinateTransformer with the real pixel-to-GPS coordinate chain: pixel -> camera ray (K matrix) -> body frame (T_cam_body) -> NED (quaternion) -> WGS84.
Purpose: Without real coordinate transforms, all position estimates, object localization, and satellite matching produce wrong results. The 0.1m/pixel approximation ignores altitude, camera intrinsics, and UAV attitude entirely.
Output: Updated `src/gps_denied/core/coordinates.py` with real geometric projections.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/codebase/CONVENTIONS.md
@_docs/01_solution/solution.md (lines 187-222 — coordinate system spec)
@src/gps_denied/core/coordinates.py
@src/gps_denied/schemas/__init__.py
<interfaces>
<!-- Key types the executor needs -->
From src/gps_denied/schemas/__init__.py:
```python
class GPSPoint(BaseModel):
lat: float = Field(..., ge=-90, le=90)
lon: float = Field(..., ge=-180, le=180)
class CameraParameters(BaseModel):
focal_length: float # mm
sensor_width: float # mm
sensor_height: float # mm
resolution_width: int # pixels
resolution_height: int # pixels
principal_point: tuple[float, float] | None = None
distortion_coefficients: list[float] | None = None
```
From _docs/01_solution/solution.md (camera intrinsics):
- ADTI 20L V1 + 16mm lens: fx = fy = 16 * 5456 / 23.2 = 3763 pixels
- cx = 2728, cy = 1816
- Camera mount: nadir-pointing (Z-down), R_cam_body = Rx(180deg)
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Replace fake coordinate transforms with real camera projection math</name>
<files>src/gps_denied/core/coordinates.py</files>
<read_first>
- src/gps_denied/core/coordinates.py (current file — understand existing methods, ENU convention, OriginNotSetError)
- src/gps_denied/schemas/__init__.py (CameraParameters fields: focal_length in mm, sensor_width/height in mm, resolution_width/height in pixels, principal_point optional)
- _docs/01_solution/solution.md (lines 187-222 for reference frames: Camera C, Body B, NED N, WGS84; transformation chain; T_cam_body spec)
</read_first>
<action>
Modify `src/gps_denied/core/coordinates.py`. Keep all existing methods (set_enu_origin, get_enu_origin, gps_to_enu, enu_to_gps) UNCHANGED. Replace the implementations of `pixel_to_gps`, `gps_to_pixel`, `image_object_to_gps`, and `transform_points`.
Add imports at top:
```python
import cv2
import numpy as np
```
Add module-level helper function:
```python
def _build_intrinsic_matrix(cam: CameraParameters) -> np.ndarray:
"""Build 3x3 camera intrinsic matrix K from CameraParameters."""
fx = cam.focal_length * cam.resolution_width / cam.sensor_width
fy = cam.focal_length * cam.resolution_height / cam.sensor_height
if cam.principal_point is not None:
cx, cy = cam.principal_point
else:
cx = cam.resolution_width / 2.0
cy = cam.resolution_height / 2.0
return np.array([
[fx, 0, cx],
[ 0, fy, cy],
[ 0, 0, 1],
], dtype=np.float64)
```
Add module-level helper:
```python
def _cam_to_body_rotation() -> np.ndarray:
"""Camera-to-body rotation for nadir-pointing camera.
Camera frame: Z forward (optical axis), X right, Y down (OpenCV convention).
Body frame: X forward (nose), Y right (starboard), Z down.
Camera points nadir: camera Z-axis aligns with body -Z (downward).
The rotation is Rx(180deg): flips Y and Z axes.
R_cam_body = [[1, 0, 0], [0, -1, 0], [0, 0, -1]]
"""
return np.array([
[1, 0, 0],
[0, -1, 0],
[0, 0, -1],
], dtype=np.float64)
```
Add module-level helper:
```python
def _quat_to_rotation_matrix(q: np.ndarray) -> np.ndarray:
"""Convert [w, x, y, z] quaternion to 3x3 rotation matrix."""
w, x, y, z = q
return np.array([
[1 - 2*(y*y + z*z), 2*(x*y - w*z), 2*(x*z + w*y)],
[2*(x*y + w*z), 1 - 2*(x*x + z*z), 2*(y*z - w*x)],
[2*(x*z - w*y), 2*(y*z + w*x), 1 - 2*(x*x + y*y)],
], dtype=np.float64)
```
Replace `pixel_to_gps` method:
```python
def pixel_to_gps(
self,
flight_id: str,
pixel: tuple[float, float],
frame_pose: dict,
camera_params: CameraParameters,
altitude: float,
quaternion: np.ndarray | None = None,
) -> GPSPoint:
"""Unproject pixel to GPS via ray-ground intersection.
Chain: pixel -> camera ray (K^-1) -> body (T_cam_body) -> ENU (quaternion) -> WGS84.
Args:
flight_id: Flight identifier for ENU origin lookup.
pixel: (u, v) pixel coordinates.
frame_pose: Dict with "position" key -> [east, north, up] in ENU meters.
camera_params: Camera intrinsic parameters.
altitude: UAV altitude above ground in meters (positive up).
quaternion: [w, x, y, z] body-to-ENU rotation quaternion.
If None, assumes identity (level flight, north-facing).
"""
# Step 1: Pixel -> camera ray
K = _build_intrinsic_matrix(camera_params)
K_inv = np.linalg.inv(K)
pixel_h = np.array([pixel[0], pixel[1], 1.0])
ray_cam = K_inv @ pixel_h # direction in camera frame
# Step 2: Camera -> body
R_cam_body = _cam_to_body_rotation()
ray_body = R_cam_body @ ray_cam
# Step 3: Body -> ENU
if quaternion is not None:
R_body_enu = _quat_to_rotation_matrix(quaternion)
else:
R_body_enu = np.eye(3)
ray_enu = R_body_enu @ ray_body
# Step 4: Ray-ground intersection
# UAV is at altitude (Up component), ground is at Up=0
# Ray: P = P_uav + t * ray_enu
# Ground plane: Up = 0 -> t = -altitude / ray_enu[2]
if abs(ray_enu[2]) < 1e-10:
# Ray parallel to ground — return UAV position projected
frame_enu = frame_pose.get("position", [0, 0, 0])
return self.enu_to_gps(flight_id, (frame_enu[0], frame_enu[1], 0.0))
t = -altitude / ray_enu[2]
if t < 0:
# Ray points upward — use UAV position
frame_enu = frame_pose.get("position", [0, 0, 0])
return self.enu_to_gps(flight_id, (frame_enu[0], frame_enu[1], 0.0))
frame_enu = frame_pose.get("position", [0, 0, 0])
ground_east = frame_enu[0] + t * ray_enu[0]
ground_north = frame_enu[1] + t * ray_enu[1]
# Step 5: ENU -> WGS84
return self.enu_to_gps(flight_id, (ground_east, ground_north, 0.0))
```
Replace `gps_to_pixel` method:
```python
def gps_to_pixel(
self,
flight_id: str,
gps: GPSPoint,
frame_pose: dict,
camera_params: CameraParameters,
altitude: float,
quaternion: np.ndarray | None = None,
) -> tuple[float, float]:
"""Project GPS coordinate to image pixel.
Inverse of pixel_to_gps: WGS84 -> ENU -> body -> camera -> pixel.
"""
# Step 1: GPS -> ENU
enu = self.gps_to_enu(flight_id, gps)
# Step 2: ENU point relative to UAV
frame_enu = frame_pose.get("position", [0, 0, 0])
point_enu = np.array([
enu[0] - frame_enu[0],
enu[1] - frame_enu[1],
-altitude, # ground is at -altitude relative to UAV (UAV is at +altitude)
])
# Step 3: ENU -> body
if quaternion is not None:
R_body_enu = _quat_to_rotation_matrix(quaternion)
else:
R_body_enu = np.eye(3)
point_body = R_body_enu.T @ point_enu
# Step 4: Body -> camera
R_cam_body = _cam_to_body_rotation()
point_cam = R_cam_body.T @ point_body
# Step 5: Camera -> pixel
if abs(point_cam[2]) < 1e-10:
cx = camera_params.resolution_width / 2.0
cy = camera_params.resolution_height / 2.0
return (cx, cy)
K = _build_intrinsic_matrix(camera_params)
pixel_h = K @ (point_cam / point_cam[2])
return (float(pixel_h[0]), float(pixel_h[1]))
```
Replace `image_object_to_gps` method:
```python
def image_object_to_gps(
self,
flight_id: str,
frame_id: int,
object_pixel: tuple[float, float],
frame_pose: dict | None = None,
camera_params: CameraParameters | None = None,
altitude: float = 100.0,
quaternion: np.ndarray | None = None,
) -> GPSPoint:
"""Convert object pixel coordinates to GPS using full projection chain.
If frame_pose or camera_params are not provided, uses defaults for
backward compatibility (level flight, ADTI 20L V1 camera).
"""
if frame_pose is None:
frame_pose = {"position": [0, 0, 0]}
if camera_params is None:
camera_params = CameraParameters(
focal_length=16.0,
sensor_width=23.2,
sensor_height=15.4,
resolution_width=5456,
resolution_height=3632,
)
return self.pixel_to_gps(
flight_id, object_pixel, frame_pose, camera_params, altitude, quaternion
)
```
Replace `transform_points` method:
```python
def transform_points(
self,
points: list[tuple[float, float]],
transformation: list[list[float]],
) -> list[tuple[float, float]]:
"""Apply 3x3 homography to a list of 2D points using cv2.perspectiveTransform."""
if not points:
return []
H = np.array(transformation, dtype=np.float64)
pts = np.array(points, dtype=np.float64).reshape(-1, 1, 2)
transformed = cv2.perspectiveTransform(pts, H)
return [(float(p[0][0]), float(p[0][1])) for p in transformed]
```
Per ESKF-06: complete coordinate chain replacing all FAKE Math stubs.
</action>
<verify>
<automated>python -c "
import numpy as np
from gps_denied.core.coordinates import CoordinateTransformer, _build_intrinsic_matrix
from gps_denied.schemas import CameraParameters, GPSPoint
ct = CoordinateTransformer()
ct.set_enu_origin('f1', GPSPoint(lat=48.0, lon=37.0))
# Test: image center at altitude 600m with identity quaternion should project near UAV position
cam = CameraParameters(focal_length=16.0, sensor_width=23.2, sensor_height=15.4, resolution_width=5456, resolution_height=3632)
pose = {'position': [0, 0, 0]}
q_identity = np.array([1.0, 0.0, 0.0, 0.0])
gps = ct.pixel_to_gps('f1', (2728.0, 1816.0), pose, cam, 600.0, q_identity)
# Center pixel should project to UAV nadir position
assert abs(gps.lat - 48.0) < 0.001, f'lat={gps.lat}'
assert abs(gps.lon - 37.0) < 0.001, f'lon={gps.lon}'
# Test K matrix
K = _build_intrinsic_matrix(cam)
assert K.shape == (3, 3)
fx_expected = 16.0 * 5456 / 23.2
assert abs(K[0, 0] - fx_expected) < 1.0, f'fx={K[0,0]}, expected={fx_expected}'
print('Coordinate chain tests OK')
"</automated>
</verify>
<acceptance_criteria>
- src/gps_denied/core/coordinates.py contains `import numpy as np`
- src/gps_denied/core/coordinates.py contains `import cv2`
- src/gps_denied/core/coordinates.py contains `def _build_intrinsic_matrix(cam: CameraParameters) -> np.ndarray`
- src/gps_denied/core/coordinates.py contains `def _cam_to_body_rotation() -> np.ndarray`
- src/gps_denied/core/coordinates.py contains `def _quat_to_rotation_matrix(q: np.ndarray) -> np.ndarray`
- src/gps_denied/core/coordinates.py contains `K_inv = np.linalg.inv(K)` in pixel_to_gps
- src/gps_denied/core/coordinates.py contains `ray_cam = K_inv @ pixel_h` in pixel_to_gps
- src/gps_denied/core/coordinates.py contains `R_cam_body @ ray_cam` in pixel_to_gps
- src/gps_denied/core/coordinates.py contains `t = -altitude / ray_enu[2]` in pixel_to_gps (ray-ground intersection)
- src/gps_denied/core/coordinates.py contains `cv2.perspectiveTransform` in transform_points
- The string "FAKE Math" does NOT appear in the file
- The string "0.1" scaling factor does NOT appear in pixel_to_gps
- gps_to_enu and enu_to_gps methods are UNCHANGED from original
- image_object_to_gps uses ADTI 20L V1 defaults: focal_length=16.0, sensor_width=23.2, resolution_width=5456
</acceptance_criteria>
<done>All FAKE Math stubs replaced. pixel_to_gps uses K^-1 unprojection, T_cam_body rotation, quaternion body-to-ENU, and ray-ground intersection. gps_to_pixel is the exact inverse. transform_points uses cv2.perspectiveTransform. image_object_to_gps uses real ADTI 20L V1 camera defaults.</done>
</task>
</tasks>
<verification>
1. `python -c "from gps_denied.core.coordinates import CoordinateTransformer, _build_intrinsic_matrix, _cam_to_body_rotation, _quat_to_rotation_matrix; print('Imports OK')"` — all new functions importable
2. `ruff check src/gps_denied/core/coordinates.py` — no lint errors
3. `grep -c "FAKE Math" src/gps_denied/core/coordinates.py` — returns 0 (all stubs removed)
</verification>
<success_criteria>
- pixel_to_gps implements real K^-1 unprojection with camera intrinsics
- Camera-to-body rotation R_cam_body accounts for nadir-pointing mount
- Body-to-ENU rotation uses quaternion parameter
- Ray-ground intersection uses altitude for ground plane
- Image center pixel projects to UAV nadir position (verified numerically)
- gps_to_pixel is the exact inverse of pixel_to_gps
- transform_points uses cv2.perspectiveTransform
- No FAKE Math stubs remain
</success_criteria>
<output>
After completion, create `.planning/phases/01-eskf-core/01-02-SUMMARY.md`
</output>
+445
View File
@@ -0,0 +1,445 @@
---
phase: 01-eskf-core
plan: 03
type: execute
wave: 2
depends_on:
- 01-01
- 01-02
files_modified:
- tests/test_eskf.py
- tests/test_coordinates.py
autonomous: true
requirements:
- ESKF-01
- ESKF-02
- ESKF-03
- ESKF-04
- ESKF-05
- ESKF-06
must_haves:
truths:
- "ESKF propagation test confirms covariance grows from IMU-only prediction"
- "ESKF VO update test confirms position uncertainty decreases after update"
- "ESKF satellite update test confirms position converges to satellite measurement"
- "Confidence tier test covers all four tiers: HIGH, MEDIUM, LOW, FAILED"
- "Coordinate chain test verifies image center projects to nadir at correct GPS"
- "Coordinate chain round-trip test: pixel -> GPS -> pixel returns original pixel"
- "All tests pass with pytest"
artifacts:
- path: "tests/test_eskf.py"
provides: "Unit tests for ESKF core algorithm"
min_lines: 150
- path: "tests/test_coordinates.py"
provides: "Updated coordinate tests with real projection math"
min_lines: 80
key_links:
- from: "tests/test_eskf.py"
to: "src/gps_denied/core/eskf.py"
via: "import ESKF"
pattern: "from gps_denied\\.core\\.eskf import ESKF"
- from: "tests/test_coordinates.py"
to: "src/gps_denied/core/coordinates.py"
via: "import CoordinateTransformer"
pattern: "from gps_denied\\.core\\.coordinates import"
---
<objective>
Create comprehensive unit tests for the ESKF algorithm and update existing coordinate tests to verify the real projection math, covering all six ESKF requirements.
Purpose: Verify correctness of all ESKF operations (propagation, VO update, satellite update, initialization, confidence tiers) and the full coordinate chain (pixel -> camera ray -> body -> ENU -> WGS84) against known geometry.
Output: `tests/test_eskf.py` (new) and updated `tests/test_coordinates.py`.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/codebase/TESTING.md
@.planning/phases/01-eskf-core/01-01-SUMMARY.md
@.planning/phases/01-eskf-core/01-02-SUMMARY.md
<interfaces>
<!-- From Plan 01 -->
From src/gps_denied/core/eskf.py:
```python
class ESKF:
def __init__(self, config: ESKFConfig | None = None): ...
def initialize(self, position_ned: np.ndarray, timestamp: float, velocity=None, quaternion=None): ...
def initialize_from_gps(self, gps: GPSPoint, altitude: float, timestamp: float, coord_transformer, flight_id: str): ...
def predict(self, imu: IMUMeasurement) -> None: ...
def update_vo(self, relative_position: np.ndarray, dt_vo: float) -> np.ndarray: ...
def update_satellite(self, position_enu: np.ndarray, noise_meters: float) -> np.ndarray: ...
def get_confidence(self, consecutive_failures: int = 0) -> ConfidenceTier: ...
def get_state(self) -> ESKFState: ...
@property
def initialized(self) -> bool: ...
```
From src/gps_denied/schemas/eskf.py:
```python
class ConfidenceTier(str, Enum): HIGH, MEDIUM, LOW, FAILED
class IMUMeasurement(BaseModel): accel (3,), gyro (3,), timestamp float
class ESKFConfig(BaseModel): accel_noise_density, gyro_noise_density, ..., satellite_max_age=30.0, covariance_high_threshold=400.0
class ESKFState(BaseModel): position (3,), velocity (3,), quaternion (4,), accel_bias (3,), gyro_bias (3,), covariance (15,15), timestamp, confidence
```
<!-- From Plan 02 -->
From src/gps_denied/core/coordinates.py:
```python
def pixel_to_gps(self, flight_id, pixel, frame_pose, camera_params, altitude, quaternion=None) -> GPSPoint
def gps_to_pixel(self, flight_id, gps, frame_pose, camera_params, altitude, quaternion=None) -> tuple[float, float]
def _build_intrinsic_matrix(cam: CameraParameters) -> np.ndarray
def _cam_to_body_rotation() -> np.ndarray
def _quat_to_rotation_matrix(q: np.ndarray) -> np.ndarray
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Create ESKF unit tests</name>
<files>tests/test_eskf.py</files>
<read_first>
- src/gps_denied/core/eskf.py (the implementation being tested — read ALL methods, understand state layout, error-state correction)
- src/gps_denied/schemas/eskf.py (ESKFConfig defaults, ConfidenceTier enum values, IMUMeasurement fields)
- tests/test_coordinates.py (test pattern in this project: pytest fixtures, assertion style, approx usage)
- tests/test_vo.py (another test file for reference on numpy assertion patterns)
</read_first>
<action>
Create `tests/test_eskf.py` with the following test functions. Follow project conventions: module docstring, pytest fixtures, no class-based tests (only test_coordinates uses functions).
Module docstring: `"""Tests for ESKF (F17) — Error-State Kalman Filter."""`
Imports:
```python
import numpy as np
import pytest
from gps_denied.core.eskf import ESKF
from gps_denied.schemas.eskf import ConfidenceTier, ESKFConfig, IMUMeasurement
from gps_denied.schemas import GPSPoint
from gps_denied.core.coordinates import CoordinateTransformer
```
Fixtures:
```python
@pytest.fixture
def eskf():
"""ESKF initialized at origin with default config."""
e = ESKF()
e.initialize(np.zeros(3), timestamp=0.0)
return e
@pytest.fixture
def config():
return ESKFConfig()
```
**Test: test_initialization_default (ESKF-04)**
- Create ESKF(), call initialize(np.array([100, 200, 0]), 0.0)
- Assert eskf.initialized is True
- state = eskf.get_state()
- Assert state.position is close to [100, 200, 0]
- Assert state.velocity is close to [0, 0, 0]
- Assert state.quaternion is close to [1, 0, 0, 0]
- Assert state.covariance.shape == (15, 15)
- Assert state.covariance[0, 0] == config.init_pos_var (100.0)
- Assert state.covariance[3, 3] == config.init_vel_var (1.0)
- Assert state.covariance[6, 6] == config.init_att_var (0.01)
**Test: test_initialization_from_gps (ESKF-04)**
- Create CoordinateTransformer, set origin GPSPoint(48.0, 37.0)
- Create ESKF(), call initialize_from_gps(GPSPoint(48.001, 37.001), altitude=600, timestamp=0.0, coord_transformer=ct, flight_id="f1")
- Assert eskf.initialized is True
- state = eskf.get_state()
- Assert state.position is NOT all zeros (GPS offset was applied)
- Assert np.linalg.norm(state.position[:2]) > 50 (0.001 deg lat ~= 111m)
**Test: test_predict_covariance_grows (ESKF-01)**
- Use eskf fixture
- Record P_before = eskf.get_state().covariance.copy()
- trace_before = np.trace(P_before[0:3, 0:3])
- Predict with IMU: accel=[0, 0, 9.81], gyro=[0, 0, 0], timestamp=0.01
- P_after = eskf.get_state().covariance
- trace_after = np.trace(P_after[0:3, 0:3])
- Assert trace_after > trace_before (covariance grew)
**Test: test_predict_gravity_compensation (ESKF-01)**
- Use eskf fixture (identity quaternion = level flight in ENU)
- Predict with 100 steps: accel=[0, 0, 9.81] (gravity in body down, but body aligned with ENU so accel_z = +g), gyro=[0,0,0], dt=0.01
- After 1 second of stationary IMU with gravity, velocity should be near zero (gravity compensated by the g vector subtraction)
- state = eskf.get_state()
- Assert np.linalg.norm(state.velocity) < 1.0 (small drift from numerical integration, but not 9.81*1=9.81)
**Test: test_predict_with_acceleration (ESKF-01)**
- Use eskf fixture
- Apply 100 IMU predictions with accel=[1.0, 0, 9.81] (1 m/s^2 forward + gravity), gyro=[0,0,0], dt=0.01
- After 1 second, velocity[0] should be approximately 1.0 m/s (from the extra 1 m/s^2 acceleration)
- state = eskf.get_state()
- Assert abs(state.velocity[0] - 1.0) < 0.5 (approximate due to discrete integration)
**Test: test_predict_position_propagation (ESKF-01)**
- Create ESKF, initialize with position=[0,0,0], velocity=[10, 0, 0]
- Predict 100 steps with accel=[0, 0, 9.81], gyro=[0,0,0], dt=0.01
- After 1 second at 10 m/s, position[0] should be approximately 10.0 meters
- Assert abs(state.position[0] - 10.0) < 2.0
**Test: test_vo_update_reduces_uncertainty (ESKF-02)**
- Use eskf fixture
- Predict a few steps to grow covariance
- Record trace_before = np.trace(eskf.get_state().covariance[0:3, 0:3])
- Call update_vo(relative_position=np.array([0.0, 0.0, 0.0]), dt_vo=0.1)
- trace_after = np.trace(eskf.get_state().covariance[0:3, 0:3])
- Assert trace_after < trace_before
**Test: test_vo_update_corrects_position (ESKF-02)**
- Use eskf fixture
- Predict 10 steps with accel=[0, 0, 9.81], gyro=[0,0,0], dt=0.1 (1 second)
- Record position_before = eskf.get_state().position.copy()
- Call update_vo with relative_position that includes a 1m north offset beyond predicted
- Record position_after = eskf.get_state().position
- Assert position_after != position_before (position was corrected)
**Test: test_vo_update_returns_innovation (ESKF-02)**
- Use eskf fixture
- innovation = eskf.update_vo(np.array([1.0, 0.0, 0.0]), dt_vo=0.1)
- Assert innovation.shape == (3,)
- Assert np.linalg.norm(innovation) > 0.5 (non-trivial innovation since predicted relative position is near zero)
**Test: test_satellite_update_corrects_position (ESKF-03)**
- Use eskf fixture
- Predict several steps to drift from origin
- Record pos_before = eskf.get_state().position.copy()
- Call update_satellite(position_enu=np.array([50.0, 50.0, 0.0]), noise_meters=10.0)
- pos_after = eskf.get_state().position
- Assert position moved toward [50, 50, 0]: np.linalg.norm(pos_after - [50,50,0]) < np.linalg.norm(pos_before - [50,50,0])
**Test: test_satellite_update_tightens_covariance (ESKF-03)**
- Use eskf fixture (high initial covariance)
- trace_before = np.trace(eskf.get_state().covariance[0:3, 0:3])
- Call update_satellite(np.zeros(3), noise_meters=5.0)
- trace_after = np.trace(eskf.get_state().covariance[0:3, 0:3])
- Assert trace_after < trace_before
**Test: test_satellite_update_covariance_bounded_by_noise (ESKF-03)**
- Use eskf fixture
- Call update_satellite(np.zeros(3), noise_meters=5.0)
- pos_cov_trace = np.trace(eskf.get_state().covariance[0:3, 0:3])
- Assert pos_cov_trace < 3 * 5.0**2 (covariance bounded by satellite noise)
**Test: test_confidence_high (ESKF-05)**
- Use eskf fixture
- Call update_satellite to set last_satellite_time
- Advance timestamp by small dt (predict one step)
- Assert eskf.get_confidence() == ConfidenceTier.HIGH
**Test: test_confidence_medium (ESKF-05)**
- Create ESKF, initialize at time 0.0
- Call update_vo to set last_vo_time (but never satellite)
- Assert eskf.get_confidence() == ConfidenceTier.MEDIUM
**Test: test_confidence_low (ESKF-05)**
- Create ESKF, initialize at time 0.0
- Predict many steps without any measurement update (no VO, no satellite)
- Eventually confidence should be LOW (no recent VO or satellite)
- Assert eskf.get_confidence() == ConfidenceTier.LOW
**Test: test_confidence_failed (ESKF-05)**
- Use eskf fixture
- Assert eskf.get_confidence(consecutive_failures=3) == ConfidenceTier.FAILED
- Assert eskf.get_confidence(consecutive_failures=0) != ConfidenceTier.FAILED
**Test: test_get_state_returns_eskf_state (ESKF-01)**
- Use eskf fixture
- state = eskf.get_state()
- Assert isinstance(state, ESKFState)
- Assert state.position.shape == (3,)
- Assert state.velocity.shape == (3,)
- Assert state.quaternion.shape == (4,)
- Assert state.accel_bias.shape == (3,)
- Assert state.gyro_bias.shape == (3,)
- Assert state.covariance.shape == (15, 15)
**Test: test_full_fusion_sequence (integration)**
- Create ESKF, initialize at origin
- Run 10 IMU predictions (simulate 0.5s at 20Hz)
- Assert covariance grew
- Run VO update with small correction
- Assert covariance decreased
- Run 10 more IMU predictions
- Run satellite update with known position
- Assert position is close to satellite position (within satellite noise)
- Assert confidence is HIGH
</action>
<verify>
<automated>cd /home/yuzviak/Azaion/gps-denied-onboard && python -m pytest tests/test_eskf.py -v --tb=short 2>&1 | tail -40</automated>
</verify>
<acceptance_criteria>
- tests/test_eskf.py contains at least 15 test functions (test_initialization_default, test_initialization_from_gps, test_predict_covariance_grows, test_predict_gravity_compensation, test_predict_with_acceleration, test_predict_position_propagation, test_vo_update_reduces_uncertainty, test_vo_update_corrects_position, test_vo_update_returns_innovation, test_satellite_update_corrects_position, test_satellite_update_tightens_covariance, test_satellite_update_covariance_bounded_by_noise, test_confidence_high, test_confidence_medium, test_confidence_low, test_confidence_failed, test_get_state_returns_eskf_state, test_full_fusion_sequence)
- tests/test_eskf.py contains `from gps_denied.core.eskf import ESKF`
- tests/test_eskf.py contains `from gps_denied.schemas.eskf import ConfidenceTier, ESKFConfig, IMUMeasurement`
- Every test function contains at least one `assert` statement
- `pytest tests/test_eskf.py` exits 0 (all tests pass)
</acceptance_criteria>
<done>All 18 ESKF tests pass, covering: initialization (ESKF-04), IMU prediction with covariance growth and gravity compensation (ESKF-01), VO update with uncertainty reduction (ESKF-02), satellite update with absolute correction (ESKF-03), all four confidence tiers (ESKF-05), and full fusion integration</done>
</task>
<task type="auto">
<name>Task 2: Update coordinate chain tests for real projection math</name>
<files>tests/test_coordinates.py</files>
<read_first>
- tests/test_coordinates.py (current tests — understand what exists, what must be preserved vs updated)
- src/gps_denied/core/coordinates.py (the updated implementation with real K matrix, ray-ground intersection, perspectiveTransform)
- src/gps_denied/schemas/__init__.py (CameraParameters fields)
</read_first>
<action>
Rewrite `tests/test_coordinates.py` to test the real coordinate chain. Keep the existing tests for gps_to_enu and enu_to_gps UNCHANGED (they still pass since those methods were not modified). Update pixel_to_gps tests and add new ones for the real math.
Module docstring: `"""Tests for CoordinateTransformer (F13) — real coordinate chain."""`
Keep existing imports, add:
```python
import numpy as np
```
Keep existing fixtures unchanged.
**Keep test_enu_origin_management** — unchanged.
**Keep test_gps_to_enu** — unchanged.
**Keep test_enu_roundtrip** — unchanged.
**Replace test_pixel_to_gps_flow** with these new tests:
**Test: test_pixel_to_gps_center_projects_to_nadir**
- Set origin GPSPoint(48.0, 37.0)
- ADTI 20L V1 camera: focal_length=16.0, sensor_width=23.2, sensor_height=15.4, resolution_width=5456, resolution_height=3632
- pixel = (2728.0, 1816.0) (image center = principal point)
- pose = {"position": [0, 0, 0]}
- quaternion = np.array([1.0, 0.0, 0.0, 0.0]) (identity = level, north-facing)
- altitude = 600.0
- gps = transformer.pixel_to_gps("flight_123", pixel, pose, cam, altitude, quaternion)
- Assert abs(gps.lat - 48.0) < 1e-6 (center pixel projects exactly below UAV)
- Assert abs(gps.lon - 37.0) < 1e-6
**Test: test_pixel_to_gps_off_center**
- Same setup as above, but pixel = (2728 + 100, 1816.0) (100 pixels to the right)
- gps = transformer.pixel_to_gps(...)
- The point should be offset east from origin (right in camera = east when north-facing with nadir camera)
- offset_enu = transformer.gps_to_enu("flight_123", gps)
- Assert offset_enu[0] > 0 (east offset from camera right)
- Verify ground offset is approximately: 100 pixels * (altitude / fx) where fx = 16*5456/23.2 = 3763.45
- Expected east offset ~= 100 * 600 / 3763.45 ~= 15.9 meters
- Assert abs(offset_enu[0] - 15.9) < 1.0
**Test: test_pixel_to_gps_roundtrip**
- Set origin, create camera, pose, identity quaternion, altitude=600
- For several test pixels: (1000, 1000), (3000, 2000), (4000, 500)
- gps = pixel_to_gps(... pixel, ... quaternion)
- pixel_back = gps_to_pixel(... gps, ... quaternion)
- Assert abs(pixel_back[0] - pixel[0]) < 0.1
- Assert abs(pixel_back[1] - pixel[1]) < 0.1
**Test: test_pixel_to_gps_altitude_scaling**
- Same pixel (off-center), two altitudes: 300m and 600m
- The ground offset at 600m should be approximately 2x the offset at 300m
- gps_300 = pixel_to_gps(... altitude=300)
- gps_600 = pixel_to_gps(... altitude=600)
- enu_300 = gps_to_enu(gps_300)
- enu_600 = gps_to_enu(gps_600)
- Assert abs(enu_600[0] / enu_300[0] - 2.0) < 0.1
**Test: test_pixel_to_gps_with_quaternion_rotation**
- Use a quaternion that represents a 90-degree yaw (heading east instead of north)
- What was "right" in camera (east when north-facing) becomes "south" when east-facing
- Verify the GPS offset direction changes accordingly
- Use quaternion for 90deg yaw around Z (down in NED, up in ENU):
- q = [cos(pi/4), 0, 0, sin(pi/4)] for yaw rotation
- pixel to the right of center should now project south, not east
**Test: test_transform_points_homography**
- points = [(0, 0), (100, 0), (100, 100), (0, 100)]
- H = identity [[1,0,0],[0,1,0],[0,0,1]]
- result = transformer.transform_points(points, H)
- Assert result == points (identity preserves points)
- H_translate = [[1,0,10],[0,1,20],[0,0,1]]
- result = transformer.transform_points(points, H_translate)
- Assert result[0] is approximately (10, 20)
**Test: test_transform_points_empty**
- result = transformer.transform_points([], [[1,0,0],[0,1,0],[0,0,1]])
- Assert result == []
**Test: test_image_object_to_gps_uses_real_camera**
- Set origin
- Use image center pixel
- result = transformer.image_object_to_gps("flight_123", 1, (2728.0, 1816.0))
- Assert result is close to origin (nadir projection with default altitude=100)
- Assert abs(result.lat - 48.0) < 0.01
**Test: test_build_intrinsic_matrix**
- from gps_denied.core.coordinates import _build_intrinsic_matrix
- cam = CameraParameters(focal_length=16.0, sensor_width=23.2, sensor_height=15.4, resolution_width=5456, resolution_height=3632)
- K = _build_intrinsic_matrix(cam)
- Assert K.shape == (3, 3)
- fx = 16.0 * 5456 / 23.2 # = 3763.45
- Assert abs(K[0, 0] - fx) < 1.0
- fy = 16.0 * 3632 / 15.4 # = 3773.51
- Assert abs(K[1, 1] - fy) < 1.0
- Assert abs(K[0, 2] - 2728.0) < 1.0 # cx = width/2
- Assert abs(K[1, 2] - 1816.0) < 1.0 # cy = height/2
Per ESKF-06: verifying the full coordinate chain pixel -> camera ray -> body -> ENU -> WGS84.
</action>
<verify>
<automated>cd /home/yuzviak/Azaion/gps-denied-onboard && python -m pytest tests/test_coordinates.py -v --tb=short 2>&1 | tail -30</automated>
</verify>
<acceptance_criteria>
- tests/test_coordinates.py contains `import numpy as np`
- tests/test_coordinates.py contains test_enu_origin_management (preserved from original)
- tests/test_coordinates.py contains test_gps_to_enu (preserved from original)
- tests/test_coordinates.py contains test_enu_roundtrip (preserved from original)
- tests/test_coordinates.py contains test_pixel_to_gps_center_projects_to_nadir
- tests/test_coordinates.py contains test_pixel_to_gps_off_center with assertion `abs(offset_enu[0] - 15.9) < 1.0` or similar ground truth check
- tests/test_coordinates.py contains test_pixel_to_gps_roundtrip testing at least 3 pixel positions
- tests/test_coordinates.py contains test_pixel_to_gps_altitude_scaling verifying 2x scaling
- tests/test_coordinates.py contains test_transform_points_homography
- tests/test_coordinates.py contains test_build_intrinsic_matrix verifying fx ~= 3763
- `pytest tests/test_coordinates.py` exits 0 (all tests pass)
</acceptance_criteria>
<done>Coordinate tests verify: center pixel projects to nadir (ESKF-06 chain), off-center pixel offset matches expected ground distance, pixel-to-GPS-to-pixel roundtrip within 0.1px, altitude scaling is linear, quaternion rotation changes projection direction, homography transform works via cv2, K matrix values match ADTI 20L V1 spec</done>
</task>
</tasks>
<verification>
1. `cd /home/yuzviak/Azaion/gps-denied-onboard && python -m pytest tests/test_eskf.py tests/test_coordinates.py -v --tb=short` — all tests pass
2. `cd /home/yuzviak/Azaion/gps-denied-onboard && python -m pytest tests/ -x --tb=short` — existing tests also still pass (no regressions)
3. Test count: `pytest tests/test_eskf.py --collect-only | grep "test session starts" -A 1` — at least 15 tests
4. Test count: `pytest tests/test_coordinates.py --collect-only | grep "test session starts" -A 1` — at least 10 tests
</verification>
<success_criteria>
- All ESKF unit tests pass: initialization, prediction, VO update, satellite update, confidence tiers, state retrieval, full fusion sequence
- All coordinate chain tests pass: center pixel nadir, off-center with ground truth, roundtrip, altitude scaling, quaternion rotation, homography, K matrix
- Existing tests in tests/ directory are not broken (no regressions)
- Phase 1 success criteria are met:
1. ESKF propagates and covariance grows (test_predict_covariance_grows)
2. VO update reduces uncertainty (test_vo_update_reduces_uncertainty)
3. Satellite update corrects position (test_satellite_update_corrects_position)
4. Confidence tiers work (test_confidence_high/medium/low/failed)
5. Full coordinate chain works (test_pixel_to_gps_center_projects_to_nadir, test_pixel_to_gps_roundtrip)
</success_criteria>
<output>
After completion, create `.planning/phases/01-eskf-core/01-03-SUMMARY.md`
</output>