1use nalgebra::{Matrix4, Vector2, Vector4};
7use rand::Rng;
8use rand_distr::{Distribution, Normal};
9use std::collections::HashSet;
10use std::f64::consts::PI;
11
12use rust_robotics_core::{RoboticsError, RoboticsResult, State2D, StateEstimator};
13
14pub type MCLState = Vector4<f64>;
16
17pub type MCLControl = Vector2<f64>;
19
20pub type MCLMeasurement = Vec<(f64, f64, f64)>;
22
23const X_BIN_SIZE: f64 = 0.5;
24const Y_BIN_SIZE: f64 = 0.5;
25const YAW_BIN_SIZE: f64 = 15.0 * PI / 180.0;
26
27#[derive(Debug, Clone)]
29pub struct Particle {
30 pub x: f64,
31 pub y: f64,
32 pub yaw: f64,
33 pub v: f64,
34 pub w: f64,
35}
36
37impl Particle {
38 fn new(x: f64, y: f64, yaw: f64, v: f64, n_particles: usize) -> Self {
39 Self {
40 x,
41 y,
42 yaw,
43 v,
44 w: 1.0 / n_particles as f64,
45 }
46 }
47}
48
49#[derive(Debug, Clone)]
51pub struct MonteCarloLocalizationConfig {
52 pub min_particles: usize,
54 pub max_particles: usize,
56 pub kld_epsilon: f64,
58 pub kld_z: f64,
60 pub range_noise: f64,
62 pub velocity_noise: f64,
64 pub yaw_rate_noise: f64,
66 pub dt: f64,
68}
69
70impl Default for MonteCarloLocalizationConfig {
71 fn default() -> Self {
72 Self {
73 min_particles: 100,
74 max_particles: 5000,
75 kld_epsilon: 0.05,
76 kld_z: 2.326,
77 range_noise: 0.2,
78 velocity_noise: 2.0,
79 yaw_rate_noise: 40.0_f64.to_radians(),
80 dt: 0.1,
81 }
82 }
83}
84
85impl MonteCarloLocalizationConfig {
86 pub fn validate(&self) -> RoboticsResult<()> {
88 if self.min_particles == 0 {
89 return Err(RoboticsError::InvalidParameter(
90 "MCL min_particles must be greater than zero".to_string(),
91 ));
92 }
93 if self.max_particles < self.min_particles {
94 return Err(RoboticsError::InvalidParameter(
95 "MCL max_particles must be greater than or equal to min_particles".to_string(),
96 ));
97 }
98 if !self.kld_epsilon.is_finite() || self.kld_epsilon <= 0.0 {
99 return Err(RoboticsError::InvalidParameter(
100 "MCL kld_epsilon must be positive and finite".to_string(),
101 ));
102 }
103 if !self.kld_z.is_finite() || self.kld_z <= 0.0 {
104 return Err(RoboticsError::InvalidParameter(
105 "MCL kld_z must be positive and finite".to_string(),
106 ));
107 }
108 if !self.range_noise.is_finite() || self.range_noise <= 0.0 {
109 return Err(RoboticsError::InvalidParameter(
110 "MCL range_noise must be positive and finite".to_string(),
111 ));
112 }
113 if !self.velocity_noise.is_finite() || self.velocity_noise < 0.0 {
114 return Err(RoboticsError::InvalidParameter(
115 "MCL velocity_noise must be non-negative and finite".to_string(),
116 ));
117 }
118 if !self.yaw_rate_noise.is_finite() || self.yaw_rate_noise < 0.0 {
119 return Err(RoboticsError::InvalidParameter(
120 "MCL yaw_rate_noise must be non-negative and finite".to_string(),
121 ));
122 }
123 if !self.dt.is_finite() || self.dt <= 0.0 {
124 return Err(RoboticsError::InvalidParameter(
125 "MCL dt must be positive and finite".to_string(),
126 ));
127 }
128
129 Ok(())
130 }
131}
132
133pub struct MonteCarloLocalizer {
135 particles: Vec<Particle>,
136 config: MonteCarloLocalizationConfig,
137 state_estimate: MCLState,
138 covariance_dyn: nalgebra::DMatrix<f64>,
139}
140
141impl MonteCarloLocalizer {
142 pub fn new(config: MonteCarloLocalizationConfig) -> Self {
144 Self::try_new(config).expect(
145 "invalid MCL configuration: particle bounds, KLD params, noises, and dt must be valid",
146 )
147 }
148
149 pub fn try_new(config: MonteCarloLocalizationConfig) -> RoboticsResult<Self> {
151 config.validate()?;
152 let n = config.min_particles;
153 let particles = (0..n)
154 .map(|_| Particle::new(0.0, 0.0, 0.0, 0.0, n))
155 .collect();
156 let mut mcl = Self {
157 particles,
158 config,
159 state_estimate: MCLState::zeros(),
160 covariance_dyn: nalgebra::DMatrix::zeros(4, 4),
161 };
162 mcl.refresh_cache();
163 Ok(mcl)
164 }
165
166 pub fn with_initial_state(
168 initial_state: MCLState,
169 config: MonteCarloLocalizationConfig,
170 ) -> Self {
171 Self::try_with_initial_state(initial_state, config)
172 .expect("invalid MCL initialization: state and configuration must be valid")
173 }
174
175 pub fn try_with_initial_state(
177 initial_state: MCLState,
178 config: MonteCarloLocalizationConfig,
179 ) -> RoboticsResult<Self> {
180 config.validate()?;
181 if initial_state.iter().any(|value| !value.is_finite()) {
182 return Err(RoboticsError::InvalidParameter(
183 "MCL initial state must contain only finite values".to_string(),
184 ));
185 }
186
187 let mut rng = rand::rng();
188 let n = config.min_particles;
189 let mut particles = Vec::with_capacity(n);
190 for _ in 0..n {
191 let x = initial_state[0] + rng.random_range(-1.0..1.0);
192 let y = initial_state[1] + rng.random_range(-1.0..1.0);
193 let yaw = initial_state[2] + rng.random_range(-0.25..0.25);
194 let v = initial_state[3] + rng.random_range(-0.5..0.5);
195 particles.push(Particle::new(x, y, yaw, v, n));
196 }
197
198 let mut mcl = Self {
199 particles,
200 config,
201 state_estimate: MCLState::zeros(),
202 covariance_dyn: nalgebra::DMatrix::zeros(4, 4),
203 };
204 mcl.refresh_cache();
205 Ok(mcl)
206 }
207
208 pub fn try_predict_with_control(&mut self, control: &MCLControl) -> RoboticsResult<()> {
210 if control.iter().any(|value| !value.is_finite()) {
211 return Err(RoboticsError::InvalidParameter(
212 "MCL control input must contain only finite values".to_string(),
213 ));
214 }
215
216 let mut rng = rand::rng();
217 let normal_v = if self.config.velocity_noise > 0.0 {
218 Some(Normal::new(0.0, self.config.velocity_noise).map_err(|_| {
219 RoboticsError::InvalidParameter(
220 "MCL velocity_noise must be a valid standard deviation".to_string(),
221 )
222 })?)
223 } else {
224 None
225 };
226 let normal_yaw = if self.config.yaw_rate_noise > 0.0 {
227 Some(Normal::new(0.0, self.config.yaw_rate_noise).map_err(|_| {
228 RoboticsError::InvalidParameter(
229 "MCL yaw_rate_noise must be a valid standard deviation".to_string(),
230 )
231 })?)
232 } else {
233 None
234 };
235
236 for particle in &mut self.particles {
237 let v_noise = normal_v
238 .as_ref()
239 .map(|normal| normal.sample(&mut rng))
240 .unwrap_or(0.0);
241 let yaw_noise = normal_yaw
242 .as_ref()
243 .map(|normal| normal.sample(&mut rng))
244 .unwrap_or(0.0);
245
246 let v_noisy = control[0] + v_noise;
247 let yaw_rate_noisy = control[1] + yaw_noise;
248
249 particle.x += v_noisy * particle.yaw.cos() * self.config.dt;
250 particle.y += v_noisy * particle.yaw.sin() * self.config.dt;
251 particle.yaw += yaw_rate_noisy * self.config.dt;
252 particle.v = v_noisy;
253 }
254
255 self.refresh_cache();
256 Ok(())
257 }
258
259 pub fn try_update_with_observations(
261 &mut self,
262 observations: &MCLMeasurement,
263 ) -> RoboticsResult<()> {
264 if observations
265 .iter()
266 .any(|(d, x, y)| !d.is_finite() || !x.is_finite() || !y.is_finite() || *d < 0.0)
267 {
268 return Err(RoboticsError::InvalidParameter(
269 "MCL observations must have finite, non-negative distances".to_string(),
270 ));
271 }
272
273 for particle in &mut self.particles {
274 let mut w = 1.0;
275 for &(d_obs, lx, ly) in observations {
276 let dx = particle.x - lx;
277 let dy = particle.y - ly;
278 let d_pred = (dx * dx + dy * dy).sqrt();
279 let diff = d_obs - d_pred;
280 w *= Self::gauss_likelihood(diff, self.config.range_noise);
281 }
282 particle.w = w;
283 }
284
285 self.normalize_weights();
286 self.refresh_cache();
287 Ok(())
288 }
289
290 pub fn try_step(
292 &mut self,
293 control: &MCLControl,
294 observations: &MCLMeasurement,
295 ) -> RoboticsResult<MCLState> {
296 self.try_predict_with_control(control)?;
297 self.try_update_with_observations(observations)?;
298 self.resample_adaptive();
299 Ok(self.estimate())
300 }
301
302 pub fn estimate(&self) -> MCLState {
304 self.state_estimate
305 }
306
307 pub fn state_2d(&self) -> State2D {
309 State2D::new(
310 self.state_estimate[0],
311 self.state_estimate[1],
312 self.state_estimate[2],
313 self.state_estimate[3],
314 )
315 }
316
317 pub fn particle_count(&self) -> usize {
319 self.particles.len()
320 }
321
322 fn resample_adaptive(&mut self) {
323 let n_current = self.particles.len();
324 if n_current == 0 {
325 return;
326 }
327
328 let mut cumulative_weights = Vec::with_capacity(n_current);
329 let mut cum_sum = 0.0;
330 for particle in &self.particles {
331 cum_sum += particle.w;
332 cumulative_weights.push(cum_sum);
333 }
334 if let Some(last) = cumulative_weights.last_mut() {
335 *last = 1.0;
336 }
337
338 let mut rng = rand::rng();
339 let mut bins: HashSet<(i32, i32, i32)> = HashSet::new();
340 let mut new_particles = Vec::with_capacity(self.config.max_particles);
341 let mut required = self.config.min_particles;
342
343 while new_particles.len() < self.config.max_particles {
344 let idx = Self::sample_index(&cumulative_weights, rng.random::<f64>());
345 let sampled = &self.particles[idx];
346
347 bins.insert(Self::quantize_particle(sampled));
348 let k = bins.len();
349 required = required.max(self.kld_required_particles(k));
350
351 new_particles.push(sampled.clone());
352 if new_particles.len() >= self.config.min_particles && new_particles.len() >= required {
353 break;
354 }
355 }
356
357 let n_new = new_particles.len();
358 let uniform_weight = 1.0 / n_new as f64;
359 for particle in &mut new_particles {
360 particle.w = uniform_weight;
361 }
362
363 self.particles = new_particles;
364 self.refresh_cache();
365 }
366
367 fn kld_required_particles(&self, k_bins: usize) -> usize {
368 if k_bins <= 1 {
369 return self.config.min_particles;
370 }
371
372 let k_minus_one = (k_bins - 1) as f64;
373 let term = 1.0 - 2.0 / (9.0 * k_minus_one)
374 + self.config.kld_z * (2.0 / (9.0 * k_minus_one)).sqrt();
375 let n = (k_minus_one / (2.0 * self.config.kld_epsilon)) * term.powi(3);
376
377 (n.ceil() as usize).clamp(self.config.min_particles, self.config.max_particles)
378 }
379
380 fn quantize_particle(particle: &Particle) -> (i32, i32, i32) {
381 let x_bin = (particle.x / X_BIN_SIZE).floor() as i32;
382 let y_bin = (particle.y / Y_BIN_SIZE).floor() as i32;
383 let yaw_bin = (particle.yaw / YAW_BIN_SIZE).floor() as i32;
384 (x_bin, y_bin, yaw_bin)
385 }
386
387 fn sample_index(cumulative_weights: &[f64], r: f64) -> usize {
388 cumulative_weights
389 .iter()
390 .position(|&w| r <= w)
391 .unwrap_or(cumulative_weights.len() - 1)
392 }
393
394 fn normalize_weights(&mut self) {
395 let sum_w: f64 = self.particles.iter().map(|p| p.w).sum();
396 if sum_w > 0.0 {
397 for particle in &mut self.particles {
398 particle.w /= sum_w;
399 }
400 } else {
401 let uniform_weight = 1.0 / self.particles.len() as f64;
402 for particle in &mut self.particles {
403 particle.w = uniform_weight;
404 }
405 }
406 }
407
408 fn gauss_likelihood(x: f64, sigma: f64) -> f64 {
409 let coeff = 1.0 / (2.0 * PI * sigma.powi(2)).sqrt();
410 coeff * (-x.powi(2) / (2.0 * sigma.powi(2))).exp()
411 }
412
413 fn compute_estimate(&self) -> MCLState {
414 let mut x_est = 0.0;
415 let mut y_est = 0.0;
416 let mut yaw_est = 0.0;
417 let mut v_est = 0.0;
418
419 for particle in &self.particles {
420 x_est += particle.w * particle.x;
421 y_est += particle.w * particle.y;
422 yaw_est += particle.w * particle.yaw;
423 v_est += particle.w * particle.v;
424 }
425
426 Vector4::new(x_est, y_est, yaw_est, v_est)
427 }
428
429 fn compute_covariance(&self, x_est: &MCLState) -> Matrix4<f64> {
430 let mut cov = Matrix4::zeros();
431 for particle in &self.particles {
432 let dx = Vector4::new(
433 particle.x - x_est[0],
434 particle.y - x_est[1],
435 particle.yaw - x_est[2],
436 particle.v - x_est[3],
437 );
438 cov += particle.w * dx * dx.transpose();
439 }
440 cov
441 }
442
443 fn refresh_cache(&mut self) {
444 self.state_estimate = self.compute_estimate();
445 let covariance = self.compute_covariance(&self.state_estimate);
446 self.covariance_dyn = nalgebra::DMatrix::from_fn(4, 4, |i, j| covariance[(i, j)]);
447 }
448}
449
450impl StateEstimator for MonteCarloLocalizer {
451 type State = MCLState;
452 type Measurement = MCLMeasurement;
453 type Control = MCLControl;
454
455 fn predict(&mut self, control: &Self::Control, _dt: f64) {
456 let _ = self.try_predict_with_control(control);
457 }
458
459 fn update(&mut self, measurement: &Self::Measurement) {
460 let _ = self.try_update_with_observations(measurement);
461 self.resample_adaptive();
462 }
463
464 fn get_state(&self) -> &Self::State {
465 &self.state_estimate
466 }
467
468 fn get_covariance(&self) -> Option<&nalgebra::DMatrix<f64>> {
469 Some(&self.covariance_dyn)
470 }
471}
472
473#[cfg(test)]
474mod tests {
475 use super::*;
476
477 fn synthetic_observations(state: &MCLState, landmarks: &[(f64, f64)]) -> MCLMeasurement {
478 landmarks
479 .iter()
480 .map(|&(lx, ly)| {
481 let dx = state[0] - lx;
482 let dy = state[1] - ly;
483 ((dx * dx + dy * dy).sqrt(), lx, ly)
484 })
485 .collect()
486 }
487
488 #[test]
489 fn test_mcl_converges() {
490 let config = MonteCarloLocalizationConfig {
491 min_particles: 250,
492 max_particles: 1200,
493 range_noise: 0.25,
494 velocity_noise: 0.05,
495 yaw_rate_noise: 0.02,
496 dt: 0.1,
497 ..Default::default()
498 };
499 let mut mcl = MonteCarloLocalizer::with_initial_state(MCLState::zeros(), config);
500 let landmarks = vec![(0.0, 0.0), (10.0, 0.0), (5.0, 8.0)];
501 let control = MCLControl::new(1.0, 0.03);
502 let mut truth = MCLState::zeros();
503
504 for _ in 0..60 {
505 truth[0] += control[0] * truth[2].cos() * 0.1;
506 truth[1] += control[0] * truth[2].sin() * 0.1;
507 truth[2] += control[1] * 0.1;
508 truth[3] = control[0];
509 let obs = synthetic_observations(&truth, &landmarks);
510 let _ = mcl.try_step(&control, &obs).unwrap();
511 }
512
513 let est = mcl.estimate();
514 let pos_err = ((est[0] - truth[0]).powi(2) + (est[1] - truth[1]).powi(2)).sqrt();
515 assert!(pos_err < 1.0, "position error too high: {pos_err}");
516 }
517
518 #[test]
519 fn test_mcl_particle_count_adapts() {
520 let config = MonteCarloLocalizationConfig {
521 min_particles: 100,
522 max_particles: 1500,
523 ..Default::default()
524 };
525 let mut mcl = MonteCarloLocalizer::new(config);
526
527 mcl.particles.clear();
528 let multimodal_n = 800;
529 for i in 0..multimodal_n {
530 let cluster = i % 4;
531 let base_x = cluster as f64 * 3.0;
532 let base_y = cluster as f64 * 2.0;
533 let mut p = Particle::new(base_x + (i as f64) * 0.002, base_y, 0.0, 0.0, multimodal_n);
534 p.w = 1.0 / multimodal_n as f64;
535 mcl.particles.push(p);
536 }
537 mcl.resample_adaptive();
538 let expanded_count = mcl.particle_count();
539 assert!(expanded_count > mcl.config.min_particles);
540
541 let concentrated_n = 600;
542 mcl.particles.clear();
543 for _ in 0..concentrated_n {
544 let mut p = Particle::new(1.0, 1.0, 0.1, 0.0, concentrated_n);
545 p.w = 1.0 / concentrated_n as f64;
546 mcl.particles.push(p);
547 }
548 mcl.resample_adaptive();
549 let reduced_count = mcl.particle_count();
550 assert!(reduced_count <= expanded_count);
551 }
552
553 #[test]
554 fn test_mcl_particle_count_stays_within_bounds() {
555 let config = MonteCarloLocalizationConfig {
556 min_particles: 120,
557 max_particles: 600,
558 velocity_noise: 0.5,
559 yaw_rate_noise: 0.2,
560 ..Default::default()
561 };
562 let mut mcl = MonteCarloLocalizer::new(config);
563 let landmarks = vec![(0.0, 0.0), (15.0, 0.0), (8.0, 12.0)];
564 let control = MCLControl::new(0.8, 0.05);
565 let mut truth = MCLState::zeros();
566
567 for _ in 0..40 {
568 truth[0] += control[0] * truth[2].cos() * 0.1;
569 truth[1] += control[0] * truth[2].sin() * 0.1;
570 truth[2] += control[1] * 0.1;
571 let obs = synthetic_observations(&truth, &landmarks);
572 let _ = mcl.try_step(&control, &obs).unwrap();
573 assert!(mcl.particle_count() >= mcl.config.min_particles);
574 assert!(mcl.particle_count() <= mcl.config.max_particles);
575 }
576 }
577
578 #[test]
579 fn test_mcl_config_defaults() {
580 let config = MonteCarloLocalizationConfig::default();
581 assert_eq!(config.min_particles, 100);
582 assert_eq!(config.max_particles, 5000);
583 assert!((config.kld_epsilon - 0.05).abs() < f64::EPSILON);
584 assert!((config.kld_z - 2.326).abs() < f64::EPSILON);
585 }
586}