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}