1use rust_robotics_core::{RoboticsError, RoboticsResult};
8
9use crate::mppi::{MppiMovingObstacle2D, MppiState2D};
10
11const EPS: f64 = 1e-9;
12
13#[derive(Debug, Clone, Copy, PartialEq)]
15pub struct MppiPersonFollowingCandidate2D {
16 pub x: f64,
17 pub y: f64,
18 pub offset_x: f64,
19 pub offset_y: f64,
20 pub distance_to_target: f64,
21 pub bearing: f64,
22 pub total_cost: f64,
23 pub visibility_cost: f64,
24 pub proximity_cost: f64,
25 pub distance_cost: f64,
26 pub travel_cost: f64,
27 pub stickiness_cost: f64,
28 pub feasible: bool,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq)]
33pub struct MppiPersonFollowingConfig2D {
34 pub candidate_count: usize,
35 pub horizon: usize,
36 pub dt: f64,
37 pub personal_radius: f64,
38 pub social_radius: f64,
39 pub desired_distance: f64,
40 pub proximity_margin: f64,
41 pub occlusion_margin: f64,
42 pub visibility_weight: f64,
43 pub proximity_weight: f64,
44 pub distance_weight: f64,
45 pub travel_weight: f64,
46 pub stickiness_weight: f64,
47}
48
49impl Default for MppiPersonFollowingConfig2D {
50 fn default() -> Self {
51 Self {
52 candidate_count: 64,
53 horizon: 16,
54 dt: 0.1,
55 personal_radius: 0.55,
56 social_radius: 1.45,
57 desired_distance: 0.95,
58 proximity_margin: 0.28,
59 occlusion_margin: 0.18,
60 visibility_weight: 7.0,
61 proximity_weight: 5.0,
62 distance_weight: 1.8,
63 travel_weight: 0.18,
64 stickiness_weight: 0.65,
65 }
66 }
67}
68
69#[derive(Debug, Clone, PartialEq)]
71pub struct MppiPersonFollowingSampler2D {
72 config: MppiPersonFollowingConfig2D,
73 previous_offset: Option<(f64, f64)>,
74}
75
76impl MppiPersonFollowingSampler2D {
77 pub fn new(config: MppiPersonFollowingConfig2D) -> RoboticsResult<Self> {
78 validate_person_following_config(&config)?;
79 Ok(Self {
80 config,
81 previous_offset: None,
82 })
83 }
84
85 pub fn config(&self) -> MppiPersonFollowingConfig2D {
86 self.config
87 }
88
89 pub fn previous_offset(&self) -> Option<(f64, f64)> {
90 self.previous_offset
91 }
92
93 pub fn reset_stickiness(&mut self) {
94 self.previous_offset = None;
95 }
96
97 pub fn sample_candidates(
98 &self,
99 robot: MppiState2D,
100 target: MppiMovingObstacle2D,
101 pedestrians: &[MppiMovingObstacle2D],
102 ) -> RoboticsResult<Vec<MppiPersonFollowingCandidate2D>> {
103 validate_state(robot)?;
104 validate_moving_obstacle(target)?;
105 validate_moving_obstacles(pedestrians)?;
106
107 let heading = target_heading(robot, target);
108 let future_target = target.predict(self.config.dt * self.config.horizon as f64);
109 let mut candidates = Vec::with_capacity(self.config.candidate_count);
110
111 for index in 0..self.config.candidate_count {
112 let u = halton(index + 1, 2);
113 let v = halton(index + 1, 3);
114 let radius_sq = self.config.personal_radius * self.config.personal_radius
115 + u * (self.config.social_radius * self.config.social_radius
116 - self.config.personal_radius * self.config.personal_radius);
117 let radius = radius_sq.sqrt();
118 let bearing = heading + std::f64::consts::PI + (v - 0.5) * std::f64::consts::PI;
119 let offset_x = radius * bearing.cos();
120 let offset_y = radius * bearing.sin();
121 let x = future_target.x + offset_x;
122 let y = future_target.y + offset_y;
123 candidates.push(self.score_candidate(
124 robot,
125 target,
126 pedestrians,
127 x,
128 y,
129 offset_x,
130 offset_y,
131 bearing,
132 ));
133 }
134
135 Ok(candidates)
136 }
137
138 pub fn select_following_point(
139 &mut self,
140 robot: MppiState2D,
141 target: MppiMovingObstacle2D,
142 pedestrians: &[MppiMovingObstacle2D],
143 ) -> RoboticsResult<MppiPersonFollowingCandidate2D> {
144 let candidates = self.sample_candidates(robot, target, pedestrians)?;
145 let selected = candidates
146 .iter()
147 .filter(|candidate| candidate.feasible)
148 .min_by(|a, b| a.total_cost.total_cmp(&b.total_cost))
149 .or_else(|| {
150 candidates
151 .iter()
152 .min_by(|a, b| a.total_cost.total_cmp(&b.total_cost))
153 })
154 .copied()
155 .expect("validated sampler creates at least one candidate");
156 self.previous_offset = Some((selected.offset_x, selected.offset_y));
157 Ok(selected)
158 }
159
160 pub fn goal_trajectory_for_candidate(
161 &self,
162 target: MppiMovingObstacle2D,
163 candidate: MppiPersonFollowingCandidate2D,
164 ) -> Vec<(f64, f64)> {
165 (0..=self.config.horizon)
166 .map(|step| {
167 let predicted = target.predict(step as f64 * self.config.dt);
168 (
169 predicted.x + candidate.offset_x,
170 predicted.y + candidate.offset_y,
171 )
172 })
173 .collect()
174 }
175
176 #[allow(clippy::too_many_arguments)]
177 fn score_candidate(
178 &self,
179 robot: MppiState2D,
180 target: MppiMovingObstacle2D,
181 pedestrians: &[MppiMovingObstacle2D],
182 x: f64,
183 y: f64,
184 offset_x: f64,
185 offset_y: f64,
186 bearing: f64,
187 ) -> MppiPersonFollowingCandidate2D {
188 let distance_to_target = (offset_x * offset_x + offset_y * offset_y).sqrt();
189 let distance_cost = (distance_to_target - self.config.desired_distance).powi(2);
190 let travel_cost = squared_distance((robot.x, robot.y), (x, y));
191 let stickiness_cost = self.previous_offset.map_or(0.0, |previous| {
192 squared_distance(previous, (offset_x, offset_y))
193 });
194 let mut proximity_cost = 0.0;
195 let mut visibility_cost = 0.0;
196 let mut feasible = distance_to_target >= self.config.personal_radius;
197
198 for step in 0..=self.config.horizon {
199 let time = step as f64 * self.config.dt;
200 let target_predicted = target.predict(time);
201 let candidate_predicted =
202 (target_predicted.x + offset_x, target_predicted.y + offset_y);
203 for pedestrian in pedestrians {
204 let pedestrian_predicted = pedestrian.predict(time);
205 let clearance = point_distance(
206 candidate_predicted,
207 (pedestrian_predicted.x, pedestrian_predicted.y),
208 ) - pedestrian_predicted.radius
209 - self.config.proximity_margin;
210 if clearance < 0.0 {
211 feasible = false;
212 proximity_cost += (1.0 - clearance).powi(2);
213 } else if clearance < self.config.social_radius {
214 proximity_cost += 1.0 / (clearance + 0.2);
215 }
216
217 let occlusion_clearance = point_segment_distance(
218 (pedestrian_predicted.x, pedestrian_predicted.y),
219 candidate_predicted,
220 (target_predicted.x, target_predicted.y),
221 ) - pedestrian_predicted.radius
222 - self.config.occlusion_margin;
223 if occlusion_clearance < 0.0 {
224 visibility_cost += (1.0 - occlusion_clearance).powi(2);
225 }
226 }
227 }
228
229 let scale = (self.config.horizon + 1) as f64;
230 proximity_cost /= scale;
231 visibility_cost /= scale;
232 let total_cost = self.config.distance_weight * distance_cost
233 + self.config.travel_weight * travel_cost
234 + self.config.stickiness_weight * stickiness_cost
235 + self.config.proximity_weight * proximity_cost
236 + self.config.visibility_weight * visibility_cost;
237
238 MppiPersonFollowingCandidate2D {
239 x,
240 y,
241 offset_x,
242 offset_y,
243 distance_to_target,
244 bearing,
245 total_cost,
246 visibility_cost,
247 proximity_cost,
248 distance_cost,
249 travel_cost,
250 stickiness_cost,
251 feasible,
252 }
253 }
254}
255
256#[derive(Debug, Clone, Copy, PartialEq)]
258pub struct PersonFollowingMetricsConfig2D {
259 pub desired_distance: f64,
261 pub spacing_tolerance: f64,
263 pub visibility_margin: f64,
267}
268
269impl PersonFollowingMetricsConfig2D {
270 pub fn new(desired_distance: f64, spacing_tolerance: f64, visibility_margin: f64) -> Self {
271 Self {
272 desired_distance,
273 spacing_tolerance,
274 visibility_margin,
275 }
276 }
277}
278
279impl Default for PersonFollowingMetricsConfig2D {
280 fn default() -> Self {
281 let follower = MppiPersonFollowingConfig2D::default();
282 Self {
283 desired_distance: follower.desired_distance,
284 spacing_tolerance: 0.35,
285 visibility_margin: follower.occlusion_margin,
286 }
287 }
288}
289
290#[derive(Debug, Clone, Copy, PartialEq)]
296pub struct PersonFollowingRolloutMetrics2D {
297 pub steps: usize,
298 pub visible_steps: usize,
299 pub in_band_steps: usize,
300 pub collision_steps: usize,
301 pub min_clearance: f64,
302 sum_spacing: f64,
303 sum_spacing_error: f64,
304}
305
306impl PersonFollowingRolloutMetrics2D {
307 pub fn target_visibility_ratio(&self) -> f64 {
309 if self.steps == 0 {
310 0.0
311 } else {
312 self.visible_steps as f64 / self.steps as f64
313 }
314 }
315
316 pub fn target_spacing_ratio(&self) -> f64 {
318 if self.steps == 0 {
319 0.0
320 } else {
321 self.in_band_steps as f64 / self.steps as f64
322 }
323 }
324
325 pub fn mean_spacing(&self) -> f64 {
327 if self.steps == 0 {
328 0.0
329 } else {
330 self.sum_spacing / self.steps as f64
331 }
332 }
333
334 pub fn mean_spacing_error(&self) -> f64 {
336 if self.steps == 0 {
337 0.0
338 } else {
339 self.sum_spacing_error / self.steps as f64
340 }
341 }
342}
343
344#[derive(Debug, Clone, PartialEq)]
349pub struct PersonFollowingMetricsAccumulator2D {
350 config: PersonFollowingMetricsConfig2D,
351 metrics: PersonFollowingRolloutMetrics2D,
352}
353
354impl PersonFollowingMetricsAccumulator2D {
355 pub fn new(config: PersonFollowingMetricsConfig2D) -> RoboticsResult<Self> {
356 if !config.desired_distance.is_finite()
357 || !config.spacing_tolerance.is_finite()
358 || !config.visibility_margin.is_finite()
359 || config.desired_distance <= 0.0
360 || config.spacing_tolerance < 0.0
361 || config.visibility_margin < 0.0
362 {
363 return Err(RoboticsError::InvalidParameter(
364 "person-following metrics config must be finite and non-negative".to_string(),
365 ));
366 }
367 Ok(Self {
368 config,
369 metrics: PersonFollowingRolloutMetrics2D {
370 steps: 0,
371 visible_steps: 0,
372 in_band_steps: 0,
373 collision_steps: 0,
374 min_clearance: f64::INFINITY,
375 sum_spacing: 0.0,
376 sum_spacing_error: 0.0,
377 },
378 })
379 }
380
381 pub fn target_visible(
383 robot: MppiState2D,
384 target: MppiMovingObstacle2D,
385 pedestrians: &[MppiMovingObstacle2D],
386 visibility_margin: f64,
387 ) -> bool {
388 pedestrians.iter().all(|pedestrian| {
389 point_segment_distance(
390 (pedestrian.x, pedestrian.y),
391 (robot.x, robot.y),
392 (target.x, target.y),
393 ) >= pedestrian.radius + visibility_margin
394 })
395 }
396
397 pub fn record_step(
398 &mut self,
399 robot: MppiState2D,
400 target: MppiMovingObstacle2D,
401 pedestrians: &[MppiMovingObstacle2D],
402 ) -> RoboticsResult<()> {
403 validate_state(robot)?;
404 validate_moving_obstacle(target)?;
405 validate_moving_obstacles(pedestrians)?;
406
407 let spacing = point_distance((robot.x, robot.y), (target.x, target.y));
408 let spacing_error = (spacing - self.config.desired_distance).abs();
409 self.metrics.sum_spacing += spacing;
410 self.metrics.sum_spacing_error += spacing_error;
411 if spacing_error <= self.config.spacing_tolerance {
412 self.metrics.in_band_steps += 1;
413 }
414
415 if Self::target_visible(robot, target, pedestrians, self.config.visibility_margin) {
416 self.metrics.visible_steps += 1;
417 }
418
419 let clearance = pedestrians
420 .iter()
421 .map(|pedestrian| {
422 point_distance((robot.x, robot.y), (pedestrian.x, pedestrian.y)) - pedestrian.radius
423 })
424 .fold(f64::INFINITY, f64::min);
425 self.metrics.min_clearance = self.metrics.min_clearance.min(clearance);
426 if clearance < 0.0 {
427 self.metrics.collision_steps += 1;
428 }
429
430 self.metrics.steps += 1;
431 Ok(())
432 }
433
434 pub fn finish(self) -> PersonFollowingRolloutMetrics2D {
435 let mut metrics = self.metrics;
436 if metrics.steps == 0 {
437 metrics.min_clearance = 0.0;
438 }
439 metrics
440 }
441}
442
443fn validate_person_following_config(config: &MppiPersonFollowingConfig2D) -> RoboticsResult<()> {
444 if config.candidate_count == 0 || config.horizon == 0 {
445 return Err(RoboticsError::InvalidParameter(
446 "person-following candidate_count and horizon must be positive".to_string(),
447 ));
448 }
449 for (label, value) in [
450 ("person-following dt", config.dt),
451 ("person-following personal_radius", config.personal_radius),
452 ("person-following social_radius", config.social_radius),
453 ("person-following desired_distance", config.desired_distance),
454 ("person-following proximity_margin", config.proximity_margin),
455 ("person-following occlusion_margin", config.occlusion_margin),
456 (
457 "person-following visibility_weight",
458 config.visibility_weight,
459 ),
460 ("person-following proximity_weight", config.proximity_weight),
461 ("person-following distance_weight", config.distance_weight),
462 ("person-following travel_weight", config.travel_weight),
463 (
464 "person-following stickiness_weight",
465 config.stickiness_weight,
466 ),
467 ] {
468 if value < 0.0 || !value.is_finite() {
469 return Err(RoboticsError::InvalidParameter(format!(
470 "{label} must be finite and non-negative"
471 )));
472 }
473 }
474 if config.dt <= 0.0 {
475 return Err(RoboticsError::InvalidParameter(
476 "person-following dt must be positive".to_string(),
477 ));
478 }
479 if config.social_radius <= config.personal_radius {
480 return Err(RoboticsError::InvalidParameter(
481 "person-following social_radius must exceed personal_radius".to_string(),
482 ));
483 }
484 Ok(())
485}
486
487fn validate_state(state: MppiState2D) -> RoboticsResult<()> {
488 if !state.x.is_finite()
489 || !state.y.is_finite()
490 || !state.vx.is_finite()
491 || !state.vy.is_finite()
492 {
493 return Err(RoboticsError::InvalidParameter(
494 "person-following robot state must be finite".to_string(),
495 ));
496 }
497 Ok(())
498}
499
500fn validate_moving_obstacles(obstacles: &[MppiMovingObstacle2D]) -> RoboticsResult<()> {
501 for &obstacle in obstacles {
502 validate_moving_obstacle(obstacle)?;
503 }
504 Ok(())
505}
506
507fn validate_moving_obstacle(obstacle: MppiMovingObstacle2D) -> RoboticsResult<()> {
508 if !obstacle.x.is_finite()
509 || !obstacle.y.is_finite()
510 || !obstacle.vx.is_finite()
511 || !obstacle.vy.is_finite()
512 {
513 return Err(RoboticsError::InvalidParameter(
514 "person-following moving obstacle state must be finite".to_string(),
515 ));
516 }
517 if obstacle.radius <= 0.0 || !obstacle.radius.is_finite() {
518 return Err(RoboticsError::InvalidParameter(
519 "person-following moving obstacle radius must be finite and positive".to_string(),
520 ));
521 }
522 Ok(())
523}
524
525fn target_heading(robot: MppiState2D, target: MppiMovingObstacle2D) -> f64 {
526 let speed_sq = target.vx * target.vx + target.vy * target.vy;
527 if speed_sq > EPS {
528 target.vy.atan2(target.vx)
529 } else {
530 (target.y - robot.y).atan2(target.x - robot.x)
531 }
532}
533
534fn halton(mut index: usize, base: usize) -> f64 {
535 let mut factor = 1.0 / base as f64;
536 let mut result = 0.0;
537 while index > 0 {
538 result += factor * (index % base) as f64;
539 index /= base;
540 factor /= base as f64;
541 }
542 result
543}
544
545fn squared_distance(a: (f64, f64), b: (f64, f64)) -> f64 {
546 let dx = a.0 - b.0;
547 let dy = a.1 - b.1;
548 dx * dx + dy * dy
549}
550
551fn point_distance(a: (f64, f64), b: (f64, f64)) -> f64 {
552 squared_distance(a, b).sqrt()
553}
554
555fn point_segment_distance(point: (f64, f64), start: (f64, f64), end: (f64, f64)) -> f64 {
556 let dx = end.0 - start.0;
557 let dy = end.1 - start.1;
558 let length_sq = dx * dx + dy * dy;
559 if length_sq <= EPS {
560 return point_distance(point, start);
561 }
562 let t = (((point.0 - start.0) * dx + (point.1 - start.1) * dy) / length_sq).clamp(0.0, 1.0);
563 point_distance(point, (start.0 + t * dx, start.1 + t * dy))
564}
565
566#[cfg(test)]
567mod tests {
568 use super::*;
569
570 #[test]
571 fn sampler_generates_backward_social_candidates() {
572 let sampler =
573 MppiPersonFollowingSampler2D::new(MppiPersonFollowingConfig2D::default()).unwrap();
574 let robot = MppiState2D::new(-1.0, 0.0, 0.0, 0.0);
575 let target = MppiMovingObstacle2D::new(0.0, 0.0, 1.0, 0.0, 0.3);
576 let candidates = sampler.sample_candidates(robot, target, &[]).unwrap();
577
578 assert_eq!(candidates.len(), sampler.config().candidate_count);
579 assert!(candidates.iter().all(|candidate| candidate.x < 2.0));
580 assert!(candidates
581 .iter()
582 .all(|candidate| candidate.distance_to_target >= sampler.config().personal_radius));
583 assert!(candidates
584 .iter()
585 .all(|candidate| candidate.distance_to_target <= sampler.config().social_radius));
586 }
587
588 #[test]
589 fn occlusion_changes_selected_candidate_side() {
590 let mut sampler =
591 MppiPersonFollowingSampler2D::new(MppiPersonFollowingConfig2D::default()).unwrap();
592 let robot = MppiState2D::new(-1.0, 0.0, 0.0, 0.0);
593 let target = MppiMovingObstacle2D::new(0.0, 0.0, 0.7, 0.0, 0.3);
594 let blocking_pedestrian = MppiMovingObstacle2D::new(-0.55, 0.0, 0.7, 0.0, 0.32);
595
596 let selected = sampler
597 .select_following_point(robot, target, &[blocking_pedestrian])
598 .unwrap();
599
600 assert!(selected.feasible);
601 assert!(selected.offset_y.abs() > 0.15);
602 assert!(selected.visibility_cost < 2.0);
603 }
604
605 #[test]
606 fn selected_candidate_produces_horizon_goal_trajectory() {
607 let mut sampler =
608 MppiPersonFollowingSampler2D::new(MppiPersonFollowingConfig2D::default()).unwrap();
609 let robot = MppiState2D::new(-1.0, 0.0, 0.0, 0.0);
610 let target = MppiMovingObstacle2D::new(0.0, 0.0, 0.5, 0.0, 0.3);
611 let selected = sampler.select_following_point(robot, target, &[]).unwrap();
612 let goals = sampler.goal_trajectory_for_candidate(target, selected);
613
614 assert_eq!(goals.len(), sampler.config().horizon + 1);
615 assert!((goals[0].0 - selected.offset_x).abs() < 1e-9);
616 assert!(goals.last().unwrap().0 > goals[0].0);
617 }
618
619 #[test]
620 fn visibility_metric_detects_blocking_pedestrian() {
621 let robot = MppiState2D::new(-1.0, 0.0, 0.0, 0.0);
624 let target = MppiMovingObstacle2D::new(0.0, 0.0, 0.0, 0.0, 0.3);
625 let blocking = MppiMovingObstacle2D::new(-0.5, 0.0, 0.0, 0.0, 0.3);
626 let aside = MppiMovingObstacle2D::new(-0.5, 1.5, 0.0, 0.0, 0.3);
627
628 assert!(!PersonFollowingMetricsAccumulator2D::target_visible(
629 robot,
630 target,
631 &[blocking],
632 0.05
633 ));
634 assert!(PersonFollowingMetricsAccumulator2D::target_visible(
635 robot,
636 target,
637 &[aside],
638 0.05
639 ));
640 }
641
642 #[test]
643 fn spacing_ratio_counts_in_band_steps() {
644 let config = PersonFollowingMetricsConfig2D::new(1.0, 0.2, 0.05);
645 let mut accumulator = PersonFollowingMetricsAccumulator2D::new(config).unwrap();
646 let target = MppiMovingObstacle2D::new(0.0, 0.0, 0.0, 0.0, 0.3);
647
648 accumulator
650 .record_step(MppiState2D::new(-1.0, 0.0, 0.0, 0.0), target, &[])
651 .unwrap();
652 accumulator
653 .record_step(MppiState2D::new(-0.9, 0.0, 0.0, 0.0), target, &[])
654 .unwrap();
655 accumulator
656 .record_step(MppiState2D::new(-2.0, 0.0, 0.0, 0.0), target, &[])
657 .unwrap();
658 let metrics = accumulator.finish();
659
660 assert_eq!(metrics.steps, 3);
661 assert_eq!(metrics.in_band_steps, 2);
662 assert!((metrics.target_spacing_ratio() - 2.0 / 3.0).abs() < 1e-9);
663 assert!((metrics.mean_spacing() - (1.0 + 0.9 + 2.0) / 3.0).abs() < 1e-9);
664 }
665
666 #[test]
667 fn metrics_flag_pedestrian_collision_and_clearance() {
668 let config = PersonFollowingMetricsConfig2D::new(1.0, 0.5, 0.05);
669 let mut accumulator = PersonFollowingMetricsAccumulator2D::new(config).unwrap();
670 let target = MppiMovingObstacle2D::new(0.0, 0.0, 0.0, 0.0, 0.3);
671 let pedestrian = MppiMovingObstacle2D::new(-0.8, 0.0, 0.0, 0.0, 0.4);
673 accumulator
674 .record_step(MppiState2D::new(-1.0, 0.0, 0.0, 0.0), target, &[pedestrian])
675 .unwrap();
676 let metrics = accumulator.finish();
677
678 assert_eq!(metrics.collision_steps, 1);
679 assert!((metrics.min_clearance - (-0.2)).abs() < 1e-9);
680 assert_eq!(metrics.target_visibility_ratio(), 0.0);
681 }
682
683 #[test]
684 fn metrics_reject_invalid_config() {
685 assert!(
686 PersonFollowingMetricsAccumulator2D::new(PersonFollowingMetricsConfig2D::new(
687 -1.0, 0.2, 0.05
688 ))
689 .is_err()
690 );
691 }
692}