Skip to main content

pamoja_kit/
chassis.rs

1//! Wheel kinematics for the common mobile-robot chassis layouts.
2//!
3//! [`DiffDrive`](crate::DiffDrive) covers the two-wheel differential robot. This module adds
4//! the other layouts a builder is likely to meet: a car-like [`Ackermann`] steer, a tracked or
5//! four-wheel [`SkidSteer`], and a sideways-capable [`Mecanum`] base. Each converts both ways,
6//! turning a desired body motion into wheel commands (inverse kinematics) and measured wheel
7//! motion back into the body's velocity (forward kinematics), so the same model drives the
8//! robot and reads its odometry.
9
10use crate::motion::{magnitude, Twist};
11use libm::{atanf, tanf};
12
13/// Car-like (Ackermann) steering: one steered axle and a driven axle a wheelbase apart.
14///
15/// A car cannot turn in place; it follows an arc whose radius is set by the steering angle. The
16/// kinematic bicycle model collapses each axle to a single central wheel, so the steering angle
17/// `delta`, the wheelbase `L`, the forward speed `v`, and the yaw rate `omega` are related by
18/// `tan(delta) = L * omega / v`, equivalently a turn radius `R = L / tan(delta)`. This is the
19/// standard model behind a rover, a tractor, or any rack-and-pinion vehicle.
20///
21/// # Examples
22///
23/// ```
24/// use pamoja_kit::Ackermann;
25///
26/// // A rover with a 2.5 m wheelbase, steering 30 degrees (about 0.5236 rad).
27/// let car = Ackermann::new(2.5);
28/// let radius = car.turn_radius(0.5236);
29/// assert!((radius - 4.33).abs() < 0.01); // R = 2.5 / tan(30 deg)
30///
31/// // At 5 m/s that steering yields a yaw rate of about 1.155 rad/s.
32/// let omega = car.yaw_rate(5.0, 0.5236);
33/// assert!((omega - 1.1547).abs() < 1e-3);
34/// // And asking for that yaw rate at that speed recovers the steering angle.
35/// assert!((car.steering_angle(5.0, omega) - 0.5236).abs() < 1e-3);
36/// ```
37#[derive(Clone, Copy, Debug)]
38pub struct Ackermann {
39    wheelbase: f32,
40}
41
42impl Ackermann {
43    /// Creates a model for a vehicle whose axles are `wheelbase` apart.
44    ///
45    /// # Arguments
46    ///
47    /// * `wheelbase` - the distance from the steered axle to the driven axle; its magnitude
48    ///   is used.
49    ///
50    /// # Returns
51    ///
52    /// The kinematics model.
53    pub fn new(wheelbase: f32) -> Self {
54        Self {
55            wheelbase: magnitude(wheelbase),
56        }
57    }
58
59    /// Returns the steering angle for a desired forward speed and yaw rate.
60    ///
61    /// # Arguments
62    ///
63    /// * `linear` - the forward speed.
64    /// * `angular` - the desired yaw rate, positive turning left.
65    ///
66    /// # Returns
67    ///
68    /// The steering angle in radians, `atan(wheelbase * angular / linear)`. Returns zero when
69    /// the vehicle is stopped (`linear` is zero), since a stationary car cannot yaw by steering.
70    pub fn steering_angle(&self, linear: f32, angular: f32) -> f32 {
71        if linear == 0.0 {
72            return 0.0;
73        }
74        atanf(self.wheelbase * angular / linear)
75    }
76
77    /// Returns the yaw rate produced by a forward speed and steering angle.
78    ///
79    /// # Arguments
80    ///
81    /// * `linear` - the forward speed.
82    /// * `steering` - the steering angle in radians.
83    ///
84    /// # Returns
85    ///
86    /// The yaw rate `linear * tan(steering) / wheelbase`, zero when the wheelbase is zero.
87    pub fn yaw_rate(&self, linear: f32, steering: f32) -> f32 {
88        if self.wheelbase == 0.0 {
89            return 0.0;
90        }
91        linear * tanf(steering) / self.wheelbase
92    }
93
94    /// Returns the turn radius for a steering angle.
95    ///
96    /// # Arguments
97    ///
98    /// * `steering` - the steering angle in radians.
99    ///
100    /// # Returns
101    ///
102    /// The radius `wheelbase / tan(steering)` in metres, or [`f32::INFINITY`] when the wheels
103    /// point straight ahead (`steering` is zero), since the path is then a straight line.
104    pub fn turn_radius(&self, steering: f32) -> f32 {
105        let t = tanf(steering);
106        if t == 0.0 {
107            return f32::INFINITY;
108        }
109        self.wheelbase / t
110    }
111
112    /// Returns the path curvature for a steering angle.
113    ///
114    /// # Arguments
115    ///
116    /// * `steering` - the steering angle in radians.
117    ///
118    /// # Returns
119    ///
120    /// The curvature `tan(steering) / wheelbase`, the reciprocal of the turn radius, zero when
121    /// the wheelbase is zero.
122    pub fn curvature(&self, steering: f32) -> f32 {
123        if self.wheelbase == 0.0 {
124            return 0.0;
125        }
126        tanf(steering) / self.wheelbase
127    }
128}
129
130/// Skid-steer (tracked or four-wheel) drive: turning by spinning each side at a different speed.
131///
132/// A skid-steer robot steers like a differential one, but its wheels or tracks must slip
133/// sideways to turn, so the geometric track under-predicts the turn. The standard correction is
134/// an effective track wider than the real one by a `slip` factor (at least one), found by
135/// calibration: commanding a yaw rate needs a larger left-right speed difference than the bare
136/// geometry suggests. With `slip` of one this reduces to plain differential drive.
137///
138/// # Examples
139///
140/// ```
141/// use pamoja_kit::SkidSteer;
142///
143/// // Wheels 0.5 m apart that slip enough to need a 1.2x wider effective track.
144/// let drive = SkidSteer::new(0.5, 1.2);
145/// // Spin in place at 2 rad/s: each side runs at omega * effective_track / 2.
146/// let (left, right) = drive.wheel_speeds(0.0, 2.0);
147/// assert!((left + 0.6).abs() < 1e-6 && (right - 0.6).abs() < 1e-6);
148/// // Reading the wheels back recovers the body motion.
149/// let (linear, angular) = drive.body_motion(left, right);
150/// assert!(linear.abs() < 1e-6 && (angular - 2.0).abs() < 1e-6);
151/// ```
152#[derive(Clone, Copy, Debug)]
153pub struct SkidSteer {
154    track: f32,
155    slip: f32,
156}
157
158impl SkidSteer {
159    /// Creates a model for wheels `track` apart with a given `slip` factor.
160    ///
161    /// # Arguments
162    ///
163    /// * `track` - the distance between the left and right wheels or tracks; its magnitude is
164    ///   used.
165    /// * `slip` - how much wider the effective track is than the geometric one; its magnitude
166    ///   is used, and a value of zero is treated as one (no slip).
167    ///
168    /// # Returns
169    ///
170    /// The kinematics model.
171    pub fn new(track: f32, slip: f32) -> Self {
172        let slip = magnitude(slip);
173        Self {
174            track: magnitude(track),
175            slip: if slip == 0.0 { 1.0 } else { slip },
176        }
177    }
178
179    fn effective_track(&self) -> f32 {
180        self.track * self.slip
181    }
182
183    /// Returns the `(left, right)` wheel speeds for a desired body motion.
184    ///
185    /// # Arguments
186    ///
187    /// * `linear` - the forward speed.
188    /// * `angular` - the yaw rate, positive turning left.
189    ///
190    /// # Returns
191    ///
192    /// `(left, right)`, where the split uses the effective (slip-corrected) track.
193    pub fn wheel_speeds(&self, linear: f32, angular: f32) -> (f32, f32) {
194        let half = angular * self.effective_track() / 2.0;
195        (linear - half, linear + half)
196    }
197
198    /// Returns the body `(linear, angular)` motion for measured wheel speeds.
199    ///
200    /// # Arguments
201    ///
202    /// * `left` - the left wheel speed.
203    /// * `right` - the right wheel speed.
204    ///
205    /// # Returns
206    ///
207    /// `(linear, angular)`, where `angular` divides by the effective track and is zero when
208    /// that track is zero.
209    pub fn body_motion(&self, left: f32, right: f32) -> (f32, f32) {
210        let linear = (right + left) / 2.0;
211        let track = self.effective_track();
212        let angular = if track == 0.0 {
213            0.0
214        } else {
215            (right - left) / track
216        };
217        (linear, angular)
218    }
219}
220
221/// The four wheel speeds of a mecanum or omni base, front and rear, left and right.
222///
223/// Each value is the speed of that wheel's contact point in the same units as the body
224/// velocity, positive when the wheel drives the robot forward.
225#[derive(Clone, Copy, Debug, PartialEq)]
226pub struct WheelSpeeds {
227    /// Front-left wheel speed.
228    pub front_left: f32,
229    /// Front-right wheel speed.
230    pub front_right: f32,
231    /// Rear-left wheel speed.
232    pub rear_left: f32,
233    /// Rear-right wheel speed.
234    pub rear_right: f32,
235}
236
237/// Mecanum (four-wheel omnidirectional) drive: forward, sideways, and turning at once.
238///
239/// A mecanum base carries four wheels whose angled rollers let it strafe sideways as well as
240/// drive and turn, so it tracks a full planar [`Twist`] (`vx`, `vy`, `omega`). This uses the
241/// standard kinematics for the common "O" roller layout, with `k = (half-wheelbase +
242/// half-track)`:
243///
244/// ```text
245/// front_left  = vx - vy - k*omega      rear_left  = vx + vy - k*omega
246/// front_right = vx + vy + k*omega      rear_right = vx - vy + k*omega
247/// ```
248///
249/// The forward kinematics invert these by averaging the wheels. Speeds are wheel contact
250/// speeds in the body's units; convert to motor rates by dividing by the wheel radius.
251///
252/// # Examples
253///
254/// ```
255/// use pamoja_kit::{Mecanum, Twist, WheelSpeeds};
256///
257/// // 0.4 m wheelbase, 0.3 m track.
258/// let base = Mecanum::new(0.4, 0.3);
259///
260/// // Pure left strafe: the O-layout spins the diagonals against each other.
261/// let w = base.wheel_speeds(Twist::new(0.0, 1.0, 0.0));
262/// assert_eq!(w, WheelSpeeds { front_left: -1.0, front_right: 1.0, rear_left: 1.0, rear_right: -1.0 });
263///
264/// // Reading the wheels back recovers the body twist.
265/// let t = base.body_motion(w);
266/// assert!(t.vx.abs() < 1e-6 && (t.vy - 1.0).abs() < 1e-6 && t.omega.abs() < 1e-6);
267/// ```
268#[derive(Clone, Copy, Debug)]
269pub struct Mecanum {
270    half_length: f32,
271    half_width: f32,
272}
273
274impl Mecanum {
275    /// Creates a model from the wheelbase and track.
276    ///
277    /// # Arguments
278    ///
279    /// * `wheelbase` - the front-to-rear distance between axles; its magnitude is used.
280    /// * `track` - the left-to-right distance between wheels; its magnitude is used.
281    ///
282    /// # Returns
283    ///
284    /// The kinematics model.
285    pub fn new(wheelbase: f32, track: f32) -> Self {
286        Self {
287            half_length: magnitude(wheelbase) / 2.0,
288            half_width: magnitude(track) / 2.0,
289        }
290    }
291
292    fn lever(&self) -> f32 {
293        self.half_length + self.half_width
294    }
295
296    /// Returns the four wheel speeds for a desired body twist.
297    ///
298    /// # Arguments
299    ///
300    /// * `twist` - the desired body velocity, using all of `vx`, `vy`, and `omega`.
301    ///
302    /// # Returns
303    ///
304    /// The [`WheelSpeeds`] that produce that twist.
305    pub fn wheel_speeds(&self, twist: Twist) -> WheelSpeeds {
306        let r = self.lever() * twist.omega;
307        WheelSpeeds {
308            front_left: twist.vx - twist.vy - r,
309            front_right: twist.vx + twist.vy + r,
310            rear_left: twist.vx + twist.vy - r,
311            rear_right: twist.vx - twist.vy + r,
312        }
313    }
314
315    /// Returns the body twist for measured wheel speeds.
316    ///
317    /// # Arguments
318    ///
319    /// * `wheels` - the four measured wheel speeds.
320    ///
321    /// # Returns
322    ///
323    /// The body [`Twist`]; `omega` is zero when the base has no size (lever arm zero).
324    pub fn body_motion(&self, wheels: WheelSpeeds) -> Twist {
325        let WheelSpeeds {
326            front_left,
327            front_right,
328            rear_left,
329            rear_right,
330        } = wheels;
331        let vx = (front_left + front_right + rear_left + rear_right) / 4.0;
332        let vy = (-front_left + front_right + rear_left - rear_right) / 4.0;
333        let lever = self.lever();
334        let omega = if lever == 0.0 {
335            0.0
336        } else {
337            (-front_left + front_right - rear_left + rear_right) / (4.0 * lever)
338        };
339        Twist::new(vx, vy, omega)
340    }
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    #[test]
348    fn ackermann_round_trips_steering_and_yaw() {
349        let car = Ackermann::new(2.5);
350        let steering = 0.4;
351        let omega = car.yaw_rate(5.0, steering);
352        assert!((car.steering_angle(5.0, omega) - steering).abs() < 1e-5);
353    }
354
355    #[test]
356    fn ackermann_drives_straight_with_no_steering() {
357        let car = Ackermann::new(2.0);
358        assert_eq!(car.yaw_rate(3.0, 0.0), 0.0);
359        assert_eq!(car.turn_radius(0.0), f32::INFINITY);
360        assert_eq!(car.steering_angle(0.0, 5.0), 0.0); // stationary cannot steer-yaw
361    }
362
363    #[test]
364    fn skid_steer_with_unit_slip_is_differential_drive() {
365        let drive = SkidSteer::new(0.5, 1.0);
366        assert_eq!(drive.wheel_speeds(1.0, 0.0), (1.0, 1.0)); // straight
367        assert_eq!(drive.wheel_speeds(0.0, 2.0), (-0.5, 0.5)); // spin
368    }
369
370    #[test]
371    fn skid_steer_widens_the_track_by_the_slip_factor() {
372        let drive = SkidSteer::new(0.5, 1.2); // effective track 0.6
373        let (left, right) = drive.wheel_speeds(0.0, 2.0);
374        assert!((left + 0.6).abs() < 1e-6 && (right - 0.6).abs() < 1e-6);
375        let (linear, angular) = drive.body_motion(left, right);
376        assert!(linear.abs() < 1e-6 && (angular - 2.0).abs() < 1e-6);
377    }
378
379    #[test]
380    fn mecanum_handles_each_pure_motion() {
381        let base = Mecanum::new(0.4, 0.3); // lever = 0.2 + 0.15 = 0.35
382                                           // Pure forward: every wheel at the forward speed.
383        assert_eq!(
384            base.wheel_speeds(Twist::new(1.0, 0.0, 0.0)),
385            WheelSpeeds {
386                front_left: 1.0,
387                front_right: 1.0,
388                rear_left: 1.0,
389                rear_right: 1.0,
390            }
391        );
392        // Pure rotation: left wheels back, right wheels forward.
393        let spin = base.wheel_speeds(Twist::new(0.0, 0.0, 1.0));
394        assert!((spin.front_left + 0.35).abs() < 1e-6);
395        assert!((spin.front_right - 0.35).abs() < 1e-6);
396        assert!((spin.rear_left + 0.35).abs() < 1e-6);
397        assert!((spin.rear_right - 0.35).abs() < 1e-6);
398    }
399
400    #[test]
401    fn mecanum_round_trips_an_arbitrary_twist() {
402        let base = Mecanum::new(0.5, 0.4);
403        let twist = Twist::new(0.8, -0.3, 0.6);
404        let back = base.body_motion(base.wheel_speeds(twist));
405        assert!((back.vx - twist.vx).abs() < 1e-6);
406        assert!((back.vy - twist.vy).abs() < 1e-6);
407        assert!((back.omega - twist.omega).abs() < 1e-6);
408    }
409}