1use rand::{rngs::StdRng, SeedableRng};
35use rand_distr::{Distribution, Normal};
36use rust_robotics_core::{RoboticsError, RoboticsResult};
37
38#[derive(Debug, Clone, Copy, PartialEq)]
40pub struct SliderState {
41 pub pose: [f64; 3],
42}
43
44impl SliderState {
45 pub fn new(x: f64, y: f64, theta: f64) -> Self {
46 Self {
47 pose: [x, y, theta],
48 }
49 }
50
51 pub fn x(self) -> f64 {
52 self.pose[0]
53 }
54 pub fn y(self) -> f64 {
55 self.pose[1]
56 }
57 pub fn theta(self) -> f64 {
58 self.pose[2]
59 }
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum ContactMode {
65 Separated,
67 Stick,
69 SlideUp,
71 SlideDown,
73}
74
75#[derive(Debug, Clone, Copy, PartialEq)]
83pub struct PusherCommand {
84 pub face: usize,
85 pub contact: f64,
86 pub push_speed: f64,
87 pub tangent_speed: f64,
88}
89
90impl PusherCommand {
91 pub fn new(contact: f64, push_speed: f64, tangent_speed: f64) -> Self {
93 Self {
94 face: 0,
95 contact,
96 push_speed,
97 tangent_speed,
98 }
99 }
100
101 pub fn on_face(face: usize, contact: f64, push_speed: f64, tangent_speed: f64) -> Self {
103 Self {
104 face: face % 4,
105 contact,
106 push_speed,
107 tangent_speed,
108 }
109 }
110}
111
112#[derive(Debug, Clone, Copy, PartialEq)]
114pub struct PusherSliderParams {
115 pub half_extent: f64,
117 pub char_len: f64,
119 pub pusher_friction: f64,
121}
122
123impl Default for PusherSliderParams {
124 fn default() -> Self {
125 Self {
127 half_extent: 0.05,
128 char_len: 0.03,
129 pusher_friction: 0.3,
130 }
131 }
132}
133
134impl PusherSliderParams {
135 pub fn new(half_extent: f64, char_len: f64, pusher_friction: f64) -> RoboticsResult<Self> {
136 let positive = |v: f64| v.is_finite() && v > 0.0;
137 if !positive(half_extent) || !positive(char_len) {
138 return Err(RoboticsError::InvalidParameter(
139 "pusher-slider half_extent and char_len must be finite and positive".to_string(),
140 ));
141 }
142 if !pusher_friction.is_finite() || pusher_friction < 0.0 {
143 return Err(RoboticsError::InvalidParameter(
144 "pusher-slider pusher_friction must be finite and non-negative".to_string(),
145 ));
146 }
147 Ok(Self {
148 half_extent,
149 char_len,
150 pusher_friction,
151 })
152 }
153
154 fn contact_frame(self, face: usize, contact: f64) -> ([f64; 2], [f64; 2], [f64; 2]) {
157 let b = self.half_extent;
158 let s = contact.clamp(-b, b);
159 match face % 4 {
160 0 => ([-b, s], [1.0, 0.0], [0.0, 1.0]),
161 1 => ([s, b], [0.0, -1.0], [1.0, 0.0]),
162 2 => ([b, s], [-1.0, 0.0], [0.0, -1.0]),
163 _ => ([s, -b], [0.0, 1.0], [-1.0, 0.0]),
164 }
165 }
166
167 fn twist(self, command: PusherCommand) -> ([f64; 3], ContactMode) {
173 let c2 = self.char_len * self.char_len;
174 let (p, d, t) = self.contact_frame(command.face, command.contact);
175 let [px, py] = p;
176 let vn = command.push_speed.max(0.0);
177 let vt = command.tangent_speed;
178
179 if vn <= 1e-12 {
180 return ([0.0; 3], ContactMode::Separated);
181 }
182
183 let wx = vn * d[0] + vt * t[0];
187 let wy = vn * d[1] + vt * t[1];
188 let m11 = (c2 + py * py) / c2;
189 let m12 = -(px * py) / c2;
190 let m22 = (c2 + px * px) / c2;
191 let det = m11 * m22 - m12 * m12;
192 let (fx, fy) = if det.abs() > 1e-15 {
193 ((m22 * wx - m12 * wy) / det, (-m12 * wx + m11 * wy) / det)
194 } else {
195 (wx, wy)
196 };
197
198 let fn_ = fx * d[0] + fy * d[1];
200 let ft = fx * t[0] + fy * t[1];
201 if fn_ <= 0.0 {
202 return ([0.0; 3], ContactMode::Separated);
204 }
205
206 let mu = self.pusher_friction;
207 if ft.abs() <= mu * fn_ + 1e-12 {
208 let omega = (px * fy - py * fx) / c2;
210 ([fx, fy, omega], ContactMode::Stick)
211 } else {
212 let sign = if ft > 0.0 { 1.0 } else { -1.0 };
216 let fe = [d[0] + sign * mu * t[0], d[1] + sign * mu * t[1]];
217 let omega1 = (px * fe[1] - py * fe[0]) / c2;
218 let cv = [fe[0] - omega1 * py, fe[1] + omega1 * px];
220 let proj = cv[0] * d[0] + cv[1] * d[1];
221 let k = if proj.abs() > 1e-12 { vn / proj } else { vn };
222 let k = k.max(0.0);
223 let mode = if sign > 0.0 {
224 ContactMode::SlideUp
225 } else {
226 ContactMode::SlideDown
227 };
228 ([k * fe[0], k * fe[1], k * omega1], mode)
229 }
230 }
231
232 pub fn step(
235 self,
236 state: SliderState,
237 command: PusherCommand,
238 dt: f64,
239 ) -> (SliderState, ContactMode) {
240 let ([vx_b, vy_b, omega], mode) = self.twist(command);
241 if mode == ContactMode::Separated {
242 return (state, mode);
243 }
244 let theta = state.theta();
246 let (s, co) = theta.sin_cos();
247 let vx_w = co * vx_b - s * vy_b;
248 let vy_w = s * vx_b + co * vy_b;
249 let next = SliderState::new(
250 state.x() + vx_w * dt,
251 state.y() + vy_w * dt,
252 theta + omega * dt,
253 );
254 (next, mode)
255 }
256
257 pub fn contact_point(self, state: SliderState, command: PusherCommand) -> [f64; 2] {
259 let ([px, py], _, _) = self.contact_frame(command.face, command.contact);
260 let (s, co) = state.theta().sin_cos();
261 [state.x() + co * px - s * py, state.y() + s * px + co * py]
262 }
263
264 pub fn two_contact_twist(
276 self,
277 c1: PusherCommand,
278 c2: PusherCommand,
279 ) -> ([f64; 3], [ContactMode; 2]) {
280 let c2c = self.char_len * self.char_len;
281 let (p1, d1, t1) = self.contact_frame(c1.face, c1.contact);
282 let (p2, d2, t2) = self.contact_frame(c2.face, c2.contact);
283 let mu = self.pusher_friction;
284
285 let g = [-p1[1] / c2c, p1[0] / c2c, -p2[1] / c2c, p2[0] / c2c];
287 let wx_row = [1.0, 0.0, 1.0, 0.0];
288 let wy_row = [0.0, 1.0, 0.0, 1.0];
289 let add = |a: [f64; 4], b: [f64; 4], k: f64| {
290 [
291 a[0] + k * b[0],
292 a[1] + k * b[1],
293 a[2] + k * b[2],
294 a[3] + k * b[3],
295 ]
296 };
297 let vc1x = add(wx_row, g, -p1[1]);
299 let vc1y = add(wy_row, g, p1[0]);
300 let vc2x = add(wx_row, g, -p2[1]);
301 let vc2y = add(wy_row, g, p2[0]);
302 let dot = |a: [f64; 4], b: [f64; 4]| a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3];
303
304 let row_normal = |vcx: [f64; 4], vcy: [f64; 4], d: [f64; 2]| {
306 [
307 d[0] * vcx[0] + d[1] * vcy[0],
308 d[0] * vcx[1] + d[1] * vcy[1],
309 d[0] * vcx[2] + d[1] * vcy[2],
310 d[0] * vcx[3] + d[1] * vcy[3],
311 ]
312 };
313 let n1 = row_normal(vc1x, vc1y, d1);
314 let n2 = row_normal(vc2x, vc2y, d2);
315
316 let modes = [0i32, 1, -1];
318 let mut chosen: Option<([f64; 3], [ContactMode; 2])> = None;
319 for &m1 in &modes {
320 for &m2 in &modes {
321 let (tang1, rhs1) =
322 tangential_row(vc1x, vc1y, t1, d1, mu, m1, c1.tangent_speed, true);
323 let (tang2, rhs2) =
324 tangential_row(vc2x, vc2y, t2, d2, mu, m2, c2.tangent_speed, false);
325 let a = [n1, tang1, n2, tang2];
326 let b = [c1.push_speed.max(0.0), rhs1, c2.push_speed.max(0.0), rhs2];
327 let Some(f) = solve4(a, b) else { continue };
328
329 let f1 = [f[0], f[1]];
330 let f2 = [f[2], f[3]];
331 let fn1 = f1[0] * d1[0] + f1[1] * d1[1];
332 let fn2 = f2[0] * d2[0] + f2[1] * d2[1];
333 let ft1 = f1[0] * t1[0] + f1[1] * t1[1];
334 let ft2 = f2[0] * t2[0] + f2[1] * t2[1];
335 if fn1 < -1e-9 || fn2 < -1e-9 {
336 continue;
337 }
338 let slip1 = dot(vc1x, f) * t1[0] + dot(vc1y, f) * t1[1] - c1.tangent_speed;
339 let slip2 = dot(vc2x, f) * t2[0] + dot(vc2y, f) * t2[1] - c2.tangent_speed;
340 if !mode_valid(m1, fn1, ft1, slip1, mu) || !mode_valid(m2, fn2, ft2, slip2, mu) {
341 continue;
342 }
343
344 let wx = dot(wx_row, f);
345 let wy = dot(wy_row, f);
346 let omega = dot(g, f);
347 chosen = Some(([wx, wy, omega], [mode_tag(m1), mode_tag(m2)]));
348 break;
349 }
350 if chosen.is_some() {
351 break;
352 }
353 }
354
355 chosen.unwrap_or(([0.0; 3], [ContactMode::Separated, ContactMode::Separated]))
356 }
357
358 pub fn two_contact_step(
360 self,
361 state: SliderState,
362 c1: PusherCommand,
363 c2: PusherCommand,
364 dt: f64,
365 ) -> (SliderState, [ContactMode; 2]) {
366 let ([vx_b, vy_b, omega], modes) = self.two_contact_twist(c1, c2);
367 let theta = state.theta();
368 let (s, co) = theta.sin_cos();
369 let vx_w = co * vx_b - s * vy_b;
370 let vy_w = s * vx_b + co * vy_b;
371 let next = SliderState::new(
372 state.x() + vx_w * dt,
373 state.y() + vy_w * dt,
374 theta + omega * dt,
375 );
376 (next, modes)
377 }
378}
379
380#[allow(clippy::too_many_arguments)]
383fn tangential_row(
384 vcx: [f64; 4],
385 vcy: [f64; 4],
386 t: [f64; 2],
387 d: [f64; 2],
388 mu: f64,
389 m: i32,
390 vt: f64,
391 first_contact: bool,
392) -> ([f64; 4], f64) {
393 if m == 0 {
394 let row = [
396 t[0] * vcx[0] + t[1] * vcy[0],
397 t[0] * vcx[1] + t[1] * vcy[1],
398 t[0] * vcx[2] + t[1] * vcy[2],
399 t[0] * vcx[3] + t[1] * vcy[3],
400 ];
401 (row, vt)
402 } else {
403 let sgn = m as f64;
407 let cx = t[0] - sgn * mu * d[0];
408 let cy = t[1] - sgn * mu * d[1];
409 let row = if first_contact {
410 [cx, cy, 0.0, 0.0]
411 } else {
412 [0.0, 0.0, cx, cy]
413 };
414 (row, 0.0)
415 }
416}
417
418fn mode_valid(m: i32, fn_: f64, ft: f64, slip: f64, mu: f64) -> bool {
420 match m {
421 0 => ft.abs() <= mu * fn_ + 1e-9,
422 1 => fn_ >= -1e-9 && slip <= 1e-9,
423 _ => fn_ >= -1e-9 && slip >= -1e-9,
424 }
425}
426
427fn mode_tag(m: i32) -> ContactMode {
428 match m {
429 0 => ContactMode::Stick,
430 1 => ContactMode::SlideUp,
431 _ => ContactMode::SlideDown,
432 }
433}
434
435#[allow(clippy::needless_range_loop)]
437fn solve4(mut a: [[f64; 4]; 4], mut b: [f64; 4]) -> Option<[f64; 4]> {
438 for col in 0..4 {
439 let mut piv = col;
441 let mut best = a[col][col].abs();
442 for (r, row) in a.iter().enumerate().skip(col + 1) {
443 if row[col].abs() > best {
444 best = row[col].abs();
445 piv = r;
446 }
447 }
448 if best < 1e-12 {
449 return None;
450 }
451 a.swap(col, piv);
452 b.swap(col, piv);
453 let pivot = a[col][col];
454 for r in (col + 1)..4 {
455 let factor = a[r][col] / pivot;
456 for k in col..4 {
457 a[r][k] -= factor * a[col][k];
458 }
459 b[r] -= factor * b[col];
460 }
461 }
462 let mut x = [0.0; 4];
463 for i in (0..4).rev() {
464 let mut sum = b[i];
465 for k in (i + 1)..4 {
466 sum -= a[i][k] * x[k];
467 }
468 x[i] = sum / a[i][i];
469 }
470 Some(x)
471}
472
473#[derive(Debug, Clone, PartialEq)]
475pub struct PusherMppiConfig {
476 pub horizon: usize,
477 pub samples: usize,
478 pub dt: f64,
479 pub contact_sigma: f64,
481 pub tangent_sigma: f64,
483 pub push_sigma: f64,
485 pub push_speed: f64,
487 pub max_push_speed: f64,
489 pub position_weight: f64,
490 pub heading_weight: f64,
491 pub obstacle_weight: f64,
493 pub obstacle_radius: f64,
495 pub lambda: f64,
496 pub seed: u64,
497}
498
499impl Default for PusherMppiConfig {
500 fn default() -> Self {
501 Self {
502 horizon: 18,
503 samples: 400,
504 dt: 0.1,
505 contact_sigma: 0.03,
506 tangent_sigma: 0.05,
507 push_sigma: 0.04,
508 push_speed: 0.06,
509 max_push_speed: 0.12,
510 position_weight: 250.0,
514 heading_weight: 6.0,
515 obstacle_weight: 0.0,
516 obstacle_radius: 0.0,
517 lambda: 1.0,
518 seed: 7,
519 }
520 }
521}
522
523fn validate_config(config: &PusherMppiConfig) -> RoboticsResult<()> {
524 if config.horizon == 0 || config.samples == 0 {
525 return Err(RoboticsError::InvalidParameter(
526 "pusher MPPI horizon and samples must be positive".to_string(),
527 ));
528 }
529 if !config.dt.is_finite() || config.dt <= 0.0 {
530 return Err(RoboticsError::InvalidParameter(
531 "pusher MPPI dt must be finite and positive".to_string(),
532 ));
533 }
534 if !config.contact_sigma.is_finite()
535 || config.contact_sigma <= 0.0
536 || !config.tangent_sigma.is_finite()
537 || config.tangent_sigma <= 0.0
538 || !config.push_sigma.is_finite()
539 || config.push_sigma <= 0.0
540 {
541 return Err(RoboticsError::InvalidParameter(
542 "pusher MPPI sigmas must be finite and positive".to_string(),
543 ));
544 }
545 if !config.max_push_speed.is_finite() || config.max_push_speed <= 0.0 {
546 return Err(RoboticsError::InvalidParameter(
547 "pusher MPPI max_push_speed must be finite and positive".to_string(),
548 ));
549 }
550 if !config.lambda.is_finite() || config.lambda <= 0.0 {
551 return Err(RoboticsError::InvalidParameter(
552 "pusher MPPI lambda must be finite and positive".to_string(),
553 ));
554 }
555 if config.position_weight < 0.0
556 || config.heading_weight < 0.0
557 || config.push_speed < 0.0
558 || config.obstacle_weight < 0.0
559 || config.obstacle_radius < 0.0
560 {
561 return Err(RoboticsError::InvalidParameter(
562 "pusher MPPI weights, push_speed, and obstacle terms must be non-negative".to_string(),
563 ));
564 }
565 Ok(())
566}
567
568#[derive(Debug, Clone, PartialEq)]
570pub struct PusherMppiPlan {
571 pub command: PusherCommand,
572 pub best_cost: f64,
573}
574
575struct PusherNoise {
577 contact: Normal<f64>,
578 tangent: Normal<f64>,
579 push: Normal<f64>,
580}
581
582#[derive(Debug, Clone)]
590pub struct PusherSliderMppiController {
591 config: PusherMppiConfig,
592 params: PusherSliderParams,
593 nominals: Vec<Vec<PusherCommand>>,
595 obstacles: Vec<[f64; 2]>,
597 rng: StdRng,
598}
599
600impl PusherSliderMppiController {
601 pub fn new(config: PusherMppiConfig, params: PusherSliderParams) -> RoboticsResult<Self> {
602 validate_config(&config)?;
603 let nominals = (0..4)
604 .map(|face| {
605 vec![PusherCommand::on_face(face, 0.0, config.push_speed, 0.0); config.horizon]
606 })
607 .collect();
608 let rng = StdRng::seed_from_u64(config.seed);
609 Ok(Self {
610 config,
611 params,
612 nominals,
613 obstacles: Vec::new(),
614 rng,
615 })
616 }
617
618 pub fn with_obstacles(mut self, obstacles: Vec<[f64; 2]>) -> Self {
621 self.obstacles = obstacles;
622 self
623 }
624
625 pub fn config(&self) -> &PusherMppiConfig {
626 &self.config
627 }
628
629 fn rollout_cost(
630 &self,
631 start: SliderState,
632 goal: SliderState,
633 controls: &[PusherCommand],
634 ) -> f64 {
635 let mut state = start;
636 let mut cost = 0.0;
637 for (i, &command) in controls.iter().enumerate() {
638 let (next, _) = self.params.step(state, command, self.config.dt);
639 state = next;
640 let w = (i + 1) as f64 / controls.len() as f64;
642 cost += w * self.pose_cost(state, goal);
643 }
644 cost
645 }
646
647 fn pose_cost(&self, state: SliderState, goal: SliderState) -> f64 {
648 let dx = state.x() - goal.x();
649 let dy = state.y() - goal.y();
650 let dtheta = wrap_angle(state.theta() - goal.theta());
651 let mut cost = self.config.position_weight * (dx * dx + dy * dy)
652 + self.config.heading_weight * dtheta * dtheta;
653 let r = self.config.obstacle_radius;
655 if r > 0.0 && self.config.obstacle_weight > 0.0 {
656 for o in &self.obstacles {
657 let ox = state.x() - o[0];
658 let oy = state.y() - o[1];
659 let d = (ox * ox + oy * oy).sqrt();
660 if d < r {
661 let overlap = r - d;
662 cost += self.config.obstacle_weight * overlap * overlap;
663 }
664 }
665 }
666 cost
667 }
668
669 fn plan_face(
672 &mut self,
673 face: usize,
674 start: SliderState,
675 goal: SliderState,
676 per_face_samples: usize,
677 noise: &PusherNoise,
678 ) -> (Vec<PusherCommand>, f64) {
679 let contact_noise = &noise.contact;
680 let tangent_noise = &noise.tangent;
681 let push_noise = &noise.push;
682 let b = self.params.half_extent;
683 let max_push = self.config.max_push_speed;
684 let horizon = self.config.horizon;
685 let base_seq = self.nominals[face].clone();
686
687 let mut costs = Vec::with_capacity(per_face_samples);
688 let mut sequences = Vec::with_capacity(per_face_samples);
689 let mut best_cost = f64::INFINITY;
690
691 for _ in 0..per_face_samples {
692 let mut controls = Vec::with_capacity(horizon);
693 for &bcmd in &base_seq {
694 let contact = (bcmd.contact + contact_noise.sample(&mut self.rng)).clamp(-b, b);
695 let tangent = bcmd.tangent_speed + tangent_noise.sample(&mut self.rng);
696 let push =
697 (bcmd.push_speed + push_noise.sample(&mut self.rng)).clamp(0.0, max_push);
698 controls.push(PusherCommand::on_face(face, contact, push, tangent));
699 }
700 let cost = self.rollout_cost(start, goal, &controls);
701 best_cost = best_cost.min(cost);
702 costs.push(cost);
703 sequences.push(controls);
704 }
705
706 let min_cost = costs.iter().copied().fold(f64::INFINITY, f64::min);
707 let mut weight_sum = 0.0;
708 let mut weights = Vec::with_capacity(costs.len());
709 for &cost in &costs {
710 let w = (-(cost - min_cost) / self.config.lambda).exp();
711 weight_sum += w;
712 weights.push(w);
713 }
714
715 let updated = if weight_sum <= 0.0 || !weight_sum.is_finite() {
716 let best_index = costs
717 .iter()
718 .enumerate()
719 .min_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
720 .map(|(i, _)| i)
721 .unwrap_or(0);
722 sequences[best_index].clone()
723 } else {
724 let mut acc_seq = vec![PusherCommand::on_face(face, 0.0, 0.0, 0.0); horizon];
725 for (w, controls) in weights.iter().zip(&sequences) {
726 let n = w / weight_sum;
727 for (acc, command) in acc_seq.iter_mut().zip(controls) {
728 acc.contact += n * command.contact;
729 acc.push_speed += n * command.push_speed;
730 acc.tangent_speed += n * command.tangent_speed;
731 }
732 }
733 for command in &mut acc_seq {
734 command.contact = command.contact.clamp(-b, b);
735 command.push_speed = command.push_speed.clamp(0.0, max_push);
736 }
737 acc_seq
738 };
739
740 (updated, best_cost)
741 }
742
743 pub fn plan(
745 &mut self,
746 start: SliderState,
747 goal: SliderState,
748 ) -> RoboticsResult<PusherMppiPlan> {
749 let make = |sigma: f64, what: &str| {
750 Normal::new(0.0, sigma).map_err(|_| {
751 RoboticsError::InvalidParameter(format!("invalid pusher {what} distribution"))
752 })
753 };
754 let noise = PusherNoise {
755 contact: make(self.config.contact_sigma, "contact")?,
756 tangent: make(self.config.tangent_sigma, "tangent")?,
757 push: make(self.config.push_sigma, "push")?,
758 };
759 let per_face = (self.config.samples / 4).max(1);
760
761 let mut best: Option<(PusherCommand, f64)> = None;
762 for face in 0..4 {
763 let (updated, cost) = self.plan_face(face, start, goal, per_face, &noise);
764 let first = updated[0];
765 let mut shifted = updated[1..].to_vec();
767 shifted.push(*updated.last().unwrap());
768 self.nominals[face] = shifted;
769
770 if best.map(|(_, c)| cost < c).unwrap_or(true) {
771 best = Some((first, cost));
772 }
773 }
774
775 let (command, best_cost) = best.unwrap();
776 Ok(PusherMppiPlan { command, best_cost })
777 }
778}
779
780#[derive(Debug, Clone, PartialEq)]
782pub struct PushReport {
783 pub steps: usize,
784 pub final_pose: [f64; 3],
785 pub position_error: f64,
786 pub heading_error: f64,
787 pub stick_fraction: f64,
788 pub slide_fraction: f64,
789 pub path: Vec<[f64; 3]>,
790 pub modes: Vec<ContactMode>,
791}
792
793pub fn simulate_push(
795 config: PusherMppiConfig,
796 params: PusherSliderParams,
797 start: SliderState,
798 goal: SliderState,
799 max_steps: usize,
800) -> RoboticsResult<PushReport> {
801 simulate_push_with_obstacles(config, params, start, goal, &[], max_steps)
802}
803
804pub fn simulate_push_with_obstacles(
807 config: PusherMppiConfig,
808 params: PusherSliderParams,
809 start: SliderState,
810 goal: SliderState,
811 obstacles: &[[f64; 2]],
812 max_steps: usize,
813) -> RoboticsResult<PushReport> {
814 let dt = config.dt;
815 let mut controller =
816 PusherSliderMppiController::new(config, params)?.with_obstacles(obstacles.to_vec());
817 let mut state = start;
818 let mut path = vec![state.pose];
819 let mut modes = Vec::new();
820 let mut stick = 0usize;
821 let mut slide = 0usize;
822 let mut executed = 0usize;
823
824 for _ in 0..max_steps {
825 let plan = controller.plan(state, goal)?;
826 let (next, mode) = params.step(state, plan.command, dt);
827 match mode {
828 ContactMode::Stick => stick += 1,
829 ContactMode::SlideUp | ContactMode::SlideDown => slide += 1,
830 ContactMode::Separated => {}
831 }
832 state = next;
833 path.push(state.pose);
834 modes.push(mode);
835 executed += 1;
836
837 let dx = state.x() - goal.x();
838 let dy = state.y() - goal.y();
839 if (dx * dx + dy * dy).sqrt() < 0.2 * params.half_extent
840 && wrap_angle(state.theta() - goal.theta()).abs() < 0.05
841 {
842 break;
843 }
844 }
845
846 let dx = state.x() - goal.x();
847 let dy = state.y() - goal.y();
848 let denom = executed.max(1) as f64;
849 Ok(PushReport {
850 steps: executed,
851 final_pose: state.pose,
852 position_error: (dx * dx + dy * dy).sqrt(),
853 heading_error: wrap_angle(state.theta() - goal.theta()).abs(),
854 stick_fraction: stick as f64 / denom,
855 slide_fraction: slide as f64 / denom,
856 path,
857 modes,
858 })
859}
860
861#[derive(Debug, Clone, PartialEq)]
863pub struct MultiPushReport {
864 pub objects: Vec<PushReport>,
866 pub final_poses: Vec<[f64; 3]>,
868 pub max_position_error: f64,
869 pub max_heading_error: f64,
870}
871
872pub fn simulate_multi_push(
880 config: PusherMppiConfig,
881 params: PusherSliderParams,
882 starts: &[SliderState],
883 goals: &[SliderState],
884 max_steps: usize,
885) -> RoboticsResult<MultiPushReport> {
886 if starts.len() != goals.len() {
887 return Err(RoboticsError::InvalidParameter(
888 "multi-push starts and goals must have equal length".to_string(),
889 ));
890 }
891 let n = starts.len();
892 let mut poses: Vec<SliderState> = starts.to_vec();
893 let mut objects = Vec::with_capacity(n);
894 let mut max_position_error = 0.0_f64;
895 let mut max_heading_error = 0.0_f64;
896
897 for i in 0..n {
898 let obstacles: Vec<[f64; 2]> = (0..n)
899 .filter(|&j| j != i)
900 .map(|j| [poses[j].x(), poses[j].y()])
901 .collect();
902 let report = simulate_push_with_obstacles(
903 config.clone(),
904 params,
905 poses[i],
906 goals[i],
907 &obstacles,
908 max_steps,
909 )?;
910 poses[i] = SliderState {
911 pose: report.final_pose,
912 };
913 max_position_error = max_position_error.max(report.position_error);
914 max_heading_error = max_heading_error.max(report.heading_error);
915 objects.push(report);
916 }
917
918 Ok(MultiPushReport {
919 final_poses: poses.iter().map(|p| p.pose).collect(),
920 objects,
921 max_position_error,
922 max_heading_error,
923 })
924}
925
926fn wrap_angle(a: f64) -> f64 {
927 let mut x = a;
928 while x > std::f64::consts::PI {
929 x -= std::f64::consts::TAU;
930 }
931 while x < -std::f64::consts::PI {
932 x += std::f64::consts::TAU;
933 }
934 x
935}
936
937#[cfg(test)]
938mod tests {
939 use super::*;
940
941 #[test]
942 fn straight_centered_push_translates_without_rotation() {
943 let params = PusherSliderParams::default();
944 let mut state = SliderState::new(0.0, 0.0, 0.0);
945 for _ in 0..20 {
946 let (next, mode) = params.step(state, PusherCommand::new(0.0, 0.05, 0.0), 0.05);
947 assert_eq!(mode, ContactMode::Stick);
948 state = next;
949 }
950 assert!(state.x() > 0.0, "should move +x: {}", state.x());
951 assert!(state.y().abs() < 1e-9, "no lateral drift: {}", state.y());
952 assert!(state.theta().abs() < 1e-9, "no rotation: {}", state.theta());
953 }
954
955 #[test]
956 fn offcenter_push_induces_rotation() {
957 let params = PusherSliderParams::default();
958 let mut state = SliderState::new(0.0, 0.0, 0.0);
959 let cmd = PusherCommand::new(0.7 * params.half_extent, 0.05, 0.0);
960 for _ in 0..20 {
961 let (next, _) = params.step(state, cmd, 0.05);
962 state = next;
963 }
964 assert!(
965 state.theta().abs() > 1e-3,
966 "should rotate: {}",
967 state.theta()
968 );
969 }
970
971 #[test]
972 fn large_tangent_slip_slides() {
973 let params = PusherSliderParams::default();
974 let state = SliderState::new(0.0, 0.0, 0.0);
975 let (_, mode) = params.step(state, PusherCommand::new(0.0, 0.05, 1.0), 0.05);
976 assert!(matches!(
977 mode,
978 ContactMode::SlideUp | ContactMode::SlideDown
979 ));
980 }
981
982 #[test]
983 fn no_push_keeps_state() {
984 let params = PusherSliderParams::default();
985 let state = SliderState::new(0.3, -0.2, 0.5);
986 let (next, mode) = params.step(state, PusherCommand::new(0.0, 0.0, 0.2), 0.05);
987 assert_eq!(mode, ContactMode::Separated);
988 assert_eq!(next, state);
989 }
990
991 #[test]
992 fn rejects_invalid_params() {
993 assert!(PusherSliderParams::new(0.0, 0.03, 0.3).is_err());
994 assert!(PusherSliderParams::new(0.05, -0.01, 0.3).is_err());
995 assert!(PusherSliderParams::new(0.05, 0.03, -0.1).is_err());
996 }
997
998 #[test]
999 fn controller_pushes_toward_goal() {
1000 let params = PusherSliderParams::default();
1001 let start = SliderState::new(0.0, 0.0, 0.0);
1002 let goal = SliderState::new(0.3, 0.0, 0.0);
1004 let report = simulate_push(PusherMppiConfig::default(), params, start, goal, 150).unwrap();
1005 let start_err = ((start.x() - goal.x()).powi(2) + (start.y() - goal.y()).powi(2)).sqrt();
1006 assert!(
1007 report.position_error < 0.3 * start_err,
1008 "should approach goal: {} vs start {}",
1009 report.position_error,
1010 start_err
1011 );
1012 }
1013
1014 #[test]
1015 fn simulation_is_deterministic() {
1016 let params = PusherSliderParams::default();
1017 let start = SliderState::new(0.0, 0.0, 0.0);
1018 let goal = SliderState::new(0.3, 0.0, 0.0);
1019 let run = || simulate_push(PusherMppiConfig::default(), params, start, goal, 80).unwrap();
1020 let a = run();
1021 let b = run();
1022 assert_eq!(a.path, b.path);
1023 assert_eq!(a.position_error, b.position_error);
1024 }
1025
1026 #[test]
1027 fn pushing_on_a_chosen_face_reverses_translation() {
1028 let params = PusherSliderParams::default();
1030 let state = SliderState::new(0.0, 0.0, 0.0);
1031 let (back, _) = params.step(state, PusherCommand::on_face(0, 0.0, 0.05, 0.0), 0.1);
1032 let (front, _) = params.step(state, PusherCommand::on_face(2, 0.0, 0.05, 0.0), 0.1);
1033 assert!(back.x() > 0.0, "back face pushes +x: {}", back.x());
1034 assert!(front.x() < 0.0, "front face pushes -x: {}", front.x());
1035 }
1036
1037 #[test]
1038 fn multi_push_places_every_object() {
1039 let params = PusherSliderParams::default();
1040 let config = PusherMppiConfig {
1041 obstacle_weight: 400.0,
1042 obstacle_radius: 2.4 * params.half_extent,
1043 ..PusherMppiConfig::default()
1044 };
1045 let starts = [
1046 SliderState::new(0.0, 0.10, 0.0),
1047 SliderState::new(0.0, 0.0, 0.0),
1048 SliderState::new(0.0, -0.10, 0.0),
1049 ];
1050 let goals = [
1051 SliderState::new(0.28, 0.10, 0.0),
1052 SliderState::new(0.28, 0.0, 0.0),
1053 SliderState::new(0.28, -0.10, 0.0),
1054 ];
1055 let report = simulate_multi_push(config, params, &starts, &goals, 200).unwrap();
1056 assert_eq!(report.objects.len(), 3);
1057 assert!(
1058 report.max_position_error < 3.0 * params.half_extent,
1059 "all objects should reach: max err {}",
1060 report.max_position_error
1061 );
1062 }
1063
1064 #[test]
1065 fn two_contact_symmetric_push_is_pure_translation() {
1066 let params = PusherSliderParams::default();
1069 let h = 0.6 * params.half_extent;
1070 let c1 = PusherCommand::on_face(0, h, 0.05, 0.0);
1071 let c2 = PusherCommand::on_face(0, -h, 0.05, 0.0);
1072 let ([vx, vy, omega], _modes) = params.two_contact_twist(c1, c2);
1073 assert!(vx > 0.0, "should translate +x: {vx}");
1074 assert!(omega.abs() < 1e-9, "no net rotation: {omega}");
1075 assert!(vy.abs() < 1e-9, "no lateral: {vy}");
1076 let ([_, _, omega_single], _) = params.twist(PusherCommand::on_face(0, h, 0.05, 0.0));
1078 assert!(
1079 omega_single.abs() > 1e-6,
1080 "single contact rotates: {omega_single}"
1081 );
1082 }
1083
1084 #[test]
1085 fn two_contact_antipodal_couple_rotates_in_place() {
1086 let params = PusherSliderParams::default();
1089 let h = 0.7 * params.half_extent;
1090 let c1 = PusherCommand::on_face(0, h, 0.05, 0.0);
1091 let c2 = PusherCommand::on_face(2, -h, 0.05, 0.0);
1092 let ([vx, vy, omega], _) = params.two_contact_twist(c1, c2);
1093 let speed = (vx * vx + vy * vy).sqrt();
1094 assert!(omega.abs() > 1e-3, "should rotate: {omega}");
1095 assert!(
1097 speed < 0.5 * omega.abs() * params.half_extent + 1e-6,
1098 "translation {speed} should be small vs rotation {omega}"
1099 );
1100 }
1101
1102 #[test]
1103 fn multi_push_rejects_mismatched_lengths() {
1104 let params = PusherSliderParams::default();
1105 let starts = [SliderState::new(0.0, 0.0, 0.0)];
1106 let goals = [
1107 SliderState::new(0.2, 0.0, 0.0),
1108 SliderState::new(0.2, 0.1, 0.0),
1109 ];
1110 assert!(
1111 simulate_multi_push(PusherMppiConfig::default(), params, &starts, &goals, 50).is_err()
1112 );
1113 }
1114
1115 #[test]
1116 fn multiface_rotates_without_net_translation() {
1117 let params = PusherSliderParams::default();
1120 let start = SliderState::new(0.0, 0.0, 0.0);
1121 let goal = SliderState::new(0.0, 0.0, 0.5);
1122 let report = simulate_push(PusherMppiConfig::default(), params, start, goal, 300).unwrap();
1123 assert!(
1124 report.heading_error < 0.1,
1125 "should reach the heading: err {}",
1126 report.heading_error
1127 );
1128 assert!(
1129 report.position_error < 2.0 * params.half_extent,
1130 "should keep position near origin: err {}",
1131 report.position_error
1132 );
1133 }
1134}