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 cu_sensor_payloads::ImuPayload;
7use cu29::prelude::*;
8use dcmimu::DCMIMU;
9use serde::{Deserialize, Serialize};
10use uom::si::acceleration::meter_per_second_squared;
11use uom::si::angular_velocity::radian_per_second;
12
13#[cfg(not(feature = "std"))]
14use alloc::vec::Vec;
15
16use core::mem::size_of;
17use core::ptr;
18
19/// Pose expressed as Euler angles (radians) using the aerospace body frame.
20#[derive(Debug, Clone, Copy, Default, Encode, Decode, Serialize, Deserialize, PartialEq)]
21pub struct AhrsPose {
22    pub roll: f32,
23    pub pitch: f32,
24    pub yaw: f32,
25}
26
27impl AhrsPose {
28    fn relative_to(self, reference: AhrsPose) -> Self {
29        Self {
30            roll: self.roll - reference.roll,
31            pitch: self.pitch - reference.pitch,
32            yaw: self.yaw - reference.yaw,
33        }
34    }
35}
36
37/// Copper AHRS task that fuses IMU payloads into roll/pitch/yaw.
38pub struct CuAhrs {
39    dcm: DCMIMU,
40    reference: Option<AhrsPose>,
41    last_tov: Option<CuTime>,
42}
43
44impl CuAhrs {
45    pub const fn new_filter() -> Self {
46        Self {
47            dcm: DCMIMU::new(),
48            reference: None,
49            last_tov: None,
50        }
51    }
52
53    fn update_pose(&mut self, payload: &ImuPayload, dt_s: f32) -> AhrsPose {
54        let accel = [
55            payload.accel_x.get::<meter_per_second_squared>(),
56            payload.accel_y.get::<meter_per_second_squared>(),
57            payload.accel_z.get::<meter_per_second_squared>(),
58        ];
59        let gyro = [
60            payload.gyro_x.get::<radian_per_second>(),
61            payload.gyro_y.get::<radian_per_second>(),
62            payload.gyro_z.get::<radian_per_second>(),
63        ];
64
65        let (angles, _) = self.dcm.update(
66            (gyro[0], gyro[1], gyro[2]),
67            (accel[0], accel[1], accel[2]),
68            dt_s.max(0.0),
69        );
70
71        let pose = AhrsPose {
72            roll: angles.roll,
73            pitch: angles.pitch,
74            yaw: angles.yaw,
75        };
76
77        let reference = self.reference.get_or_insert(pose);
78        pose.relative_to(*reference)
79    }
80
81    fn dt_seconds(&mut self, tov: &Tov) -> Option<f32> {
82        let current = match tov {
83            Tov::Time(t) => Some(*t),
84            Tov::Range(r) => Some(r.end),
85            Tov::None => None,
86        }?;
87
88        let dt = self.last_tov.map(|previous| current - previous);
89        self.last_tov = Some(current);
90
91        dt.map(|duration| duration.as_nanos() as f32 * 1e-9)
92    }
93}
94
95pub mod sinks {
96    //! Simple sinks for AHRS outputs.
97    use super::*;
98
99    /// Logs incoming RPY to stdout/log and forwards the payload.
100    pub struct RpyPrinter;
101
102    impl Freezable for RpyPrinter {}
103
104    impl CuTask for RpyPrinter {
105        type Resources<'r> = ();
106        type Input<'m> = input_msg!(AhrsPose);
107        type Output<'m> = output_msg!(AhrsPose);
108
109        fn new(_config: Option<&ComponentConfig>, _resources: Self::Resources<'_>) -> CuResult<Self>
110        where
111            Self: Sized,
112        {
113            Ok(Self)
114        }
115
116        fn process(
117            &mut self,
118            _clock: &RobotClock,
119            input: &Self::Input<'_>,
120            output: &mut Self::Output<'_>,
121        ) -> CuResult<()> {
122            if let Some(pose) = input.payload() {
123                info!(
124                    "AHRS RPY [rad]: roll={} pitch={} yaw={}",
125                    pose.roll, pose.pitch, pose.yaw
126                );
127                output.set_payload(*pose);
128            } else {
129                output.clear_payload();
130            }
131            Ok(())
132        }
133    }
134}
135
136impl Freezable for CuAhrs {
137    fn freeze<E: bincode::enc::Encoder>(
138        &self,
139        encoder: &mut E,
140    ) -> Result<(), bincode::error::EncodeError> {
141        // Safety: DCMIMU is a plain-old-data struct of floats; we snapshot its bytes to preserve filter state.
142        let bytes = unsafe {
143            core::slice::from_raw_parts(
144                (&self.dcm as *const DCMIMU) as *const u8,
145                size_of::<DCMIMU>(),
146            )
147        };
148        Encode::encode(&bytes, encoder)?;
149        Encode::encode(&self.reference, encoder)?;
150        Encode::encode(&self.last_tov, encoder)?;
151        Ok(())
152    }
153
154    fn thaw<D: bincode::de::Decoder>(
155        &mut self,
156        decoder: &mut D,
157    ) -> Result<(), bincode::error::DecodeError> {
158        let raw: Vec<u8> = Decode::decode(decoder)?;
159        let expected = size_of::<DCMIMU>();
160        if raw.len() == expected {
161            // Safety: we created the vector ourselves from a previous freeze; copy bytes back.
162            unsafe {
163                let ptr = (&mut self.dcm as *mut DCMIMU) as *mut u8;
164                ptr::copy_nonoverlapping(raw.as_ptr(), ptr, expected);
165            }
166        } else {
167            // Mismatch: fall back to a fresh filter to avoid undefined state.
168            self.dcm = DCMIMU::new();
169        }
170        self.reference = Decode::decode(decoder)?;
171        self.last_tov = Decode::decode(decoder)?;
172        Ok(())
173    }
174}
175
176impl CuTask for CuAhrs {
177    type Resources<'r> = ();
178    type Input<'m> = input_msg!(ImuPayload);
179    type Output<'m> = output_msg!(AhrsPose);
180
181    fn new(_config: Option<&ComponentConfig>, _resources: Self::Resources<'_>) -> CuResult<Self>
182    where
183        Self: Sized,
184    {
185        Ok(Self::new_filter())
186    }
187
188    fn process(
189        &mut self,
190        _clock: &RobotClock,
191        input: &Self::Input<'_>,
192        output: &mut Self::Output<'_>,
193    ) -> CuResult<()> {
194        output.tov = input.tov;
195        let Some(payload) = input.payload() else {
196            output.clear_payload();
197            return Ok(());
198        };
199
200        let dt_s = match self.dt_seconds(&input.tov) {
201            Some(dt) if dt > 0.0 => dt,
202            _ => 1e-3,
203        };
204        let pose = self.update_pose(payload, dt_s);
205        output.set_payload(pose);
206
207        Ok(())
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use core::f32::consts::{FRAC_PI_2, FRAC_PI_3};
215    use cu29::cutask::CuMsg;
216
217    fn accel_from_orientation(roll: f32, pitch: f32) -> [f32; 3] {
218        // Using aerospace body frame (X forward, Y right, Z down).
219        // Gravity-only measurement rotated into body frame.
220        let g = 9.81;
221        let sr = roll.sin();
222        let cr = roll.cos();
223        let sp = pitch.sin();
224        let cp = pitch.cos();
225        [-g * sp, g * sr * cp, g * cr * cp]
226    }
227
228    fn process_sample(
229        task: &mut CuAhrs,
230        clock: &RobotClock,
231        payload: ImuPayload,
232        tov_ns: u64,
233    ) -> Option<AhrsPose> {
234        let mut input = CuMsg::new(Some(payload));
235        input.tov = Tov::Time(CuTime::from(tov_ns));
236        let mut output = CuMsg::new(None);
237        task.process(clock, &input, &mut output).unwrap();
238        output.payload().copied()
239    }
240
241    fn settle_pose(roll: f32, pitch: f32, iterations: usize, step_ns: u64) -> AhrsPose {
242        let (clock, _) = RobotClock::mock();
243        let mut task = CuAhrs::new_filter();
244        let accel = accel_from_orientation(roll, pitch);
245        let payload = ImuPayload::from_raw(accel, [0.0; 3], 25.0);
246
247        let mut latest = None;
248        for i in 0..iterations {
249            latest = process_sample(&mut task, &clock, payload, step_ns * (i as u64 + 1));
250        }
251        latest.expect("pose should be produced")
252    }
253
254    #[test]
255    fn level_orientation_stays_zeroed() {
256        let pose = settle_pose(0.0, 0.0, 5, 10_000_000);
257        assert!(pose.roll.abs() < 1e-3, "roll {}", pose.roll);
258        assert!(pose.pitch.abs() < 1e-3, "pitch {}", pose.pitch);
259        assert!(pose.yaw.abs() < 1e-3, "yaw {}", pose.yaw);
260    }
261
262    #[test]
263    fn pitch_up_is_positive() {
264        let target_pitch = FRAC_PI_3; // 60 deg nose up
265        let pose = settle_pose(0.0, target_pitch, 80, 10_000_000);
266        assert!(
267            (pose.pitch - target_pitch).abs() < 0.1,
268            "pitch {} vs {}",
269            pose.pitch,
270            target_pitch
271        );
272        assert!(pose.roll.abs() < 0.05);
273    }
274
275    #[test]
276    fn roll_left_is_negative() {
277        let target_roll = -FRAC_PI_3; // left wing down
278        let pose = settle_pose(target_roll, 0.0, 80, 10_000_000);
279        assert!(
280            (pose.roll - target_roll).abs() < 0.1,
281            "roll {} vs {}",
282            pose.roll,
283            target_roll
284        );
285        assert!(pose.pitch.abs() < 0.05);
286    }
287
288    #[test]
289    fn yaw_integrates_gyro() {
290        let (clock, _) = RobotClock::mock();
291        let mut task = CuAhrs::new_filter();
292        let accel = [0.0, 0.0, 9.81];
293        let gyro = [0.0, 0.0, FRAC_PI_2]; // 90 deg/s about +Z
294        let payload = ImuPayload::from_raw(accel, gyro, 25.0);
295
296        let mut latest = None;
297        for i in 0..10 {
298            latest = process_sample(&mut task, &clock, payload, 100_000_000 * (i as u64 + 1));
299        }
300
301        let pose = latest.expect("pose should be produced");
302        assert!(
303            (pose.yaw - FRAC_PI_2).abs() < 0.2,
304            "yaw {} vs {}",
305            pose.yaw,
306            FRAC_PI_2
307        );
308    }
309}