18 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 01-eskf-core | 01 | execute | 1 |
|
true |
|
|
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).
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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 Task 1: Create ESKF schema contracts src/gps_denied/schemas/eskf.py - 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) Create `src/gps_denied/schemas/eskf.py` with these exact types:"""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:
from gps_denied.schemas.eskf import ConfidenceTier, ESKFConfig, ESKFState, IMUMeasurement
Per ESKF-01 (state vector), ESKF-04 (initialization config), ESKF-05 (confidence tiers).
python -c "from gps_denied.schemas.eskf import ESKFState, ESKFConfig, ConfidenceTier, IMUMeasurement; print('OK')"
<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>
All four ESKF schema classes importable, fields match solution.md spec exactly, ConfidenceTier has all four tiers
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(vv^T) + 2wskew(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.
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')
"
<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>
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)
<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>