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#[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#[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#[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 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 use super::*;
234
235 #[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 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; 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; 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]; 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 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}