use crate::core::scalar::ControlScalar;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NavigationError {
InvalidParameter,
InvalidMeasurement,
InvalidLandmarkId,
InvalidNode,
TooManyEdges,
SingularSystem,
}
#[derive(Debug)]
pub struct DeadReckoning<S: ControlScalar> {
state: [S; 5],
wheelbase: S,
alpha: S,
dt: S,
theta_imu: S,
}
impl<S: ControlScalar> DeadReckoning<S> {
pub fn new(wheelbase: S, alpha: S, dt: S) -> Result<Self, NavigationError> {
if wheelbase <= S::ZERO {
return Err(NavigationError::InvalidParameter);
}
if dt <= S::ZERO {
return Err(NavigationError::InvalidParameter);
}
if alpha < S::ZERO || alpha > S::ONE {
return Err(NavigationError::InvalidParameter);
}
Ok(Self {
state: [S::ZERO; 5],
wheelbase,
alpha,
dt,
theta_imu: S::ZERO,
})
}
pub fn update_odometry(&mut self, v_l: S, v_r: S) -> Result<(), NavigationError> {
if !v_l.is_finite() || !v_r.is_finite() {
return Err(NavigationError::InvalidMeasurement);
}
let two = S::TWO;
let v = (v_r + v_l) / two;
let omega = (v_r - v_l) / self.wheelbase;
self.state[3] = v;
self.state[4] = omega;
let theta = self.state[2];
let cos_theta = S::from_f64(libm::cos(theta.to_f64()));
let sin_theta = S::from_f64(libm::sin(theta.to_f64()));
self.state[0] += v * cos_theta * self.dt;
self.state[1] += v * sin_theta * self.dt;
self.state[2] = theta + omega * self.dt;
Ok(())
}
pub fn update_imu(&mut self, gyro_z: S) -> Result<(), NavigationError> {
if !gyro_z.is_finite() {
return Err(NavigationError::InvalidMeasurement);
}
self.theta_imu += gyro_z * self.dt;
let theta_odom = self.state[2];
self.state[2] = self.alpha * theta_odom + (S::ONE - self.alpha) * self.theta_imu;
Ok(())
}
#[inline]
pub fn pose(&self) -> [S; 3] {
[self.state[0], self.state[1], self.state[2]]
}
#[inline]
pub fn velocity(&self) -> [S; 2] {
[self.state[3], self.state[4]]
}
pub fn reset(&mut self) {
self.state = [S::ZERO; 5];
self.theta_imu = S::ZERO;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn straight_line_increases_x() {
let mut dr = DeadReckoning::<f64>::new(0.5, 1.0, 0.1).unwrap();
for _ in 0..10 {
dr.update_odometry(1.0, 1.0).unwrap();
}
let pose = dr.pose();
assert!(pose[0] > 0.9, "x should be ~1.0, got {}", pose[0]);
assert!(pose[1].abs() < 1e-10, "y should be 0, got {}", pose[1]);
assert!(pose[2].abs() < 1e-10, "theta should be 0");
}
#[test]
fn pure_rotation_changes_heading() {
let wheelbase = 0.5_f64;
let dt = 0.1_f64;
let mut dr = DeadReckoning::<f64>::new(wheelbase, 1.0, dt).unwrap();
let v_spin = 0.5_f64; for _ in 0..10 {
dr.update_odometry(-v_spin, v_spin).unwrap();
}
let pose = dr.pose();
let expected_theta = 2.0_f64;
assert!(
(pose[2] - expected_theta).abs() < 1e-9,
"theta mismatch: got {}, expected {}",
pose[2],
expected_theta
);
assert!(pose[0].abs() < 1e-10, "x should be 0 for pure rotation");
assert!(pose[1].abs() < 1e-10, "y should be 0 for pure rotation");
}
#[test]
fn imu_fusion_alpha_zero_uses_imu() {
let mut dr = DeadReckoning::<f64>::new(0.5, 0.0, 0.1).unwrap();
dr.update_odometry(1.0, 1.0).unwrap();
dr.update_imu(5.0).unwrap(); let pose = dr.pose();
assert!(
(pose[2] - 0.5).abs() < 1e-10,
"with alpha=0 theta should equal imu-integrated heading: {}",
pose[2]
);
}
#[test]
fn imu_fusion_alpha_one_uses_odometry() {
let mut dr = DeadReckoning::<f64>::new(0.5, 1.0, 0.1).unwrap();
dr.update_odometry(1.0, 1.0).unwrap();
dr.update_imu(100.0).unwrap();
let pose = dr.pose();
assert!(
pose[2].abs() < 1e-10,
"with alpha=1 theta should equal odometry heading: {}",
pose[2]
);
}
#[test]
fn zero_motion_state_unchanged() {
let mut dr = DeadReckoning::<f64>::new(0.5, 0.8, 0.01).unwrap();
for _ in 0..100 {
dr.update_odometry(0.0, 0.0).unwrap();
dr.update_imu(0.0).unwrap();
}
let pose = dr.pose();
assert!(pose[0].abs() < 1e-12);
assert!(pose[1].abs() < 1e-12);
assert!(pose[2].abs() < 1e-12);
}
#[test]
fn complementary_filter_weighted_average() {
let alpha = 0.7_f64;
let dt = 0.1_f64;
let mut dr = DeadReckoning::<f64>::new(0.5, alpha, dt).unwrap();
let omega_odom = 2.0_f64;
let v_r = omega_odom * 0.5 / 2.0; let v_l = -v_r;
dr.update_odometry(v_l, v_r).unwrap();
let theta_odom = omega_odom * dt;
let gyro_z = 3.0_f64;
dr.update_imu(gyro_z).unwrap();
let theta_imu = gyro_z * dt;
let expected = alpha * theta_odom + (1.0 - alpha) * theta_imu;
let pose = dr.pose();
assert!(
(pose[2] - expected).abs() < 1e-10,
"complementary filter: got {}, expected {}",
pose[2],
expected
);
}
#[test]
fn invalid_wheelbase_returns_error() {
let result = DeadReckoning::<f64>::new(0.0, 0.5, 0.01);
assert_eq!(result.unwrap_err(), NavigationError::InvalidParameter);
}
#[test]
fn invalid_alpha_returns_error() {
let result = DeadReckoning::<f64>::new(0.5, 1.5, 0.01);
assert_eq!(result.unwrap_err(), NavigationError::InvalidParameter);
}
#[test]
fn nan_odometry_returns_error() {
let mut dr = DeadReckoning::<f64>::new(0.5, 0.8, 0.01).unwrap();
let result = dr.update_odometry(f64::NAN, 1.0);
assert_eq!(result.unwrap_err(), NavigationError::InvalidMeasurement);
}
#[test]
fn reset_clears_state() {
let mut dr = DeadReckoning::<f64>::new(0.5, 0.8, 0.1).unwrap();
dr.update_odometry(1.0, 1.0).unwrap();
dr.update_imu(1.0).unwrap();
dr.reset();
let pose = dr.pose();
assert!(pose[0].abs() < 1e-12);
assert!(pose[1].abs() < 1e-12);
assert!(pose[2].abs() < 1e-12);
let vel = dr.velocity();
assert!(vel[0].abs() < 1e-12);
assert!(vel[1].abs() < 1e-12);
}
}