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}