1use rand::{rngs::StdRng, SeedableRng};
7use rand_distr::{Distribution, Normal};
8use rust_robotics_core::{RoboticsError, RoboticsResult};
9use std::collections::VecDeque;
10
11#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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
671pub 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#[derive(Debug, Clone, PartialEq)]
715pub struct MppiTerminalValueReplayBuffer2D {
716 capacity: usize,
717 rollouts: VecDeque<MppiRollout2D>,
718}
719
720#[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#[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#[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#[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#[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
891pub 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}