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}