1#![allow(dead_code)]
2
3use nalgebra::{Matrix2, Matrix2x3, Matrix3, Vector2, Vector3};
14use rand::Rng;
15use rand_distr::{Distribution, Normal, Uniform};
16use std::f64::consts::PI;
17
18const DT: f64 = 0.1;
20const MAX_RANGE: f64 = 20.0;
21
22const N_PARTICLE: usize = 100;
24const NTH: f64 = N_PARTICLE as f64 / 1.5;
25
26const Q_SIM: [[f64; 2]; 2] = [[0.3, 0.0], [0.0, 0.0305]];
28const R_SIM: [[f64; 2]; 2] = [[0.5, 0.0], [0.0, 0.0305]];
29
30const MOTION_COV: [[f64; 3]; 3] = [[0.1, 0.0, 0.0], [0.0, 0.1, 0.0], [0.0, 0.0, 0.01]];
32
33#[derive(Clone)]
34pub struct Landmark {
35 pub x: f64,
36 pub y: f64,
37 pub cov: Matrix2<f64>,
38}
39
40impl Landmark {
41 fn new() -> Self {
42 Landmark {
43 x: 0.0,
44 y: 0.0,
45 cov: Matrix2::identity() * 1000.0,
46 }
47 }
48
49 fn is_initialized(&self) -> bool {
50 self.cov[(0, 0)] < 100.0
51 }
52}
53
54#[derive(Clone)]
55pub struct Particle {
56 pub weight: f64,
57 pub x: f64,
58 pub y: f64,
59 pub yaw: f64,
60 pub landmarks: Vec<Landmark>,
61}
62
63impl Particle {
64 fn new(n_landmarks: usize) -> Self {
65 Particle {
66 weight: 1.0 / N_PARTICLE as f64,
67 x: 0.0,
68 y: 0.0,
69 yaw: 0.0,
70 landmarks: vec![Landmark::new(); n_landmarks],
71 }
72 }
73
74 fn pose(&self) -> Vector3<f64> {
75 Vector3::new(self.x, self.y, self.yaw)
76 }
77
78 fn set_pose(&mut self, pose: &Vector3<f64>) {
79 self.x = pose[0];
80 self.y = pose[1];
81 self.yaw = normalize_angle(pose[2]);
82 }
83}
84
85fn normalize_angle(angle: f64) -> f64 {
86 let mut a = angle;
87 while a > PI {
88 a -= 2.0 * PI;
89 }
90 while a < -PI {
91 a += 2.0 * PI;
92 }
93 a
94}
95
96fn motion_model(x: Vector3<f64>, u: Vector2<f64>) -> Vector3<f64> {
97 let yaw = x[2];
98 Vector3::new(
99 x[0] + u[0] * DT * yaw.cos(),
100 x[1] + u[0] * DT * yaw.sin(),
101 normalize_angle(x[2] + u[1] * DT),
102 )
103}
104
105fn motion_jacobian(x: &Vector3<f64>, u: &Vector2<f64>) -> Matrix3<f64> {
107 let yaw = x[2];
108 let v = u[0];
109 Matrix3::new(
110 1.0,
111 0.0,
112 -v * DT * yaw.sin(),
113 0.0,
114 1.0,
115 v * DT * yaw.cos(),
116 0.0,
117 0.0,
118 1.0,
119 )
120}
121
122fn observation_model(px: f64, py: f64, pyaw: f64, lm_x: f64, lm_y: f64) -> Vector2<f64> {
123 let dx = lm_x - px;
124 let dy = lm_y - py;
125 let d = (dx * dx + dy * dy).sqrt();
126 let angle = normalize_angle(dy.atan2(dx) - pyaw);
127 Vector2::new(d, angle)
128}
129
130fn obs_jacobian_landmark(px: f64, py: f64, lm_x: f64, lm_y: f64) -> Matrix2<f64> {
132 let dx = lm_x - px;
133 let dy = lm_y - py;
134 let d2 = dx * dx + dy * dy;
135 let d = d2.sqrt();
136 Matrix2::new(dx / d, dy / d, -dy / d2, dx / d2)
137}
138
139fn obs_jacobian_pose(px: f64, py: f64, pyaw: f64, lm_x: f64, lm_y: f64) -> Matrix2x3<f64> {
141 let dx = lm_x - px;
142 let dy = lm_y - py;
143 let d2 = dx * dx + dy * dy;
144 let d = d2.sqrt();
145 let _ = pyaw; Matrix2x3::new(-dx / d, -dy / d, 0.0, dy / d2, -dx / d2, -1.0)
147}
148
149fn get_q() -> Matrix2<f64> {
150 Matrix2::new(Q_SIM[0][0], Q_SIM[0][1], Q_SIM[1][0], Q_SIM[1][1])
151}
152
153fn get_r() -> Matrix2<f64> {
154 Matrix2::new(R_SIM[0][0], R_SIM[0][1], R_SIM[1][0], R_SIM[1][1])
155}
156
157fn get_motion_cov() -> Matrix3<f64> {
158 Matrix3::new(
159 MOTION_COV[0][0],
160 MOTION_COV[0][1],
161 MOTION_COV[0][2],
162 MOTION_COV[1][0],
163 MOTION_COV[1][1],
164 MOTION_COV[1][2],
165 MOTION_COV[2][0],
166 MOTION_COV[2][1],
167 MOTION_COV[2][2],
168 )
169}
170
171fn compute_proposal(
174 particle: &Particle,
175 u: &Vector2<f64>,
176 z: &Vector2<f64>,
177 lm_id: usize,
178 r: &Matrix2<f64>,
179) -> (Vector3<f64>, Matrix3<f64>) {
180 let lm = &particle.landmarks[lm_id];
181
182 let x_pred = motion_model(particle.pose(), *u);
184 let g = motion_jacobian(&particle.pose(), u);
185 let motion_cov = get_motion_cov();
186 let p_pred = g * motion_cov * g.transpose();
187
188 if !lm.is_initialized() {
189 return (x_pred, p_pred);
191 }
192
193 let h_pose = obs_jacobian_pose(x_pred[0], x_pred[1], x_pred[2], lm.x, lm.y);
195
196 let h_lm = obs_jacobian_landmark(x_pred[0], x_pred[1], lm.x, lm.y);
198 let q_obs = h_lm * lm.cov * h_lm.transpose() + r;
199
200 let h_pose_t = h_pose.transpose();
203 let q_obs_inv = q_obs.try_inverse().unwrap_or(Matrix2::identity());
204
205 let p_pred_inv = p_pred.try_inverse().unwrap_or(Matrix3::identity() * 1e-6);
206 let p_post_inv = p_pred_inv + h_pose_t * q_obs_inv * h_pose;
207 let p_post = p_post_inv.try_inverse().unwrap_or(p_pred);
208
209 let z_pred = observation_model(x_pred[0], x_pred[1], x_pred[2], lm.x, lm.y);
211 let innovation = Vector2::new(z[0] - z_pred[0], normalize_angle(z[1] - z_pred[1]));
212
213 let x_post = x_pred + p_post * h_pose_t * q_obs_inv * innovation;
214
215 (x_post, p_post)
216}
217
218fn sample_pose_with_rng<R: Rng + ?Sized>(
220 mean: &Vector3<f64>,
221 cov: &Matrix3<f64>,
222 rng: &mut R,
223) -> Vector3<f64> {
224 let normal = Normal::new(0.0, 1.0).unwrap();
225
226 let l = cov.cholesky().map(|c| c.l()).unwrap_or_else(|| {
228 Matrix3::from_diagonal(&Vector3::new(
230 cov[(0, 0)].max(0.0).sqrt(),
231 cov[(1, 1)].max(0.0).sqrt(),
232 cov[(2, 2)].max(0.0).sqrt(),
233 ))
234 });
235
236 let noise = Vector3::new(normal.sample(rng), normal.sample(rng), normal.sample(rng));
237
238 mean + l * noise
239}
240
241fn update_landmark_and_weight(
243 particle: &mut Particle,
244 z: &Vector2<f64>,
245 lm_id: usize,
246 r: &Matrix2<f64>,
247) -> f64 {
248 let lm = &particle.landmarks[lm_id];
249
250 if !lm.is_initialized() {
251 particle.landmarks[lm_id].x = particle.x + z[0] * (particle.yaw + z[1]).cos();
253 particle.landmarks[lm_id].y = particle.y + z[0] * (particle.yaw + z[1]).sin();
254 particle.landmarks[lm_id].cov = Matrix2::identity() * 10.0;
255 return 1.0; }
257
258 let z_pred = observation_model(particle.x, particle.y, particle.yaw, lm.x, lm.y);
259 let innovation = Vector2::new(z[0] - z_pred[0], normalize_angle(z[1] - z_pred[1]));
260
261 let h = obs_jacobian_landmark(particle.x, particle.y, lm.x, lm.y);
262 let s = h * particle.landmarks[lm_id].cov * h.transpose() + r;
263 let s_inv = s.try_inverse().unwrap_or(Matrix2::identity());
264 let k = particle.landmarks[lm_id].cov * h.transpose() * s_inv;
265
266 let delta = k * innovation;
268 particle.landmarks[lm_id].x += delta[0];
269 particle.landmarks[lm_id].y += delta[1];
270 particle.landmarks[lm_id].cov = (Matrix2::identity() - k * h) * particle.landmarks[lm_id].cov;
271
272 let det_s = s.determinant();
274 if det_s > 0.0 {
275 let mahal = innovation.transpose() * s_inv * innovation;
276 (-0.5 * mahal[(0, 0)]).exp() / (2.0 * PI * det_s.sqrt())
277 } else {
278 1e-10
279 }
280}
281
282fn compute_neff(particles: &[Particle]) -> f64 {
283 let sum_w2: f64 = particles.iter().map(|p| p.weight * p.weight).sum();
284 if sum_w2 > 0.0 {
285 1.0 / sum_w2
286 } else {
287 0.0
288 }
289}
290
291fn normalize_weights(particles: &mut [Particle]) {
292 let sum_w: f64 = particles.iter().map(|p| p.weight).sum();
293 if sum_w > 0.0 {
294 for p in particles.iter_mut() {
295 p.weight /= sum_w;
296 }
297 }
298}
299
300fn resample_with_rng<R: Rng + ?Sized>(particles: &mut Vec<Particle>, rng: &mut R) {
301 normalize_weights(particles);
302 let n = particles.len();
303 let mut new_particles = Vec::with_capacity(n);
304
305 let mut cum_sum = vec![0.0; n + 1];
306 for (i, p) in particles.iter().enumerate() {
307 cum_sum[i + 1] = cum_sum[i] + p.weight;
308 }
309
310 let uniform = Uniform::new(0.0, 1.0 / n as f64).expect("valid resampling range");
311 let mut r = uniform.sample(rng);
312 let mut j = 0;
313 for _ in 0..n {
314 while r > cum_sum[j + 1] && j < n - 1 {
315 j += 1;
316 }
317 let mut new_p = particles[j].clone();
318 new_p.weight = 1.0 / n as f64;
319 new_particles.push(new_p);
320 r += 1.0 / n as f64;
321 }
322
323 *particles = new_particles;
324}
325
326fn fastslam2_update_with_rng<R: Rng + ?Sized>(
332 particles: &mut Vec<Particle>,
333 u: Vector2<f64>,
334 z: &[(f64, f64, usize)], rng: &mut R,
336) {
337 let r = get_r();
338
339 for particle in particles.iter_mut() {
340 if let Some(&(d, angle, lm_id)) = z.first() {
342 let z_vec = Vector2::new(d, angle);
343 let (proposal_mean, proposal_cov) = compute_proposal(particle, &u, &z_vec, lm_id, &r);
344
345 let sampled_pose = sample_pose_with_rng(&proposal_mean, &proposal_cov, rng);
347 particle.set_pose(&sampled_pose);
348 } else {
349 let normal = Normal::new(0.0, 1.0).unwrap();
351 let q = get_q();
352 let u_noisy = Vector2::new(
353 u[0] + normal.sample(rng) * q[(0, 0)].sqrt(),
354 u[1] + normal.sample(rng) * q[(1, 1)].sqrt(),
355 );
356 let pose = motion_model(particle.pose(), u_noisy);
357 particle.set_pose(&pose);
358 }
359
360 for &(d, angle, lm_id) in z {
362 let z_vec = Vector2::new(d, angle);
363 let w = update_landmark_and_weight(particle, &z_vec, lm_id, &r);
364 particle.weight *= w;
365 }
366 }
367
368 normalize_weights(particles);
369
370 let neff = compute_neff(particles);
371 if neff < NTH {
372 resample_with_rng(particles, rng);
373 }
374}
375
376pub fn fastslam2_update(
377 particles: &mut Vec<Particle>,
378 u: Vector2<f64>,
379 z: &[(f64, f64, usize)], ) {
381 let mut rng = rand::rng();
382 fastslam2_update_with_rng(particles, u, z, &mut rng);
383}
384
385pub fn get_best_particle(particles: &[Particle]) -> &Particle {
386 particles
387 .iter()
388 .max_by(|a, b| a.weight.partial_cmp(&b.weight).unwrap())
389 .unwrap()
390}
391
392fn get_observations_with_rng<R: Rng + ?Sized>(
393 x_true: &Vector3<f64>,
394 landmarks: &[(f64, f64)],
395 rng: &mut R,
396) -> Vec<(f64, f64, usize)> {
397 let normal = Normal::new(0.0, 1.0).unwrap();
398 let r = get_r();
399 let mut z = Vec::new();
400
401 for (lm_id, (lx, ly)) in landmarks.iter().enumerate() {
402 let dx = lx - x_true[0];
403 let dy = ly - x_true[1];
404 let d = (dx * dx + dy * dy).sqrt();
405
406 if d <= MAX_RANGE {
407 let angle = normalize_angle(dy.atan2(dx) - x_true[2]);
408 let d_noisy = d + normal.sample(rng) * r[(0, 0)].sqrt();
409 let angle_noisy = angle + normal.sample(rng) * r[(1, 1)].sqrt();
410 z.push((d_noisy, angle_noisy, lm_id));
411 }
412 }
413
414 z
415}
416
417pub fn get_observations(x_true: &Vector3<f64>, landmarks: &[(f64, f64)]) -> Vec<(f64, f64, usize)> {
418 let mut rng = rand::rng();
419 get_observations_with_rng(x_true, landmarks, &mut rng)
420}
421
422pub fn create_particles(n_particles: usize, n_landmarks: usize) -> Vec<Particle> {
423 (0..n_particles)
424 .map(|_| Particle::new(n_landmarks))
425 .collect()
426}
427
428#[cfg(test)]
429mod tests {
430 use super::*;
431 use rand::{rngs::StdRng, SeedableRng};
432
433 #[test]
434 fn test_create_particles() {
435 let particles = create_particles(50, 5);
436 assert_eq!(particles.len(), 50);
437 for p in &particles {
438 assert_eq!(p.landmarks.len(), 5);
439 }
440 }
441
442 #[test]
443 fn test_fastslam2_update_does_not_panic() {
444 let mut particles = create_particles(20, 3);
445 let u = Vector2::new(1.0, 0.1);
446 let landmarks = vec![(10.0, 0.0), (0.0, 10.0), (10.0, 10.0)];
447 let x_true = Vector3::new(0.0, 0.0, 0.0);
448 let mut rng = StdRng::seed_from_u64(7);
449
450 for _ in 0..5 {
451 let z = get_observations_with_rng(&x_true, &landmarks, &mut rng);
452 fastslam2_update_with_rng(&mut particles, u, &z, &mut rng);
453 }
454
455 assert_eq!(particles.len(), 20);
456 }
457
458 #[test]
459 fn test_proposal_improves_with_observation() {
460 let mut p = Particle::new(1);
461 p.landmarks[0].x = 5.0;
463 p.landmarks[0].y = 0.0;
464 p.landmarks[0].cov = Matrix2::identity() * 0.5;
465
466 let u = Vector2::new(1.0, 0.0);
467 let z = Vector2::new(5.0, 0.0); let r = get_r();
469
470 let (mean, cov) = compute_proposal(&p, &u, &z, 0, &r);
471
472 let g = motion_jacobian(&p.pose(), &u);
474 let motion_only_cov = g * get_motion_cov() * g.transpose();
475
476 assert!(
477 cov.determinant() < motion_only_cov.determinant(),
478 "proposal should have less uncertainty than motion-only"
479 );
480
481 let x_pred = motion_model(p.pose(), u);
483 let diff = (mean - x_pred).norm();
484 assert!(
485 diff < 1.0,
486 "proposal mean should be near prediction: diff={diff}"
487 );
488 }
489
490 #[test]
491 fn test_landmark_convergence() {
492 let mut particles = create_particles(120, 1);
493 let landmarks = vec![(5.0, 5.0)];
494 let mut x_true = Vector3::new(0.0, 0.0, PI / 4.0);
495 let u = Vector2::new(0.5, 0.0);
496 let mut rng = StdRng::seed_from_u64(17);
497
498 for _ in 0..60 {
499 x_true = motion_model(x_true, u);
500 let z = get_observations_with_rng(&x_true, &landmarks, &mut rng);
501 fastslam2_update_with_rng(&mut particles, u, &z, &mut rng);
502 }
503
504 let initialized: Vec<&Particle> = particles
505 .iter()
506 .filter(|particle| particle.landmarks[0].is_initialized())
507 .collect();
508 assert!(
509 !initialized.is_empty(),
510 "at least one particle should initialize the landmark"
511 );
512
513 let total_weight: f64 = initialized.iter().map(|particle| particle.weight).sum();
514 let (mean_x, mean_y) = if total_weight > 0.0 {
515 let weighted_x = initialized
516 .iter()
517 .map(|particle| particle.weight * particle.landmarks[0].x)
518 .sum::<f64>()
519 / total_weight;
520 let weighted_y = initialized
521 .iter()
522 .map(|particle| particle.weight * particle.landmarks[0].y)
523 .sum::<f64>()
524 / total_weight;
525 (weighted_x, weighted_y)
526 } else {
527 let avg_x = initialized
528 .iter()
529 .map(|particle| particle.landmarks[0].x)
530 .sum::<f64>()
531 / initialized.len() as f64;
532 let avg_y = initialized
533 .iter()
534 .map(|particle| particle.landmarks[0].y)
535 .sum::<f64>()
536 / initialized.len() as f64;
537 (avg_x, avg_y)
538 };
539
540 let lm_err = ((mean_x - 5.0).powi(2) + (mean_y - 5.0).powi(2)).sqrt();
541 assert!(
542 lm_err < 6.0,
543 "landmark estimate should converge: err={lm_err}"
544 );
545 }
546}