Skip to main content

rust_robotics_control/
mppi.rs

1//! Vanilla MPPI controller for a 2-D double integrator.
2//!
3//! This is the dependency-light baseline needed before TD-CD-MPPI-style
4//! terminal value and constraint-discounted extensions.
5
6use rand::{rngs::StdRng, SeedableRng};
7use rand_distr::{Distribution, Normal};
8use rust_robotics_core::{RoboticsError, RoboticsResult};
9use std::collections::VecDeque;
10
11/// State for a planar double-integrator model.
12#[derive(Debug, Clone, Copy, PartialEq)]
13pub struct MppiState2D {
14    pub x: f64,
15    pub y: f64,
16    pub vx: f64,
17    pub vy: f64,
18}
19
20impl MppiState2D {
21    pub fn new(x: f64, y: f64, vx: f64, vy: f64) -> Self {
22        Self { x, y, vx, vy }
23    }
24
25    pub fn step(self, control: MppiControl2D, dt: f64) -> Self {
26        Self {
27            x: self.x + self.vx * dt + 0.5 * control.ax * dt * dt,
28            y: self.y + self.vy * dt + 0.5 * control.ay * dt * dt,
29            vx: self.vx + control.ax * dt,
30            vy: self.vy + control.ay * dt,
31        }
32    }
33}
34
35/// Acceleration command for the double-integrator model.
36#[derive(Debug, Clone, Copy, PartialEq)]
37pub struct MppiControl2D {
38    pub ax: f64,
39    pub ay: f64,
40}
41
42impl MppiControl2D {
43    pub fn new(ax: f64, ay: f64) -> Self {
44        Self { ax, ay }
45    }
46}
47
48/// Circular obstacle used by constraint-discounted MPPI rollouts.
49#[derive(Debug, Clone, Copy, PartialEq)]
50pub struct MppiCircularObstacle2D {
51    pub x: f64,
52    pub y: f64,
53    pub radius: f64,
54}
55
56impl MppiCircularObstacle2D {
57    pub fn new(x: f64, y: f64, radius: f64) -> Self {
58        Self { x, y, radius }
59    }
60}
61
62/// Linearly predicted circular obstacle for prediction-aware MPPI rollouts.
63#[derive(Debug, Clone, Copy, PartialEq)]
64pub struct MppiMovingObstacle2D {
65    pub x: f64,
66    pub y: f64,
67    pub vx: f64,
68    pub vy: f64,
69    pub radius: f64,
70}
71
72impl MppiMovingObstacle2D {
73    pub fn new(x: f64, y: f64, vx: f64, vy: f64, radius: f64) -> Self {
74        Self {
75            x,
76            y,
77            vx,
78            vy,
79            radius,
80        }
81    }
82
83    pub fn predict(self, time: f64) -> MppiCircularObstacle2D {
84        MppiCircularObstacle2D::new(
85            self.x + self.vx * time,
86            self.y + self.vy * time,
87            self.radius,
88        )
89    }
90}
91
92/// 2-D racing gate for reference-free MPPI progress objectives.
93///
94/// The normal points in the race direction. A gate is crossed when a rollout
95/// segment moves from the negative to the positive side of the gate plane and
96/// intersects the gate within `half_width`.
97#[derive(Debug, Clone, Copy, PartialEq)]
98pub struct MppiRacingGate2D {
99    pub center_x: f64,
100    pub center_y: f64,
101    pub normal_x: f64,
102    pub normal_y: f64,
103    pub half_width: f64,
104}
105
106impl MppiRacingGate2D {
107    pub fn new(
108        center_x: f64,
109        center_y: f64,
110        normal_x: f64,
111        normal_y: f64,
112        half_width: f64,
113    ) -> RoboticsResult<Self> {
114        let norm = (normal_x * normal_x + normal_y * normal_y).sqrt();
115        if !center_x.is_finite()
116            || !center_y.is_finite()
117            || !normal_x.is_finite()
118            || !normal_y.is_finite()
119            || norm <= 0.0
120        {
121            return Err(RoboticsError::InvalidParameter(
122                "racing gate center and normal must be finite".to_string(),
123            ));
124        }
125        if half_width <= 0.0 || !half_width.is_finite() {
126            return Err(RoboticsError::InvalidParameter(
127                "racing gate half_width must be finite and positive".to_string(),
128            ));
129        }
130        Ok(Self {
131            center_x,
132            center_y,
133            normal_x: normal_x / norm,
134            normal_y: normal_y / norm,
135            half_width,
136        })
137    }
138
139    pub fn center(&self) -> (f64, f64) {
140        (self.center_x, self.center_y)
141    }
142
143    pub fn signed_distance(&self, x: f64, y: f64) -> f64 {
144        (x - self.center_x) * self.normal_x + (y - self.center_y) * self.normal_y
145    }
146
147    pub fn lateral_distance(&self, x: f64, y: f64) -> f64 {
148        let tangent_x = -self.normal_y;
149        let tangent_y = self.normal_x;
150        ((x - self.center_x) * tangent_x + (y - self.center_y) * tangent_y).abs()
151    }
152
153    pub fn squared_distance(&self, x: f64, y: f64) -> f64 {
154        (x - self.center_x).powi(2) + (y - self.center_y).powi(2)
155    }
156
157    pub fn crosses(&self, from: MppiState2D, to: MppiState2D, tolerance: f64) -> bool {
158        let from_signed = self.signed_distance(from.x, from.y);
159        let to_signed = self.signed_distance(to.x, to.y);
160        if from_signed > tolerance || to_signed < -tolerance {
161            return false;
162        }
163        let denom = to_signed - from_signed;
164        let t = if denom.abs() > 1e-9 {
165            (-from_signed / denom).clamp(0.0, 1.0)
166        } else {
167            0.0
168        };
169        let cross_x = from.x + t * (to.x - from.x);
170        let cross_y = from.y + t * (to.y - from.y);
171        self.lateral_distance(cross_x, cross_y) <= self.half_width + tolerance
172    }
173
174    pub fn state_is_past_gate(&self, state: MppiState2D, tolerance: f64) -> bool {
175        self.signed_distance(state.x, state.y) >= -tolerance
176            && self.lateral_distance(state.x, state.y) <= self.half_width + tolerance
177    }
178}
179
180/// Reference-free gate-progress objective for race-style MPPI.
181///
182/// The per-step progress term follows the racing-MPPI idea of minimizing the
183/// change in squared distance to the active gate. Moving toward the gate creates
184/// negative cost, moving away creates positive cost, and a valid gate crossing
185/// switches the rollout to the next gate.
186#[derive(Debug, Clone, PartialEq)]
187pub struct MppiGateRace2D {
188    pub gates: Vec<MppiRacingGate2D>,
189    pub progress_weight: f64,
190    pub lateral_weight: f64,
191    pub pass_bonus: f64,
192    pub miss_penalty: f64,
193    pub terminal_gate_weight: f64,
194    pub crossing_tolerance: f64,
195    active_gate_index: usize,
196}
197
198/// Summary for scoring a trajectory against a gate race.
199#[derive(Debug, Clone, Copy, PartialEq)]
200pub struct MppiGateRaceReport2D {
201    pub passed_gates: usize,
202    pub active_gate_index: usize,
203    pub cost: f64,
204    pub final_gate_distance: f64,
205    pub final_lateral_error: f64,
206}
207
208#[derive(Debug, Clone, Copy, PartialEq)]
209struct MppiGateRaceStep2D {
210    cost: f64,
211    active_gate_index: usize,
212    passed_gate: bool,
213}
214
215impl MppiGateRace2D {
216    pub fn new(gates: Vec<MppiRacingGate2D>) -> RoboticsResult<Self> {
217        let race = Self {
218            gates,
219            progress_weight: 1.0,
220            lateral_weight: 0.08,
221            pass_bonus: 4.0,
222            miss_penalty: 18.0,
223            terminal_gate_weight: 0.6,
224            crossing_tolerance: 0.03,
225            active_gate_index: 0,
226        };
227        validate_gate_race(&race)?;
228        Ok(race)
229    }
230
231    pub fn gate_count(&self) -> usize {
232        self.gates.len()
233    }
234
235    pub fn active_gate_index(&self) -> usize {
236        self.active_gate_index
237    }
238
239    pub fn reset(&mut self) {
240        self.active_gate_index = 0;
241    }
242
243    pub fn set_active_gate_index(&mut self, active_gate_index: usize) -> RoboticsResult<()> {
244        if active_gate_index > self.gates.len() {
245            return Err(RoboticsError::InvalidParameter(
246                "active gate index must be within the gate list".to_string(),
247            ));
248        }
249        self.active_gate_index = active_gate_index;
250        Ok(())
251    }
252
253    pub fn advance_active_gate_from_state(&mut self, state: MppiState2D) -> usize {
254        while let Some(gate) = self.gates.get(self.active_gate_index) {
255            if !gate.state_is_past_gate(state, self.crossing_tolerance) {
256                break;
257            }
258            self.active_gate_index += 1;
259        }
260        self.active_gate_index
261    }
262
263    pub fn score_trajectory(&self, states: &[MppiState2D]) -> RoboticsResult<MppiGateRaceReport2D> {
264        if states.is_empty() {
265            return Err(RoboticsError::InvalidParameter(
266                "gate race trajectory must contain at least one state".to_string(),
267            ));
268        }
269        for &state in states {
270            validate_state(state)?;
271        }
272        let mut active_gate_index = 0;
273        let mut cost = 0.0;
274        let mut passed_gates = 0;
275        for pair in states.windows(2) {
276            let step = self.transition_cost(pair[0], pair[1], active_gate_index);
277            cost += step.cost;
278            active_gate_index = step.active_gate_index;
279            if step.passed_gate {
280                passed_gates += 1;
281            }
282        }
283        let final_state = *states
284            .last()
285            .expect("validated gate race trajectory is non-empty");
286        cost += self.terminal_cost(final_state, active_gate_index);
287        let (final_gate_distance, final_lateral_error) =
288            self.final_gate_errors(final_state, active_gate_index);
289        Ok(MppiGateRaceReport2D {
290            passed_gates,
291            active_gate_index,
292            cost,
293            final_gate_distance,
294            final_lateral_error,
295        })
296    }
297
298    fn transition_cost(
299        &self,
300        from: MppiState2D,
301        to: MppiState2D,
302        active_gate_index: usize,
303    ) -> MppiGateRaceStep2D {
304        let Some(gate) = self.gates.get(active_gate_index) else {
305            return MppiGateRaceStep2D {
306                cost: 0.0,
307                active_gate_index,
308                passed_gate: false,
309            };
310        };
311
312        let before_distance_sq = gate.squared_distance(from.x, from.y);
313        let after_distance_sq = gate.squared_distance(to.x, to.y);
314        let after_lateral = gate.lateral_distance(to.x, to.y);
315        let after_distance = after_distance_sq.sqrt();
316        let lateral_proximity = 1.0 / (1.0 + after_distance);
317        let mut cost = self.progress_weight * (after_distance_sq - before_distance_sq)
318            + self.lateral_weight * lateral_proximity * after_lateral * after_lateral;
319        let mut next_active = active_gate_index;
320        let mut passed_gate = false;
321
322        if gate.crosses(from, to, self.crossing_tolerance) {
323            cost -= self.pass_bonus;
324            next_active += 1;
325            passed_gate = true;
326        } else if gate.signed_distance(to.x, to.y) >= 0.0 && after_lateral > gate.half_width {
327            let miss = after_lateral - gate.half_width;
328            cost += self.miss_penalty * miss * miss;
329        }
330
331        MppiGateRaceStep2D {
332            cost,
333            active_gate_index: next_active,
334            passed_gate,
335        }
336    }
337
338    fn terminal_cost(&self, state: MppiState2D, active_gate_index: usize) -> f64 {
339        let Some(gate) = self.gates.get(active_gate_index) else {
340            return 0.0;
341        };
342        self.terminal_gate_weight * gate.squared_distance(state.x, state.y)
343            + self.pass_bonus * (self.gates.len() - active_gate_index) as f64
344    }
345
346    fn final_gate_errors(&self, state: MppiState2D, active_gate_index: usize) -> (f64, f64) {
347        let Some(gate) = self.gates.get(active_gate_index) else {
348            return (0.0, 0.0);
349        };
350        (
351            gate.squared_distance(state.x, state.y).sqrt(),
352            gate.lateral_distance(state.x, state.y),
353        )
354    }
355}
356
357/// Grid-based terminal value estimate for MPPI rollouts.
358///
359/// Values are indexed as `values[x][y]` and queried with bilinear
360/// interpolation. Out-of-bounds queries clamp to the nearest grid edge.
361#[derive(Debug, Clone, PartialEq)]
362pub struct MppiTerminalValueGrid2D {
363    pub origin_x: f64,
364    pub origin_y: f64,
365    pub resolution: f64,
366    pub values: Vec<Vec<f64>>,
367}
368
369impl MppiTerminalValueGrid2D {
370    pub fn new(
371        origin_x: f64,
372        origin_y: f64,
373        resolution: f64,
374        values: Vec<Vec<f64>>,
375    ) -> RoboticsResult<Self> {
376        let grid = Self {
377            origin_x,
378            origin_y,
379            resolution,
380            values,
381        };
382        validate_terminal_value_grid(&grid)?;
383        Ok(grid)
384    }
385
386    pub fn from_goal_distance(
387        width: usize,
388        height: usize,
389        origin_x: f64,
390        origin_y: f64,
391        resolution: f64,
392        goal: (f64, f64),
393    ) -> RoboticsResult<Self> {
394        if width == 0 || height == 0 {
395            return Err(RoboticsError::InvalidParameter(
396                "terminal value grid dimensions must be non-empty".to_string(),
397            ));
398        }
399        let mut values = vec![vec![0.0; height]; width];
400        for (x, column) in values.iter_mut().enumerate() {
401            for (y, value) in column.iter_mut().enumerate() {
402                let wx = origin_x + x as f64 * resolution;
403                let wy = origin_y + y as f64 * resolution;
404                let dx = wx - goal.0;
405                let dy = wy - goal.1;
406                *value = (dx * dx + dy * dy).sqrt();
407            }
408        }
409        Self::new(origin_x, origin_y, resolution, values)
410    }
411
412    pub fn value_at_state(&self, state: MppiState2D) -> f64 {
413        self.value_at(state.x, state.y)
414    }
415
416    pub fn value_at(&self, x: f64, y: f64) -> f64 {
417        let width = self.values.len();
418        let height = self.values[0].len();
419        let gx = ((x - self.origin_x) / self.resolution).clamp(0.0, (width - 1) as f64);
420        let gy = ((y - self.origin_y) / self.resolution).clamp(0.0, (height - 1) as f64);
421        let x0 = gx.floor() as usize;
422        let y0 = gy.floor() as usize;
423        let x1 = (x0 + 1).min(width - 1);
424        let y1 = (y0 + 1).min(height - 1);
425        let tx = gx - x0 as f64;
426        let ty = gy - y0 as f64;
427
428        let v00 = self.values[x0][y0];
429        let v10 = self.values[x1][y0];
430        let v01 = self.values[x0][y1];
431        let v11 = self.values[x1][y1];
432        let vx0 = v00 * (1.0 - tx) + v10 * tx;
433        let vx1 = v01 * (1.0 - tx) + v11 * tx;
434        vx0 * (1.0 - ty) + vx1 * ty
435    }
436
437    pub fn width(&self) -> usize {
438        self.values.len()
439    }
440
441    pub fn height(&self) -> usize {
442        self.values[0].len()
443    }
444
445    pub fn nearest_cell_indices(&self, x: f64, y: f64) -> (usize, usize) {
446        let gx = ((x - self.origin_x) / self.resolution)
447            .round()
448            .clamp(0.0, (self.width() - 1) as f64);
449        let gy = ((y - self.origin_y) / self.resolution)
450            .round()
451            .clamp(0.0, (self.height() - 1) as f64);
452        (gx as usize, gy as usize)
453    }
454
455    pub fn value_at_cell(&self, x: usize, y: usize) -> RoboticsResult<f64> {
456        if x >= self.width() || y >= self.height() {
457            return Err(RoboticsError::InvalidParameter(
458                "terminal value grid cell is out of bounds".to_string(),
459            ));
460        }
461        Ok(self.values[x][y])
462    }
463
464    pub fn update_cell_toward(
465        &mut self,
466        x: usize,
467        y: usize,
468        target: f64,
469        learning_rate: f64,
470    ) -> RoboticsResult<f64> {
471        if x >= self.width() || y >= self.height() {
472            return Err(RoboticsError::InvalidParameter(
473                "terminal value grid cell is out of bounds".to_string(),
474            ));
475        }
476        if target < 0.0 || !target.is_finite() {
477            return Err(RoboticsError::InvalidParameter(
478                "terminal value target must be finite and non-negative".to_string(),
479            ));
480        }
481        if !(0.0..=1.0).contains(&learning_rate) || learning_rate == 0.0 {
482            return Err(RoboticsError::InvalidParameter(
483                "terminal value learning_rate must be in (0, 1]".to_string(),
484            ));
485        }
486        let old = self.values[x][y];
487        let new_value = old + learning_rate * (target - old);
488        self.values[x][y] = new_value.max(0.0);
489        Ok((self.values[x][y] - old).abs())
490    }
491}
492
493/// Projection of a point onto a waypoint track.
494#[derive(Debug, Clone, Copy, PartialEq)]
495pub struct MppiTrackProjection2D {
496    pub segment_index: usize,
497    pub progress: f64,
498    pub lateral_error: f64,
499    pub closest_x: f64,
500    pub closest_y: f64,
501}
502
503/// Polyline track helper for MPPI progress-based terminal values.
504#[derive(Debug, Clone, PartialEq)]
505pub struct MppiWaypointTrack2D {
506    pub waypoints: Vec<(f64, f64)>,
507    cumulative_lengths: Vec<f64>,
508}
509
510impl MppiWaypointTrack2D {
511    pub fn new(waypoints: Vec<(f64, f64)>) -> RoboticsResult<Self> {
512        validate_track_waypoints(&waypoints)?;
513        let mut cumulative_lengths = vec![0.0; waypoints.len()];
514        for index in 1..waypoints.len() {
515            cumulative_lengths[index] = cumulative_lengths[index - 1]
516                + point_distance(waypoints[index - 1], waypoints[index]);
517        }
518        Ok(Self {
519            waypoints,
520            cumulative_lengths,
521        })
522    }
523
524    pub fn total_length(&self) -> f64 {
525        *self
526            .cumulative_lengths
527            .last()
528            .expect("validated track has at least two waypoints")
529    }
530
531    pub fn goal(&self) -> (f64, f64) {
532        *self
533            .waypoints
534            .last()
535            .expect("validated track has at least two waypoints")
536    }
537
538    pub fn point_at_progress(&self, progress: f64) -> RoboticsResult<(f64, f64)> {
539        if !progress.is_finite() {
540            return Err(RoboticsError::InvalidParameter(
541                "track progress query must be finite".to_string(),
542            ));
543        }
544        let clamped = progress.clamp(0.0, self.total_length());
545        for index in 0..self.waypoints.len() - 1 {
546            let segment_start = self.cumulative_lengths[index];
547            let segment_end = self.cumulative_lengths[index + 1];
548            if clamped <= segment_end || index + 2 == self.waypoints.len() {
549                let a = self.waypoints[index];
550                let b = self.waypoints[index + 1];
551                let segment_length = segment_end - segment_start;
552                let t = if segment_length > 0.0 {
553                    (clamped - segment_start) / segment_length
554                } else {
555                    0.0
556                };
557                return Ok((a.0 + t * (b.0 - a.0), a.1 + t * (b.1 - a.1)));
558            }
559        }
560        Ok(self.goal())
561    }
562
563    pub fn project(&self, x: f64, y: f64) -> RoboticsResult<MppiTrackProjection2D> {
564        if !x.is_finite() || !y.is_finite() {
565            return Err(RoboticsError::InvalidParameter(
566                "track projection point must be finite".to_string(),
567            ));
568        }
569
570        let mut best = MppiTrackProjection2D {
571            segment_index: 0,
572            progress: 0.0,
573            lateral_error: f64::INFINITY,
574            closest_x: self.waypoints[0].0,
575            closest_y: self.waypoints[0].1,
576        };
577        for index in 0..self.waypoints.len() - 1 {
578            let a = self.waypoints[index];
579            let b = self.waypoints[index + 1];
580            let dx = b.0 - a.0;
581            let dy = b.1 - a.1;
582            let segment_length_sq = dx * dx + dy * dy;
583            let t = (((x - a.0) * dx + (y - a.1) * dy) / segment_length_sq).clamp(0.0, 1.0);
584            let closest_x = a.0 + t * dx;
585            let closest_y = a.1 + t * dy;
586            let lateral_error = ((x - closest_x).powi(2) + (y - closest_y).powi(2)).sqrt();
587            if lateral_error < best.lateral_error {
588                best = MppiTrackProjection2D {
589                    segment_index: index,
590                    progress: self.cumulative_lengths[index] + t * segment_length_sq.sqrt(),
591                    lateral_error,
592                    closest_x,
593                    closest_y,
594                };
595            }
596        }
597        Ok(best)
598    }
599
600    pub fn remaining_distance(&self, x: f64, y: f64) -> RoboticsResult<f64> {
601        Ok((self.total_length() - self.project(x, y)?.progress).max(0.0))
602    }
603
604    #[allow(clippy::too_many_arguments)]
605    pub fn terminal_value_grid(
606        &self,
607        width: usize,
608        height: usize,
609        origin_x: f64,
610        origin_y: f64,
611        resolution: f64,
612        progress_weight: f64,
613        lateral_weight: f64,
614    ) -> RoboticsResult<MppiTerminalValueGrid2D> {
615        if width == 0 || height == 0 {
616            return Err(RoboticsError::InvalidParameter(
617                "track terminal value grid dimensions must be non-empty".to_string(),
618            ));
619        }
620        if progress_weight < 0.0 || lateral_weight < 0.0 {
621            return Err(RoboticsError::InvalidParameter(
622                "track terminal value weights must be non-negative".to_string(),
623            ));
624        }
625        if !progress_weight.is_finite() || !lateral_weight.is_finite() {
626            return Err(RoboticsError::InvalidParameter(
627                "track terminal value weights must be finite".to_string(),
628            ));
629        }
630
631        let mut values = vec![vec![0.0; height]; width];
632        for (grid_x, column) in values.iter_mut().enumerate() {
633            for (grid_y, value) in column.iter_mut().enumerate() {
634                let wx = origin_x + grid_x as f64 * resolution;
635                let wy = origin_y + grid_y as f64 * resolution;
636                let projection = self.project(wx, wy)?;
637                let remaining = (self.total_length() - projection.progress).max(0.0);
638                *value = progress_weight * remaining + lateral_weight * projection.lateral_error;
639            }
640        }
641        MppiTerminalValueGrid2D::new(origin_x, origin_y, resolution, values)
642    }
643}
644
645/// Configuration for online terminal-value updates from MPPI rollouts.
646#[derive(Debug, Clone, Copy, PartialEq)]
647pub struct MppiTerminalValueUpdateConfig2D {
648    pub learning_rate: f64,
649    pub discount: f64,
650}
651
652impl Default for MppiTerminalValueUpdateConfig2D {
653    fn default() -> Self {
654        Self {
655            learning_rate: 0.25,
656            discount: 0.98,
657        }
658    }
659}
660
661/// Summary returned after updating a terminal value grid from one rollout.
662#[derive(Debug, Clone, Copy, PartialEq)]
663pub struct MppiTerminalValueUpdateReport2D {
664    pub updates: usize,
665    pub mean_abs_delta: f64,
666    pub max_abs_delta: f64,
667    pub start_target: f64,
668    pub terminal_target: f64,
669}
670
671/// Online updater for terminal value grids.
672pub struct MppiTerminalValueUpdater2D {
673    config: MppiTerminalValueUpdateConfig2D,
674}
675
676impl MppiTerminalValueUpdater2D {
677    pub fn new(config: MppiTerminalValueUpdateConfig2D) -> RoboticsResult<Self> {
678        validate_terminal_value_update_config(&config)?;
679        Ok(Self { config })
680    }
681
682    pub fn update_from_rollout(
683        &self,
684        grid: &mut MppiTerminalValueGrid2D,
685        rollout: &MppiRollout2D,
686    ) -> RoboticsResult<MppiTerminalValueUpdateReport2D> {
687        validate_rollout_for_terminal_value_update(rollout)?;
688        let targets = discounted_cost_to_go(&rollout.stage_costs, self.config.discount);
689        let mut updates = 0;
690        let mut total_abs_delta = 0.0;
691        let mut max_abs_delta: f64 = 0.0;
692
693        for (&state, &target) in rollout.states.iter().zip(&targets) {
694            let (x, y) = grid.nearest_cell_indices(state.x, state.y);
695            let delta = grid.update_cell_toward(x, y, target, self.config.learning_rate)?;
696            updates += 1;
697            total_abs_delta += delta;
698            max_abs_delta = max_abs_delta.max(delta);
699        }
700
701        Ok(MppiTerminalValueUpdateReport2D {
702            updates,
703            mean_abs_delta: total_abs_delta / updates as f64,
704            max_abs_delta,
705            start_target: targets[0],
706            terminal_target: *targets
707                .last()
708                .expect("validated rollout has at least one stage cost"),
709        })
710    }
711}
712
713/// Replay buffer for reusing recent MPPI rollouts in terminal-value learning.
714#[derive(Debug, Clone, PartialEq)]
715pub struct MppiTerminalValueReplayBuffer2D {
716    capacity: usize,
717    rollouts: VecDeque<MppiRollout2D>,
718}
719
720/// Summary returned after replay-buffer terminal-value updates.
721#[derive(Debug, Clone, Copy, PartialEq)]
722pub struct MppiTerminalValueReplayUpdateReport2D {
723    pub rollouts: usize,
724    pub updates: usize,
725    pub mean_abs_delta: f64,
726    pub max_abs_delta: f64,
727}
728
729impl MppiTerminalValueReplayBuffer2D {
730    pub fn new(capacity: usize) -> RoboticsResult<Self> {
731        if capacity == 0 {
732            return Err(RoboticsError::InvalidParameter(
733                "terminal value replay capacity must be positive".to_string(),
734            ));
735        }
736        Ok(Self {
737            capacity,
738            rollouts: VecDeque::with_capacity(capacity),
739        })
740    }
741
742    pub fn capacity(&self) -> usize {
743        self.capacity
744    }
745
746    pub fn len(&self) -> usize {
747        self.rollouts.len()
748    }
749
750    pub fn is_empty(&self) -> bool {
751        self.rollouts.is_empty()
752    }
753
754    pub fn push(&mut self, rollout: MppiRollout2D) -> RoboticsResult<()> {
755        validate_rollout_for_terminal_value_update(&rollout)?;
756        if self.rollouts.len() == self.capacity {
757            self.rollouts.pop_front();
758        }
759        self.rollouts.push_back(rollout);
760        Ok(())
761    }
762
763    pub fn update_grid(
764        &self,
765        grid: &mut MppiTerminalValueGrid2D,
766        updater: &MppiTerminalValueUpdater2D,
767    ) -> RoboticsResult<MppiTerminalValueReplayUpdateReport2D> {
768        if self.rollouts.is_empty() {
769            return Err(RoboticsError::InvalidParameter(
770                "terminal value replay buffer is empty".to_string(),
771            ));
772        }
773
774        let mut updates = 0;
775        let mut weighted_delta_sum = 0.0;
776        let mut max_abs_delta: f64 = 0.0;
777        for rollout in &self.rollouts {
778            let report = updater.update_from_rollout(grid, rollout)?;
779            updates += report.updates;
780            weighted_delta_sum += report.mean_abs_delta * report.updates as f64;
781            max_abs_delta = max_abs_delta.max(report.max_abs_delta);
782        }
783
784        Ok(MppiTerminalValueReplayUpdateReport2D {
785            rollouts: self.rollouts.len(),
786            updates,
787            mean_abs_delta: weighted_delta_sum / updates as f64,
788            max_abs_delta,
789        })
790    }
791}
792
793/// MPPI configuration.
794#[derive(Debug, Clone, PartialEq)]
795pub struct MppiConfig {
796    pub horizon: usize,
797    pub samples: usize,
798    pub dt: f64,
799    pub lambda: f64,
800    pub adaptive_lambda: bool,
801    pub min_lambda: f64,
802    pub max_lambda: f64,
803    pub target_effective_sample_ratio: f64,
804    pub adaptive_lambda_iterations: usize,
805    pub noise_sigma: f64,
806    pub control_limit: f64,
807    pub goal_weight: f64,
808    pub velocity_weight: f64,
809    pub control_weight: f64,
810    pub terminal_weight: f64,
811    pub constraint_weight: f64,
812    pub constraint_discount: f64,
813    pub safety_margin: f64,
814    pub obstacles: Vec<MppiCircularObstacle2D>,
815    pub moving_obstacles: Vec<MppiMovingObstacle2D>,
816    pub terminal_value_weight: f64,
817    pub terminal_value_grid: Option<MppiTerminalValueGrid2D>,
818    pub goal_trajectory: Option<Vec<(f64, f64)>>,
819    pub gate_race: Option<MppiGateRace2D>,
820    pub seed: u64,
821}
822
823impl Default for MppiConfig {
824    fn default() -> Self {
825        Self {
826            horizon: 20,
827            samples: 128,
828            dt: 0.1,
829            lambda: 1.0,
830            adaptive_lambda: false,
831            min_lambda: 0.05,
832            max_lambda: 10.0,
833            target_effective_sample_ratio: 0.25,
834            adaptive_lambda_iterations: 20,
835            noise_sigma: 0.8,
836            control_limit: 2.0,
837            goal_weight: 2.0,
838            velocity_weight: 0.1,
839            control_weight: 0.02,
840            terminal_weight: 8.0,
841            constraint_weight: 40.0,
842            constraint_discount: 0.95,
843            safety_margin: 0.05,
844            obstacles: Vec::new(),
845            moving_obstacles: Vec::new(),
846            terminal_value_weight: 1.0,
847            terminal_value_grid: None,
848            goal_trajectory: None,
849            gate_race: None,
850            seed: 7,
851        }
852    }
853}
854
855/// Diagnostics for MPPI path-integral sample weights.
856#[derive(Debug, Clone, Copy, PartialEq)]
857pub struct MppiSamplingDiagnostics2D {
858    pub sample_count: usize,
859    pub lambda: f64,
860    pub min_cost: f64,
861    pub mean_cost: f64,
862    pub max_cost: f64,
863    pub effective_sample_size: f64,
864    pub normalized_effective_sample_size: f64,
865    pub weight_entropy: f64,
866    pub normalized_weight_entropy: f64,
867}
868
869/// One MPPI rollout result.
870#[derive(Debug, Clone, PartialEq)]
871pub struct MppiRollout2D {
872    pub cost: f64,
873    pub model_cost: f64,
874    pub constraint_cost: f64,
875    pub terminal_value_cost: f64,
876    pub max_constraint_violation: f64,
877    pub states: Vec<MppiState2D>,
878    pub controls: Vec<MppiControl2D>,
879    pub stage_costs: Vec<f64>,
880}
881
882/// Result for one MPPI optimization step.
883#[derive(Debug, Clone, PartialEq)]
884pub struct MppiPlan2D {
885    pub first_control: MppiControl2D,
886    pub nominal_controls: Vec<MppiControl2D>,
887    pub best_rollout: MppiRollout2D,
888    pub sampling_diagnostics: MppiSamplingDiagnostics2D,
889}
890
891/// Vanilla MPPI controller.
892pub struct MppiController2D {
893    config: MppiConfig,
894    nominal_controls: Vec<MppiControl2D>,
895    rng: StdRng,
896}
897
898impl MppiController2D {
899    pub fn new(config: MppiConfig) -> RoboticsResult<Self> {
900        validate_config(&config)?;
901        Ok(Self {
902            nominal_controls: vec![MppiControl2D::new(0.0, 0.0); config.horizon],
903            rng: StdRng::seed_from_u64(config.seed),
904            config,
905        })
906    }
907
908    pub fn plan(&mut self, start: MppiState2D, goal: (f64, f64)) -> RoboticsResult<MppiPlan2D> {
909        validate_state(start)?;
910        validate_goal(goal)?;
911        if let Some(gate_race) = &mut self.config.gate_race {
912            gate_race.advance_active_gate_from_state(start);
913        }
914        let normal = Normal::new(0.0, self.config.noise_sigma).map_err(|err| {
915            RoboticsError::InvalidParameter(format!("invalid MPPI noise distribution: {err}"))
916        })?;
917        let mut candidates = Vec::with_capacity(self.config.samples + 1);
918
919        candidates.push(self.rollout(start, goal, &self.nominal_controls));
920        for _ in 0..self.config.samples {
921            let mut controls = self.nominal_controls.clone();
922            for control in &mut controls {
923                control.ax = clamp_control(control.ax + normal.sample(&mut self.rng), &self.config);
924                control.ay = clamp_control(control.ay + normal.sample(&mut self.rng), &self.config);
925            }
926            candidates.push(self.rollout(start, goal, &controls));
927        }
928
929        let costs = candidates
930            .iter()
931            .map(|rollout| rollout.cost)
932            .collect::<Vec<_>>();
933        let sampling_lambda = select_sampling_lambda(&costs, &self.config)?;
934        let sampling_diagnostics = sampling_diagnostics_for_costs(&costs, sampling_lambda)?;
935        let beta = sampling_diagnostics.min_cost;
936        let mut weight_sum = 0.0;
937        let mut next_nominal = vec![MppiControl2D::new(0.0, 0.0); self.config.horizon];
938        for rollout in &candidates {
939            let weight = sampling_weight(rollout.cost, beta, sampling_lambda);
940            weight_sum += weight;
941            for (accumulator, control) in next_nominal.iter_mut().zip(&rollout.controls) {
942                accumulator.ax += weight * control.ax;
943                accumulator.ay += weight * control.ay;
944            }
945        }
946        if weight_sum <= 0.0 || !weight_sum.is_finite() {
947            return Err(RoboticsError::PlanningError(
948                "MPPI sample weights collapsed".to_string(),
949            ));
950        }
951        for control in &mut next_nominal {
952            control.ax = clamp_control(control.ax / weight_sum, &self.config);
953            control.ay = clamp_control(control.ay / weight_sum, &self.config);
954        }
955
956        let first_control = next_nominal[0];
957        self.nominal_controls = shift_controls(&next_nominal);
958        let best_rollout = candidates
959            .into_iter()
960            .min_by(|a, b| a.cost.total_cmp(&b.cost))
961            .expect("at least one candidate rollout");
962
963        Ok(MppiPlan2D {
964            first_control,
965            nominal_controls: next_nominal,
966            best_rollout,
967            sampling_diagnostics,
968        })
969    }
970
971    pub fn terminal_value_grid(&self) -> Option<&MppiTerminalValueGrid2D> {
972        self.config.terminal_value_grid.as_ref()
973    }
974
975    pub fn terminal_value_grid_mut(&mut self) -> Option<&mut MppiTerminalValueGrid2D> {
976        self.config.terminal_value_grid.as_mut()
977    }
978
979    pub fn set_terminal_value_grid(
980        &mut self,
981        grid: Option<MppiTerminalValueGrid2D>,
982    ) -> RoboticsResult<()> {
983        if let Some(grid) = &grid {
984            validate_terminal_value_grid(grid)?;
985        }
986        self.config.terminal_value_grid = grid;
987        Ok(())
988    }
989
990    pub fn set_moving_obstacles(
991        &mut self,
992        moving_obstacles: Vec<MppiMovingObstacle2D>,
993    ) -> RoboticsResult<()> {
994        validate_moving_obstacles(&moving_obstacles)?;
995        self.config.moving_obstacles = moving_obstacles;
996        Ok(())
997    }
998
999    pub fn set_goal_trajectory(
1000        &mut self,
1001        goal_trajectory: Option<Vec<(f64, f64)>>,
1002    ) -> RoboticsResult<()> {
1003        if let Some(goal_trajectory) = &goal_trajectory {
1004            validate_goal_trajectory(goal_trajectory)?;
1005        }
1006        self.config.goal_trajectory = goal_trajectory;
1007        Ok(())
1008    }
1009
1010    fn rollout(
1011        &self,
1012        start: MppiState2D,
1013        goal: (f64, f64),
1014        controls: &[MppiControl2D],
1015    ) -> MppiRollout2D {
1016        let mut state = start;
1017        let mut states = Vec::with_capacity(controls.len() + 1);
1018        let mut model_cost = 0.0;
1019        let mut constraint_cost = 0.0;
1020        let mut max_constraint_violation: f64 = 0.0;
1021        let mut stage_costs = Vec::with_capacity(controls.len() + 1);
1022        let mut active_gate_index = self
1023            .config
1024            .gate_race
1025            .as_ref()
1026            .map_or(0, MppiGateRace2D::active_gate_index);
1027        states.push(state);
1028        for (step, &control) in controls.iter().enumerate() {
1029            let (step_constraint_cost, step_violation) = self.constraint_cost(state, step);
1030            constraint_cost += step_constraint_cost;
1031            max_constraint_violation = max_constraint_violation.max(step_violation);
1032            let next_state = state.step(control, self.config.dt);
1033            let gate_step_cost = if let Some(gate_race) = &self.config.gate_race {
1034                let gate_step = gate_race.transition_cost(state, next_state, active_gate_index);
1035                active_gate_index = gate_step.active_gate_index;
1036                gate_step.cost
1037            } else {
1038                0.0
1039            };
1040            let step_goal = self.goal_at(step, goal);
1041            let step_cost = self.running_cost(state, control, step_goal)
1042                + step_constraint_cost
1043                + gate_step_cost;
1044            model_cost += step_cost;
1045            stage_costs.push(step_cost);
1046            state = next_state;
1047            states.push(state);
1048        }
1049        let (terminal_constraint_cost, terminal_violation) =
1050            self.constraint_cost(state, controls.len());
1051        constraint_cost += terminal_constraint_cost;
1052        max_constraint_violation = max_constraint_violation.max(terminal_violation);
1053        let terminal_value_cost = self.terminal_value_cost(state);
1054        let terminal_gate_cost = self.config.gate_race.as_ref().map_or(0.0, |gate_race| {
1055            gate_race.terminal_cost(state, active_gate_index)
1056        });
1057        let terminal_goal = self.goal_at(controls.len(), goal);
1058        let terminal_model_cost = self.config.terminal_weight
1059            * squared_goal_distance(state, terminal_goal)
1060            + terminal_constraint_cost
1061            + terminal_gate_cost;
1062        model_cost += terminal_model_cost;
1063        stage_costs.push(terminal_model_cost);
1064        MppiRollout2D {
1065            cost: model_cost + terminal_value_cost,
1066            model_cost,
1067            constraint_cost,
1068            terminal_value_cost,
1069            max_constraint_violation,
1070            states,
1071            controls: controls.to_vec(),
1072            stage_costs,
1073        }
1074    }
1075
1076    fn goal_at(&self, step: usize, fallback_goal: (f64, f64)) -> (f64, f64) {
1077        self.config
1078            .goal_trajectory
1079            .as_ref()
1080            .and_then(|goals| goals.get(step).copied().or_else(|| goals.last().copied()))
1081            .unwrap_or(fallback_goal)
1082    }
1083
1084    fn running_cost(&self, state: MppiState2D, control: MppiControl2D, goal: (f64, f64)) -> f64 {
1085        self.config.goal_weight * squared_goal_distance(state, goal)
1086            + self.config.velocity_weight * (state.vx * state.vx + state.vy * state.vy)
1087            + self.config.control_weight * (control.ax * control.ax + control.ay * control.ay)
1088    }
1089
1090    fn constraint_cost(&self, state: MppiState2D, step: usize) -> (f64, f64) {
1091        let mut penalty = 0.0;
1092        let mut max_violation: f64 = 0.0;
1093        for obstacle in &self.config.obstacles {
1094            let violation =
1095                circular_obstacle_violation(state, *obstacle, self.config.safety_margin);
1096            max_violation = max_violation.max(violation);
1097            penalty += violation * violation;
1098        }
1099        let predicted_time = step as f64 * self.config.dt;
1100        for obstacle in &self.config.moving_obstacles {
1101            let predicted_obstacle = obstacle.predict(predicted_time);
1102            let violation =
1103                circular_obstacle_violation(state, predicted_obstacle, self.config.safety_margin);
1104            max_violation = max_violation.max(violation);
1105            penalty += violation * violation;
1106        }
1107        let discount = self.config.constraint_discount.powi(step as i32);
1108        (
1109            self.config.constraint_weight * discount * penalty,
1110            max_violation,
1111        )
1112    }
1113
1114    fn terminal_value_cost(&self, state: MppiState2D) -> f64 {
1115        self.config
1116            .terminal_value_grid
1117            .as_ref()
1118            .map_or(0.0, |grid| {
1119                self.config.terminal_value_weight * grid.value_at_state(state)
1120            })
1121    }
1122}
1123
1124fn validate_config(config: &MppiConfig) -> RoboticsResult<()> {
1125    if config.horizon == 0 || config.samples == 0 {
1126        return Err(RoboticsError::InvalidParameter(
1127            "horizon and samples must be positive".to_string(),
1128        ));
1129    }
1130    for (label, value) in [
1131        ("dt", config.dt),
1132        ("lambda", config.lambda),
1133        ("min_lambda", config.min_lambda),
1134        ("max_lambda", config.max_lambda),
1135        ("noise_sigma", config.noise_sigma),
1136        ("control_limit", config.control_limit),
1137    ] {
1138        if value <= 0.0 || !value.is_finite() {
1139            return Err(RoboticsError::InvalidParameter(format!(
1140                "{label} must be finite and positive"
1141            )));
1142        }
1143    }
1144    for (label, value) in [
1145        ("goal_weight", config.goal_weight),
1146        ("velocity_weight", config.velocity_weight),
1147        ("control_weight", config.control_weight),
1148        ("terminal_weight", config.terminal_weight),
1149        ("constraint_weight", config.constraint_weight),
1150        ("constraint_discount", config.constraint_discount),
1151        ("terminal_value_weight", config.terminal_value_weight),
1152    ] {
1153        if value < 0.0 || !value.is_finite() {
1154            return Err(RoboticsError::InvalidParameter(format!(
1155                "{label} must be finite and non-negative"
1156            )));
1157        }
1158    }
1159    if config.max_lambda < config.min_lambda {
1160        return Err(RoboticsError::InvalidParameter(
1161            "max_lambda must be at least min_lambda".to_string(),
1162        ));
1163    }
1164    if !(0.0..=1.0).contains(&config.target_effective_sample_ratio)
1165        || config.target_effective_sample_ratio == 0.0
1166    {
1167        return Err(RoboticsError::InvalidParameter(
1168            "target_effective_sample_ratio must be in (0, 1]".to_string(),
1169        ));
1170    }
1171    if config.adaptive_lambda_iterations == 0 {
1172        return Err(RoboticsError::InvalidParameter(
1173            "adaptive_lambda_iterations must be positive".to_string(),
1174        ));
1175    }
1176    if config.constraint_discount > 1.0 {
1177        return Err(RoboticsError::InvalidParameter(
1178            "constraint_discount must be at most 1.0".to_string(),
1179        ));
1180    }
1181    if config.safety_margin < 0.0 || !config.safety_margin.is_finite() {
1182        return Err(RoboticsError::InvalidParameter(
1183            "safety_margin must be finite and non-negative".to_string(),
1184        ));
1185    }
1186    for obstacle in &config.obstacles {
1187        if !obstacle.x.is_finite() || !obstacle.y.is_finite() {
1188            return Err(RoboticsError::InvalidParameter(
1189                "obstacle coordinates must be finite".to_string(),
1190            ));
1191        }
1192        if obstacle.radius <= 0.0 || !obstacle.radius.is_finite() {
1193            return Err(RoboticsError::InvalidParameter(
1194                "obstacle radius must be finite and positive".to_string(),
1195            ));
1196        }
1197    }
1198    validate_moving_obstacles(&config.moving_obstacles)?;
1199    if let Some(goal_trajectory) = &config.goal_trajectory {
1200        validate_goal_trajectory(goal_trajectory)?;
1201    }
1202    if let Some(grid) = &config.terminal_value_grid {
1203        validate_terminal_value_grid(grid)?;
1204    }
1205    if let Some(gate_race) = &config.gate_race {
1206        validate_gate_race(gate_race)?;
1207    }
1208    Ok(())
1209}
1210
1211fn validate_moving_obstacles(obstacles: &[MppiMovingObstacle2D]) -> RoboticsResult<()> {
1212    for obstacle in obstacles {
1213        if !obstacle.x.is_finite()
1214            || !obstacle.y.is_finite()
1215            || !obstacle.vx.is_finite()
1216            || !obstacle.vy.is_finite()
1217        {
1218            return Err(RoboticsError::InvalidParameter(
1219                "moving obstacle state must be finite".to_string(),
1220            ));
1221        }
1222        if obstacle.radius <= 0.0 || !obstacle.radius.is_finite() {
1223            return Err(RoboticsError::InvalidParameter(
1224                "moving obstacle radius must be finite and positive".to_string(),
1225            ));
1226        }
1227    }
1228    Ok(())
1229}
1230
1231fn validate_goal_trajectory(goal_trajectory: &[(f64, f64)]) -> RoboticsResult<()> {
1232    if goal_trajectory.is_empty() {
1233        return Err(RoboticsError::InvalidParameter(
1234            "MPPI goal trajectory must be non-empty".to_string(),
1235        ));
1236    }
1237    for &goal in goal_trajectory {
1238        validate_goal(goal)?;
1239    }
1240    Ok(())
1241}
1242
1243fn validate_gate_race(gate_race: &MppiGateRace2D) -> RoboticsResult<()> {
1244    if gate_race.gates.is_empty() {
1245        return Err(RoboticsError::InvalidParameter(
1246            "gate race must contain at least one gate".to_string(),
1247        ));
1248    }
1249    if gate_race.active_gate_index > gate_race.gates.len() {
1250        return Err(RoboticsError::InvalidParameter(
1251            "active gate index must be within the gate list".to_string(),
1252        ));
1253    }
1254    for (label, value) in [
1255        ("gate race progress_weight", gate_race.progress_weight),
1256        ("gate race pass_bonus", gate_race.pass_bonus),
1257        ("gate race miss_penalty", gate_race.miss_penalty),
1258        (
1259            "gate race terminal_gate_weight",
1260            gate_race.terminal_gate_weight,
1261        ),
1262    ] {
1263        if value <= 0.0 || !value.is_finite() {
1264            return Err(RoboticsError::InvalidParameter(format!(
1265                "{label} must be finite and positive"
1266            )));
1267        }
1268    }
1269    for (label, value) in [
1270        ("gate race lateral_weight", gate_race.lateral_weight),
1271        ("gate race crossing_tolerance", gate_race.crossing_tolerance),
1272    ] {
1273        if value < 0.0 || !value.is_finite() {
1274            return Err(RoboticsError::InvalidParameter(format!(
1275                "{label} must be finite and non-negative"
1276            )));
1277        }
1278    }
1279    for gate in &gate_race.gates {
1280        let normal_norm = (gate.normal_x * gate.normal_x + gate.normal_y * gate.normal_y).sqrt();
1281        if !gate.center_x.is_finite()
1282            || !gate.center_y.is_finite()
1283            || !gate.normal_x.is_finite()
1284            || !gate.normal_y.is_finite()
1285            || normal_norm <= 0.0
1286        {
1287            return Err(RoboticsError::InvalidParameter(
1288                "racing gate center and normal must be finite".to_string(),
1289            ));
1290        }
1291        if gate.half_width <= 0.0 || !gate.half_width.is_finite() {
1292            return Err(RoboticsError::InvalidParameter(
1293                "racing gate half_width must be finite and positive".to_string(),
1294            ));
1295        }
1296    }
1297    Ok(())
1298}
1299
1300fn validate_track_waypoints(waypoints: &[(f64, f64)]) -> RoboticsResult<()> {
1301    if waypoints.len() < 2 {
1302        return Err(RoboticsError::InvalidParameter(
1303            "track must contain at least two waypoints".to_string(),
1304        ));
1305    }
1306    for &(x, y) in waypoints {
1307        if !x.is_finite() || !y.is_finite() {
1308            return Err(RoboticsError::InvalidParameter(
1309                "track waypoints must be finite".to_string(),
1310            ));
1311        }
1312    }
1313    for pair in waypoints.windows(2) {
1314        if point_distance(pair[0], pair[1]) <= 0.0 {
1315            return Err(RoboticsError::InvalidParameter(
1316                "track segments must have positive length".to_string(),
1317            ));
1318        }
1319    }
1320    Ok(())
1321}
1322
1323fn point_distance(a: (f64, f64), b: (f64, f64)) -> f64 {
1324    let dx = a.0 - b.0;
1325    let dy = a.1 - b.1;
1326    (dx * dx + dy * dy).sqrt()
1327}
1328
1329fn sampling_weight(cost: f64, beta: f64, lambda: f64) -> f64 {
1330    (-(cost - beta) / lambda).exp()
1331}
1332
1333fn sampling_diagnostics_for_costs(
1334    costs: &[f64],
1335    lambda: f64,
1336) -> RoboticsResult<MppiSamplingDiagnostics2D> {
1337    if costs.is_empty() {
1338        return Err(RoboticsError::InvalidParameter(
1339            "MPPI sampling costs must be non-empty".to_string(),
1340        ));
1341    }
1342    if lambda <= 0.0 || !lambda.is_finite() {
1343        return Err(RoboticsError::InvalidParameter(
1344            "MPPI sampling lambda must be finite and positive".to_string(),
1345        ));
1346    }
1347    if costs.iter().any(|cost| !cost.is_finite()) {
1348        return Err(RoboticsError::InvalidParameter(
1349            "MPPI sampling costs must be finite".to_string(),
1350        ));
1351    }
1352
1353    let sample_count = costs.len();
1354    let min_cost = costs.iter().copied().fold(f64::INFINITY, f64::min);
1355    let max_cost = costs.iter().copied().fold(f64::NEG_INFINITY, f64::max);
1356    let mean_cost = costs.iter().sum::<f64>() / sample_count as f64;
1357    let mut weight_sum = 0.0;
1358    let mut squared_weight_sum = 0.0;
1359    let mut weights = Vec::with_capacity(sample_count);
1360    for &cost in costs {
1361        let weight = sampling_weight(cost, min_cost, lambda);
1362        weight_sum += weight;
1363        squared_weight_sum += weight * weight;
1364        weights.push(weight);
1365    }
1366    if weight_sum <= 0.0 || squared_weight_sum <= 0.0 || !weight_sum.is_finite() {
1367        return Err(RoboticsError::PlanningError(
1368            "MPPI sample weights collapsed".to_string(),
1369        ));
1370    }
1371
1372    let effective_sample_size = weight_sum * weight_sum / squared_weight_sum;
1373    let mut weight_entropy = 0.0;
1374    for weight in weights {
1375        let probability = weight / weight_sum;
1376        if probability > 0.0 {
1377            weight_entropy -= probability * probability.ln();
1378        }
1379    }
1380    let sample_count_f = sample_count as f64;
1381    let normalized_weight_entropy = if sample_count > 1 {
1382        weight_entropy / sample_count_f.ln()
1383    } else {
1384        1.0
1385    };
1386
1387    Ok(MppiSamplingDiagnostics2D {
1388        sample_count,
1389        lambda,
1390        min_cost,
1391        mean_cost,
1392        max_cost,
1393        effective_sample_size,
1394        normalized_effective_sample_size: effective_sample_size / sample_count_f,
1395        weight_entropy,
1396        normalized_weight_entropy,
1397    })
1398}
1399
1400fn select_sampling_lambda(costs: &[f64], config: &MppiConfig) -> RoboticsResult<f64> {
1401    if !config.adaptive_lambda {
1402        return Ok(config.lambda);
1403    }
1404
1405    let target_effective_sample_size = config.target_effective_sample_ratio * costs.len() as f64;
1406    let min_diag = sampling_diagnostics_for_costs(costs, config.min_lambda)?;
1407    if min_diag.effective_sample_size >= target_effective_sample_size {
1408        return Ok(config.min_lambda);
1409    }
1410    let max_diag = sampling_diagnostics_for_costs(costs, config.max_lambda)?;
1411    if max_diag.effective_sample_size <= target_effective_sample_size {
1412        return Ok(config.max_lambda);
1413    }
1414
1415    let mut low = config.min_lambda;
1416    let mut high = config.max_lambda;
1417    for _ in 0..config.adaptive_lambda_iterations {
1418        let mid = 0.5 * (low + high);
1419        let diagnostics = sampling_diagnostics_for_costs(costs, mid)?;
1420        if diagnostics.effective_sample_size < target_effective_sample_size {
1421            low = mid;
1422        } else {
1423            high = mid;
1424        }
1425    }
1426    Ok(high)
1427}
1428
1429fn validate_terminal_value_grid(grid: &MppiTerminalValueGrid2D) -> RoboticsResult<()> {
1430    if !grid.origin_x.is_finite() || !grid.origin_y.is_finite() {
1431        return Err(RoboticsError::InvalidParameter(
1432            "terminal value grid origin must be finite".to_string(),
1433        ));
1434    }
1435    if grid.resolution <= 0.0 || !grid.resolution.is_finite() {
1436        return Err(RoboticsError::InvalidParameter(
1437            "terminal value grid resolution must be finite and positive".to_string(),
1438        ));
1439    }
1440    if grid.values.is_empty() || grid.values[0].is_empty() {
1441        return Err(RoboticsError::InvalidParameter(
1442            "terminal value grid values must be non-empty".to_string(),
1443        ));
1444    }
1445    let height = grid.values[0].len();
1446    for column in &grid.values {
1447        if column.len() != height {
1448            return Err(RoboticsError::InvalidParameter(
1449                "terminal value grid must be rectangular".to_string(),
1450            ));
1451        }
1452        if column
1453            .iter()
1454            .any(|value| !value.is_finite() || *value < 0.0)
1455        {
1456            return Err(RoboticsError::InvalidParameter(
1457                "terminal value grid values must be finite and non-negative".to_string(),
1458            ));
1459        }
1460    }
1461    Ok(())
1462}
1463
1464fn validate_terminal_value_update_config(
1465    config: &MppiTerminalValueUpdateConfig2D,
1466) -> RoboticsResult<()> {
1467    if !(0.0..=1.0).contains(&config.learning_rate) || config.learning_rate == 0.0 {
1468        return Err(RoboticsError::InvalidParameter(
1469            "terminal value update learning_rate must be in (0, 1]".to_string(),
1470        ));
1471    }
1472    if !(0.0..=1.0).contains(&config.discount) {
1473        return Err(RoboticsError::InvalidParameter(
1474            "terminal value update discount must be in [0, 1]".to_string(),
1475        ));
1476    }
1477    Ok(())
1478}
1479
1480fn validate_rollout_for_terminal_value_update(rollout: &MppiRollout2D) -> RoboticsResult<()> {
1481    if rollout.states.is_empty() || rollout.stage_costs.is_empty() {
1482        return Err(RoboticsError::InvalidParameter(
1483            "rollout states and stage_costs must be non-empty".to_string(),
1484        ));
1485    }
1486    if rollout.states.len() != rollout.stage_costs.len() {
1487        return Err(RoboticsError::InvalidParameter(
1488            "rollout states and stage_costs must have the same length".to_string(),
1489        ));
1490    }
1491    for &state in &rollout.states {
1492        validate_state(state)?;
1493    }
1494    if rollout
1495        .stage_costs
1496        .iter()
1497        .any(|value| !value.is_finite() || *value < 0.0)
1498    {
1499        return Err(RoboticsError::InvalidParameter(
1500            "rollout stage_costs must be finite and non-negative".to_string(),
1501        ));
1502    }
1503    Ok(())
1504}
1505
1506fn discounted_cost_to_go(stage_costs: &[f64], discount: f64) -> Vec<f64> {
1507    let mut targets = vec![0.0; stage_costs.len()];
1508    let mut target = 0.0;
1509    for index in (0..stage_costs.len()).rev() {
1510        target = stage_costs[index] + discount * target;
1511        targets[index] = target;
1512    }
1513    targets
1514}
1515
1516fn validate_state(state: MppiState2D) -> RoboticsResult<()> {
1517    for (label, value) in [
1518        ("x", state.x),
1519        ("y", state.y),
1520        ("vx", state.vx),
1521        ("vy", state.vy),
1522    ] {
1523        if !value.is_finite() {
1524            return Err(RoboticsError::InvalidParameter(format!(
1525                "{label} must be finite"
1526            )));
1527        }
1528    }
1529    Ok(())
1530}
1531
1532fn validate_goal(goal: (f64, f64)) -> RoboticsResult<()> {
1533    if goal.0.is_finite() && goal.1.is_finite() {
1534        Ok(())
1535    } else {
1536        Err(RoboticsError::InvalidParameter(
1537            "goal coordinates must be finite".to_string(),
1538        ))
1539    }
1540}
1541
1542fn squared_goal_distance(state: MppiState2D, goal: (f64, f64)) -> f64 {
1543    let dx = state.x - goal.0;
1544    let dy = state.y - goal.1;
1545    dx * dx + dy * dy
1546}
1547
1548fn circular_obstacle_violation(
1549    state: MppiState2D,
1550    obstacle: MppiCircularObstacle2D,
1551    safety_margin: f64,
1552) -> f64 {
1553    let dx = state.x - obstacle.x;
1554    let dy = state.y - obstacle.y;
1555    let clearance = (dx * dx + dy * dy).sqrt() - obstacle.radius - safety_margin;
1556    (-clearance).max(0.0)
1557}
1558
1559fn clamp_control(value: f64, config: &MppiConfig) -> f64 {
1560    value.clamp(-config.control_limit, config.control_limit)
1561}
1562
1563fn shift_controls(controls: &[MppiControl2D]) -> Vec<MppiControl2D> {
1564    let mut shifted = controls[1..].to_vec();
1565    shifted.push(MppiControl2D::new(0.0, 0.0));
1566    shifted
1567}
1568
1569#[cfg(test)]
1570mod tests {
1571    use super::*;
1572
1573    fn small_config(seed: u64) -> MppiConfig {
1574        MppiConfig {
1575            horizon: 12,
1576            samples: 64,
1577            dt: 0.1,
1578            seed,
1579            ..MppiConfig::default()
1580        }
1581    }
1582
1583    #[test]
1584    fn mppi_accelerates_toward_goal() {
1585        let mut controller = MppiController2D::new(small_config(3)).unwrap();
1586        let plan = controller
1587            .plan(MppiState2D::new(0.0, 0.0, 0.0, 0.0), (2.0, 0.0))
1588            .unwrap();
1589
1590        assert!(plan.first_control.ax > 0.0);
1591        assert!(plan.first_control.ay.abs() < 0.8);
1592        assert_eq!(plan.nominal_controls.len(), 12);
1593    }
1594
1595    #[test]
1596    fn rollout_best_cost_is_finite() {
1597        let mut controller = MppiController2D::new(small_config(4)).unwrap();
1598        let plan = controller
1599            .plan(MppiState2D::new(0.0, 0.0, 0.0, 0.0), (1.0, 1.0))
1600            .unwrap();
1601
1602        assert!(plan.best_rollout.cost.is_finite());
1603        assert_eq!(plan.best_rollout.states.len(), 13);
1604    }
1605
1606    #[test]
1607    fn plan_reports_sampling_diagnostics() {
1608        let mut controller = MppiController2D::new(small_config(8)).unwrap();
1609        let plan = controller
1610            .plan(MppiState2D::new(0.0, 0.0, 0.0, 0.0), (1.5, 0.0))
1611            .unwrap();
1612
1613        let diagnostics = plan.sampling_diagnostics;
1614        assert_eq!(diagnostics.sample_count, 65);
1615        assert_eq!(diagnostics.lambda, 1.0);
1616        assert!(diagnostics.min_cost <= diagnostics.mean_cost);
1617        assert!(diagnostics.mean_cost <= diagnostics.max_cost);
1618        assert!(diagnostics.effective_sample_size >= 1.0);
1619        assert!(diagnostics.effective_sample_size <= diagnostics.sample_count as f64);
1620        assert!(diagnostics.normalized_effective_sample_size > 0.0);
1621        assert!(diagnostics.normalized_effective_sample_size <= 1.0);
1622        assert!(diagnostics.normalized_weight_entropy >= 0.0);
1623        assert!(diagnostics.normalized_weight_entropy <= 1.0);
1624    }
1625
1626    #[test]
1627    fn adaptive_lambda_raises_effective_sample_size() {
1628        let costs = vec![0.0, 10.0, 20.0, 30.0, 40.0];
1629        let fixed = sampling_diagnostics_for_costs(&costs, 0.05).unwrap();
1630        let config = MppiConfig {
1631            adaptive_lambda: true,
1632            min_lambda: 0.05,
1633            max_lambda: 100.0,
1634            target_effective_sample_ratio: 0.6,
1635            adaptive_lambda_iterations: 32,
1636            ..MppiConfig::default()
1637        };
1638        let selected_lambda = select_sampling_lambda(&costs, &config).unwrap();
1639        let adaptive = sampling_diagnostics_for_costs(&costs, selected_lambda).unwrap();
1640
1641        assert!(selected_lambda > config.min_lambda);
1642        assert!(adaptive.effective_sample_size > fixed.effective_sample_size);
1643        assert!(adaptive.normalized_effective_sample_size >= 0.55);
1644    }
1645
1646    #[test]
1647    fn circular_obstacle_violation_respects_safety_margin() {
1648        let obstacle = MppiCircularObstacle2D::new(1.0, 0.0, 0.5);
1649
1650        let outside =
1651            circular_obstacle_violation(MppiState2D::new(2.0, 0.0, 0.0, 0.0), obstacle, 0.1);
1652        let inside =
1653            circular_obstacle_violation(MppiState2D::new(1.2, 0.0, 0.0, 0.0), obstacle, 0.1);
1654
1655        assert_eq!(outside, 0.0);
1656        assert!((inside - 0.4).abs() < 1e-9);
1657    }
1658
1659    #[test]
1660    fn constraint_cost_is_discounted_over_horizon() {
1661        let config = MppiConfig {
1662            obstacles: vec![MppiCircularObstacle2D::new(1.0, 0.0, 0.5)],
1663            constraint_weight: 10.0,
1664            constraint_discount: 0.5,
1665            safety_margin: 0.0,
1666            ..small_config(5)
1667        };
1668        let controller = MppiController2D::new(config).unwrap();
1669        let state = MppiState2D::new(1.25, 0.0, 0.0, 0.0);
1670
1671        let (early_cost, early_violation) = controller.constraint_cost(state, 0);
1672        let (late_cost, late_violation) = controller.constraint_cost(state, 3);
1673
1674        assert!((early_violation - 0.25).abs() < 1e-9);
1675        assert_eq!(early_violation, late_violation);
1676        assert!((early_cost - 0.625).abs() < 1e-9);
1677        assert!((late_cost - 0.078125).abs() < 1e-9);
1678    }
1679
1680    #[test]
1681    fn rollout_reports_constraint_cost_and_violation() {
1682        let config = MppiConfig {
1683            obstacles: vec![MppiCircularObstacle2D::new(0.0, 0.0, 0.5)],
1684            constraint_weight: 10.0,
1685            safety_margin: 0.0,
1686            ..small_config(6)
1687        };
1688        let controller = MppiController2D::new(config).unwrap();
1689        let controls = vec![MppiControl2D::new(0.0, 0.0); 12];
1690        let rollout =
1691            controller.rollout(MppiState2D::new(0.0, 0.0, 0.0, 0.0), (1.0, 0.0), &controls);
1692
1693        assert!(rollout.constraint_cost > 0.0);
1694        assert!((rollout.max_constraint_violation - 0.5).abs() < 1e-9);
1695    }
1696
1697    #[test]
1698    fn terminal_value_grid_interpolates_and_clamps() {
1699        let grid =
1700            MppiTerminalValueGrid2D::new(0.0, 0.0, 1.0, vec![vec![0.0, 1.0], vec![1.0, 2.0]])
1701                .unwrap();
1702
1703        assert!((grid.value_at(0.5, 0.5) - 1.0).abs() < 1e-9);
1704        assert!((grid.value_at(-10.0, 0.5) - 0.5).abs() < 1e-9);
1705        assert!((grid.value_at(10.0, 10.0) - 2.0).abs() < 1e-9);
1706    }
1707
1708    #[test]
1709    fn waypoint_track_projects_points_to_progress() {
1710        let track = MppiWaypointTrack2D::new(vec![(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)]).unwrap();
1711
1712        let first = track.project(0.5, 0.2).unwrap();
1713        let second = track.project(1.2, 0.6).unwrap();
1714
1715        assert_eq!(first.segment_index, 0);
1716        assert!((first.progress - 0.5).abs() < 1e-9);
1717        assert!((first.lateral_error - 0.2).abs() < 1e-9);
1718        assert_eq!(second.segment_index, 1);
1719        assert!((second.progress - 1.6).abs() < 1e-9);
1720        assert!((track.total_length() - 2.0).abs() < 1e-9);
1721    }
1722
1723    #[test]
1724    fn waypoint_track_returns_point_at_progress() {
1725        let track = MppiWaypointTrack2D::new(vec![(0.0, 0.0), (1.0, 0.0), (1.0, 2.0)]).unwrap();
1726
1727        let first = track.point_at_progress(0.5).unwrap();
1728        let second = track.point_at_progress(2.0).unwrap();
1729        let clamped = track.point_at_progress(10.0).unwrap();
1730
1731        assert!((first.0 - 0.5).abs() < 1e-9);
1732        assert!((first.1 - 0.0).abs() < 1e-9);
1733        assert!((second.0 - 1.0).abs() < 1e-9);
1734        assert!((second.1 - 1.0).abs() < 1e-9);
1735        assert_eq!(clamped, track.goal());
1736    }
1737
1738    #[test]
1739    fn waypoint_track_terminal_grid_prefers_progress_and_low_lateral_error() {
1740        let track = MppiWaypointTrack2D::new(vec![(0.0, 0.0), (1.0, 0.0), (2.0, 0.0)]).unwrap();
1741        let grid = track
1742            .terminal_value_grid(5, 5, 0.0, -1.0, 0.5, 1.0, 2.0)
1743            .unwrap();
1744
1745        let start_value = grid.value_at(0.0, 0.0);
1746        let middle_value = grid.value_at(1.0, 0.0);
1747        let goal_value = grid.value_at(2.0, 0.0);
1748        let off_track_value = grid.value_at(1.0, 1.0);
1749
1750        assert!(start_value > middle_value);
1751        assert!(middle_value > goal_value);
1752        assert!(off_track_value > middle_value);
1753    }
1754
1755    #[test]
1756    fn moving_obstacle_is_predicted_in_constraint_cost() {
1757        let config = MppiConfig {
1758            moving_obstacles: vec![MppiMovingObstacle2D::new(0.0, 0.0, 1.0, 0.0, 0.4)],
1759            constraint_weight: 10.0,
1760            safety_margin: 0.0,
1761            ..small_config(5)
1762        };
1763        let controller = MppiController2D::new(config).unwrap();
1764        let state = MppiState2D::new(0.2, 0.0, 0.0, 0.0);
1765        let future_state = MppiState2D::new(0.5, 0.0, 0.0, 0.0);
1766
1767        let (early_cost, early_violation) = controller.constraint_cost(state, 0);
1768        let (future_cost, future_violation) = controller.constraint_cost(future_state, 5);
1769
1770        assert!(early_cost > 0.0);
1771        assert!((early_violation - 0.2).abs() < 1e-9);
1772        assert!(future_cost > 0.0);
1773        assert!((future_violation - 0.4).abs() < 1e-9);
1774    }
1775
1776    #[test]
1777    fn rollout_uses_horizon_goal_trajectory_when_present() {
1778        let config = MppiConfig {
1779            horizon: 1,
1780            samples: 1,
1781            goal_weight: 1.0,
1782            velocity_weight: 0.0,
1783            control_weight: 0.0,
1784            terminal_weight: 1.0,
1785            goal_trajectory: Some(vec![(0.0, 0.0), (2.0, 0.0)]),
1786            ..MppiConfig::default()
1787        };
1788        let controller = MppiController2D::new(config).unwrap();
1789        let controls = vec![MppiControl2D::new(0.0, 0.0)];
1790        let rollout =
1791            controller.rollout(MppiState2D::new(0.0, 0.0, 0.0, 0.0), (10.0, 0.0), &controls);
1792
1793        assert!((rollout.stage_costs[0] - 0.0).abs() < 1e-9);
1794        assert!((rollout.stage_costs[1] - 4.0).abs() < 1e-9);
1795        assert!((rollout.model_cost - 4.0).abs() < 1e-9);
1796    }
1797
1798    #[test]
1799    fn racing_gate_normalizes_and_detects_crossing() {
1800        let gate = MppiRacingGate2D::new(1.0, 0.0, 2.0, 0.0, 0.35).unwrap();
1801        let from = MppiState2D::new(0.8, 0.1, 0.0, 0.0);
1802        let through = MppiState2D::new(1.2, 0.1, 0.0, 0.0);
1803        let wide = MppiState2D::new(1.2, 0.8, 0.0, 0.0);
1804
1805        assert!((gate.normal_x - 1.0).abs() < 1e-9);
1806        assert!(gate.crosses(from, through, 0.0));
1807        assert!(!gate.crosses(from, wide, 0.0));
1808        assert!(gate.state_is_past_gate(through, 0.0));
1809        assert!(!gate.state_is_past_gate(wide, 0.0));
1810    }
1811
1812    #[test]
1813    fn gate_race_scores_progress_and_switches_gates() {
1814        let race = MppiGateRace2D::new(vec![
1815            MppiRacingGate2D::new(1.0, 0.0, 1.0, 0.0, 0.35).unwrap(),
1816            MppiRacingGate2D::new(2.0, 0.0, 1.0, 0.0, 0.35).unwrap(),
1817        ])
1818        .unwrap();
1819        let states = vec![
1820            MppiState2D::new(0.0, 0.0, 0.0, 0.0),
1821            MppiState2D::new(1.1, 0.0, 0.0, 0.0),
1822            MppiState2D::new(2.1, 0.0, 0.0, 0.0),
1823        ];
1824
1825        let report = race.score_trajectory(&states).unwrap();
1826
1827        assert_eq!(report.passed_gates, 2);
1828        assert_eq!(report.active_gate_index, 2);
1829        assert_eq!(report.final_gate_distance, 0.0);
1830        assert!(report.cost < 0.0);
1831    }
1832
1833    #[test]
1834    fn gate_race_rollout_can_run_without_goal_tracking_cost() {
1835        let race = MppiGateRace2D::new(vec![
1836            MppiRacingGate2D::new(0.01, 0.0, 1.0, 0.0, 0.3).unwrap()
1837        ])
1838        .unwrap();
1839        let config = MppiConfig {
1840            horizon: 1,
1841            samples: 1,
1842            goal_weight: 0.0,
1843            velocity_weight: 0.0,
1844            control_weight: 0.0,
1845            terminal_weight: 0.0,
1846            gate_race: Some(race),
1847            ..MppiConfig::default()
1848        };
1849        let controller = MppiController2D::new(config).unwrap();
1850        let controls = vec![MppiControl2D::new(2.0, 0.0)];
1851        let rollout = controller.rollout(
1852            MppiState2D::new(0.0, 0.0, 0.0, 0.0),
1853            (10.0, 10.0),
1854            &controls,
1855        );
1856
1857        assert!(rollout.cost < 0.0);
1858        assert_eq!(rollout.constraint_cost, 0.0);
1859        assert_eq!(rollout.terminal_value_cost, 0.0);
1860    }
1861
1862    #[test]
1863    fn waypoint_track_rejects_invalid_waypoints() {
1864        assert!(MppiWaypointTrack2D::new(vec![(0.0, 0.0)]).is_err());
1865        assert!(MppiWaypointTrack2D::new(vec![(0.0, 0.0), (0.0, 0.0)]).is_err());
1866        assert!(MppiWaypointTrack2D::new(vec![(0.0, 0.0), (f64::NAN, 1.0)]).is_err());
1867    }
1868
1869    #[test]
1870    fn terminal_value_cost_is_added_to_rollout() {
1871        let grid = MppiTerminalValueGrid2D::new(0.0, 0.0, 1.0, vec![vec![3.0]]).unwrap();
1872        let config = MppiConfig {
1873            horizon: 1,
1874            samples: 1,
1875            terminal_value_weight: 2.0,
1876            terminal_value_grid: Some(grid),
1877            ..MppiConfig::default()
1878        };
1879        let controller = MppiController2D::new(config).unwrap();
1880        let controls = vec![MppiControl2D::new(0.0, 0.0)];
1881        let rollout =
1882            controller.rollout(MppiState2D::new(0.0, 0.0, 0.0, 0.0), (0.0, 0.0), &controls);
1883
1884        assert!((rollout.terminal_value_cost - 6.0).abs() < 1e-9);
1885        assert!((rollout.cost - 6.0).abs() < 1e-9);
1886        assert!((rollout.model_cost - 0.0).abs() < 1e-9);
1887        assert!((rollout.cost - rollout.model_cost - rollout.terminal_value_cost).abs() < 1e-9);
1888        assert_eq!(rollout.stage_costs.len(), rollout.states.len());
1889    }
1890
1891    #[test]
1892    fn rejects_invalid_terminal_value_grid() {
1893        let ragged = MppiTerminalValueGrid2D::new(0.0, 0.0, 1.0, vec![vec![1.0], vec![]]);
1894        assert!(matches!(ragged, Err(RoboticsError::InvalidParameter(_))));
1895
1896        let negative = MppiTerminalValueGrid2D::new(0.0, 0.0, 1.0, vec![vec![-1.0]]);
1897        assert!(matches!(negative, Err(RoboticsError::InvalidParameter(_))));
1898    }
1899
1900    #[test]
1901    fn terminal_value_updater_learns_discounted_rollout_costs() {
1902        let mut grid = MppiTerminalValueGrid2D::new(0.0, 0.0, 1.0, vec![vec![0.0, 0.0]]).unwrap();
1903        let updater = MppiTerminalValueUpdater2D::new(MppiTerminalValueUpdateConfig2D {
1904            learning_rate: 1.0,
1905            discount: 0.5,
1906        })
1907        .unwrap();
1908        let rollout = MppiRollout2D {
1909            cost: 3.0,
1910            model_cost: 3.0,
1911            constraint_cost: 0.0,
1912            terminal_value_cost: 0.0,
1913            max_constraint_violation: 0.0,
1914            states: vec![
1915                MppiState2D::new(0.0, 0.0, 0.0, 0.0),
1916                MppiState2D::new(0.0, 1.0, 0.0, 0.0),
1917            ],
1918            controls: vec![MppiControl2D::new(0.0, 0.0)],
1919            stage_costs: vec![2.0, 1.0],
1920        };
1921
1922        let report = updater.update_from_rollout(&mut grid, &rollout).unwrap();
1923
1924        assert_eq!(report.updates, 2);
1925        assert!((report.start_target - 2.5).abs() < 1e-9);
1926        assert!((report.terminal_target - 1.0).abs() < 1e-9);
1927        assert!((grid.value_at_cell(0, 0).unwrap() - 2.5).abs() < 1e-9);
1928        assert!((grid.value_at_cell(0, 1).unwrap() - 1.0).abs() < 1e-9);
1929    }
1930
1931    #[test]
1932    fn terminal_value_updater_rejects_mismatched_rollout() {
1933        let mut grid = MppiTerminalValueGrid2D::new(0.0, 0.0, 1.0, vec![vec![0.0]]).unwrap();
1934        let updater =
1935            MppiTerminalValueUpdater2D::new(MppiTerminalValueUpdateConfig2D::default()).unwrap();
1936        let rollout = MppiRollout2D {
1937            cost: 0.0,
1938            model_cost: 0.0,
1939            constraint_cost: 0.0,
1940            terminal_value_cost: 0.0,
1941            max_constraint_violation: 0.0,
1942            states: vec![MppiState2D::new(0.0, 0.0, 0.0, 0.0)],
1943            controls: Vec::new(),
1944            stage_costs: Vec::new(),
1945        };
1946
1947        assert!(matches!(
1948            updater.update_from_rollout(&mut grid, &rollout),
1949            Err(RoboticsError::InvalidParameter(_))
1950        ));
1951    }
1952
1953    fn synthetic_terminal_rollout(y: f64, stage_cost: f64) -> MppiRollout2D {
1954        MppiRollout2D {
1955            cost: stage_cost,
1956            model_cost: stage_cost,
1957            constraint_cost: 0.0,
1958            terminal_value_cost: 0.0,
1959            max_constraint_violation: 0.0,
1960            states: vec![MppiState2D::new(0.0, y, 0.0, 0.0)],
1961            controls: Vec::new(),
1962            stage_costs: vec![stage_cost],
1963        }
1964    }
1965
1966    #[test]
1967    fn terminal_value_replay_buffer_caps_capacity_and_updates_grid() {
1968        let updater = MppiTerminalValueUpdater2D::new(MppiTerminalValueUpdateConfig2D {
1969            learning_rate: 1.0,
1970            discount: 0.0,
1971        })
1972        .unwrap();
1973        let mut buffer = MppiTerminalValueReplayBuffer2D::new(2).unwrap();
1974        buffer.push(synthetic_terminal_rollout(0.0, 9.0)).unwrap();
1975        buffer.push(synthetic_terminal_rollout(1.0, 2.0)).unwrap();
1976        buffer.push(synthetic_terminal_rollout(2.0, 3.0)).unwrap();
1977
1978        let mut grid =
1979            MppiTerminalValueGrid2D::new(0.0, 0.0, 1.0, vec![vec![0.0, 0.0, 0.0]]).unwrap();
1980        let report = buffer.update_grid(&mut grid, &updater).unwrap();
1981
1982        assert_eq!(buffer.capacity(), 2);
1983        assert_eq!(buffer.len(), 2);
1984        assert_eq!(report.rollouts, 2);
1985        assert_eq!(report.updates, 2);
1986        assert_eq!(grid.value_at_cell(0, 0).unwrap(), 0.0);
1987        assert_eq!(grid.value_at_cell(0, 1).unwrap(), 2.0);
1988        assert_eq!(grid.value_at_cell(0, 2).unwrap(), 3.0);
1989    }
1990
1991    #[test]
1992    fn terminal_value_replay_buffer_rejects_empty_updates() {
1993        let updater =
1994            MppiTerminalValueUpdater2D::new(MppiTerminalValueUpdateConfig2D::default()).unwrap();
1995        let buffer = MppiTerminalValueReplayBuffer2D::new(2).unwrap();
1996        let mut grid = MppiTerminalValueGrid2D::new(0.0, 0.0, 1.0, vec![vec![0.0]]).unwrap();
1997
1998        assert!(MppiTerminalValueReplayBuffer2D::new(0).is_err());
1999        assert!(buffer.is_empty());
2000        assert!(matches!(
2001            buffer.update_grid(&mut grid, &updater),
2002            Err(RoboticsError::InvalidParameter(_))
2003        ));
2004    }
2005
2006    #[test]
2007    fn repeated_seed_is_reproducible() {
2008        let mut first = MppiController2D::new(small_config(11)).unwrap();
2009        let mut second = MppiController2D::new(small_config(11)).unwrap();
2010        let a = first
2011            .plan(MppiState2D::new(0.0, 0.0, 0.0, 0.0), (1.0, 0.0))
2012            .unwrap();
2013        let b = second
2014            .plan(MppiState2D::new(0.0, 0.0, 0.0, 0.0), (1.0, 0.0))
2015            .unwrap();
2016
2017        assert_eq!(a.first_control, b.first_control);
2018    }
2019
2020    #[test]
2021    fn rejects_invalid_config() {
2022        let config = MppiConfig {
2023            horizon: 0,
2024            ..MppiConfig::default()
2025        };
2026        assert!(matches!(
2027            MppiController2D::new(config),
2028            Err(RoboticsError::InvalidParameter(_))
2029        ));
2030    }
2031}