Skip to main content

rust_robotics_control/
pusher_slider.rs

1//! Quasi-static planar pushing (pusher-slider) with contact modes and MPPI.
2//!
3//! A pure-Rust reproduction slice for contact-rich planar manipulation in the
4//! spirit of "Push Anything": a point pusher that may contact any of the
5//! slider's four faces shoves a rigid square slider across a table to a goal
6//! pose. The slider obeys the classic quasi-static ellipsoidal limit-surface
7//! model (Goyal/Howe/Mason; Lynch; Hogan-Rodriguez): motion is determined by the
8//! contact, not by inertia, and the contact can *stick* or *slide* depending on
9//! whether the required tangential force stays inside the pusher friction cone.
10//!
11//! - [`PusherSliderParams`] holds the slider half-extent, the limit-surface
12//!   characteristic length, and the pusher friction coefficient.
13//! - [`PusherCommand`] is a body-frame pusher motion on a chosen face: the face
14//!   index, a contact offset along it, a normal push speed, and a tangential
15//!   slip speed.
16//! - [`PusherSliderParams::step`] advances the slider one quasi-static step and
17//!   reports the realized [`ContactMode`].
18//! - [`PusherSliderMppiController`] runs MPPI per face and executes the command
19//!   from the lowest-cost face, so it can switch faces to reach goals (such as a
20//!   pure rotation) that a single face cannot; [`simulate_push`] runs the closed
21//!   loop and returns a [`PushReport`].
22//! - [`simulate_multi_push`] arranges several sliders into goal slots one at a
23//!   time, treating the other objects as keep-out discs so the active slider
24//!   routes around them.
25//! - [`PusherSliderParams::two_contact_twist`] / `two_contact_step` solve two
26//!   simultaneous contacts contact-implicitly (per-contact stick/slide mode
27//!   enumeration with a 4x4 force solve), so a couple can spin the slider in
28//!   place — motion a single contact cannot produce.
29//!
30//! The model is exact for the single-contact stick/slide regimes; the two-contact
31//! solver resolves the rigid-redundant (both-stick) case onto a cone edge, and a
32//! contact-implicit controller over the contacts is left as an extension.
33
34use rand::{rngs::StdRng, SeedableRng};
35use rand_distr::{Distribution, Normal};
36use rust_robotics_core::{RoboticsError, RoboticsResult};
37
38/// Planar pose `[x, y, theta]` of the slider in the world frame.
39#[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/// The realized contact regime for a step.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum ContactMode {
65    /// No (or negative) normal force: the pusher is not pushing.
66    Separated,
67    /// Contact sticks: the contact point moves with the pusher.
68    Stick,
69    /// Contact slides along the face toward +tangent (friction cone saturated).
70    SlideUp,
71    /// Contact slides along the face toward -tangent.
72    SlideDown,
73}
74
75/// Body-frame pusher command on one of the slider's four faces.
76///
77/// `face` selects the contact face (`0` = back `-x`, `1` = `+y`, `2` = front
78/// `+x`, `3` = `-y`). The pusher pushes along the inward normal of that face;
79/// `contact` is the offset along the face, `push_speed` is the normal speed
80/// (clamped to be non-negative), and `tangent_speed` is the commanded tangential
81/// slip.
82#[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    /// A command on the back face (`face = 0`).
92    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    /// A command on an explicit face (wrapped into `0..4`).
102    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/// Quasi-static pusher-slider parameters.
113#[derive(Debug, Clone, Copy, PartialEq)]
114pub struct PusherSliderParams {
115    /// Half side length of the square slider \[m\].
116    pub half_extent: f64,
117    /// Limit-surface characteristic length `c` \[m\] (couples force to torque).
118    pub char_len: f64,
119    /// Coulomb friction coefficient between pusher and slider.
120    pub pusher_friction: f64,
121}
122
123impl Default for PusherSliderParams {
124    fn default() -> Self {
125        // c ~ 0.6 * half-extent is the usual uniform-pressure square estimate.
126        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    /// Body-frame contact point `p`, inward normal `d`, and tangent `t` for a
155    /// face index (`0..4`) and offset along the face.
156    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    /// Body-frame slider twist `[vx, vy, omega]` and contact mode for a command.
168    ///
169    /// The limit-surface solve is identical for every face; only the contact
170    /// frame `(p, d, t)` rotates, so the friction cone is evaluated along the
171    /// face's own normal/tangent rather than the body axes.
172    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        // Pusher velocity in the body frame and the limit-surface solve for the
184        // contact force whose motion matches it (same M for every face):
185        //   [wx, wy]^T = (1/c^2) [[c^2+py^2, -px*py], [-px*py, c^2+px^2]] [fx, fy]^T.
186        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        // Resolve the force into the face's normal/tangent for the friction cone.
199        let fn_ = fx * d[0] + fy * d[1];
200        let ft = fx * t[0] + fy * t[1];
201        if fn_ <= 0.0 {
202            // Would require pulling on the object: no contact motion.
203            return ([0.0; 3], ContactMode::Separated);
204        }
205
206        let mu = self.pusher_friction;
207        if ft.abs() <= mu * fn_ + 1e-12 {
208            // Stick: the slider twist is the limit-surface image of the wrench.
209            let omega = (px * fy - py * fx) / c2;
210            ([fx, fy, omega], ContactMode::Stick)
211        } else {
212            // Slide: the tangential force saturates on the friction-cone edge and
213            // the contact slides along the face. Scale the cone-edge wrench so the
214            // normal contact-point speed still matches the commanded push.
215            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            // Contact-point velocity per unit scale, projected on the normal d.
219            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    /// Advance the slider one quasi-static step, returning the new state and the
233    /// realized contact mode.
234    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        // Rotate the body-frame CoM velocity into the world and integrate.
245        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    /// World-frame contact point for a command (useful for rendering).
258    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    /// Body-frame slider twist `[vx, vy, omega]` and per-contact modes for *two*
265    /// simultaneous point contacts, solved contact-implicitly.
266    ///
267    /// Each contact maintains its commanded normal speed; its tangential degree
268    /// of freedom is either sticking (tangential contact-point velocity matches
269    /// the pusher) or sliding (the tangential force saturates the friction cone).
270    /// The realized regime is found by enumerating the per-contact stick/slide
271    /// modes, solving the resulting 4x4 contact-force system, and keeping the
272    /// first combination whose forces are pushing (`fn >= 0`), respect the
273    /// friction cone (stick) or sit on the correct cone edge with a consistent
274    /// slip direction (slide).
275    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        // omega = g . f, with f = [f1x, f1y, f2x, f2y].
286        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        // Contact-point velocity rows (as linear forms in f).
298        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        // Normal equations (always): contact velocity along the normal = vn.
305        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        // Candidate tangential modes: stick, slide+ (ft = +mu fn), slide- .
317        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    /// Advance the slider one quasi-static step under two simultaneous contacts.
359    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/// Build a tangential equation row and rhs for contact mode `m`
381/// (`0` = stick, `+1`/`-1` = slide on the upper/lower cone edge).
382#[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        // Stick: contact velocity along the tangent equals the pusher tangent.
395        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        // Slide: f . t = m * mu * (f . d), a pure force constraint on this
404        // contact's own two force components (slots 0,1 for contact 1, 2,3 for
405        // contact 2).
406        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
418/// Validate a contact mode against the solved forces and slip.
419fn 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/// Solve a 4x4 linear system by Gaussian elimination with partial pivoting.
436#[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        // Partial pivot.
440        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/// Configuration for the sampling-based pushing controller.
474#[derive(Debug, Clone, PartialEq)]
475pub struct PusherMppiConfig {
476    pub horizon: usize,
477    pub samples: usize,
478    pub dt: f64,
479    /// Std-dev of the contact-offset perturbation \[m\].
480    pub contact_sigma: f64,
481    /// Std-dev of the tangential-slip perturbation \[m/s\].
482    pub tangent_sigma: f64,
483    /// Std-dev of the normal-push perturbation \[m/s\].
484    pub push_sigma: f64,
485    /// Initial (nominal) normal push speed \[m/s\].
486    pub push_speed: f64,
487    /// Maximum normal push speed \[m/s\] (the controller may slow toward 0).
488    pub max_push_speed: f64,
489    pub position_weight: f64,
490    pub heading_weight: f64,
491    /// Penalty weight for the slider center entering an obstacle keep-out disc.
492    pub obstacle_weight: f64,
493    /// Keep-out radius around each obstacle center \[m\] (`0` disables it).
494    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            // Pose weights are large because positions are O(0.1 m): squared
511            // errors are tiny, so the softmax temperature needs cost values of
512            // order 1-100 to discriminate rollouts (and let the pusher brake).
513            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/// Result of one `plan` call.
569#[derive(Debug, Clone, PartialEq)]
570pub struct PusherMppiPlan {
571    pub command: PusherCommand,
572    pub best_cost: f64,
573}
574
575/// The three per-control Gaussian perturbations used while sampling.
576struct PusherNoise {
577    contact: Normal<f64>,
578    tangent: Normal<f64>,
579    push: Normal<f64>,
580}
581
582/// Deterministic, seeded sampling controller for planar pushing.
583///
584/// The controller is face-aware: it keeps a warm-started nominal plan for each of
585/// the slider's four faces, runs MPPI within each face, and executes the command
586/// from whichever face yields the lowest-cost rollout. Switching faces is what
587/// makes rotation-without-lateral-drift goals reachable (e.g. push the back face
588/// to spin one way, the front face to spin back and cancel the translation).
589#[derive(Debug, Clone)]
590pub struct PusherSliderMppiController {
591    config: PusherMppiConfig,
592    params: PusherSliderParams,
593    /// One warm-started nominal sequence per face (`0..4`).
594    nominals: Vec<Vec<PusherCommand>>,
595    /// Obstacle centers (e.g. other objects) the slider should keep clear of.
596    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    /// Attach obstacle centers (keep-out discs of `config.obstacle_radius`) that
619    /// the slider should avoid — used for multi-object pushing.
620    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            // Weight later steps more heavily (terminal emphasis).
641            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        // Keep-out penalty for overlapping other objects (multi-object pushing).
654        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    /// Run MPPI for a single face, returning its warm-start-shifted nominal and
670    /// the best (minimum) rollout cost seen for that face.
671    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    /// Plan the next pusher command toward `goal`, choosing the best contact face.
744    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            // Warm-start: shift this face's nominal by one step.
766            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/// Metrics for a closed-loop push.
781#[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
793/// Drive the pushing controller toward a goal pose and report the result.
794pub 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
804/// Like [`simulate_push`] but the controller keeps the slider clear of the given
805/// obstacle centers (keep-out discs of `config.obstacle_radius`).
806pub 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/// Metrics for a multi-object push.
862#[derive(Debug, Clone, PartialEq)]
863pub struct MultiPushReport {
864    /// Per-object push reports, in the order the objects were pushed.
865    pub objects: Vec<PushReport>,
866    /// Final pose of every object (same indexing as the inputs).
867    pub final_poses: Vec<[f64; 3]>,
868    pub max_position_error: f64,
869    pub max_heading_error: f64,
870}
871
872/// Push several sliders to their goals one at a time, treating the *other*
873/// objects (at their current poses) as keep-out obstacles for the active one.
874///
875/// Objects are pushed in index order; each object's resting pose then becomes an
876/// obstacle for the objects pushed after it. This reproduces the multi-object
877/// arrangement setting of "Push Anything" without a simultaneous multi-contact
878/// solve.
879pub 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        // On-axis reach: a single back-face pusher drives the slider forward.
1003        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        // The front face (face 2) pushes the slider in -x, the back face in +x.
1029        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        // Two contacts on the back face at +/-h, both pushing forward, cancel the
1067        // off-center rotation a single contact would cause.
1068        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        // A single off-center contact at +h does rotate.
1077        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        // Back-face contact high pushing +x and front-face contact low pushing -x
1087        // form a couple: rotation with little net translation.
1088        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        // The rotation dominates the (couple-cancelled) translation.
1096        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        // A pure rotation goal (theta only) is unreachable by a single back-face
1118        // pusher but reachable once the controller may switch faces.
1119        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}