Skip to main content

pamoja_kit/
navigate.rs

1//! Steering a robot toward a waypoint, and stopping before an obstacle.
2
3use crate::motion::{clamp, magnitude, Twist};
4use crate::Coordinate;
5use core::f32::consts::PI;
6use libm::cosf;
7
8// Wraps an angle in degrees into the half-open interval `(-180, 180]`.
9fn wrap_deg_180(angle: f32) -> f32 {
10    let mut a = angle % 360.0;
11    if a > 180.0 {
12        a -= 360.0;
13    } else if a <= -180.0 {
14        a += 360.0;
15    }
16    a
17}
18
19// `f64::abs` lives in `std`, so this `no_std` crate takes an f64 magnitude by hand.
20fn magnitude_f64(value: f64) -> f64 {
21    if value < 0.0 {
22        -value
23    } else {
24        value
25    }
26}
27
28/// The steering command toward a waypoint, with the geometry behind it.
29#[derive(Clone, Copy, Debug, PartialEq)]
30pub struct Guidance {
31    /// The body twist to drive: a forward speed and a yaw rate toward the target, positive
32    /// turning left as [`Twist`] has it.
33    pub twist: Twist,
34    /// The remaining distance to the target, in meters.
35    pub distance_m: f64,
36    /// The heading error to the target, in degrees, in `(-180, 180]`: compass degrees, so
37    /// positive when the target lies clockwise of the heading, to the robot's right.
38    pub heading_error_deg: f32,
39    /// Whether the target is within the arrival radius.
40    pub arrived: bool,
41}
42
43/// Guides a robot from waypoint to waypoint by GPS-style coordinates (carrot following).
44///
45/// This is the "go to that point" primitive behind a patrol route, a return-to-base, or a field
46/// pass: given where the robot is, which way it faces, and the next waypoint, it produces the
47/// twist to get there. It turns toward the target in proportion to the heading error and slows
48/// the forward speed as that error grows (by the cosine of the error), so the robot pivots toward
49/// a target behind it before driving off, rather than swinging wide. The caller holds the list of
50/// waypoints and advances to the next once [`Guidance::arrived`] is set, which keeps this
51/// allocation-free.
52///
53/// Headings are compass degrees, clockwise from north, while the [`Twist`] it returns turns left
54/// for a positive yaw rate, so a target to the robot's right gives a negative yaw rate.
55///
56/// # Examples
57///
58/// ```
59/// use pamoja_kit::{Coordinate, WaypointFollower};
60///
61/// // Cruise 1.5 m/s, arrive within 3 m, turn at 1.5 rad per rad of error, cap 1 rad/s.
62/// let follower = WaypointFollower::new(1.5, 3.0, 1.5, 1.0);
63///
64/// // At the equator/prime meridian facing east (90 deg), with the target due east.
65/// let here = Coordinate::new(0.0, 0.0);
66/// let target = Coordinate::new(0.0, 0.01);
67/// let g = follower.guide(here, 90.0, target);
68/// assert!(g.heading_error_deg.abs() < 1e-3); // already pointed at it
69/// assert!((g.twist.vx - 1.5).abs() < 1e-3); // so drive at cruise
70/// assert!(!g.arrived);
71/// ```
72#[derive(Clone, Copy, Debug)]
73pub struct WaypointFollower {
74    cruise: f32,
75    arrival_m: f64,
76    heading_gain: f32,
77    max_angular: f32,
78}
79
80impl WaypointFollower {
81    /// Creates a follower with the given speeds and tolerances.
82    ///
83    /// # Arguments
84    ///
85    /// * `cruise` - the forward speed when pointed at the target; its magnitude is used.
86    /// * `arrival_m` - how close, in meters, counts as arrived; its magnitude is used.
87    /// * `heading_gain` - yaw rate commanded per radian of heading error; its magnitude is used.
88    /// * `max_angular` - the largest yaw rate to command; its magnitude is used.
89    ///
90    /// # Returns
91    ///
92    /// The follower.
93    pub fn new(cruise: f32, arrival_m: f64, heading_gain: f32, max_angular: f32) -> Self {
94        Self {
95            cruise: magnitude(cruise),
96            arrival_m: magnitude_f64(arrival_m),
97            heading_gain: magnitude(heading_gain),
98            max_angular: magnitude(max_angular),
99        }
100    }
101
102    /// Produces the steering command from the robot's position and heading to a target.
103    ///
104    /// # Arguments
105    ///
106    /// * `here` - the robot's current coordinate.
107    /// * `heading_deg` - the robot's heading in degrees clockwise from north (a compass course).
108    /// * `target` - the waypoint to head toward.
109    ///
110    /// # Returns
111    ///
112    /// The [`Guidance`]; once within the arrival radius the twist is zero and `arrived` is set.
113    /// A heading or coordinate that is not a finite number also gives a zero twist, so a lost
114    /// fix stops the robot rather than steering it by NaN.
115    pub fn guide(&self, here: Coordinate, heading_deg: f32, target: Coordinate) -> Guidance {
116        let distance_m = here.distance_to(target);
117        let bearing_deg = here.bearing_to(target) as f32;
118        let heading_error_deg = wrap_deg_180(bearing_deg - heading_deg);
119
120        if distance_m <= self.arrival_m {
121            return Guidance {
122                twist: Twist::zero(),
123                distance_m,
124                heading_error_deg,
125                arrived: true,
126            };
127        }
128
129        let error_rad = heading_error_deg * (PI / 180.0);
130        let angular = clamp(
131            -self.heading_gain * error_rad,
132            -self.max_angular,
133            self.max_angular,
134        );
135        let facing = cosf(error_rad);
136        let forward = if facing > 0.0 {
137            self.cruise * facing
138        } else {
139            0.0
140        };
141
142        let twist = if forward.is_finite() && angular.is_finite() {
143            Twist::planar(forward, angular)
144        } else {
145            Twist::zero()
146        };
147        Guidance {
148            twist,
149            distance_m,
150            heading_error_deg,
151            arrived: false,
152        }
153    }
154}
155
156/// Stops forward motion when an obstacle is within the stopping distance, leaving turning free.
157///
158/// This is the simplest reliable safety reflex for a robot with a forward range sensor: hold the
159/// requested rotation so the robot can still turn away, but cut `vx` and `vy` to zero once the
160/// nearest reading falls inside the stop distance, so it does not drive into what it sees.
161///
162/// # Arguments
163///
164/// * `twist` - the requested body motion.
165/// * `range_m` - the nearest measured range ahead, in meters.
166/// * `stop_distance_m` - the range at or below which forward motion is cut; its magnitude is used.
167///
168/// # Returns
169///
170/// The original twist when the way is clear, or one with no translation (rotation preserved) when
171/// an obstacle is within range. A range that is not a number, which a sensor that saw nothing
172/// it could measure can report, counts as an obstacle: the way is clear only when a reading says
173/// so.
174///
175/// # Examples
176///
177/// ```
178/// use pamoja_kit::{obstacle_stop, Twist};
179///
180/// let driving = Twist::new(1.0, 0.0, 0.5);
181/// // Clear ahead: unchanged.
182/// assert_eq!(obstacle_stop(driving, 2.0, 0.5), driving);
183/// // Obstacle at 0.3 m: forward cut, turn kept so it can escape.
184/// assert_eq!(obstacle_stop(driving, 0.3, 0.5), Twist::new(0.0, 0.0, 0.5));
185/// ```
186pub fn obstacle_stop(twist: Twist, range_m: f32, stop_distance_m: f32) -> Twist {
187    if range_m > magnitude(stop_distance_m) {
188        twist
189    } else {
190        Twist::new(0.0, 0.0, twist.omega)
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn it_drives_at_cruise_when_pointed_at_the_target() {
200        let follower = WaypointFollower::new(1.5, 3.0, 1.5, 1.0);
201        let here = Coordinate::new(0.0, 0.0);
202        let target = Coordinate::new(0.0, 0.01); // due east
203        let g = follower.guide(here, 90.0, target); // facing east
204        assert!(g.heading_error_deg.abs() < 1e-3);
205        assert!((g.twist.vx - 1.5).abs() < 1e-3);
206        assert!(g.twist.omega.abs() < 1e-3);
207        assert!(!g.arrived);
208    }
209
210    #[test]
211    fn it_pivots_without_driving_when_the_target_is_behind() {
212        let follower = WaypointFollower::new(1.5, 3.0, 1.5, 1.0);
213        let here = Coordinate::new(0.0, 0.0);
214        let target = Coordinate::new(0.0, 0.01); // due east (bearing 90)
215        let g = follower.guide(here, 270.0, target); // facing west: 180 deg error
216        assert!(g.twist.vx.abs() < 1e-6); // cosine of 180 is negative -> no forward
217        assert!(g.twist.omega.abs() > 0.0); // but it turns
218    }
219
220    #[test]
221    fn it_turns_toward_the_side_the_target_is_on() {
222        let follower = WaypointFollower::new(1.5, 3.0, 1.5, 1.0);
223        let here = Coordinate::new(0.0, 0.0);
224        let east = Coordinate::new(0.0, 0.01);
225        let north = Coordinate::new(0.01, 0.0);
226        // Facing north with the target east, it is to the right: turn clockwise.
227        assert!(follower.guide(here, 0.0, east).twist.omega < 0.0);
228        // Facing east with the target north, it is to the left: turn counter-clockwise.
229        assert!(follower.guide(here, 90.0, north).twist.omega > 0.0);
230    }
231
232    #[test]
233    fn a_robot_that_follows_the_guidance_comes_to_face_the_target() {
234        let follower = WaypointFollower::new(0.0, 3.0, 1.5, 1.0);
235        let here = Coordinate::new(0.0, 0.0);
236        let east = Coordinate::new(0.0, 0.01);
237        // Start facing north and turn as told: a counter-clockwise yaw rate lowers a
238        // compass heading.
239        let mut heading_deg = 0.0f32;
240        for _ in 0..200 {
241            let omega = follower.guide(here, heading_deg, east).twist.omega;
242            heading_deg -= omega * 0.05 * (180.0 / PI);
243        }
244        assert!(
245            (wrap_deg_180(heading_deg - 90.0)).abs() < 0.5,
246            "ended at {heading_deg}"
247        );
248    }
249
250    #[test]
251    fn it_reports_arrival_inside_the_radius() {
252        let follower = WaypointFollower::new(1.5, 50.0, 1.5, 1.0);
253        let here = Coordinate::new(0.0, 0.0);
254        let target = Coordinate::new(0.0, 0.0001); // about 11 m east, inside 50 m
255        let g = follower.guide(here, 90.0, target);
256        assert!(g.arrived);
257        assert_eq!(g.twist, Twist::zero());
258    }
259
260    #[test]
261    fn the_angular_command_is_capped() {
262        let follower = WaypointFollower::new(1.0, 1.0, 10.0, 0.5); // huge gain, small cap
263        let here = Coordinate::new(0.0, 0.0);
264        let target = Coordinate::new(0.0001, 0.0); // due north, 90 deg off
265        let g = follower.guide(here, 90.0, target); // facing east
266        assert!((g.twist.omega.abs() - 0.5).abs() < 1e-6); // clamped to the cap
267    }
268
269    #[test]
270    fn a_lost_fix_or_heading_stops_the_robot() {
271        let follower = WaypointFollower::new(1.5, 3.0, 1.5, 1.0);
272        let here = Coordinate::new(0.0, 0.0);
273        let target = Coordinate::new(0.0, 0.01);
274        let lost_fix = follower.guide(Coordinate::new(f64::NAN, 0.0), 90.0, target);
275        assert_eq!(lost_fix.twist, Twist::zero());
276        assert!(!lost_fix.arrived);
277        let lost_heading = follower.guide(here, f32::NAN, target);
278        assert_eq!(lost_heading.twist, Twist::zero());
279    }
280
281    #[test]
282    fn a_range_that_is_not_a_number_counts_as_an_obstacle() {
283        let driving = Twist::new(1.0, 0.0, 0.5);
284        assert_eq!(
285            obstacle_stop(driving, f32::NAN, 0.5),
286            Twist::new(0.0, 0.0, 0.5)
287        );
288        assert_eq!(obstacle_stop(driving, f32::INFINITY, 0.5), driving);
289    }
290}