Skip to main content

rust_robotics_slam/
ekf_slam.rs

1#![allow(dead_code)]
2
3// EKF SLAM (Extended Kalman Filter SLAM)
4// author: Atsushi Sakai (@Atsushi_twi)
5//         Ryohei Sasaki (@rsasaki0109)
6//         Rust port
7//
8// Reference:
9// - Probabilistic Robotics (Thrun, Burgard, Fox)
10// - https://github.com/AtsushiSakai/PythonRobotics
11
12use nalgebra::{DMatrix, DVector, Matrix2, Matrix3, Vector2, Vector3};
13use rand_distr::{Distribution, Normal};
14use std::f64::consts::PI;
15
16// Simulation parameters
17const DT: f64 = 0.1; // time step [s]
18const MAX_RANGE: f64 = 20.0; // maximum observation range [m]
19const M_DIST_TH: f64 = 4.0; // Mahalanobis distance threshold for data association (chi-square 95% for 2 DOF)
20
21// State dimension
22const STATE_SIZE: usize = 3; // robot state [x, y, yaw]
23const LM_SIZE: usize = 2; // landmark state [x, y]
24
25// Noise parameters
26const Q_SIM: [[f64; 2]; 2] = [[0.2, 0.0], [0.0, (5.0 * PI / 180.0) * (5.0 * PI / 180.0)]]; // process noise (reduced)
27const R_SIM: [[f64; 2]; 2] = [[0.3, 0.0], [0.0, (5.0 * PI / 180.0) * (5.0 * PI / 180.0)]]; // observation noise (reduced)
28
29/// Normalize angle to [-pi, pi]
30fn normalize_angle(angle: f64) -> f64 {
31    let mut a = angle;
32    while a > PI {
33        a -= 2.0 * PI;
34    }
35    while a < -PI {
36        a += 2.0 * PI;
37    }
38    a
39}
40
41/// Process noise covariance for control input
42fn get_q_control() -> Matrix2<f64> {
43    Matrix2::new(Q_SIM[0][0], Q_SIM[0][1], Q_SIM[1][0], Q_SIM[1][1])
44}
45
46/// Observation noise covariance
47fn get_r() -> Matrix2<f64> {
48    Matrix2::new(R_SIM[0][0], R_SIM[0][1], R_SIM[1][0], R_SIM[1][1])
49}
50
51/// EKF SLAM state
52/// State vector: [x, y, yaw, lm1_x, lm1_y, lm2_x, lm2_y, ...]
53pub struct EKFSLAMState {
54    /// State vector
55    pub x: DVector<f64>,
56    /// Covariance matrix
57    pub p: DMatrix<f64>,
58    /// Number of observed landmarks
59    pub n_lm: usize,
60}
61
62impl EKFSLAMState {
63    /// Create a new EKF SLAM state
64    pub fn new() -> Self {
65        EKFSLAMState {
66            x: DVector::zeros(STATE_SIZE),
67            p: DMatrix::identity(STATE_SIZE, STATE_SIZE),
68            n_lm: 0,
69        }
70    }
71
72    /// Get robot pose [x, y, yaw]
73    pub fn get_robot_pose(&self) -> Vector3<f64> {
74        Vector3::new(self.x[0], self.x[1], self.x[2])
75    }
76
77    /// Get landmark position by index
78    pub fn get_landmark(&self, idx: usize) -> Option<Vector2<f64>> {
79        if idx < self.n_lm {
80            let lm_idx = STATE_SIZE + idx * LM_SIZE;
81            Some(Vector2::new(self.x[lm_idx], self.x[lm_idx + 1]))
82        } else {
83            None
84        }
85    }
86
87    /// Get number of landmarks
88    pub fn n_landmarks(&self) -> usize {
89        self.n_lm
90    }
91}
92
93impl Default for EKFSLAMState {
94    fn default() -> Self {
95        Self::new()
96    }
97}
98
99/// Motion model for robot
100/// x_t = f(x_{t-1}, u_t)
101fn motion_model(x: &Vector3<f64>, u: &Vector2<f64>) -> Vector3<f64> {
102    Vector3::new(
103        x[0] + u[0] * DT * x[2].cos(),
104        x[1] + u[0] * DT * x[2].sin(),
105        normalize_angle(x[2] + u[1] * DT),
106    )
107}
108
109/// Jacobian of motion model with respect to state
110fn jacob_motion(x: &Vector3<f64>, u: &Vector2<f64>) -> (Matrix3<f64>, nalgebra::Matrix3x2<f64>) {
111    let yaw = x[2];
112    let v = u[0];
113
114    // Jacobian with respect to state (G matrix)
115    let g = Matrix3::new(
116        1.0,
117        0.0,
118        -DT * v * yaw.sin(),
119        0.0,
120        1.0,
121        DT * v * yaw.cos(),
122        0.0,
123        0.0,
124        1.0,
125    );
126
127    // Jacobian with respect to control (V matrix): 3x2
128    let v_mat = nalgebra::Matrix3x2::new(DT * yaw.cos(), 0.0, DT * yaw.sin(), 0.0, 0.0, DT);
129
130    (g, v_mat)
131}
132
133/// Calculate observation from robot pose to landmark
134fn calc_observation(robot_pose: &Vector3<f64>, landmark: &Vector2<f64>) -> Vector2<f64> {
135    let dx = landmark[0] - robot_pose[0];
136    let dy = landmark[1] - robot_pose[1];
137    let d = (dx * dx + dy * dy).sqrt();
138    let angle = normalize_angle(dy.atan2(dx) - robot_pose[2]);
139    Vector2::new(d, angle)
140}
141
142/// Jacobian of observation model with respect to robot pose and landmark position
143fn jacob_observation(
144    robot_pose: &Vector3<f64>,
145    landmark: &Vector2<f64>,
146) -> (nalgebra::Matrix2x3<f64>, Matrix2<f64>) {
147    let dx = landmark[0] - robot_pose[0];
148    let dy = landmark[1] - robot_pose[1];
149    let d2 = dx * dx + dy * dy;
150    let d = d2.sqrt();
151
152    // Jacobian with respect to robot pose [x, y, yaw]
153    let h_robot = nalgebra::Matrix2x3::new(-dx / d, -dy / d, 0.0, dy / d2, -dx / d2, -1.0);
154
155    // Jacobian with respect to landmark position [lm_x, lm_y]
156    let h_lm = Matrix2::new(dx / d, dy / d, -dy / d2, dx / d2);
157
158    (h_robot, h_lm)
159}
160
161/// EKF SLAM prediction step
162fn ekf_slam_predict(state: &mut EKFSLAMState, u: &Vector2<f64>) {
163    let _n = state.x.len();
164
165    // Get current robot pose
166    let robot_pose = state.get_robot_pose();
167
168    // Jacobians (computed before state update, using current state)
169    let (g, v_mat) = jacob_motion(&robot_pose, u);
170
171    // Predict robot pose using motion model
172    let new_pose = motion_model(&robot_pose, u);
173    state.x[0] = new_pose[0];
174    state.x[1] = new_pose[1];
175    state.x[2] = new_pose[2];
176
177    // Process noise in control space
178    let q_control = get_q_control();
179
180    // Process noise in state space (robot part only): V * Q * V^T (3x2 * 2x2 * 2x3 = 3x3)
181    let q_robot = v_mat * q_control * v_mat.transpose();
182
183    // Update covariance following EKF formula:
184    // P_new = F @ P @ F^T + Q_augmented
185    // where F is the Jacobian of full state transition (identity for landmarks)
186
187    // For EKF-SLAM, we need to propagate covariance properly:
188    // P_rr = G @ P_rr @ G^T + Q
189    // P_rm = G @ P_rm (cross-covariance between robot and landmarks)
190    // P_mr = P_rm^T
191    // P_mm = P_mm (landmarks covariance unchanged)
192
193    // Extract robot covariance (3x3)
194    let mut p_rr = Matrix3::zeros();
195    for i in 0..STATE_SIZE {
196        for j in 0..STATE_SIZE {
197            p_rr[(i, j)] = state.p[(i, j)];
198        }
199    }
200
201    // Update P_rr
202    let p_rr_new = g * p_rr * g.transpose() + q_robot;
203
204    // Update state covariance
205    for i in 0..STATE_SIZE {
206        for j in 0..STATE_SIZE {
207            state.p[(i, j)] = p_rr_new[(i, j)];
208        }
209    }
210
211    // Update cross-covariance P_rm = G @ P_rm
212    for lm in 0..state.n_lm {
213        let lm_idx = STATE_SIZE + lm * LM_SIZE;
214
215        // Extract P_rm for this landmark (3x2)
216        let mut p_rm = nalgebra::Matrix3x2::zeros();
217        for i in 0..STATE_SIZE {
218            for j in 0..LM_SIZE {
219                p_rm[(i, j)] = state.p[(i, lm_idx + j)];
220            }
221        }
222
223        // Update: P_rm_new = G @ P_rm
224        let p_rm_new = g * p_rm;
225
226        // Write back
227        for i in 0..STATE_SIZE {
228            for j in 0..LM_SIZE {
229                state.p[(i, lm_idx + j)] = p_rm_new[(i, j)];
230                state.p[(lm_idx + j, i)] = p_rm_new[(i, j)]; // Keep symmetry
231            }
232        }
233    }
234}
235
236/// Calculate innovation (measurement residual) for a landmark observation
237fn calc_innovation(
238    state: &EKFSLAMState,
239    lm_idx: usize,
240    z: &Vector2<f64>,
241) -> (Vector2<f64>, Matrix2<f64>, DMatrix<f64>) {
242    let robot_pose = state.get_robot_pose();
243    let landmark = state.get_landmark(lm_idx).unwrap();
244
245    // Predicted observation
246    let z_pred = calc_observation(&robot_pose, &landmark);
247
248    // Innovation
249    let y = Vector2::new(z[0] - z_pred[0], normalize_angle(z[1] - z_pred[1]));
250
251    // Jacobians
252    let (h_robot, h_lm) = jacob_observation(&robot_pose, &landmark);
253
254    // Build full Jacobian H
255    let n = state.x.len();
256    let mut h_full = DMatrix::zeros(2, n);
257
258    // Robot part
259    for i in 0..2 {
260        for j in 0..STATE_SIZE {
261            h_full[(i, j)] = h_robot[(i, j)];
262        }
263    }
264
265    // Landmark part
266    let lm_state_idx = STATE_SIZE + lm_idx * LM_SIZE;
267    for i in 0..2 {
268        for j in 0..LM_SIZE {
269            h_full[(i, lm_state_idx + j)] = h_lm[(i, j)];
270        }
271    }
272
273    // Innovation covariance
274    let r = get_r();
275    let s = &h_full * &state.p * h_full.transpose() + DMatrix::from_fn(2, 2, |i, j| r[(i, j)]);
276
277    (
278        y,
279        Matrix2::new(s[(0, 0)], s[(0, 1)], s[(1, 0)], s[(1, 1)]),
280        h_full,
281    )
282}
283
284/// Search for corresponding landmark using Mahalanobis distance
285fn search_correspond_landmark_id(state: &EKFSLAMState, z: &Vector2<f64>) -> Option<usize> {
286    let mut min_dist = f64::MAX;
287    let mut min_id = None;
288
289    for i in 0..state.n_lm {
290        let (y, s, _) = calc_innovation(state, i, z);
291
292        // Mahalanobis distance
293        if let Some(s_inv) = s.try_inverse() {
294            let mahal = (y.transpose() * s_inv * y)[(0, 0)];
295            if mahal < min_dist {
296                min_dist = mahal;
297                min_id = Some(i);
298            }
299        }
300    }
301
302    // Return match only if below threshold
303    if min_dist < M_DIST_TH * M_DIST_TH {
304        min_id
305    } else {
306        None
307    }
308}
309
310/// Add a new landmark to the state
311fn add_new_landmark(state: &mut EKFSLAMState, z: &Vector2<f64>) {
312    let robot_pose = state.get_robot_pose();
313
314    // Calculate landmark position from observation
315    let lm_x = robot_pose[0] + z[0] * (robot_pose[2] + z[1]).cos();
316    let lm_y = robot_pose[1] + z[0] * (robot_pose[2] + z[1]).sin();
317
318    // Extend state vector
319    let old_n = state.x.len();
320    let new_n = old_n + LM_SIZE;
321
322    let mut new_x = DVector::zeros(new_n);
323    for i in 0..old_n {
324        new_x[i] = state.x[i];
325    }
326    new_x[old_n] = lm_x;
327    new_x[old_n + 1] = lm_y;
328    state.x = new_x;
329
330    // Extend covariance matrix
331    let mut new_p = DMatrix::zeros(new_n, new_n);
332
333    // Copy old covariance
334    for i in 0..old_n {
335        for j in 0..old_n {
336            new_p[(i, j)] = state.p[(i, j)];
337        }
338    }
339
340    // Initialize new landmark covariance with large uncertainty
341    let r = get_r();
342
343    // Jacobian of landmark initialization with respect to robot pose and observation
344    let c = (robot_pose[2] + z[1]).cos();
345    let s = (robot_pose[2] + z[1]).sin();
346
347    // G_r: Jacobian w.r.t. robot pose [x, y, yaw]
348    let g_r = nalgebra::Matrix2x3::new(1.0, 0.0, -z[0] * s, 0.0, 1.0, z[0] * c);
349
350    // G_z: Jacobian w.r.t. observation [d, angle]
351    let g_z = Matrix2::new(c, -z[0] * s, s, z[0] * c);
352
353    // Initial landmark covariance
354    let p_rr = state.p.fixed_view::<3, 3>(0, 0);
355    let p_lm = g_r * p_rr * g_r.transpose() + g_z * r * g_z.transpose();
356
357    // Set landmark-landmark covariance
358    new_p[(old_n, old_n)] = p_lm[(0, 0)];
359    new_p[(old_n, old_n + 1)] = p_lm[(0, 1)];
360    new_p[(old_n + 1, old_n)] = p_lm[(1, 0)];
361    new_p[(old_n + 1, old_n + 1)] = p_lm[(1, 1)];
362
363    // Cross-covariance between robot and new landmark
364    let p_rl = p_rr * g_r.transpose();
365    for i in 0..STATE_SIZE {
366        for j in 0..LM_SIZE {
367            new_p[(i, old_n + j)] = p_rl[(i, j)];
368            new_p[(old_n + j, i)] = p_rl[(i, j)];
369        }
370    }
371
372    // Cross-covariance between existing landmarks and new landmark
373    for k in 0..state.n_lm {
374        let lm_idx = STATE_SIZE + k * LM_SIZE;
375        for i in 0..LM_SIZE {
376            for j in 0..STATE_SIZE {
377                let p_lk_r = state.p[(lm_idx + i, j)];
378                for l in 0..LM_SIZE {
379                    new_p[(lm_idx + i, old_n + l)] += p_lk_r * g_r[(l, j)];
380                    new_p[(old_n + l, lm_idx + i)] = new_p[(lm_idx + i, old_n + l)];
381                }
382            }
383        }
384    }
385
386    state.p = new_p;
387    state.n_lm += 1;
388}
389
390/// EKF SLAM update step for a single observation
391fn ekf_slam_update(state: &mut EKFSLAMState, z: &Vector2<f64>, lm_idx: usize) {
392    let (y, s, h_full) = calc_innovation(state, lm_idx, z);
393
394    // Kalman gain
395    let s_dmatrix = DMatrix::from_fn(2, 2, |i, j| s[(i, j)]);
396    let s_inv = s_dmatrix
397        .try_inverse()
398        .unwrap_or_else(|| DMatrix::identity(2, 2));
399    let k = &state.p * h_full.transpose() * s_inv;
400
401    // State update
402    let y_dvec = DVector::from_vec(vec![y[0], y[1]]);
403    state.x = &state.x + &k * y_dvec;
404
405    // Normalize yaw
406    state.x[2] = normalize_angle(state.x[2]);
407
408    // Covariance update
409    let n = state.x.len();
410    let i_kh = DMatrix::identity(n, n) - &k * h_full;
411    state.p = &i_kh * &state.p;
412
413    // Ensure symmetry
414    state.p = (&state.p + state.p.transpose()) * 0.5;
415}
416
417/// Full EKF SLAM step (prediction + update) with unknown data association
418pub fn ekf_slam(
419    state: &mut EKFSLAMState,
420    u: &Vector2<f64>,
421    observations: &[(f64, f64)], // (distance, angle)
422) {
423    // Prediction step
424    ekf_slam_predict(state, u);
425
426    // Update step for each observation
427    for (d, angle) in observations {
428        let z = Vector2::new(*d, *angle);
429
430        // Data association
431        let lm_idx = search_correspond_landmark_id(state, &z);
432
433        match lm_idx {
434            Some(idx) => {
435                // Update existing landmark
436                ekf_slam_update(state, &z, idx);
437            }
438            None => {
439                // Add new landmark
440                add_new_landmark(state, &z);
441            }
442        }
443    }
444}
445
446/// Full EKF SLAM step (prediction + update) with known data association
447/// This version uses landmark IDs from observations (ideal case)
448pub fn ekf_slam_known_correspondences(
449    state: &mut EKFSLAMState,
450    u: &Vector2<f64>,
451    observations: &[(f64, f64, usize)], // (distance, angle, landmark_id)
452    n_landmarks: usize,
453) {
454    // Prediction step
455    ekf_slam_predict(state, u);
456
457    // Pre-allocate space for all landmarks on first observation
458    if state.n_lm == 0 && !observations.is_empty() {
459        // Initialize state to hold all potential landmarks
460        let n = STATE_SIZE + n_landmarks * LM_SIZE;
461        let mut new_x = DVector::zeros(n);
462        for i in 0..STATE_SIZE {
463            new_x[i] = state.x[i];
464        }
465        // Initialize landmarks to (0, 0) with large covariance
466        state.x = new_x;
467
468        let mut new_p = DMatrix::identity(n, n) * 1e6; // Large initial uncertainty
469                                                       // Copy robot covariance
470        for i in 0..STATE_SIZE {
471            for j in 0..STATE_SIZE {
472                new_p[(i, j)] = state.p[(i, j)];
473            }
474        }
475        state.p = new_p;
476        state.n_lm = n_landmarks;
477    }
478
479    // Update step for each observation
480    for (d, angle, lm_id) in observations {
481        let z = Vector2::new(*d, *angle);
482
483        // Check if this is a valid landmark ID
484        if *lm_id < state.n_lm {
485            // Check if landmark is initialized (covariance is reasonable)
486            let lm_idx = STATE_SIZE + lm_id * LM_SIZE;
487            if state.p[(lm_idx, lm_idx)] > 1e5 {
488                // First observation of this landmark - initialize it
489                let robot_pose = state.get_robot_pose();
490                let lm_x = robot_pose[0] + z[0] * (robot_pose[2] + z[1]).cos();
491                let lm_y = robot_pose[1] + z[0] * (robot_pose[2] + z[1]).sin();
492                state.x[lm_idx] = lm_x;
493                state.x[lm_idx + 1] = lm_y;
494
495                // Initialize with observation covariance
496                let r = get_r();
497                let c = (robot_pose[2] + z[1]).cos();
498                let s = (robot_pose[2] + z[1]).sin();
499                let g_z = Matrix2::new(c, -z[0] * s, s, z[0] * c);
500                let p_lm = g_z * r * g_z.transpose();
501                state.p[(lm_idx, lm_idx)] = p_lm[(0, 0)] + 0.1;
502                state.p[(lm_idx, lm_idx + 1)] = p_lm[(0, 1)];
503                state.p[(lm_idx + 1, lm_idx)] = p_lm[(1, 0)];
504                state.p[(lm_idx + 1, lm_idx + 1)] = p_lm[(1, 1)] + 0.1;
505            } else {
506                // Update existing landmark
507                ekf_slam_update(state, &z, *lm_id);
508            }
509        }
510    }
511}
512
513/// Simulate observations from true robot pose to landmarks (without IDs)
514pub fn get_observations(x_true: &Vector3<f64>, landmarks: &[(f64, f64)]) -> Vec<(f64, f64)> {
515    let normal = Normal::new(0.0, 1.0).unwrap();
516    let r = get_r();
517    let mut z = Vec::new();
518
519    for (lx, ly) in landmarks.iter() {
520        let dx = lx - x_true[0];
521        let dy = ly - x_true[1];
522        let d = (dx * dx + dy * dy).sqrt();
523
524        if d <= MAX_RANGE {
525            let angle = normalize_angle(dy.atan2(dx) - x_true[2]);
526
527            // Add noise
528            let d_noisy = d + normal.sample(&mut rand::rng()) * r[(0, 0)].sqrt();
529            let angle_noisy = angle + normal.sample(&mut rand::rng()) * r[(1, 1)].sqrt();
530
531            z.push((d_noisy, angle_noisy));
532        }
533    }
534
535    z
536}
537
538/// Simulate observations from true robot pose to landmarks (with IDs - known correspondences)
539pub fn get_observations_with_id(
540    x_true: &Vector3<f64>,
541    landmarks: &[(f64, f64)],
542) -> Vec<(f64, f64, usize)> {
543    let normal = Normal::new(0.0, 1.0).unwrap();
544    let r = get_r();
545    let mut z = Vec::new();
546
547    for (lm_id, (lx, ly)) in landmarks.iter().enumerate() {
548        let dx = lx - x_true[0];
549        let dy = ly - x_true[1];
550        let d = (dx * dx + dy * dy).sqrt();
551
552        if d <= MAX_RANGE {
553            let angle = normalize_angle(dy.atan2(dx) - x_true[2]);
554
555            // Add noise
556            let d_noisy = d + normal.sample(&mut rand::rng()) * r[(0, 0)].sqrt();
557            let angle_noisy = angle + normal.sample(&mut rand::rng()) * r[(1, 1)].sqrt();
558
559            z.push((d_noisy, angle_noisy, lm_id));
560        }
561    }
562
563    z
564}
565
566#[cfg(test)]
567mod tests {
568    use super::*;
569
570    #[test]
571    fn test_ekf_slam_state_creation() {
572        let state = EKFSLAMState::new();
573        assert_eq!(state.x.len(), STATE_SIZE);
574        assert_eq!(state.n_lm, 0);
575    }
576
577    #[test]
578    fn test_normalize_angle() {
579        assert!((normalize_angle(0.0) - 0.0).abs() < 1e-10);
580        assert!((normalize_angle(PI) - PI).abs() < 1e-10);
581        assert!((normalize_angle(2.0 * PI) - 0.0).abs() < 1e-10);
582        assert!((normalize_angle(-2.0 * PI) - 0.0).abs() < 1e-10);
583        // 3π = π (after normalization), both π and -π are equivalent at boundary
584        assert!((normalize_angle(3.0 * PI).abs() - PI).abs() < 1e-10);
585    }
586
587    #[test]
588    fn test_motion_model() {
589        let x = Vector3::new(0.0, 0.0, 0.0);
590        let u = Vector2::new(1.0, 0.0); // move forward at 1 m/s
591        let new_x = motion_model(&x, &u);
592
593        // Should move in x direction (yaw is 0)
594        assert!(new_x[0] > 0.0);
595        assert!(new_x[1].abs() < 1e-10);
596        assert!(new_x[2].abs() < 1e-10);
597    }
598
599    #[test]
600    fn test_add_landmark() {
601        let mut state = EKFSLAMState::new();
602        let z = Vector2::new(5.0, 0.0); // landmark at 5m ahead
603
604        add_new_landmark(&mut state, &z);
605
606        assert_eq!(state.n_lm, 1);
607        assert_eq!(state.x.len(), STATE_SIZE + LM_SIZE);
608
609        // Landmark should be at (5, 0) since robot is at origin facing +x
610        let lm = state.get_landmark(0).unwrap();
611        assert!((lm[0] - 5.0).abs() < 0.1);
612        assert!(lm[1].abs() < 0.1);
613    }
614
615    #[test]
616    fn test_ekf_slam_prediction() {
617        let mut state = EKFSLAMState::new();
618        let u = Vector2::new(1.0, 0.1);
619
620        ekf_slam_predict(&mut state, &u);
621
622        // Robot should have moved forward and turned slightly
623        assert!(state.x[0] > 0.0);
624        assert!(state.x[2].abs() > 0.0);
625    }
626
627    #[test]
628    fn test_ekf_slam_full() {
629        let mut state = EKFSLAMState::new();
630        let u = Vector2::new(1.0, 0.0);
631        let observations = vec![(5.0, 0.0), (5.0, PI / 2.0)];
632
633        ekf_slam(&mut state, &u, &observations);
634
635        // Should have added 2 landmarks
636        assert_eq!(state.n_lm, 2);
637        assert_eq!(state.x.len(), STATE_SIZE + 2 * LM_SIZE);
638    }
639}