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#[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
37pub 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 use super::*;
98
99 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 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 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 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 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; 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; 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]; 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}