evian_tracking/
wheeled.rs

1//! Wheeled odometry.
2
3use evian_math::{Angle, IntoAngle, Vec2};
4
5use alloc::rc::Rc;
6use core::{
7    cell::RefCell,
8    f64::consts::{PI, TAU},
9};
10use vexide::{
11    devices::smart::{InertialSensor, Motor},
12    prelude::{Task, sleep, spawn},
13    time::Instant,
14};
15
16use crate::sensor::RotarySensor;
17use crate::{TracksForwardTravel, TracksHeading, TracksPosition};
18
19use super::TracksVelocity;
20
21// MARK: Tracking Wheel
22
23/// A wheel attached to a rotary sensor for position tracking.
24#[derive(Debug, Clone, PartialEq)]
25pub struct TrackingWheel<T: RotarySensor> {
26    /// Rotary sensor for measuring the wheel's travel.
27    pub sensor: T,
28
29    /// Diameter of the wheel.
30    pub wheel_diameter: f64,
31
32    /// Signed offset from the drivetrain's center of rotation.
33    ///
34    /// Negative `offset` implies that the wheel is left or behind
35    /// the center of rotation.
36    pub offset: f64,
37
38    /// External gearing of the wheel.
39    ///
40    /// Used as a multiplier when determining wheel travel.
41    pub gearing: Option<f64>,
42}
43
44impl<T: RotarySensor> TrackingWheel<T> {
45    /// Creates a new tracking wheel with the given parameters.
46    ///
47    /// # Parameters
48    ///
49    /// * `sensor` - The rotary sensor to read wheel rotation from.
50    /// * `wheel_diameter` - The diameter of the wheel in linear units.
51    /// * `offset` - Distance from wheel to robot's center of rotation.
52    /// * `gearing` - Optional gear ratio between sensor and wheel (use None for 1:1 if ungeared).
53    pub const fn new(sensor: T, wheel_diameter: f64, offset: f64, gearing: Option<f64>) -> Self {
54        Self {
55            sensor,
56            wheel_diameter,
57            offset,
58            gearing,
59        }
60    }
61
62    /// Calculates the total linear distance traveled by this wheel.
63    ///
64    /// This method uses the wheel's diameter and gear ratio to convert
65    /// sensor rotations into linear distance traveled.
66    ///
67    /// # Errors
68    ///
69    /// Returns an error if [`RotarySensor::position`] fails.
70    pub fn travel(&self) -> Result<f64, T::Error> {
71        let wheel_circumference = self.wheel_diameter * PI;
72
73        Ok(self.sensor.position()?.as_revolutions()
74            * self.gearing.unwrap_or(1.0)
75            * wheel_circumference)
76    }
77}
78
79enum HeadingError<T: RotarySensor> {
80    /// IMU failed for whatever reason (we don't care exactly what happened,
81    /// since all IMU failures are potentially fatal to tracking).
82    ///
83    /// A backup angle computed from two parallel forward tracking is provided,
84    /// if such wheels are available.
85    Imu(Option<Angle>),
86
87    /// [`RotarySensor`] failed to return position data required to compute heading
88    /// from a pair of parallel forward tracking wheels.
89    RotarySensor(T::Error),
90}
91
92/// Generic tracking data returned by [`ParallelWheelTracking`] and [`PerpendicularWheelTracking`].
93#[derive(Default, Debug, Clone, Copy, PartialEq)]
94pub(crate) struct TrackingData {
95    position: Vec2<f64>,
96    raw_heading: Angle,
97    heading_offset: Angle,
98    forward_travel: f64,
99    linear_velocity: f64,
100    angular_velocity: f64,
101}
102
103// MARK: Tracking Implementation
104
105/// Tracking system that uses wheels to track position and orientation.
106#[derive(Debug)]
107pub struct WheeledTracking {
108    data: Rc<RefCell<TrackingData>>,
109    _task: Task<()>,
110}
111
112impl WheeledTracking {
113    /// Creates a new wheeled tracking system.
114    pub fn new<
115        T: RotarySensor + 'static,
116        U: RotarySensor + 'static,
117        const NUM_FORWARD: usize,
118        const NUM_SIDEWAYS: usize,
119    >(
120        origin: Vec2<f64>,
121        heading: Angle,
122        forward_wheels: [TrackingWheel<T>; NUM_FORWARD],
123        sideways_wheels: [TrackingWheel<U>; NUM_SIDEWAYS],
124        mut imu: Option<InertialSensor>,
125    ) -> Self {
126        const FORWARD_TRACKER_OFFSET_TOLERANCE: f64 = 0.5;
127
128        const {
129            assert!(
130                NUM_FORWARD > 0,
131                "Wheeled tracking requires at least one forward tracking wheel."
132            );
133        }
134
135        assert!(
136            NUM_FORWARD >= 2 || imu.is_some(),
137            "Wheeled tracking requires either an IMU or two parallel forward tracking wheels to determine robot orientation."
138        );
139
140        // Locate two parallel tracking wheels with roughly the same absolute offset from the
141        // center of tracking. We use these for our backup wheeled heading calculations.
142        let mut parallel_forward_indicies = None;
143        if NUM_FORWARD >= 2 {
144            for i in 0..NUM_FORWARD {
145                for j in (i + 1)..NUM_FORWARD {
146                    let i_wheel = &forward_wheels[i];
147                    let j_wheel = &forward_wheels[j];
148
149                    // Check if their offsets are acceptable enough
150                    if (i_wheel.offset + j_wheel.offset).abs() <= FORWARD_TRACKER_OFFSET_TOLERANCE {
151                        parallel_forward_indicies = Some(if i_wheel.offset < j_wheel.offset {
152                            (i, j)
153                        } else {
154                            (j, i)
155                        });
156                    }
157                }
158            }
159        }
160
161        assert!(
162            imu.is_some() || parallel_forward_indicies.is_some(),
163            "No IMU provided or viable tracking wheels available to determine robot orientation."
164        );
165
166        let initial_forward_wheel_data = forward_wheels
167            .each_ref()
168            .map(|wheel| wheel.travel().map(|travel| (travel, wheel.offset)));
169        let initial_sideways_wheel_data = sideways_wheels
170            .each_ref()
171            .map(|wheel| wheel.travel().map(|travel| (travel, wheel.offset)));
172        let initial_raw_heading = match Self::compute_raw_heading(
173            imu.as_ref(),
174            parallel_forward_indicies.map(|(left_index, right_index)| {
175                (&forward_wheels[left_index], &forward_wheels[right_index])
176            }),
177        ) {
178            Ok(heading) => heading,
179            Err(HeadingError::Imu(wheel_heading)) => {
180                imu = None;
181                // NOTE: This returning `None` means that there's no real point in spawning the task since
182                // the IMU disconnected, but we'll leave that to the task to figure out, since there's an
183                // early return condition in the loop if this occurs and we don't want to have to handle
184                // errors in this specific function.
185                wheel_heading.unwrap_or_default()
186            }
187            _ => Angle::default(),
188        };
189        let initial_forward_travel = {
190            let mut travel_sum = 0.0;
191
192            let mut count = 0;
193
194            // Sum up all of our wheel values to determine average forward wheel travel and average local
195            // x-axis displacement.
196            for (travel, _) in initial_forward_wheel_data.iter().flatten() {
197                travel_sum += travel;
198                count += 1;
199            }
200
201            if count != 0 {
202                travel_sum / f64::from(count)
203            } else {
204                0.0
205            }
206        };
207
208        let data = Rc::new(RefCell::new(TrackingData {
209            position: origin,
210            heading_offset: heading,
211            raw_heading: initial_raw_heading,
212            ..Default::default()
213        }));
214
215        Self {
216            data: data.clone(),
217            _task: spawn(Self::task(
218                forward_wheels,
219                sideways_wheels,
220                imu,
221                data,
222                parallel_forward_indicies,
223                initial_forward_wheel_data,
224                initial_sideways_wheel_data,
225                initial_raw_heading,
226                initial_forward_travel,
227            )),
228        }
229    }
230
231    /// Creates a new wheeled tracking system with no sideways tracking wheels.
232    pub fn forward_only<T: RotarySensor + 'static, const NUM_FORWARD: usize>(
233        origin: Vec2<f64>,
234        heading: Angle,
235        forward_wheels: [TrackingWheel<T>; NUM_FORWARD],
236        imu: Option<InertialSensor>,
237    ) -> Self {
238        Self::new(
239            origin,
240            heading,
241            forward_wheels,
242            [] as [TrackingWheel<T>; 0],
243            imu,
244        )
245    }
246
247    // MARK: Heading Calculation
248
249    /// Determines the orientation of the robot.
250    ///
251    /// "raw" in this case refers to the fact that the angle returned by this method has not been offset by any amount
252    /// (meaning the user's "initial heading" configuration isn't considered), and has unspecified bounds (it may be
253    /// out of the range of [0, 2π]). The angle is guaranteed to be counterclockwise-positive, but is otherwise a raw
254    /// reading from whatever sensor is being used to determine orientation (either tracking wheels or an IMU).
255    ///
256    /// To determine the final heading value returned by [`Self::heading`], you must add the heading offset value and
257    /// wrap the angle from [0, 2π] using [`Angle::wrapped_positive`].
258    ///
259    /// # Errors
260    ///
261    /// There are two ways to determine robot orientation with wheeled tracking. You can either use an IMU, or you can
262    /// use two parallel tracking wheels with roughly the same offset (spacing) from the center of rotation on the robot.
263    ///
264    /// - If the IMU fails to return a value, then [`HeadingError::Imu`] will be returned, and a fallback wheeled heading
265    ///   may be available to use in this error type if the tracking setup has parallel tracking wheels that support it.
266    /// - If a tracking wheel fails then [`HeadingError::RotarySensor`] will be returned containing the underlying error
267    ///   that occurred.
268    ///
269    /// # Panics
270    ///
271    /// An assertion will panic if both `imu` and `parallel_wheels` is `None`. This should never happen.
272    fn compute_raw_heading<T: RotarySensor>(
273        imu: Option<&InertialSensor>,
274        parallel_wheels: Option<(&TrackingWheel<T>, &TrackingWheel<T>)>,
275    ) -> Result<Angle, HeadingError<T>> {
276        assert!(
277            imu.is_some() || parallel_wheels.is_some(),
278            "No IMU or wheeled tracking sensors provided to compute_heading."
279        );
280
281        // Try to get a reading of the robot's heading from the IMU. We should only do this if the IMU
282        // hasn't returned a port-related error before (flagged by the `imu_invalid` variable). If it
283        // has, the IMU has no chance of recovery and we should fallback to wheeled heading calculation.
284        let imu_rotation = imu.as_ref().map(|imu| imu.heading());
285
286        // Compute the unbounded robot orientation in radians. In the case of the IMU, this actually is bounded to [0, TAU]
287        // already due to how IMU heading works, but this will be wrapped to [0, TAU] regardless later either way.
288        let raw_heading = if let Some(Ok(imu_heading)) = imu_rotation {
289            // IMU's frame of reference is NED (Z-Down), meaning heading increases as the robot turns
290            // clockwise. We don't want this, since it doesn't match up with how the unit circle works
291            // with cartesian coordinates (what we localize in), so we need to convert to a CCW+ angle
292            // system.
293            TAU - imu_heading.to_radians()
294        } else if let Some((left_wheel, right_wheel)) = parallel_wheels {
295            // Distance between the left and right wheels.
296            let track_width = left_wheel.offset.abs() + right_wheel.offset;
297
298            // Nothing we can use if either of these disconnects, so all we can do is wait for them to
299            // reconnect. Seriously, fix your wiring!
300            let left_travel = left_wheel
301                .travel()
302                .map_err(|err| HeadingError::RotarySensor(err))?;
303            let right_travel = right_wheel
304                .travel()
305                .map_err(|err| HeadingError::RotarySensor(err))?;
306
307            (right_travel - left_travel) / track_width
308        } else if let Some(Err(_)) = imu_rotation {
309            // IMU failed and we have no viable sensors to determine heading. Nothing we can do to recover from this.
310            return Err(HeadingError::Imu(None));
311        } else {
312            unreachable!() // handled by the assertion at the top of this function
313        }
314        .rad();
315
316        match imu_rotation {
317            // IMU failed but we have a wheeled heading source to fall back to.
318            Some(Err(_)) => Err(HeadingError::Imu(Some(raw_heading))),
319            _ => Ok(raw_heading),
320        }
321    }
322
323    // MARK: Task
324
325    #[allow(clippy::too_many_arguments)]
326    async fn task<
327        T: RotarySensor,
328        U: RotarySensor,
329        const NUM_FORWARD: usize,
330        const NUM_SIDEWAYS: usize,
331    >(
332        forward_wheels: [TrackingWheel<T>; NUM_FORWARD],
333        sideways_wheels: [TrackingWheel<U>; NUM_SIDEWAYS],
334        mut imu: Option<InertialSensor>,
335        data: Rc<RefCell<TrackingData>>,
336        parallel_forward_indicies: Option<(usize, usize)>,
337        mut prev_forward_wheel_data: [Result<(f64, f64), <T as RotarySensor>::Error>; NUM_FORWARD],
338        mut prev_sideways_wheel_data: [Result<(f64, f64), <U as RotarySensor>::Error>;
339            NUM_SIDEWAYS],
340        mut prev_raw_heading: Angle,
341        mut prev_forward_travel: f64,
342    ) {
343        let mut prev_time = Instant::now();
344
345        loop {
346            sleep(Motor::WRITE_INTERVAL).await;
347
348            let mut data = data.borrow_mut();
349
350            let forward_wheel_data = forward_wheels
351                .each_ref()
352                .map(|wheel| wheel.travel().map(|travel| (travel, wheel.offset)));
353            let sideways_wheel_data = sideways_wheels
354                .each_ref()
355                .map(|wheel| wheel.travel().map(|travel| (travel, wheel.offset)));
356
357            // Calculate absolute robot orientation (heading).
358            //
359            // This can be done in two possible ways - Either using an IMU (if it is available and actually
360            // working) or through the use of two parallel forward trackers. The former is generally far more
361            // reliable and isn't prone to wheel slip.
362            data.raw_heading = match Self::compute_raw_heading(
363                imu.as_ref(),
364                parallel_forward_indicies.map(|(left_index, right_index)| {
365                    (&forward_wheels[left_index], &forward_wheels[right_index])
366                }),
367            ) {
368                // Cool
369                Ok(raw_heading) => raw_heading,
370
371                // We got an error from the IMU, which means it likely disconnected. Once an IMU disconnects it
372                // will reclibrate upon regaining power, which will mess tracking up badly, so we need to stop using
373                // it in this case and switched to a wheeled method of determining heading.
374                Err(HeadingError::Imu(raw_wheel_heading)) => {
375                    imu = None; // Set imu to `None` so we don't use it in the future.
376
377                    // Use the backup wheeled heading value in the IMU failed.
378                    if let Some(raw_wheel_heading) = raw_wheel_heading {
379                        raw_wheel_heading
380                    } else {
381                        // If no backup heading is available, that means we have no means of determining heading
382                        // in the future (because we have no parallel forward trackers), meaning there's no point
383                        // in continuing this task. Womp womp.
384                        return;
385                    }
386                }
387
388                // Occurs if both the IMU failed and the backup heading source failed.
389                Err(HeadingError::RotarySensor(_)) if imu.is_some() => {
390                    imu = None; // No more imu :(
391                    continue;
392                }
393
394                // One of the tracking wheels failed and we don't have an imu, so just wait for it to reconnect I guess.
395                _ => continue,
396            };
397
398            // Change in raw heading from the previous loop iteration.
399            let delta_heading = (data.raw_heading - prev_raw_heading).wrapped();
400
401            // Average between the current and previous heading reading used conversion between
402            // global and local coordinate displacements.
403            //
404            // No need to wrap since we only plug this into trig functions.
405            let avg_heading = ((data.raw_heading + prev_raw_heading) / 2.0) + data.heading_offset;
406            prev_raw_heading = data.raw_heading;
407
408            let mut local_displacement: Vec2<f64> = Vec2::default();
409            let unit_chord = 2.0 * (delta_heading / 2.0).sin();
410
411            // MARK: Sideways Wheels
412
413            // Doing all the stuff with sideways trackers in this block below.
414            {
415                let mut local_y_sum = 0.0;
416                let mut count = 0;
417
418                for (data, prev_data) in sideways_wheel_data.iter().zip(&prev_sideways_wheel_data) {
419                    if let Ok((travel, _)) = data {
420                        if let Ok((prev_travel, offset)) = prev_data {
421                            let delta_travel = travel - prev_travel;
422                            count += 1;
423
424                            local_y_sum += if delta_heading == Angle::ZERO {
425                                delta_travel
426                            } else {
427                                unit_chord * (delta_travel / delta_heading.as_radians() + offset)
428                            };
429                        }
430                    }
431
432                    if count != 0 {
433                        local_displacement.y += local_y_sum / f64::from(count);
434                    }
435                }
436
437                prev_sideways_wheel_data = sideways_wheel_data;
438            }
439
440            // MARK: Forward Wheels
441
442            // Doing all the stuff with forward trackers in this block below.
443            {
444                let mut local_x_sum = 0.0;
445                let mut travel_sum = 0.0;
446
447                let mut count = 0;
448                let mut prev_count = 0;
449
450                // Sum up all of our wheel values to determine average forward wheel travel and average local
451                // x-axis displacement.
452                for (data, prev_data) in forward_wheel_data.iter().zip(&prev_forward_wheel_data) {
453                    if let Ok((travel, _)) = data {
454                        travel_sum += travel;
455                        count += 1;
456
457                        // For x-axis displacement, we need to calculate a delta of how much our wheel travel
458                        // has changed. To do this, we need to have a record of both our current and previous
459                        // wheel travel, meaning we can only consider wheels that returned `Ok(_)` in both the
460                        // current AND previous loop iteration here.
461                        if let Ok((prev_travel, offset)) = prev_data {
462                            let delta_travel = travel - prev_travel;
463                            prev_count += 1;
464
465                            // NOTE: I get the feeling that this could be more efficient, since we already know
466                            // if `delta_heading` is zero before the for-loop here, meaning it's kinda wasteful
467                            // to check it each iteration. On the other hand, it probably make this code more
468                            // cancerous than it already is...
469                            local_x_sum += if delta_heading == Angle::ZERO {
470                                delta_travel
471                            } else {
472                                // shoutout to my man nick btw
473                                unit_chord * (delta_travel / delta_heading.as_radians() + offset)
474                            };
475                        }
476                    }
477                }
478
479                // Average local x-axis displacement (change in forward positioning along the robot's reference
480                // frame) using all functioning forward tracking wheels.
481                if prev_count != 0 {
482                    local_displacement.x = local_x_sum / f64::from(prev_count);
483                }
484
485                // Calculate the forward wheel travel.
486                //
487                // This calculates the average of all forward tracking sensors on the robot as an estimate of
488                // "how far we've driven". It's used for basic straight driving motion.
489                if count != 0 {
490                    data.forward_travel = travel_sum / f64::from(count);
491                }
492
493                prev_forward_wheel_data = forward_wheel_data;
494            };
495
496            // Used for estimating instantaneous velocity from wheel deltas.
497            let dt = prev_time.elapsed();
498            prev_time = Instant::now();
499
500            // Linear/angular drivetrain velocity estimation
501            //
502            // TODO: Any kind of "dx/dt"-style differentiations here are flawed and will return zero
503            //       sometimes due to the sample rate of our loop being higher than the sample rate
504            //       of our sensor. We should also maybe consider EMA filtering this or something.
505            data.linear_velocity = (data.forward_travel - prev_forward_travel) / dt.as_secs_f64();
506            prev_forward_travel = data.forward_travel;
507
508            data.angular_velocity = imu
509                .as_ref()
510                .and_then(|imu| imu.gyro_rate().ok())
511                .map_or(delta_heading.as_radians() / dt.as_secs_f64(), |gyro_rate| {
512                    gyro_rate.z.to_radians()
513                });
514
515            // Update global position by converting our local displacement vector into a global offset (by
516            // rotating our local offset by our heading). Each iteration, we apply this estimate of our change
517            // in position to get a new estimate of the global position.
518            //
519            // If all this seems like gibberish to you, check out <https://www.youtube.com/watch?v=ZW7T6EFyYnc>.
520            data.position += local_displacement.rotated(avg_heading.as_radians());
521        }
522    }
523
524    // MARK: Setters
525
526    /// Offsets the currently tracked heading to a given [`Angle`].
527    pub fn set_heading(&mut self, heading: Angle) {
528        let mut data = self.data.borrow_mut();
529        data.heading_offset = heading - data.raw_heading;
530    }
531
532    /// Sets the currently tracked position to a new point.
533    pub fn set_position(&mut self, position: Vec2<f64>) {
534        self.data.borrow_mut().position = position;
535    }
536}
537
538impl TracksPosition for WheeledTracking {
539    fn position(&self) -> Vec2<f64> {
540        self.data.borrow().position
541    }
542}
543
544impl TracksHeading for WheeledTracking {
545    fn heading(&self) -> Angle {
546        let data = self.data.borrow();
547
548        // Apply heading offset and wrap from [0, 2π]
549        (data.raw_heading + data.heading_offset).wrapped_positive()
550    }
551}
552
553impl TracksForwardTravel for WheeledTracking {
554    fn forward_travel(&self) -> f64 {
555        self.data.borrow().forward_travel
556    }
557}
558
559impl TracksVelocity for WheeledTracking {
560    fn angular_velocity(&self) -> f64 {
561        self.data.borrow().angular_velocity
562    }
563
564    fn linear_velocity(&self) -> f64 {
565        self.data.borrow().linear_velocity
566    }
567}