Skip to main content

pamoja_kit/
safety.rs

1//! Keeping a moving robot safe: stop on command, stop on silence, and never lurch.
2//!
3//! A robot that drives itself is dangerous when something goes wrong, so safety here is a real
4//! feature rather than an afterthought. Three pieces compose into a [`SafetyGate`] that every
5//! motion command passes through: an [`EStop`] that latches the machine stopped until a person
6//! clears it, a [`Watchdog`] that stops the machine if the commands stop arriving (a crashed
7//! controller or a dropped link), and [`Limits`] that bound how fast and how abruptly the robot
8//! may move. The gate fails safe: when stopped it commands zero, and it eases back up through the
9//! limits rather than jumping.
10
11use crate::motion::{clamp, magnitude, Twist};
12use crate::Ramp;
13use libm::sqrtf;
14
15/// An emergency stop that latches: once engaged it holds until explicitly reset.
16///
17/// Unlike a transient condition, an e-stop must stay tripped after the event that caused it, so a
18/// person decides when it is safe to move again. While engaged, [`gate`](EStop::gate) forces any
19/// command to a full stop.
20///
21/// # Examples
22///
23/// ```
24/// use pamoja_kit::{EStop, Twist};
25///
26/// let mut estop = EStop::new();
27/// let cmd = Twist::planar(1.0, 0.0);
28/// assert_eq!(estop.gate(cmd), cmd); // clear: passes through
29///
30/// estop.engage();
31/// assert_eq!(estop.gate(cmd), Twist::zero()); // latched: stopped
32/// estop.reset();
33/// assert_eq!(estop.gate(cmd), cmd); // cleared by a person
34/// ```
35#[derive(Clone, Copy, Debug, Default)]
36pub struct EStop {
37    engaged: bool,
38}
39
40impl EStop {
41    /// Creates a cleared e-stop.
42    ///
43    /// # Returns
44    ///
45    /// An e-stop that is not engaged.
46    pub fn new() -> Self {
47        Self { engaged: false }
48    }
49
50    /// Engages the stop; it latches until [`reset`](EStop::reset).
51    pub fn engage(&mut self) {
52        self.engaged = true;
53    }
54
55    /// Clears the stop, allowing motion again.
56    pub fn reset(&mut self) {
57        self.engaged = false;
58    }
59
60    /// Returns whether the stop is currently engaged.
61    ///
62    /// # Returns
63    ///
64    /// `true` while latched.
65    pub fn is_engaged(&self) -> bool {
66        self.engaged
67    }
68
69    /// Returns the command to apply: the input when clear, a full stop when engaged.
70    ///
71    /// # Arguments
72    ///
73    /// * `desired` - the command that would be applied if clear.
74    ///
75    /// # Returns
76    ///
77    /// `desired` when clear, or [`Twist::zero`] when engaged.
78    pub fn gate(&self, desired: Twist) -> Twist {
79        if self.engaged {
80            Twist::zero()
81        } else {
82            desired
83        }
84    }
85}
86
87/// A deadman timer: it expires unless fed often enough, catching a stalled controller or link.
88///
89/// Autonomy assumes a stream of fresh commands. If that stream stops, because the controller hung
90/// or the radio dropped, the last command must not run forever. A watchdog counts the time since it
91/// was last fed and reports expiry once that exceeds its timeout; the caller feeds it each time a
92/// fresh command arrives. It accumulates a supplied `dt` rather than reading a clock, so it works
93/// the same on a microcontroller with no wall time.
94///
95/// # Examples
96///
97/// ```
98/// use pamoja_kit::Watchdog;
99///
100/// let mut dog = Watchdog::new(0.5); // expire after 0.5 s of silence
101/// dog.feed();
102/// assert!(!dog.update(0.3)); // 0.3 s since feeding: still alive
103/// assert!(dog.update(0.3)); // 0.6 s total: expired
104/// dog.feed(); // a fresh command revives it
105/// assert!(!dog.is_expired());
106/// ```
107#[derive(Clone, Copy, Debug)]
108pub struct Watchdog {
109    timeout: f32,
110    elapsed: f32,
111}
112
113impl Watchdog {
114    /// Creates a watchdog that expires after `timeout` without being fed.
115    ///
116    /// # Arguments
117    ///
118    /// * `timeout` - the allowed silence before expiry; its magnitude is used.
119    ///
120    /// # Returns
121    ///
122    /// A freshly fed watchdog.
123    pub fn new(timeout: f32) -> Self {
124        Self {
125            timeout: magnitude(timeout),
126            elapsed: 0.0,
127        }
128    }
129
130    /// Feeds the watchdog, resetting the silence timer.
131    pub fn feed(&mut self) {
132        self.elapsed = 0.0;
133    }
134
135    /// Advances the timer by `dt` and returns whether it has expired.
136    ///
137    /// # Arguments
138    ///
139    /// * `dt` - the time since the previous update; its magnitude is used.
140    ///
141    /// # Returns
142    ///
143    /// `true` if the watchdog is now expired.
144    pub fn update(&mut self, dt: f32) -> bool {
145        self.elapsed += magnitude(dt);
146        self.is_expired()
147    }
148
149    /// Returns whether the watchdog is currently expired.
150    ///
151    /// # Returns
152    ///
153    /// `true` if the time since feeding exceeds the timeout.
154    pub fn is_expired(&self) -> bool {
155        self.elapsed > self.timeout
156    }
157}
158
159/// Bounds a robot's speed and acceleration so commands stay within what the machine can do safely.
160///
161/// A raw command can be too fast or too sudden: a full-speed setpoint snaps the wheels, a hard
162/// reverse strips traction. [`Limits`] caps the planar speed and yaw rate, then eases each toward
163/// the capped target at a bounded acceleration, so motion is smooth and within envelope. The
164/// easing reuses [`Ramp`] as its slew-rate limiter, with the step set to `accel * dt` each update.
165///
166/// # Examples
167///
168/// ```
169/// use pamoja_kit::{Limits, Twist};
170///
171/// // Up to 1 m/s and 2 rad/s, easing on at 0.5 m/s^2 and 4 rad/s^2.
172/// let mut limits = Limits::new(1.0, 2.0, 0.5, 4.0);
173///
174/// // Asking for full speed from rest: the first 0.1 s step is acceleration-limited.
175/// let cmd = limits.apply(Twist::planar(1.0, 0.0), 0.1);
176/// assert!((cmd.vx - 0.05).abs() < 1e-6); // 0.5 m/s^2 * 0.1 s
177/// ```
178#[derive(Clone, Copy, Debug)]
179pub struct Limits {
180    max_linear: f32,
181    max_angular: f32,
182    max_linear_accel: f32,
183    max_angular_accel: f32,
184    vx: Ramp,
185    vy: Ramp,
186    omega: Ramp,
187}
188
189impl Limits {
190    /// Creates limits from the speed and acceleration ceilings.
191    ///
192    /// # Arguments
193    ///
194    /// * `max_linear` - the largest planar speed; its magnitude is used.
195    /// * `max_angular` - the largest yaw rate; its magnitude is used.
196    /// * `max_linear_accel` - the largest change in linear speed per second; its magnitude is used.
197    /// * `max_angular_accel` - the largest change in yaw rate per second; its magnitude is used.
198    ///
199    /// # Returns
200    ///
201    /// Limits starting from rest.
202    pub fn new(
203        max_linear: f32,
204        max_angular: f32,
205        max_linear_accel: f32,
206        max_angular_accel: f32,
207    ) -> Self {
208        Self {
209            max_linear: magnitude(max_linear),
210            max_angular: magnitude(max_angular),
211            max_linear_accel: magnitude(max_linear_accel),
212            max_angular_accel: magnitude(max_angular_accel),
213            vx: Ramp::new(0.0, 0.0),
214            vy: Ramp::new(0.0, 0.0),
215            omega: Ramp::new(0.0, 0.0),
216        }
217    }
218
219    /// Resets the remembered motion to rest, so the next command eases up from zero.
220    pub fn reset(&mut self) {
221        self.vx.set(0.0);
222        self.vy.set(0.0);
223        self.omega.set(0.0);
224    }
225
226    /// Bounds a desired command in speed and acceleration and returns the safe command.
227    ///
228    /// # Arguments
229    ///
230    /// * `desired` - the requested body motion.
231    /// * `dt` - the time since the previous call, setting the acceleration step.
232    ///
233    /// # Returns
234    ///
235    /// The command after capping speed and easing toward it within the acceleration limit.
236    pub fn apply(&mut self, desired: Twist, dt: f32) -> Twist {
237        let bounded = self.clamp_speed(desired);
238        let linear_step = self.max_linear_accel * magnitude(dt);
239        let angular_step = self.max_angular_accel * magnitude(dt);
240        Twist::new(
241            self.vx.update_capped(bounded.vx, linear_step),
242            self.vy.update_capped(bounded.vy, linear_step),
243            self.omega.update_capped(bounded.omega, angular_step),
244        )
245    }
246
247    // Caps the planar speed (scaling vx and vy together so direction is kept) and the yaw rate.
248    fn clamp_speed(&self, twist: Twist) -> Twist {
249        let speed = sqrtf(twist.vx * twist.vx + twist.vy * twist.vy);
250        let (vx, vy) = if speed > self.max_linear {
251            let scale = self.max_linear / speed;
252            (twist.vx * scale, twist.vy * scale)
253        } else {
254            (twist.vx, twist.vy)
255        };
256        Twist::new(
257            vx,
258            vy,
259            clamp(twist.omega, -self.max_angular, self.max_angular),
260        )
261    }
262}
263
264/// The single gate every motion command passes through, composing e-stop, watchdog, and limits.
265///
266/// This is the one call a control loop makes to drive safely: feed it the desired motion and the
267/// time step, and it returns what is actually safe to command. It stops hard (commands zero and
268/// forgets its motion history, so resuming eases from rest) whenever the e-stop is engaged or the
269/// watchdog has expired; otherwise it returns the desired motion bounded by the [`Limits`]. Call
270/// [`feed`](SafetyGate::feed) whenever a fresh command arrives to keep the watchdog satisfied.
271///
272/// # Examples
273///
274/// ```
275/// use pamoja_kit::{Limits, SafetyGate, Twist};
276///
277/// let limits = Limits::new(1.0, 2.0, 0.5, 4.0);
278/// let mut gate = SafetyGate::new(limits, 0.2); // stop if unfed for 0.2 s
279///
280/// gate.feed();
281/// let cmd = gate.command(Twist::planar(1.0, 0.0), 0.1);
282/// assert!((cmd.vx - 0.05).abs() < 1e-6); // eased on, acceleration-limited
283///
284/// gate.engage_estop();
285/// assert_eq!(gate.command(Twist::planar(1.0, 0.0), 0.1), Twist::zero());
286/// ```
287#[derive(Clone, Copy, Debug)]
288pub struct SafetyGate {
289    estop: EStop,
290    watchdog: Watchdog,
291    limits: Limits,
292}
293
294impl SafetyGate {
295    /// Creates a gate from motion limits and a watchdog timeout.
296    ///
297    /// # Arguments
298    ///
299    /// * `limits` - the speed and acceleration bounds for normal motion.
300    /// * `watchdog_timeout` - the allowed silence before the gate stops the robot.
301    ///
302    /// # Returns
303    ///
304    /// The gate, cleared and freshly fed.
305    pub fn new(limits: Limits, watchdog_timeout: f32) -> Self {
306        Self {
307            estop: EStop::new(),
308            watchdog: Watchdog::new(watchdog_timeout),
309            limits,
310        }
311    }
312
313    /// Feeds the watchdog; call this whenever a fresh command arrives.
314    pub fn feed(&mut self) {
315        self.watchdog.feed();
316    }
317
318    /// Engages the latching emergency stop.
319    pub fn engage_estop(&mut self) {
320        self.estop.engage();
321    }
322
323    /// Clears the emergency stop.
324    pub fn reset_estop(&mut self) {
325        self.estop.reset();
326    }
327
328    /// Returns whether the gate is currently forcing a stop.
329    ///
330    /// # Returns
331    ///
332    /// `true` if the e-stop is engaged or the watchdog has expired.
333    pub fn is_stopped(&self) -> bool {
334        self.estop.is_engaged() || self.watchdog.is_expired()
335    }
336
337    /// Returns the safe command for a desired motion over a time step.
338    ///
339    /// # Arguments
340    ///
341    /// * `desired` - the requested body motion.
342    /// * `dt` - the time since the previous call.
343    ///
344    /// # Returns
345    ///
346    /// [`Twist::zero`] when stopped, otherwise the desired motion bounded by the limits.
347    pub fn command(&mut self, desired: Twist, dt: f32) -> Twist {
348        self.watchdog.update(dt);
349        if self.is_stopped() {
350            self.limits.reset();
351            return Twist::zero();
352        }
353        self.limits.apply(desired, dt)
354    }
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360
361    #[test]
362    fn estop_latches_until_reset() {
363        let mut estop = EStop::new();
364        let cmd = Twist::planar(1.0, 0.5);
365        assert_eq!(estop.gate(cmd), cmd);
366        estop.engage();
367        assert!(estop.is_engaged());
368        assert_eq!(estop.gate(cmd), Twist::zero());
369        estop.reset();
370        assert_eq!(estop.gate(cmd), cmd);
371    }
372
373    #[test]
374    fn watchdog_expires_on_silence_and_revives_on_feeding() {
375        let mut dog = Watchdog::new(0.5);
376        dog.feed();
377        assert!(!dog.update(0.3));
378        assert!(dog.update(0.3)); // 0.6 > 0.5
379        dog.feed();
380        assert!(!dog.is_expired());
381    }
382
383    #[test]
384    fn limits_cap_planar_speed_by_scaling() {
385        let mut limits = Limits::new(1.0, 10.0, 100.0, 100.0); // accel high so slew is not the cap
386                                                               // A 3-4-5 triangle at speed 5 scales down to length 1, keeping direction.
387        let cmd = limits.apply(Twist::new(3.0, 4.0, 0.0), 1.0);
388        assert!((cmd.vx - 0.6).abs() < 1e-5);
389        assert!((cmd.vy - 0.8).abs() < 1e-5);
390    }
391
392    #[test]
393    fn limits_ease_in_at_the_acceleration_bound() {
394        let mut limits = Limits::new(1.0, 2.0, 0.5, 4.0);
395        assert!((limits.apply(Twist::planar(1.0, 0.0), 0.1).vx - 0.05).abs() < 1e-6);
396        assert!((limits.apply(Twist::planar(1.0, 0.0), 0.1).vx - 0.10).abs() < 1e-6);
397    }
398
399    #[test]
400    fn gate_stops_on_estop_and_on_watchdog_expiry() {
401        let mut gate = SafetyGate::new(Limits::new(1.0, 2.0, 100.0, 100.0), 0.2);
402
403        gate.feed();
404        assert!(gate.command(Twist::planar(1.0, 0.0), 0.1).vx > 0.0);
405
406        // Stop feeding: after enough time the watchdog trips and the gate zeroes the command.
407        assert_eq!(gate.command(Twist::planar(1.0, 0.0), 0.5), Twist::zero());
408        assert!(gate.is_stopped());
409
410        // Feeding revives it.
411        gate.feed();
412        assert!(gate.command(Twist::planar(1.0, 0.0), 0.1).vx > 0.0);
413
414        // The e-stop overrides even a fed watchdog.
415        gate.feed();
416        gate.engage_estop();
417        assert_eq!(gate.command(Twist::planar(1.0, 0.0), 0.1), Twist::zero());
418    }
419}