Skip to main content

rust_robotics_control/
person_following_mppi.rs

1//! Adaptive person-following helpers for MPPI.
2//!
3//! This is a compact 2-D reproduction slice of Adap-RPF: target-centric
4//! following-point sampling, multi-objective candidate scoring, and
5//! prediction-aware MPPI goal generation.
6
7use rust_robotics_core::{RoboticsError, RoboticsResult};
8
9use crate::mppi::{MppiMovingObstacle2D, MppiState2D};
10
11const EPS: f64 = 1e-9;
12
13/// Candidate following point evaluated around the predicted target person.
14#[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/// Configuration for target-centric adaptive following-point sampling.
32#[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/// Deterministic adaptive person-following sampler.
70#[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/// Configuration for person-following rollout metrics.
257#[derive(Debug, Clone, Copy, PartialEq)]
258pub struct PersonFollowingMetricsConfig2D {
259    /// Desired robot-to-target spacing.
260    pub desired_distance: f64,
261    /// Half-width of the acceptable spacing band around `desired_distance`.
262    pub spacing_tolerance: f64,
263    /// Extra clearance a line of sight needs before the target counts as
264    /// visible: the target is visible only when every pedestrian disk stays at
265    /// least `radius + visibility_margin` away from the robot-target segment.
266    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/// Aggregated geometric metrics over a person-following rollout.
291///
292/// These are the Adap-RPF reporting counters: how often the target stayed
293/// visible (line of sight unobstructed by pedestrians), how often the robot
294/// held the desired spacing band, and the tightest pedestrian clearance seen.
295#[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    /// Fraction of steps the target line of sight stayed clear of pedestrians.
308    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    /// Fraction of steps the robot held the desired spacing band.
317    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    /// Mean robot-to-target distance across the rollout.
326    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    /// Mean absolute deviation from the desired spacing.
335    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/// Incremental accumulator for [`PersonFollowingRolloutMetrics2D`].
345///
346/// Feed it one robot/target/pedestrian snapshot per closed-loop step, then call
347/// [`PersonFollowingMetricsAccumulator2D::finish`] to read the rollout metrics.
348#[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    /// Whether the target is visible from the robot at this snapshot.
382    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        // Robot trails the target along x; a pedestrian sits on the line of
622        // sight between them, then steps aside.
623        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        // Two in-band snapshots (distance 1.0 and 0.9) and one out-of-band (2.0).
649        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        // Pedestrian radius 0.4 centered 0.2 from the robot -> clearance -0.2.
672        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}