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.
32    pub twist: Twist,
33    /// The remaining distance to the target, in metres.
34    pub distance_m: f64,
35    /// The heading error to the target, in degrees, in `(-180, 180]`.
36    pub heading_error_deg: f32,
37    /// Whether the target is within the arrival radius.
38    pub arrived: bool,
39}
40
41/// Guides a robot from waypoint to waypoint by GPS-style coordinates (carrot following).
42///
43/// This is the "go to that point" primitive behind a patrol route, a return-to-base, or a field
44/// pass: given where the robot is, which way it faces, and the next waypoint, it produces the
45/// twist to get there. It turns toward the target in proportion to the heading error and slows
46/// the forward speed as that error grows (by the cosine of the error), so the robot pivots toward
47/// a target behind it before driving off, rather than swinging wide. The caller holds the list of
48/// waypoints and advances to the next once [`Guidance::arrived`] is set, which keeps this
49/// allocation-free.
50///
51/// # Examples
52///
53/// ```
54/// use pamoja_kit::{Coordinate, WaypointFollower};
55///
56/// // Cruise 1.5 m/s, arrive within 3 m, turn at 1.5 rad per rad of error, cap 1 rad/s.
57/// let follower = WaypointFollower::new(1.5, 3.0, 1.5, 1.0);
58///
59/// // At the equator/prime meridian facing east (90 deg), with the target due east.
60/// let here = Coordinate::new(0.0, 0.0);
61/// let target = Coordinate::new(0.0, 0.01);
62/// let g = follower.guide(here, 90.0, target);
63/// assert!(g.heading_error_deg.abs() < 1e-3); // already pointed at it
64/// assert!((g.twist.vx - 1.5).abs() < 1e-3); // so drive at cruise
65/// assert!(!g.arrived);
66/// ```
67#[derive(Clone, Copy, Debug)]
68pub struct WaypointFollower {
69    cruise: f32,
70    arrival_m: f64,
71    heading_gain: f32,
72    max_angular: f32,
73}
74
75impl WaypointFollower {
76    /// Creates a follower with the given speeds and tolerances.
77    ///
78    /// # Arguments
79    ///
80    /// * `cruise` - the forward speed when pointed at the target; its magnitude is used.
81    /// * `arrival_m` - how close, in metres, counts as arrived; its magnitude is used.
82    /// * `heading_gain` - yaw rate commanded per radian of heading error; its magnitude is used.
83    /// * `max_angular` - the largest yaw rate to command; its magnitude is used.
84    ///
85    /// # Returns
86    ///
87    /// The follower.
88    pub fn new(cruise: f32, arrival_m: f64, heading_gain: f32, max_angular: f32) -> Self {
89        Self {
90            cruise: magnitude(cruise),
91            arrival_m: magnitude_f64(arrival_m),
92            heading_gain: magnitude(heading_gain),
93            max_angular: magnitude(max_angular),
94        }
95    }
96
97    /// Produces the steering command from the robot's position and heading to a target.
98    ///
99    /// # Arguments
100    ///
101    /// * `here` - the robot's current coordinate.
102    /// * `heading_deg` - the robot's heading in degrees clockwise from north (a compass course).
103    /// * `target` - the waypoint to head toward.
104    ///
105    /// # Returns
106    ///
107    /// The [`Guidance`]; once within the arrival radius the twist is zero and `arrived` is set.
108    pub fn guide(&self, here: Coordinate, heading_deg: f32, target: Coordinate) -> Guidance {
109        let distance_m = here.distance_to(target);
110        let bearing_deg = here.bearing_to(target) as f32;
111        let heading_error_deg = wrap_deg_180(bearing_deg - heading_deg);
112
113        if distance_m <= self.arrival_m {
114            return Guidance {
115                twist: Twist::zero(),
116                distance_m,
117                heading_error_deg,
118                arrived: true,
119            };
120        }
121
122        let error_rad = heading_error_deg * (PI / 180.0);
123        let angular = clamp(
124            self.heading_gain * error_rad,
125            -self.max_angular,
126            self.max_angular,
127        );
128        let facing = cosf(error_rad);
129        let forward = if facing > 0.0 {
130            self.cruise * facing
131        } else {
132            0.0
133        };
134
135        Guidance {
136            twist: Twist::planar(forward, angular),
137            distance_m,
138            heading_error_deg,
139            arrived: false,
140        }
141    }
142}
143
144/// Stops forward motion when an obstacle is within the stopping distance, leaving turning free.
145///
146/// This is the simplest reliable safety reflex for a robot with a forward range sensor: hold the
147/// requested rotation so the robot can still turn away, but cut `vx` and `vy` to zero once the
148/// nearest reading falls inside the stop distance, so it does not drive into what it sees.
149///
150/// # Arguments
151///
152/// * `twist` - the requested body motion.
153/// * `range_m` - the nearest measured range ahead, in metres.
154/// * `stop_distance_m` - the range at or below which forward motion is cut; its magnitude is used.
155///
156/// # Returns
157///
158/// The original twist when the way is clear, or one with no translation (rotation preserved) when
159/// an obstacle is within range.
160///
161/// # Examples
162///
163/// ```
164/// use pamoja_kit::{obstacle_stop, Twist};
165///
166/// let driving = Twist::new(1.0, 0.0, 0.5);
167/// // Clear ahead: unchanged.
168/// assert_eq!(obstacle_stop(driving, 2.0, 0.5), driving);
169/// // Obstacle at 0.3 m: forward cut, turn kept so it can escape.
170/// assert_eq!(obstacle_stop(driving, 0.3, 0.5), Twist::new(0.0, 0.0, 0.5));
171/// ```
172pub fn obstacle_stop(twist: Twist, range_m: f32, stop_distance_m: f32) -> Twist {
173    if range_m <= magnitude(stop_distance_m) {
174        Twist::new(0.0, 0.0, twist.omega)
175    } else {
176        twist
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    #[test]
185    fn it_drives_at_cruise_when_pointed_at_the_target() {
186        let follower = WaypointFollower::new(1.5, 3.0, 1.5, 1.0);
187        let here = Coordinate::new(0.0, 0.0);
188        let target = Coordinate::new(0.0, 0.01); // due east
189        let g = follower.guide(here, 90.0, target); // facing east
190        assert!(g.heading_error_deg.abs() < 1e-3);
191        assert!((g.twist.vx - 1.5).abs() < 1e-3);
192        assert!(g.twist.omega.abs() < 1e-3);
193        assert!(!g.arrived);
194    }
195
196    #[test]
197    fn it_pivots_without_driving_when_the_target_is_behind() {
198        let follower = WaypointFollower::new(1.5, 3.0, 1.5, 1.0);
199        let here = Coordinate::new(0.0, 0.0);
200        let target = Coordinate::new(0.0, 0.01); // due east (bearing 90)
201        let g = follower.guide(here, 270.0, target); // facing west: 180 deg error
202        assert!(g.twist.vx.abs() < 1e-6); // cosine of 180 is negative -> no forward
203        assert!(g.twist.omega.abs() > 0.0); // but it turns
204    }
205
206    #[test]
207    fn it_reports_arrival_inside_the_radius() {
208        let follower = WaypointFollower::new(1.5, 50.0, 1.5, 1.0);
209        let here = Coordinate::new(0.0, 0.0);
210        let target = Coordinate::new(0.0, 0.0001); // about 11 m east, inside 50 m
211        let g = follower.guide(here, 90.0, target);
212        assert!(g.arrived);
213        assert_eq!(g.twist, Twist::zero());
214    }
215
216    #[test]
217    fn the_angular_command_is_capped() {
218        let follower = WaypointFollower::new(1.0, 1.0, 10.0, 0.5); // huge gain, small cap
219        let here = Coordinate::new(0.0, 0.0);
220        let target = Coordinate::new(0.0001, 0.0); // due north, 90 deg off
221        let g = follower.guide(here, 90.0, target); // facing east
222        assert!((g.twist.omega.abs() - 0.5).abs() < 1e-6); // clamped to the cap
223    }
224}