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}