Skip to main content

cu_ahrs/
lib.rs

1#![cfg_attr(not(feature = "std"), no_std)]
2#![doc = include_str!("../README.md")]
3
4extern crate alloc;
5use bincode::{Decode, Encode};
6use core::time::Duration;
7use cu_sensor_payloads::{ImuPayload, MagnetometerPayload};
8use cu29::prelude::*;
9use cu29::units::si::acceleration::meter_per_second_squared;
10use cu29::units::si::angle::radian;
11use cu29::units::si::angular_velocity::radian_per_second;
12use cu29::units::si::f32::{Angle, AngularVelocity, Ratio, Time};
13use cu29::units::si::ratio::ratio;
14use cu29::units::si::time::second;
15use nalgebra::{Quaternion, UnitQuaternion, Vector3};
16use serde::{Deserialize, Serialize};
17use uf_ahrs::{Ahrs, Mahony, MahonyParams};
18
19/// Pose expressed as Euler angles (radians) using the aerospace body frame.
20#[derive(
21    Debug, Clone, Copy, Default, Encode, Decode, Serialize, Deserialize, PartialEq, Reflect,
22)]
23pub struct AhrsPose {
24    pub roll: Angle,
25    pub pitch: Angle,
26    pub yaw: Angle,
27}
28
29/// Copper AHRS task that fuses IMU (and optional magnetometer) payloads into roll/pitch/yaw.
30#[derive(Reflect)]
31#[reflect(from_reflect = false)]
32pub struct CuAhrs {
33    #[reflect(ignore)]
34    filter: Mahony,
35    last_tov: Option<CuTime>,
36    sample_period_s: f32,
37    auto_sample_period: bool,
38    mahony_kp: f32,
39    mahony_ki: f32,
40}
41
42/// Typed debug-state view for AHRS internals.
43///
44/// This is projected from the live task state when the debugger reads it; it is
45/// not a second copy of the filter state.
46#[derive(Debug, Clone, Copy, Serialize, Reflect)]
47#[reflect(from_reflect = false)]
48pub struct CuAhrsDebugState {
49    pub orientation_w: Ratio,
50    pub orientation_i: Ratio,
51    pub orientation_j: Ratio,
52    pub orientation_k: Ratio,
53    pub roll: Angle,
54    pub pitch: Angle,
55    pub yaw: Angle,
56    pub gyro_bias_x: AngularVelocity,
57    pub gyro_bias_y: AngularVelocity,
58    pub gyro_bias_z: AngularVelocity,
59    pub last_tov: Option<CuTime>,
60    pub sample_period: Time,
61    pub auto_sample_period: bool,
62    pub mahony_kp: Ratio,
63    pub mahony_ki: Ratio,
64}
65
66impl CuAhrs {
67    const DEFAULT_SAMPLE_PERIOD_S: f32 = 1.0 / 200.0;
68
69    fn build_filter(
70        sample_period_s: f32,
71        kp: f32,
72        ki: f32,
73        orientation: UnitQuaternion<f32>,
74    ) -> Mahony {
75        let params = MahonyParams { kp, ki };
76        Mahony::new_with_orientation(
77            Duration::from_secs_f32(sample_period_s.max(1.0e-5)),
78            params,
79            orientation,
80        )
81    }
82
83    pub fn new_filter() -> Self {
84        let kp = MahonyParams::default().kp;
85        let ki = MahonyParams::default().ki;
86        let filter = Self::build_filter(
87            Self::DEFAULT_SAMPLE_PERIOD_S,
88            kp,
89            ki,
90            UnitQuaternion::identity(),
91        );
92        Self {
93            filter,
94            last_tov: None,
95            sample_period_s: Self::DEFAULT_SAMPLE_PERIOD_S,
96            auto_sample_period: true,
97            mahony_kp: kp,
98            mahony_ki: ki,
99        }
100    }
101
102    pub fn debug_state(&self) -> CuAhrsDebugState {
103        let orientation = self.filter.orientation();
104        let q = orientation.quaternion();
105        let (roll, pitch, yaw) = orientation.euler_angles();
106
107        CuAhrsDebugState {
108            orientation_w: Ratio::new::<ratio>(q.w),
109            orientation_i: Ratio::new::<ratio>(q.i),
110            orientation_j: Ratio::new::<ratio>(q.j),
111            orientation_k: Ratio::new::<ratio>(q.k),
112            roll: Angle::new::<radian>(roll),
113            pitch: Angle::new::<radian>(pitch),
114            yaw: Angle::new::<radian>(yaw),
115            gyro_bias_x: AngularVelocity::new::<radian_per_second>(self.filter.bias.x),
116            gyro_bias_y: AngularVelocity::new::<radian_per_second>(self.filter.bias.y),
117            gyro_bias_z: AngularVelocity::new::<radian_per_second>(self.filter.bias.z),
118            last_tov: self.last_tov,
119            sample_period: Time::new::<second>(self.sample_period_s),
120            auto_sample_period: self.auto_sample_period,
121            mahony_kp: Ratio::new::<ratio>(self.mahony_kp),
122            mahony_ki: Ratio::new::<ratio>(self.mahony_ki),
123        }
124    }
125
126    fn from_config(config: Option<&ComponentConfig>) -> CuResult<Self> {
127        let mut filter = Self::new_filter();
128        filter.mahony_kp =
129            cfg_f32(config, "mahony_kp", MahonyParams::default().kp)?.clamp(0.0, 10.0);
130        filter.mahony_ki =
131            cfg_f32(config, "mahony_ki", MahonyParams::default().ki)?.clamp(0.0, 10.0);
132
133        let sample_hz = cfg_f32(config, "sample_hz", 0.0)?;
134        if sample_hz.is_finite() && sample_hz > 0.0 {
135            filter.sample_period_s = (1.0 / sample_hz).clamp(1.0e-5, 1.0);
136            filter.auto_sample_period = false;
137        }
138
139        filter.filter = Self::build_filter(
140            filter.sample_period_s,
141            filter.mahony_kp,
142            filter.mahony_ki,
143            UnitQuaternion::identity(),
144        );
145        Ok(filter)
146    }
147
148    fn dt_seconds(&mut self, tov: &Tov) -> Option<f32> {
149        let current = match tov {
150            Tov::Time(t) => Some(*t),
151            Tov::Range(r) => Some(r.end),
152            Tov::None => None,
153        }?;
154
155        let dt = self.last_tov.map(|previous| current - previous);
156        self.last_tov = Some(current);
157
158        dt.map(|duration| duration.as_nanos() as f32 * 1e-9)
159    }
160
161    fn maybe_lock_sample_period(&mut self, dt_s: Option<f32>) {
162        if !self.auto_sample_period {
163            return;
164        }
165
166        let Some(dt) = dt_s else {
167            return;
168        };
169        if !dt.is_finite() || dt <= 1.0e-5 {
170            return;
171        }
172
173        let orientation = self.filter.orientation();
174        let bias = self.filter.bias;
175        self.sample_period_s = dt.clamp(1.0e-5, 1.0);
176        self.filter = Self::build_filter(
177            self.sample_period_s,
178            self.mahony_kp,
179            self.mahony_ki,
180            orientation,
181        );
182        self.filter.bias = bias;
183        self.auto_sample_period = false;
184    }
185
186    fn update_pose(
187        &mut self,
188        imu: &ImuPayload,
189        mag: Option<&MagnetometerPayload>,
190        dt_s: Option<f32>,
191    ) -> AhrsPose {
192        self.maybe_lock_sample_period(dt_s);
193
194        let gyro = Vector3::new(
195            imu.gyro_x.get::<radian_per_second>(),
196            imu.gyro_y.get::<radian_per_second>(),
197            imu.gyro_z.get::<radian_per_second>(),
198        );
199        let accel = Vector3::new(
200            imu.accel_x.get::<meter_per_second_squared>(),
201            imu.accel_y.get::<meter_per_second_squared>(),
202            imu.accel_z.get::<meter_per_second_squared>(),
203        );
204
205        let q = if let Some(mag) = mag {
206            // Copper magnetometer payload follows the historical FC convention where +Y is mirrored
207            // compared to the NED convention used by uf-ahrs. Convert at the AHRS boundary.
208            let mag_vec = Vector3::new(mag.mag_x.value, -mag.mag_y.value, mag.mag_z.value);
209            if mag_vec.x.is_finite()
210                && mag_vec.y.is_finite()
211                && mag_vec.z.is_finite()
212                && mag_vec.norm_squared() > 1.0e-12
213            {
214                self.filter.update(gyro, accel, mag_vec)
215            } else {
216                self.filter.update_imu(gyro, accel)
217            }
218        } else {
219            self.filter.update_imu(gyro, accel)
220        };
221
222        let (roll, pitch, yaw) = q.euler_angles();
223        AhrsPose {
224            roll: Angle::new::<radian>(roll),
225            pitch: Angle::new::<radian>(pitch),
226            yaw: Angle::new::<radian>(yaw),
227        }
228    }
229}
230
231pub mod sinks {
232    //! Simple sinks for AHRS outputs.
233    use super::*;
234
235    /// Logs incoming RPY to stdout/log and forwards the payload.
236    #[derive(Reflect)]
237    pub struct RpyPrinter;
238
239    impl Freezable for RpyPrinter {}
240
241    impl CuTask for RpyPrinter {
242        type Resources<'r> = ();
243        type Input<'m> = input_msg!(AhrsPose);
244        type Output<'m> = output_msg!(AhrsPose);
245
246        fn new(_config: Option<&ComponentConfig>, _resources: Self::Resources<'_>) -> CuResult<Self>
247        where
248            Self: Sized,
249        {
250            Ok(Self)
251        }
252
253        fn process(
254            &mut self,
255            ctx: &CuContext,
256            input: &Self::Input<'_>,
257            output: &mut Self::Output<'_>,
258        ) -> CuResult<()> {
259            if let Some(pose) = input.payload() {
260                info!(
261                    ctx,
262                    "AHRS RPY [rad]: roll={} pitch={} yaw={}",
263                    pose.roll.get::<radian>(),
264                    pose.pitch.get::<radian>(),
265                    pose.yaw.get::<radian>()
266                );
267                output.set_payload(*pose);
268            } else {
269                output.clear_payload();
270            }
271            Ok(())
272        }
273    }
274}
275
276impl Freezable for CuAhrs {
277    fn freeze<E: bincode::enc::Encoder>(
278        &self,
279        encoder: &mut E,
280    ) -> Result<(), bincode::error::EncodeError> {
281        let q = self.filter.orientation();
282        let qq = q.quaternion();
283        let quat = [qq.w, qq.i, qq.j, qq.k];
284        let bias = [self.filter.bias.x, self.filter.bias.y, self.filter.bias.z];
285        Encode::encode(&quat, encoder)?;
286        Encode::encode(&bias, encoder)?;
287        Encode::encode(&self.sample_period_s, encoder)?;
288        Encode::encode(&self.auto_sample_period, encoder)?;
289        Encode::encode(&self.mahony_kp, encoder)?;
290        Encode::encode(&self.mahony_ki, encoder)?;
291        Encode::encode(&self.last_tov, encoder)?;
292        Ok(())
293    }
294
295    fn thaw<D: bincode::de::Decoder>(
296        &mut self,
297        decoder: &mut D,
298    ) -> Result<(), bincode::error::DecodeError> {
299        let quat: [f32; 4] = Decode::decode(decoder)?;
300        let bias: [f32; 3] = Decode::decode(decoder)?;
301        self.sample_period_s = Decode::decode(decoder)?;
302        self.auto_sample_period = Decode::decode(decoder)?;
303        self.mahony_kp = Decode::decode(decoder)?;
304        self.mahony_ki = Decode::decode(decoder)?;
305        self.last_tov = Decode::decode(decoder)?;
306
307        let valid_quat = quat.iter().all(|v| v.is_finite())
308            && !(quat[0].abs() < 1.0e-12
309                && quat[1].abs() < 1.0e-12
310                && quat[2].abs() < 1.0e-12
311                && quat[3].abs() < 1.0e-12);
312        let orientation = if valid_quat {
313            UnitQuaternion::new_normalize(Quaternion::new(quat[0], quat[1], quat[2], quat[3]))
314        } else {
315            UnitQuaternion::identity()
316        };
317
318        self.sample_period_s = if self.sample_period_s.is_finite() {
319            self.sample_period_s.clamp(1.0e-5, 1.0)
320        } else {
321            Self::DEFAULT_SAMPLE_PERIOD_S
322        };
323        self.mahony_kp = if self.mahony_kp.is_finite() {
324            self.mahony_kp.clamp(0.0, 10.0)
325        } else {
326            MahonyParams::default().kp
327        };
328        self.mahony_ki = if self.mahony_ki.is_finite() {
329            self.mahony_ki.clamp(0.0, 10.0)
330        } else {
331            MahonyParams::default().ki
332        };
333
334        self.filter = Self::build_filter(
335            self.sample_period_s,
336            self.mahony_kp,
337            self.mahony_ki,
338            orientation,
339        );
340        if bias.iter().all(|v| v.is_finite()) {
341            self.filter.bias = Vector3::new(bias[0], bias[1], bias[2]);
342        } else {
343            self.filter.bias = Vector3::new(0.0, 0.0, 0.0);
344        }
345        Ok(())
346    }
347}
348
349impl CuTask for CuAhrs {
350    type Resources<'r> = ();
351    type Input<'m> = input_msg!('m, ImuPayload, MagnetometerPayload);
352    type Output<'m> = output_msg!(AhrsPose);
353
354    fn register_debug_state_types(registry: &mut TypeRegistry) {
355        registry.register::<CuAhrsDebugState>();
356    }
357
358    fn debug_state_type_path() -> &'static str {
359        CuAhrsDebugState::type_path()
360    }
361
362    fn with_debug_state<R>(&self, f: impl FnOnce(&dyn bevy_reflect::Reflect) -> R) -> R {
363        let state = self.debug_state();
364        f(&state)
365    }
366
367    fn new(config: Option<&ComponentConfig>, _resources: Self::Resources<'_>) -> CuResult<Self>
368    where
369        Self: Sized,
370    {
371        Self::from_config(config)
372    }
373
374    fn process(
375        &mut self,
376        _ctx: &CuContext,
377        input: &Self::Input<'_>,
378        output: &mut Self::Output<'_>,
379    ) -> CuResult<()> {
380        let (imu_msg, mag_msg) = *input;
381        output.tov = imu_msg.tov;
382        let Some(imu) = imu_msg.payload() else {
383            #[cfg(not(feature = "firmware"))]
384            output.metadata.set_status("imu none");
385            output.clear_payload();
386            return Ok(());
387        };
388
389        let dt_s = self.dt_seconds(&imu_msg.tov);
390        let pose = self.update_pose(imu, mag_msg.payload(), dt_s);
391
392        #[cfg(not(feature = "firmware"))]
393        output.metadata.set_status(alloc::format!(
394            "r{} p{} y{}",
395            round_degrees_to_i16(pose.roll.get::<radian>().to_degrees()),
396            round_degrees_to_i16(pose.pitch.get::<radian>().to_degrees()),
397            round_degrees_to_i16(pose.yaw.get::<radian>().to_degrees())
398        ));
399        output.set_payload(pose);
400
401        Ok(())
402    }
403}
404
405#[cfg(not(feature = "firmware"))]
406fn round_degrees_to_i16(value: f32) -> i16 {
407    if value >= 0.0 {
408        (value + 0.5) as i16
409    } else {
410        (value - 0.5) as i16
411    }
412}
413
414fn cfg_f32(config: Option<&ComponentConfig>, key: &str, default: f32) -> CuResult<f32> {
415    let value = match config {
416        Some(cfg) => cfg.get::<f64>(key)?,
417        None => None,
418    };
419    Ok(value.map(|v| v as f32).unwrap_or(default))
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425    use core::f32::consts::{FRAC_PI_2, FRAC_PI_3};
426    use cu29::cutask::CuMsg;
427
428    fn accel_from_orientation(roll: f32, pitch: f32) -> [f32; 3] {
429        // Using aerospace body frame (X forward, Y right, Z down).
430        // Gravity-only measurement rotated into body frame.
431        let g = 9.81;
432        let sr = roll.sin();
433        let cr = roll.cos();
434        let sp = pitch.sin();
435        let cp = pitch.cos();
436        [-g * sp, g * sr * cp, g * cr * cp]
437    }
438
439    fn process_sample(
440        task: &mut CuAhrs,
441        ctx: &CuContext,
442        payload: ImuPayload,
443        mag: Option<MagnetometerPayload>,
444        tov_ns: u64,
445    ) -> Option<AhrsPose> {
446        let tov = Tov::Time(CuTime::from(tov_ns));
447        let mut imu_msg = CuMsg::new(Some(payload));
448        imu_msg.tov = tov;
449        let mut mag_msg = CuMsg::new(mag);
450        mag_msg.tov = tov;
451        let mut output = CuMsg::new(None);
452        let input = (&imu_msg, &mag_msg);
453        task.process(ctx, &input, &mut output).unwrap();
454        output.payload().copied()
455    }
456
457    fn settle_pose(roll: f32, pitch: f32, iterations: usize, step_ns: u64) -> AhrsPose {
458        let ctx = CuContext::new_with_clock();
459        let mut task = CuAhrs::new_filter();
460        let accel = accel_from_orientation(roll, pitch);
461        let payload = ImuPayload::from_raw(accel, [0.0; 3], 25.0);
462
463        let mut latest = None;
464        for i in 0..iterations {
465            latest = process_sample(&mut task, &ctx, payload, None, step_ns * (i as u64 + 1));
466        }
467        latest.expect("pose should be produced")
468    }
469
470    #[test]
471    fn level_orientation_stays_zeroed() {
472        let pose = settle_pose(0.0, 0.0, 12, 10_000_000);
473        assert!(
474            pose.roll.get::<radian>().abs() < 0.03,
475            "roll {}",
476            pose.roll.value
477        );
478        assert!(
479            pose.pitch.get::<radian>().abs() < 0.03,
480            "pitch {}",
481            pose.pitch.value
482        );
483    }
484
485    #[test]
486    fn pitch_up_is_positive() {
487        let target_pitch = FRAC_PI_3; // 60 deg nose up
488        let pose = settle_pose(0.0, target_pitch, 120, 10_000_000);
489        assert!(
490            pose.pitch.get::<radian>() > 0.4,
491            "pitch {} should be positive and significantly nose-up (target {})",
492            pose.pitch.value,
493            target_pitch
494        );
495        assert!(pose.roll.get::<radian>().abs() < 0.15);
496    }
497
498    #[test]
499    fn roll_left_is_negative() {
500        let target_roll = -FRAC_PI_3; // left wing down
501        let pose = settle_pose(target_roll, 0.0, 120, 10_000_000);
502        assert!(
503            pose.roll.get::<radian>() < -0.4,
504            "roll {} should be negative and significantly left-down (target {})",
505            pose.roll.value,
506            target_roll
507        );
508        assert!(pose.pitch.get::<radian>().abs() < 0.15);
509    }
510
511    #[test]
512    fn yaw_integrates_gyro() {
513        let ctx = CuContext::new_with_clock();
514        let mut task = CuAhrs::new_filter();
515        let accel = [0.0, 0.0, 9.81];
516        let gyro = [0.0, 0.0, FRAC_PI_2]; // 90 deg/s about +Z
517        let payload = ImuPayload::from_raw(accel, gyro, 25.0);
518
519        let mut latest = None;
520        for i in 0..10 {
521            latest = process_sample(&mut task, &ctx, payload, None, 100_000_000 * (i as u64 + 1));
522        }
523
524        let pose = latest.expect("pose should be produced");
525        assert!(
526            (pose.yaw.get::<radian>() - FRAC_PI_2).abs() < 0.3,
527            "yaw {} vs {}",
528            pose.yaw.value,
529            FRAC_PI_2
530        );
531    }
532
533    #[test]
534    fn magnetometer_correction_anchors_yaw() {
535        let ctx = CuContext::new_with_clock();
536        let mut task = CuAhrs::new_filter();
537        let imu = ImuPayload::from_raw([0.0, 0.0, 9.81], [0.0, 0.0, 0.0], 25.0);
538        // Copper payload convention uses +Y for east. AHRS converts to uf-ahrs convention internally.
539        let mag = MagnetometerPayload::from_raw([0.0, 20.0, 0.0]);
540
541        let mut latest = None;
542        for i in 0..2_000 {
543            latest = process_sample(&mut task, &ctx, imu, Some(mag), 10_000_000 * (i as u64 + 1));
544        }
545
546        let pose = latest.expect("pose should be produced");
547        let yaw_deg = pose.yaw.get::<radian>().to_degrees().rem_euclid(360.0);
548        assert!(
549            (yaw_deg - 90.0).abs() < 5.0,
550            "yaw_deg {} should converge near 90°",
551            yaw_deg
552        );
553    }
554}