Skip to main content

rust_robotics_slam/
graph_based_slam.rs

1#![allow(dead_code, clippy::too_many_arguments)]
2
3// Graph-based SLAM
4// author: Atsushi Sakai (@Atsushi_twi)
5//         Rust port
6//
7// Ref:
8// [A Tutorial on Graph-Based SLAM]
9// http://www2.informatik.uni-freiburg.de/~stachnis/pdf/grisetti10titsmag.pdf
10
11use nalgebra::{DMatrix, DVector, Matrix3, Vector3};
12use rand_distr::{Distribution, Normal};
13use std::f64::consts::PI;
14
15// Simulation parameters
16const DT: f64 = 2.0; // time step [s]
17const MAX_RANGE: f64 = 30.0; // maximum observation range [m]
18const STATE_SIZE: usize = 3; // State size [x, y, yaw]
19
20// Noise parameters
21const Q_SIM: [[f64; 2]; 2] = [[0.2, 0.0], [0.0, 0.00030462]]; // 0.00030462 ≈ deg2rad(1.0)^2
22const R_SIM: [[f64; 2]; 2] = [[0.1, 0.0], [0.0, 0.030462]]; // 0.030462 ≈ deg2rad(10.0)^2
23
24// Covariance parameters of Graph Based SLAM
25const C_SIGMA1: f64 = 0.1;
26const C_SIGMA2: f64 = 0.1;
27const C_SIGMA3: f64 = 0.017453; // deg2rad(1.0)
28
29const MAX_ITR: usize = 20; // Maximum iteration
30
31/// Observation data structure
32#[derive(Clone)]
33pub struct Observation {
34    pub d: f64,     // distance
35    pub angle: f64, // angle relative to robot
36    pub phi: f64,   // absolute angle
37    pub id: usize,  // landmark id
38}
39
40/// Edge represents a constraint between two poses
41#[derive(Clone)]
42struct Edge {
43    e: Vector3<f64>,     // error vector
44    omega: Matrix3<f64>, // information matrix
45    d1: f64,
46    d2: f64,
47    yaw1: f64,
48    yaw2: f64,
49    angle1: f64,
50    angle2: f64,
51    id1: usize,
52    id2: usize,
53}
54
55impl Edge {
56    fn new() -> Self {
57        Edge {
58            e: Vector3::zeros(),
59            omega: Matrix3::zeros(),
60            d1: 0.0,
61            d2: 0.0,
62            yaw1: 0.0,
63            yaw2: 0.0,
64            angle1: 0.0,
65            angle2: 0.0,
66            id1: 0,
67            id2: 0,
68        }
69    }
70}
71
72/// Normalize angle to [-pi, pi]
73fn pi_2_pi(angle: f64) -> f64 {
74    let mut a = angle;
75    while a > PI {
76        a -= 2.0 * PI;
77    }
78    while a < -PI {
79        a += 2.0 * PI;
80    }
81    a
82}
83
84/// Calculate observation noise covariance
85fn cal_observation_sigma() -> Matrix3<f64> {
86    Matrix3::from_diagonal(&Vector3::new(
87        C_SIGMA1 * C_SIGMA1,
88        C_SIGMA2 * C_SIGMA2,
89        C_SIGMA3 * C_SIGMA3,
90    ))
91}
92
93/// Calculate 3D rotation matrix around z-axis
94fn calc_3d_rotational_matrix(angle: f64) -> Matrix3<f64> {
95    let c = angle.cos();
96    let s = angle.sin();
97    Matrix3::new(c, -s, 0.0, s, c, 0.0, 0.0, 0.0, 1.0)
98}
99
100/// Calculate edge between two poses that observed the same landmark
101fn calc_edge(
102    x1: f64,
103    y1: f64,
104    yaw1: f64,
105    x2: f64,
106    y2: f64,
107    yaw2: f64,
108    d1: f64,
109    angle1: f64,
110    d2: f64,
111    angle2: f64,
112    t1: usize,
113    t2: usize,
114) -> Edge {
115    let mut edge = Edge::new();
116
117    let tangle1 = pi_2_pi(yaw1 + angle1);
118    let tangle2 = pi_2_pi(yaw2 + angle2);
119
120    let tmp1 = d1 * tangle1.cos();
121    let tmp2 = d2 * tangle2.cos();
122    let tmp3 = d1 * tangle1.sin();
123    let tmp4 = d2 * tangle2.sin();
124
125    // Error vector
126    edge.e[0] = x2 - x1 - tmp1 + tmp2;
127    edge.e[1] = y2 - y1 - tmp3 + tmp4;
128    edge.e[2] = 0.0;
129
130    // Information matrix
131    let rt1 = calc_3d_rotational_matrix(tangle1);
132    let rt2 = calc_3d_rotational_matrix(tangle2);
133
134    let sig1 = cal_observation_sigma();
135    let sig2 = cal_observation_sigma();
136
137    let cov = rt1 * sig1 * rt1.transpose() + rt2 * sig2 * rt2.transpose();
138    edge.omega = cov.try_inverse().unwrap_or(Matrix3::identity());
139
140    edge.d1 = d1;
141    edge.d2 = d2;
142    edge.yaw1 = yaw1;
143    edge.yaw2 = yaw2;
144    edge.angle1 = angle1;
145    edge.angle2 = angle2;
146    edge.id1 = t1;
147    edge.id2 = t2;
148
149    edge
150}
151
152/// Calculate all edges from pose and observation lists
153fn calc_edges(x_list: &DMatrix<f64>, z_list: &[Option<Vec<Observation>>]) -> (Vec<Edge>, f64) {
154    let mut edges = Vec::new();
155    let mut cost = 0.0;
156
157    let nt = z_list.len();
158
159    // Generate all pairs of time indices
160    for t1 in 0..nt {
161        for t2 in (t1 + 1)..nt {
162            let x1 = x_list[(0, t1)];
163            let y1 = x_list[(1, t1)];
164            let yaw1 = x_list[(2, t1)];
165            let x2 = x_list[(0, t2)];
166            let y2 = x_list[(1, t2)];
167            let yaw2 = x_list[(2, t2)];
168
169            // Check if both have observations
170            if let (Some(z1), Some(z2)) = (&z_list[t1], &z_list[t2]) {
171                // Find matching landmarks
172                for obs1 in z1 {
173                    for obs2 in z2 {
174                        if obs1.id == obs2.id {
175                            let edge = calc_edge(
176                                x1, y1, yaw1, x2, y2, yaw2, obs1.d, obs1.angle, obs2.d, obs2.angle,
177                                t1, t2,
178                            );
179
180                            // Calculate cost: e^T * omega * e
181                            let e_t_omega = edge.e.transpose() * edge.omega;
182                            cost += (e_t_omega * edge.e)[(0, 0)];
183
184                            edges.push(edge);
185                        }
186                    }
187                }
188            }
189        }
190    }
191
192    (edges, cost)
193}
194
195/// Calculate Jacobians for an edge
196fn calc_jacobian(edge: &Edge) -> (Matrix3<f64>, Matrix3<f64>) {
197    let t1 = edge.yaw1 + edge.angle1;
198    let a = Matrix3::new(
199        -1.0,
200        0.0,
201        edge.d1 * t1.sin(),
202        0.0,
203        -1.0,
204        -edge.d1 * t1.cos(),
205        0.0,
206        0.0,
207        0.0,
208    );
209
210    let t2 = edge.yaw2 + edge.angle2;
211    let b = Matrix3::new(
212        1.0,
213        0.0,
214        -edge.d2 * t2.sin(),
215        0.0,
216        1.0,
217        edge.d2 * t2.cos(),
218        0.0,
219        0.0,
220        0.0,
221    );
222
223    (a, b)
224}
225
226/// Fill H matrix and b vector for an edge
227fn fill_h_and_b(h: &mut DMatrix<f64>, b: &mut DVector<f64>, edge: &Edge) {
228    let (a, b_jac) = calc_jacobian(edge);
229
230    let id1 = edge.id1 * STATE_SIZE;
231    let id2 = edge.id2 * STATE_SIZE;
232
233    // H[id1:id1+3, id1:id1+3] += A^T * omega * A
234    let at_omega = a.transpose() * edge.omega;
235    let at_omega_a = at_omega * a;
236    let at_omega_b = at_omega * b_jac;
237
238    let bt_omega = b_jac.transpose() * edge.omega;
239    let bt_omega_a = bt_omega * a;
240    let bt_omega_b = bt_omega * b_jac;
241
242    for i in 0..STATE_SIZE {
243        for j in 0..STATE_SIZE {
244            h[(id1 + i, id1 + j)] += at_omega_a[(i, j)];
245            h[(id1 + i, id2 + j)] += at_omega_b[(i, j)];
246            h[(id2 + i, id1 + j)] += bt_omega_a[(i, j)];
247            h[(id2 + i, id2 + j)] += bt_omega_b[(i, j)];
248        }
249    }
250
251    // b[id1:id1+3] += A^T * omega * e
252    let at_omega_e = at_omega * edge.e;
253    let bt_omega_e = bt_omega * edge.e;
254
255    for i in 0..STATE_SIZE {
256        b[id1 + i] += at_omega_e[i];
257        b[id2 + i] += bt_omega_e[i];
258    }
259}
260
261/// Graph-based SLAM optimization
262pub fn graph_based_slam(x_init: &DMatrix<f64>, hz: &[Option<Vec<Observation>>]) -> DMatrix<f64> {
263    let mut x_opt = x_init.clone();
264    let nt = x_opt.ncols();
265    let n = nt * STATE_SIZE;
266
267    for _itr in 0..MAX_ITR {
268        let (edges, _cost) = calc_edges(&x_opt, hz);
269
270        let mut h = DMatrix::<f64>::zeros(n, n);
271        let mut b = DVector::<f64>::zeros(n);
272
273        for edge in &edges {
274            fill_h_and_b(&mut h, &mut b, edge);
275        }
276
277        // Fix origin by adding identity to first pose block
278        for i in 0..STATE_SIZE {
279            h[(i, i)] += 1.0;
280        }
281
282        // Solve H * dx = -b
283        let h_inv = match h.clone().try_inverse() {
284            Some(inv) => inv,
285            None => {
286                // Simple fallback - add regularization
287                let mut h_reg = h.clone();
288                for i in 0..n {
289                    h_reg[(i, i)] += 0.001;
290                }
291                h_reg.try_inverse().unwrap_or(DMatrix::identity(n, n))
292            }
293        };
294
295        let dx = -h_inv * &b;
296
297        // Update poses
298        for i in 0..nt {
299            for j in 0..STATE_SIZE {
300                x_opt[(j, i)] += dx[i * STATE_SIZE + j];
301            }
302        }
303
304        let diff = dx.dot(&dx);
305
306        if diff < 1.0e-5 {
307            break;
308        }
309    }
310
311    x_opt
312}
313
314/// Motion model for robot
315pub fn motion_model(x: &Vector3<f64>, u: &[f64; 2]) -> Vector3<f64> {
316    let yaw = x[2];
317    Vector3::new(
318        x[0] + u[0] * DT * yaw.cos(),
319        x[1] + u[0] * DT * yaw.sin(),
320        x[2] + u[1] * DT,
321    )
322}
323
324/// Calculate control input
325pub fn calc_input() -> [f64; 2] {
326    let v = 1.0; // [m/s]
327    let yaw_rate = 0.1; // [rad/s]
328    [v, yaw_rate]
329}
330
331/// Simulate observation and motion
332pub fn observation(
333    x_true: &Vector3<f64>,
334    xd: &Vector3<f64>,
335    u: &[f64; 2],
336    rfid: &[(f64, f64)],
337) -> (
338    Vector3<f64>,
339    Option<Vec<Observation>>,
340    Vector3<f64>,
341    [f64; 2],
342) {
343    let normal = Normal::new(0.0, 1.0).unwrap();
344    let mut rng = rand::rng();
345
346    // True state update
347    let x_true_new = motion_model(x_true, u);
348
349    // Observations
350    let mut z = Vec::new();
351    for (i, (lx, ly)) in rfid.iter().enumerate() {
352        let dx = lx - x_true_new[0];
353        let dy = ly - x_true_new[1];
354        let d = (dx * dx + dy * dy).sqrt();
355        let angle = pi_2_pi(dy.atan2(dx) - x_true_new[2]);
356        let phi = pi_2_pi(dy.atan2(dx));
357
358        if d <= MAX_RANGE {
359            // Add noise
360            let dn = d + normal.sample(&mut rng) * Q_SIM[0][0].sqrt();
361            let angle_noise = normal.sample(&mut rng) * Q_SIM[1][1].sqrt();
362            let angle_noisy = angle + angle_noise;
363            let phi_noisy = phi + angle_noise;
364
365            z.push(Observation {
366                d: dn,
367                angle: angle_noisy,
368                phi: phi_noisy,
369                id: i,
370            });
371        }
372    }
373
374    // Dead reckoning with noise
375    let ud = [
376        u[0] + normal.sample(&mut rng) * R_SIM[0][0].sqrt(),
377        u[1] + normal.sample(&mut rng) * R_SIM[1][1].sqrt(),
378    ];
379    let xd_new = motion_model(xd, &ud);
380
381    let z_opt = if z.is_empty() { None } else { Some(z) };
382
383    (x_true_new, z_opt, xd_new, ud)
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389
390    const EPS: f64 = 1e-10;
391
392    fn assert_near(a: f64, b: f64, eps: f64) {
393        assert!(
394            (a - b).abs() < eps,
395            "expected {b}, got {a} (diff={})",
396            (a - b).abs()
397        );
398    }
399
400    #[test]
401    fn test_motion_model() {
402        // Robot at origin facing +x, moving forward at 1 m/s with no rotation
403        let x = Vector3::new(0.0, 0.0, 0.0);
404        let u = [1.0, 0.0]; // v=1 m/s, yaw_rate=0
405        let x_new = motion_model(&x, &u);
406
407        // With DT=2.0, should move 2m in x, 0 in y, yaw unchanged
408        assert_near(x_new[0], 2.0, EPS);
409        assert_near(x_new[1], 0.0, EPS);
410        assert_near(x_new[2], 0.0, EPS);
411
412        // Robot facing PI/2 (north), moving forward
413        let x2 = Vector3::new(1.0, 2.0, std::f64::consts::FRAC_PI_2);
414        let u2 = [1.0, 0.0];
415        let x2_new = motion_model(&x2, &u2);
416
417        assert_near(x2_new[0], 1.0, 1e-9); // cos(pi/2) ~ 0
418        assert_near(x2_new[1], 4.0, 1e-9); // sin(pi/2) = 1, 2 + 1*2*1
419        assert_near(x2_new[2], std::f64::consts::FRAC_PI_2, EPS);
420    }
421
422    #[test]
423    fn test_calc_input() {
424        let u = calc_input();
425        assert_near(u[0], 1.0, EPS); // v = 1.0 m/s
426        assert_near(u[1], 0.1, EPS); // yaw_rate = 0.1 rad/s
427    }
428
429    #[test]
430    fn test_observation_struct() {
431        let obs = Observation {
432            d: 5.0,
433            angle: 0.3,
434            phi: 1.2,
435            id: 7,
436        };
437        assert_near(obs.d, 5.0, EPS);
438        assert_near(obs.angle, 0.3, EPS);
439        assert_near(obs.phi, 1.2, EPS);
440        assert_eq!(obs.id, 7);
441
442        // Test Clone
443        let obs2 = obs.clone();
444        assert_eq!(obs2.id, 7);
445        assert_near(obs2.d, 5.0, EPS);
446    }
447
448    #[test]
449    fn test_motion_model_zero_input() {
450        let x = Vector3::new(3.0, 4.0, 0.5);
451        let u = [0.0, 0.0];
452        let x_new = motion_model(&x, &u);
453
454        // Zero velocity and zero yaw rate should preserve state
455        assert_near(x_new[0], 3.0, EPS);
456        assert_near(x_new[1], 4.0, EPS);
457        assert_near(x_new[2], 0.5, EPS);
458    }
459
460    #[test]
461    fn test_graph_based_slam_small() {
462        // Minimal scenario: 2 poses, one with an observation, one without.
463        // This mainly verifies the function does not panic.
464        let n_poses = 3;
465        let mut x_init = DMatrix::<f64>::zeros(3, n_poses);
466        // Place poses along x-axis
467        for i in 0..n_poses {
468            x_init[(0, i)] = i as f64 * 2.0;
469            x_init[(1, i)] = 0.0;
470            x_init[(2, i)] = 0.0;
471        }
472
473        // Create observation data: poses 0 and 2 see the same landmark
474        let hz: Vec<Option<Vec<Observation>>> = vec![
475            Some(vec![Observation {
476                d: 5.0,
477                angle: 0.3,
478                phi: 0.3,
479                id: 0,
480            }]),
481            None,
482            Some(vec![Observation {
483                d: 3.0,
484                angle: -0.2,
485                phi: -0.2,
486                id: 0,
487            }]),
488        ];
489
490        let result = graph_based_slam(&x_init, &hz);
491
492        // Result should have same dimensions
493        assert_eq!(result.nrows(), 3);
494        assert_eq!(result.ncols(), n_poses);
495
496        // Values should be finite (no NaN or Inf)
497        for i in 0..result.nrows() {
498            for j in 0..result.ncols() {
499                assert!(
500                    result[(i, j)].is_finite(),
501                    "Result contains non-finite value at ({i}, {j})"
502                );
503            }
504        }
505    }
506}