fusion_nav/units.rs
1//! Typed quantities the filter's API is expressed in.
2//!
3//! Types here make the caller write down the claims that cause navigation bugs, and leave
4//! out the ones that do not:
5//!
6//! * **Frame.** Vector quantities carry their [`Frame`], and their constructors name it —
7//! `Position::ned(..)`, `AngularRate::body(..)` — so a sign error from ENU or FLU input
8//! is a conversion the caller asked for, not one they forgot. `Position::enu(..).to_ned()`
9//! and `AngularRate::flu(..)` do the conversion here, once.
10//! * **Value versus noise.** A measurement and its uncertainty are different types, and a
11//! noise type is built `from_sigma` or `from_variance`, so a standard deviation cannot
12//! arrive where a variance was meant.
13//! * **Sign conventions** that differ from NED, like [`Altitude`], which is positive up.
14//!
15//! Units are SI throughout and are named in a constructor only where a source commonly
16//! supplies something else — degrees, a receiver's σ. A component of a position is an
17//! `f32` in meters, not a `Meters`.
18//!
19//! Components cross the boundary as plain numbers, never as `nalgebra` types. `nalgebra` is
20//! 0.x, so each of its minor versions is a semver break, and a public `Vector3` would tie
21//! every integrator to the one this crate builds against. A vector is an `[f32; 3]`, which
22//! converts to and from any version's `SVector`, and glam's and cgmath's vectors, with
23//! `.into()`. A quaternion is a [`Quaternion`] with named fields and no array conversion,
24//! because the libraries disagree on the order of four. Inside, the payloads stay
25//! `nalgebra` types, so the equations keep its fixed-size algebra.
26
27use core::f32::consts::FRAC_1_SQRT_2;
28use core::fmt;
29use core::marker::PhantomData;
30
31use nalgebra::{UnitQuaternion, Vector3};
32
33use crate::frames::{Body, Enu, Frame, Ned};
34
35/// The four components of a rotation quaternion, Hamilton convention, by name.
36///
37/// Named rather than an array because the order of four numbers is the one convention no
38/// type can check: PX4's `q[4]` and ArduPilot's `Quaternion` put the scalar first, while
39/// ROS, Eigen, glam's `to_array` and `nalgebra`'s `coords` and `From<[f32; 4]>` put it last.
40/// A quaternion read in the other order is still a finite unit quaternion, and rotates the
41/// wrong way. So there is no conversion from an array and no positional constructor: a
42/// caller writes `Quaternion { w: q.w, x: q.i, y: q.j, z: q.k }` from `nalgebra`, or
43/// `{ w: q[0], x: q[1], .. }` from PX4, and the order is on the page.
44///
45/// It carries no frames; [`Attitude`]'s constructors say which rotation it is.
46#[derive(Clone, Copy, Debug, PartialEq)]
47#[cfg_attr(feature = "defmt", derive(defmt::Format))]
48pub struct Quaternion {
49 /// The scalar part, `cos(θ/2)`.
50 pub w: f32,
51 /// The first vector component, `sin(θ/2)` times the axis's first.
52 pub x: f32,
53 /// The second vector component.
54 pub y: f32,
55 /// The third vector component.
56 pub z: f32,
57}
58
59impl Quaternion {
60 /// No rotation.
61 pub const IDENTITY: Self = Self {
62 w: 1.0,
63 x: 0.0,
64 y: 0.0,
65 z: 0.0,
66 };
67}
68
69/// Vehicle attitude: the rotation from [`Body`](crate::Body) to [`Ned`](crate::Ned).
70///
71/// Equation (7). Hamilton convention, crossing the API as a [`Quaternion`], whose fields
72/// name the components. Each constructor normalizes what it is given; a zero or non-finite
73/// quaternion comes out non-finite, and
74/// [`Eskf::initialize_from`](crate::Eskf::initialize_from) refuses it as
75/// [`InitError::NotFinite`](crate::InitError::NotFinite).
76///
77/// Every constructor names the convention it takes, and there is no `From<Quaternion>`, for
78/// the reason the module docs give for keeping `From<[f32; 3]>` off the framed vectors:
79/// `.into()` would claim body-to-NED for a quaternion that is a stored inverse or an ENU
80/// one. A seed is where that costs most,
81/// because it is the one input with no residual to expose it: the filter runs on an
82/// attitude wrong by a frame, reports [`Status::Healthy`](crate::Status::Healthy) if the
83/// seed covariance was confident, and nothing gates. How wrong depends on the attitude —
84/// the level case is a half turn, which is why a level bench check is the one that cannot
85/// find it.
86#[derive(Clone, Copy, Debug, PartialEq)]
87pub struct Attitude(UnitQuaternion<f32>);
88
89/// The quaternion's four components, scalar first, rather than `nalgebra`'s own `Format`,
90/// which would need its `defmt` feature and a second optional dependency edge to name it.
91#[cfg(feature = "defmt")]
92impl defmt::Format for Attitude {
93 fn format(&self, f: defmt::Formatter<'_>) {
94 let q = self.0.quaternion();
95 defmt::write!(
96 f,
97 "Attitude({=f32}, {=f32}, {=f32}, {=f32})",
98 q.w,
99 q.i,
100 q.j,
101 q.k
102 )
103 }
104}
105
106impl Attitude {
107 /// Level and pointing north.
108 pub fn level() -> Self {
109 Self(UnitQuaternion::identity())
110 }
111
112 /// Wrap a quaternion that already rotates body FRD to NED. Equation (7).
113 ///
114 /// The seed path from either production autopilot, and it converts nothing: PX4's
115 /// `vehicle_attitude.q` is the "rotation from the FRD body frame to the NED earth
116 /// frame", Hamilton and scalar-first (`msg/versioned/VehicleAttitude.msg:2,10`), and
117 /// ArduPilot publishes `AP_AHRS::get_quat_body_to_ned`
118 /// (`libraries/AP_AHRS/AP_AHRS.h:672`).
119 /// [`from_flu_to_nwu`](Self::from_flu_to_nwu) maps `fusion-ahrs`'s three conventions onto these
120 /// constructors, `Convention::Ned` included.
121 ///
122 /// Read at PX4-Autopilot `c4e4ef98` (v1.18.0-beta1) and ardupilot `368dc0c4`.
123 pub fn from_body_to_ned(q: Quaternion) -> Self {
124 Self(unit(q))
125 }
126
127 /// Wrap a body-to-NED rotation the filter computed.
128 pub(crate) const fn from_quaternion(q: UnitQuaternion<f32>) -> Self {
129 Self(q)
130 }
131
132 /// Invert a quaternion that rotates NED to body FRD.
133 ///
134 /// Storing the inverse — the direction-cosine matrix that takes a navigation vector
135 /// into the body frame — is a common convention, and it is the mistake no other check
136 /// reaches. An inverted attitude is finite, is a unit quaternion, and is identity
137 /// wherever the true one is, so it survives every gate and every static test; it
138 /// differs only in the sign of every rotation the vehicle actually has.
139 pub fn from_ned_to_body(q: Quaternion) -> Self {
140 Self(unit(q).inverse())
141 }
142
143 /// Convert a quaternion that rotates body FLU to ENU, the ROS REP 103 pair.
144 ///
145 /// Two-sided, because both frames differ: `q_{NED←FRD} = r_nav ⊗ q ⊗ r_body⁻¹`, where
146 /// `r_nav` is `q_{NED←ENU}` and `r_body` is `q_{FRD←FLU}`. Rotating only the
147 /// navigation frame is the half-applied form, and it is not obviously wrong: it
148 /// reports the same heading as this one and the vehicle upside down, so a level bench
149 /// check that reads a compass agrees with it.
150 pub fn from_flu_to_enu(q: Quaternion) -> Self {
151 let r_nav = ned_from_enu();
152 let r_body = frd_from_flu();
153 Self(r_nav * unit(q) * r_body.inverse())
154 }
155
156 /// Convert a quaternion that rotates body FLU to NWU, as Madgwick-family filters
157 /// report it.
158 ///
159 /// [`fusion-ahrs`](https://crates.io/crates/fusion-ahrs) is the one to hand: its
160 /// quaternion rotates the sensor frame into the earth frame, its default
161 /// `Convention::Nwu` puts north, west, up on the earth axes, and a level sensor then
162 /// reads gravity on `+z`, which is an FLU body. `Convention::Enu` is
163 /// [`from_flu_to_enu`](Self::from_flu_to_enu) and `Convention::Ned` is
164 /// [`from_body_to_ned`](Self::from_body_to_ned).
165 ///
166 /// Two-sided for the reason [`from_flu_to_enu`](Self::from_flu_to_enu) is. Both halves are the
167 /// same half turn here, so the conversion is a conjugation and a vehicle that is only
168 /// rolled comes through unchanged — a second attitude that cannot tell a conversion
169 /// from no conversion at all.
170 pub fn from_flu_to_nwu(q: Quaternion) -> Self {
171 let r_nav = ned_from_nwu();
172 let r_body = frd_from_flu();
173 Self(r_nav * unit(q) * r_body.inverse())
174 }
175
176 /// The quaternion that rotates body FRD to NED: the convention
177 /// [`from_body_to_ned`](Self::from_body_to_ned) takes, and what PX4 and ArduPilot publish.
178 pub fn body_to_ned(self) -> Quaternion {
179 components(self.0)
180 }
181
182 /// The body-to-NED rotation, for the filter's own algebra.
183 pub(crate) const fn quaternion(self) -> UnitQuaternion<f32> {
184 self.0
185 }
186
187 /// The quaternion that rotates NED to body FRD: the inverse of
188 /// [`from_ned_to_body`](Self::from_ned_to_body), for a consumer that stores the
189 /// direction-cosine matrix taking navigation vectors into the body.
190 pub fn ned_to_body(self) -> Quaternion {
191 components(self.0.inverse())
192 }
193
194 /// The quaternion that rotates body FLU to ENU, for ROS (`nav_msgs/Odometry`,
195 /// `sensor_msgs/Imu`): the inverse of [`from_flu_to_enu`](Self::from_flu_to_enu).
196 ///
197 /// Two-sided on the way out for the reason it is on the way in,
198 /// `q = r_nav⁻¹ ⊗ q_{NED←FRD} ⊗ r_body`; rotating the navigation frame alone publishes
199 /// the right heading and the vehicle upside down.
200 pub fn flu_to_enu(self) -> Quaternion {
201 components(ned_from_enu().inverse() * self.0 * frd_from_flu())
202 }
203
204 /// The quaternion that rotates body FLU to NWU, as Madgwick-family filters report it:
205 /// the inverse of [`from_flu_to_nwu`](Self::from_flu_to_nwu).
206 pub fn flu_to_nwu(self) -> Quaternion {
207 components(ned_from_nwu().inverse() * self.0 * frd_from_flu())
208 }
209
210 /// Roll, pitch, yaw in radians, from the ZYX sequence
211 /// `R = Rz(yaw) Ry(pitch) Rx(roll)`.
212 pub fn euler_angles(self) -> (f32, f32, f32) {
213 self.0.euler_angles()
214 }
215}
216
217impl Default for Attitude {
218 fn default() -> Self {
219 Self::level()
220 }
221}
222
223/// A caller's [`Quaternion`] as a unit quaternion, normalized.
224///
225/// Divided by the largest component first, because the norm is a sum of squares. One
226/// `f32::MAX` component overflows it, and `nalgebra` alone returns a finite zero that would
227/// pass every check a seed meets; components near 1e-22 underflow it, and `nalgebra` returns
228/// a finite quaternion 1 % off unit. A zero quaternion is `0 / 0`, an infinite one `∞ / ∞`,
229/// and a NaN component stays NaN, so each comes out non-finite: the refusal the
230/// [`Attitude`] docs promise.
231pub(crate) fn unit(q: Quaternion) -> UnitQuaternion<f32> {
232 let largest = [q.w, q.x, q.y, q.z]
233 .iter()
234 .fold(0.0f32, |m, c| m.max(c.abs()));
235 UnitQuaternion::from_quaternion(nalgebra::Quaternion::new(
236 q.w / largest,
237 q.x / largest,
238 q.y / largest,
239 q.z / largest,
240 ))
241}
242
243/// A unit quaternion's components, by name.
244pub(crate) fn components(q: UnitQuaternion<f32>) -> Quaternion {
245 Quaternion {
246 w: q.w,
247 x: q.i,
248 y: q.j,
249 z: q.k,
250 }
251}
252
253/// `q_{NED←ENU}`: the half turn about the north-east bisector, `(1, 1, 0)/√2`.
254///
255/// The signed permutation [`Position::to_ned`] applies to a vector, `(n, e, d) =
256/// (y, x, -z)`, written as a rotation. Swapping two axes alone would be a reflection;
257/// negating the third is what leaves determinant `+1` and a rotation to compose with.
258fn ned_from_enu() -> UnitQuaternion<f32> {
259 // w = cos(π/2) = 0, vector = sin(π/2)·axis = the axis itself. Unit by construction,
260 // so taken unchecked rather than normalized through a division by 1 ± ε.
261 UnitQuaternion::new_unchecked(nalgebra::Quaternion::new(
262 0.0,
263 FRAC_1_SQRT_2,
264 FRAC_1_SQRT_2,
265 0.0,
266 ))
267}
268
269/// `q_{NED←NWU}`: the half turn about north, which negates west and up to give east and
270/// down.
271fn ned_from_nwu() -> UnitQuaternion<f32> {
272 half_turn_about_first_axis()
273}
274
275/// `q_{FRD←FLU}`: the half turn about forward, the rotation form of
276/// [`AngularRate::flu`]'s `(f, r, d) = (f, -l, -u)`.
277fn frd_from_flu() -> UnitQuaternion<f32> {
278 half_turn_about_first_axis()
279}
280
281/// A half turn about the first axis: the second and third are negated, the first is kept.
282///
283/// Written once because NWU-to-NED and FLU-to-FRD are the same rotation on different
284/// frames, and named twice above so that a call site says which of the two it is.
285fn half_turn_about_first_axis() -> UnitQuaternion<f32> {
286 UnitQuaternion::new_unchecked(nalgebra::Quaternion::new(0.0, 1.0, 0.0, 0.0))
287}
288
289macro_rules! scalar {
290 (
291 $(#[$meta:meta])*
292 $name:ident, unit = $unit:literal, new = $new:ident, get = $get:ident
293 ) => {
294 $(#[$meta])*
295 #[derive(Clone, Copy, Debug, Default, PartialEq, PartialOrd)]
296 #[cfg_attr(feature = "defmt", derive(defmt::Format))]
297 pub struct $name(f32);
298
299 impl $name {
300 /// Zero.
301 pub const ZERO: Self = Self(0.0);
302
303 #[doc = concat!("Construct from a value in ", $unit, ".")]
304 pub const fn $new(value: f32) -> Self {
305 Self(value)
306 }
307
308 #[doc = concat!("The value in ", $unit, ".")]
309 pub const fn $get(self) -> f32 {
310 self.0
311 }
312 }
313 };
314}
315
316scalar!(
317 /// A time interval. The filter never reads a clock; intervals are always supplied,
318 /// or differenced from supplied [`Timestamp`]s.
319 Seconds,
320 unit = "seconds",
321 new = from_secs,
322 get = as_secs
323);
324
325impl Seconds {
326 /// Whether this is a real, forward step in time: positive and not NaN. `is_nan` is
327 /// spelled out because `<= 0.0` alone is false for NaN.
328 pub(crate) fn is_usable_step(self) -> bool {
329 self.0 > 0.0 && !self.0.is_nan()
330 }
331}
332
333/// A moment on the caller's clock, in whole microseconds.
334///
335/// The filter reads no clock. Every [`ImuSample`](crate::ImuSample) and every measurement
336/// carries the time it describes, and the filter differences them: the interval a step
337/// covers, and how long ago a measurement was taken. Where the epoch sits does not matter,
338/// since only differences are read, but IMU and measurement times must be on one clock.
339///
340/// Microseconds in a `u64`, the unit PX4's `hrt_abstime` and ArduPilot's `micros64` count
341/// in, rather than seconds in an `f32`: two hours in, as on `2c42096b`'s 7127 s, `f32` times
342/// are 0.49 ms apart, so a difference of two can be off by a fifth of a 400 Hz IMU interval.
343#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
344#[cfg_attr(feature = "defmt", derive(defmt::Format))]
345pub struct Timestamp(u64);
346
347impl Timestamp {
348 /// The clock's epoch.
349 pub const ZERO: Self = Self(0);
350
351 /// From a count of microseconds.
352 pub const fn from_micros(micros: u64) -> Self {
353 Self(micros)
354 }
355
356 /// The count of microseconds.
357 pub const fn as_micros(self) -> u64 {
358 self.0
359 }
360
361 /// From seconds since the epoch, rounded to the nearest microsecond. A time before the
362 /// epoch, or one that is not a number, is the epoch.
363 ///
364 /// `f64` because a log's time column is: an `f32` would lose the microseconds this type
365 /// exists to keep.
366 pub fn from_secs_f64(seconds: f64) -> Self {
367 // `as` saturates, and takes NaN and anything negative to zero.
368 Self((seconds * 1e6 + 0.5) as u64)
369 }
370
371 /// The moment `interval` after this one, to the nearest microsecond, saturating at the
372 /// ends of the clock. A negative interval is a moment before it.
373 pub fn after(self, interval: Seconds) -> Self {
374 let micros = f64::from(interval.as_secs()) * 1e6;
375 if micros >= 0.0 {
376 Self(self.0.saturating_add((micros + 0.5) as u64))
377 } else {
378 // NaN fails the test above and lands here as zero microseconds.
379 Self(self.0.saturating_sub((0.5 - micros) as u64))
380 }
381 }
382
383 /// The moment `interval` before this one: a measurement's time from its age.
384 pub fn before(self, interval: Seconds) -> Self {
385 self.after(Seconds::from_secs(-interval.as_secs()))
386 }
387
388 /// `self − earlier`, negative when `earlier` is the later of the two.
389 ///
390 /// The subtraction is exact, in integers, and only its result is rounded to `f32`, so
391 /// a short interval late in a long flight keeps every microsecond it has. The interval a
392 /// rate IMU's reading stands for, for [`ImuSample::from_rates`](crate::ImuSample::from_rates).
393 pub fn since(self, earlier: Self) -> Seconds {
394 // Two's complement: the wrapped difference read as signed is the signed difference
395 // for any two times within 2⁶³ µs of each other, which is 292 000 years.
396 let micros = self.0.wrapping_sub(earlier.0) as i64;
397 Seconds::from_secs(micros as f32 / 1_000_000.0)
398 }
399}
400
401scalar!(
402 /// An angle.
403 Radians,
404 unit = "radians",
405 new = from_radians,
406 get = as_radians
407);
408
409impl Radians {
410 /// Construct from a value in degrees, the unit charts and datasheets use — magnetic
411 /// declination, most obviously.
412 pub const fn from_degrees(degrees: f32) -> Self {
413 Self(degrees.to_radians())
414 }
415}
416
417scalar!(
418 /// A length, such as a position standard deviation.
419 Meters,
420 unit = "meters",
421 new = from_meters,
422 get = as_meters
423);
424
425scalar!(
426 /// A speed, such as a velocity standard deviation.
427 MetersPerSecond,
428 unit = "meters per second",
429 new = from_m_per_s,
430 get = as_m_per_s
431);
432
433scalar!(
434 /// A scalar acceleration, such as a specific-force tolerance or an accelerometer bias
435 /// standard deviation.
436 MetersPerSecond2,
437 unit = "meters per second squared",
438 new = from_m_per_s2,
439 get = as_m_per_s2
440);
441
442scalar!(
443 /// A scalar angular rate, such as a gyroscope tolerance or a gyroscope bias standard
444 /// deviation.
445 RadiansPerSecond,
446 unit = "radians per second",
447 new = from_rad_per_s,
448 get = as_rad_per_s
449);
450
451scalar!(
452 /// Barometric altitude above the barometer's own reference, positive **up**.
453 ///
454 /// Not NED down-position: the filter absorbs the sign and the unknown reference
455 /// offset. Equation (30).
456 Altitude,
457 unit = "meters",
458 new = from_meters,
459 get = as_meters
460);
461
462macro_rules! noise {
463 (
464 $(#[$meta:meta])*
465 $name:ident, sigma = $sigma_unit:literal, variance = $variance_unit:literal
466 ) => {
467 $(#[$meta])*
468 #[derive(Clone, Copy, Debug, PartialEq)]
469 pub struct $name {
470 variance: f32,
471 }
472
473 impl $name {
474 #[doc = concat!("From a standard deviation in ", $sigma_unit, ".")]
475 pub const fn from_sigma(sigma: f32) -> Self {
476 Self::from_variance(sigma * sigma)
477 }
478
479 #[doc = concat!("From a variance in ", $variance_unit, ".")]
480 pub const fn from_variance(variance: f32) -> Self {
481 Self { variance }
482 }
483
484 #[doc = concat!("The variance, in ", $variance_unit, ".")]
485 pub const fn variance(self) -> f32 {
486 self.variance
487 }
488
489 /// Whether the variance is a number, neither NaN nor infinite.
490 pub(crate) fn is_finite(self) -> bool {
491 self.variance.is_finite()
492 }
493
494 /// Whether the variance is one a real sensor could have: strictly positive.
495 /// See [`Fusion::InvalidNoise`](crate::Fusion::InvalidNoise).
496 pub(crate) fn is_positive(self) -> bool {
497 self.variance > 0.0
498 }
499 }
500 };
501}
502
503noise!(
504 /// Noise on a barometric altitude, `R` of equation (30).
505 AltitudeNoise,
506 sigma = "meters",
507 variance = "meters squared"
508);
509
510noise!(
511 /// Noise on a heading, `R` of equations (36), (35′) and (35″): a magnetic heading, a
512 /// dual-antenna GNSS heading, or the sideslip a course constraint allows. On the heading,
513 /// not on the field components a magnetometer reports.
514 HeadingNoise,
515 sigma = "radians",
516 variance = "radians squared"
517);
518
519/// `Clone`, `Copy`, `PartialEq` and `Debug` for a type holding a `Vector3<f32>` field and a
520/// frame marker.
521///
522/// Written out rather than derived: `derive` would add an `F: Clone` bound, and the frame
523/// markers are only ever type-level.
524macro_rules! vector_impls {
525 ($name:ident, $field:ident, $unit:literal) => {
526 impl<F: Frame> Clone for $name<F> {
527 fn clone(&self) -> Self {
528 *self
529 }
530 }
531
532 impl<F: Frame> Copy for $name<F> {}
533
534 impl<F: Frame> PartialEq for $name<F> {
535 fn eq(&self, other: &Self) -> bool {
536 self.$field == other.$field
537 }
538 }
539
540 impl<F: Frame> fmt::Debug for $name<F> {
541 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
542 let v = self.$field;
543 write!(
544 f,
545 "{}<{}>({}, {}, {}) {}",
546 stringify!($name),
547 F::NAME,
548 v.x,
549 v.y,
550 v.z,
551 $unit
552 )
553 }
554 }
555
556 #[cfg(feature = "defmt")]
557 impl<F: Frame> defmt::Format for $name<F> {
558 fn format(&self, f: defmt::Formatter<'_>) {
559 let v = self.$field;
560 defmt::write!(
561 f,
562 "{=str}<{=str}>({=f32}, {=f32}, {=f32}) {=str}",
563 stringify!($name),
564 F::NAME,
565 v.x,
566 v.y,
567 v.z,
568 $unit
569 )
570 }
571 }
572 };
573}
574
575macro_rules! framed {
576 (
577 $(#[$meta:meta])*
578 $name:ident, unit = $unit:literal
579 ) => {
580 $(#[$meta])*
581 pub struct $name<F: Frame> {
582 value: Vector3<f32>,
583 frame: PhantomData<F>,
584 }
585
586 impl<F: Frame> $name<F> {
587 #[doc = concat!("From components on the axes of `F`, in ", $unit, ".")]
588 ///
589 /// The frame is the type parameter, so name it: `Position::<Ned>::from_array` or
590 /// a binding with a stated type. The frame-named constructors say it for you.
591 pub fn from_array(components: [f32; 3]) -> Self {
592 Self::from_vector(components.into())
593 }
594
595 /// Wrap a vector the filter computed on the axes of `F`.
596 pub(crate) const fn from_vector(value: Vector3<f32>) -> Self {
597 Self {
598 value,
599 frame: PhantomData,
600 }
601 }
602
603 /// All components zero.
604 pub fn zero() -> Self {
605 Self::from_vector(Vector3::zeros())
606 }
607
608 /// The components, for the filter's own algebra.
609 pub(crate) const fn vector(self) -> Vector3<f32> {
610 self.value
611 }
612
613 #[doc = concat!("The components on the axes of `F`, in ", $unit, ".")]
614 pub fn to_array(self) -> [f32; 3] {
615 self.value.into()
616 }
617
618 /// First component on the axes of `F`: north, east for ENU, or body forward.
619 pub fn x(self) -> f32 {
620 self.value.x
621 }
622
623 /// Second component on the axes of `F`: east, north for ENU, or body right.
624 pub fn y(self) -> f32 {
625 self.value.y
626 }
627
628 /// Third component on the axes of `F`: down, up for ENU, or body down.
629 pub fn z(self) -> f32 {
630 self.value.z
631 }
632
633 /// Whether every component is a number, neither NaN nor infinite.
634 pub(crate) fn is_finite(self) -> bool {
635 self.value.iter().all(|v| v.is_finite())
636 }
637 }
638
639 impl<F: Frame> Default for $name<F> {
640 fn default() -> Self {
641 Self::zero()
642 }
643 }
644
645 vector_impls!($name, value, $unit);
646 };
647}
648
649framed!(
650 /// Position relative to the navigation origin, in meters; or, in [`Body`] axes, where a
651 /// sensor sits relative to the IMU, which is what the GNSS `fuse_*` take as `antenna`.
652 ///
653 /// See [`Eskf::origin`](crate::Eskf::origin) for where the origin is.
654 Position,
655 unit = "meters"
656);
657
658framed!(
659 /// Velocity, in meters per second.
660 Velocity,
661 unit = "meters per second"
662);
663
664framed!(
665 /// Specific force, an accelerometer bias, or a navigation-frame acceleration, in
666 /// meters per second squared.
667 ///
668 /// The frame says which. `Acceleration<Body>` is what an accelerometer reads —
669 /// specific force, which includes the reaction to gravity and is why a level,
670 /// stationary sensor reads `-γ` on its down axis. `Acceleration<Ned>` is how the
671 /// vehicle is actually accelerating, gravity excluded. Equation (11) relates them.
672 Acceleration,
673 unit = "meters per second squared"
674);
675
676framed!(
677 /// Angular rate, or a gyroscope bias, in radians per second.
678 AngularRate,
679 unit = "radians per second"
680);
681
682framed!(
683 /// A magnetic field measurement.
684 ///
685 /// Scale is irrelevant — only the direction is fused — but the components must be
686 /// hard- and soft-iron calibrated. See
687 /// [magnetometer, heading only](https://github.com/wboayue/fusion-nav/blob/main/EQUATIONS.md#magnetometer-heading-only).
688 MagField,
689 unit = "arbitrary units"
690);
691
692framed!(
693 /// A rotation increment: angular rate integrated over an interval, in radians.
694 ///
695 /// What an IMU driver that integrates between reads hands over, and what PX4's
696 /// `vehicle_imu.delta_angle` and ArduPilot's `get_delta_angle` publish. A type apart
697 /// from [`AngularRate`] because confusing the two is a factor of the interval, several
698 /// hundred at IMU rate, that nothing downstream can see.
699 DeltaAngle,
700 unit = "radians"
701);
702
703framed!(
704 /// A velocity increment: specific force integrated over an interval, in meters per
705 /// second.
706 ///
707 /// Body-frame, and specific force rather than acceleration, so a level vehicle at rest
708 /// gains `(0, 0, −γ Δt)`. A type apart from [`Velocity`], which is a navigation-frame
709 /// state, and from [`Acceleration`], which it is integrated from.
710 DeltaVelocity,
711 unit = "meters per second"
712);
713
714/// Constructors that name the navigation frame, and the ENU conversions into and out of it.
715///
716/// ENU to NED swaps the horizontal axes and flips the vertical: `(n, e, d) = (y, x, -z)`.
717/// An exact signed permutation, so nothing is lost.
718macro_rules! navigation {
719 ($name:ident) => {
720 impl $name<Ned> {
721 /// From north, east, down components.
722 pub fn ned(north: f32, east: f32, down: f32) -> Self {
723 Self::from_vector(Vector3::new(north, east, down))
724 }
725 }
726
727 impl $name<Enu> {
728 /// From east, north, up components, as ROS and most GIS tools use. Convert
729 /// with [`to_ned`](Self::to_ned) before handing it to the filter.
730 pub fn enu(east: f32, north: f32, up: f32) -> Self {
731 Self::from_vector(Vector3::new(east, north, up))
732 }
733
734 /// The same vector in north, east, down.
735 pub fn to_ned(self) -> $name<Ned> {
736 let enu = self.value;
737 $name::ned(enu.y, enu.x, -enu.z)
738 }
739 }
740
741 impl $name<Ned> {
742 /// The same vector in east, north, up, for a consumer in ROS's navigation frame:
743 /// the inverse of `to_ned`.
744 pub fn to_enu(self) -> $name<Enu> {
745 let ned = self.value;
746 $name::enu(ned.y, ned.x, -ned.z)
747 }
748 }
749 };
750}
751
752navigation!(Position);
753navigation!(Velocity);
754navigation!(Acceleration);
755
756/// Constructors that name the body frame, and the FLU conversions into and out of it.
757///
758/// The body frame is forward-right-down. ROS REP 103 bodies, and many IMU breakouts, are
759/// forward-left-up: the same forward axis, with the other two negated. An exact signed
760/// permutation, so nothing is lost.
761macro_rules! body {
762 ($name:ident, $what:literal) => {
763 impl $name<Body> {
764 #[doc = concat!($what, " from forward, right, down components.")]
765 pub fn body(forward: f32, right: f32, down: f32) -> Self {
766 Self::from_vector(Vector3::new(forward, right, down))
767 }
768
769 #[doc = concat!($what, " from forward, left, up components, converted to")]
770 /// forward, right, down.
771 pub fn flu(forward: f32, left: f32, up: f32) -> Self {
772 Self::body(forward, -left, -up)
773 }
774
775 /// Forward, left, up components: the inverse of [`flu`](Self::flu). An array
776 /// rather than a framed vector, since FLU is converted at the edge and no
777 /// quantity in the filter is held in it.
778 pub fn to_flu(self) -> [f32; 3] {
779 [self.value.x, -self.value.y, -self.value.z]
780 }
781 }
782 };
783}
784
785body!(Position, "Offset from the IMU");
786body!(Acceleration, "Specific force");
787body!(AngularRate, "Angular rate");
788body!(MagField, "Field");
789body!(DeltaAngle, "Rotation increment");
790body!(DeltaVelocity, "Velocity increment");
791
792impl AngularRate<Body> {
793 /// Angular rate from forward, right, down components in degrees per second, which is
794 /// what most MEMS gyroscope drivers report.
795 pub fn body_deg_per_s(forward: f32, right: f32, down: f32) -> Self {
796 Self::body(forward.to_radians(), right.to_radians(), down.to_radians())
797 }
798}
799
800macro_rules! noise3 {
801 (
802 $(#[$meta:meta])*
803 $name:ident, sigma = $sigma_unit:literal, variance = $variance_unit:literal
804 ) => {
805 $(#[$meta])*
806 pub struct $name<F: Frame> {
807 variance: Vector3<f32>,
808 frame: PhantomData<F>,
809 }
810
811 impl<F: Frame> $name<F> {
812 #[doc = concat!("From per-axis standard deviations in ", $sigma_unit, ".")]
813 pub fn from_sigma(x: f32, y: f32, z: f32) -> Self {
814 Self::from_variance(x * x, y * y, z * z)
815 }
816
817 #[doc = concat!("From per-axis variances in ", $variance_unit, " — the diagonal")]
818 /// of a covariance, as ROS messages carry it.
819 pub fn from_variance(x: f32, y: f32, z: f32) -> Self {
820 Self {
821 variance: Vector3::new(x, y, z),
822 frame: PhantomData,
823 }
824 }
825
826 #[doc = concat!("The per-axis variances, in ", $variance_unit, ".")]
827 pub fn variance(self) -> [f32; 3] {
828 self.variance.into()
829 }
830
831 /// Whether every variance is a number, neither NaN nor infinite.
832 pub(crate) fn is_finite(self) -> bool {
833 self.variance.iter().all(|v| v.is_finite())
834 }
835
836 /// Whether every variance is one a real sensor could have: strictly positive.
837 /// See [`Fusion::InvalidNoise`](crate::Fusion::InvalidNoise).
838 pub(crate) fn is_positive(self) -> bool {
839 self.variance.iter().all(|v| *v > 0.0)
840 }
841 }
842
843 vector_impls!($name, variance, $variance_unit);
844 };
845}
846
847noise3!(
848 /// Noise on a position measurement, `R` of equation (28), on the axes of `F`.
849 ///
850 /// A GNSS receiver's reported accuracy is bounded on both sides before it becomes
851 /// this; see [`PositionNoise::clamped`] and
852 /// [`Eskf::fuse_gnss_position`](crate::Eskf::fuse_gnss_position).
853 PositionNoise,
854 sigma = "meters",
855 variance = "meters squared"
856);
857
858noise3!(
859 /// Noise on a velocity measurement, `R` of equation (29), on the axes of `F`.
860 ///
861 /// Bounded the same way as [`PositionNoise`]; see [`VelocityNoise::clamped`] and
862 /// [`Eskf::fuse_gnss_velocity`](crate::Eskf::fuse_gnss_velocity).
863 VelocityNoise,
864 sigma = "meters per second",
865 variance = "meters squared per second squared"
866);
867
868/// Where a reported standard deviation is allowed to lie, for the `clamped` constructors of
869/// [`PositionNoise`] and [`VelocityNoise`]: a floor, a cap, or both.
870///
871/// A type rather than two bare `f32`s, and one per axis, because both production estimators
872/// bound the horizontal and vertical axes of one fix differently (PX4 and ArduPilot each floor
873/// vertical position at 1.5 times the horizontal floor), and a caller holds these in its own
874/// configuration and passes them on every fix. `Copy` for that reason, where a
875/// `RangeInclusive` would need cloning at each call.
876#[derive(Clone, Copy, Debug, PartialEq)]
877pub struct SigmaBounds {
878 min: f32,
879 max: f32,
880}
881
882impl SigmaBounds {
883 /// A σ held between `min` and `max`, in the unit of the noise it bounds.
884 ///
885 /// Bounds the wrong way round saturate to `max` rather than refusing: `clamped` is on the
886 /// per-measurement path, and a filter that cannot panic has no better answer available.
887 pub const fn new(min: f32, max: f32) -> Self {
888 Self { min, max }
889 }
890
891 /// A σ floored at `min` and not capped, as PX4 bounds a receiver's speed accuracy.
892 pub const fn at_least(min: f32) -> Self {
893 Self::new(min, f32::INFINITY)
894 }
895
896 /// Hold `sigma` between the bounds.
897 ///
898 /// Written with comparisons rather than [`f32::clamp`], which panics when `min > max`,
899 /// and rather than [`f32::max`], which returns the bound when the σ is NaN and would
900 /// turn a measurement [`Fusion::NotFinite`](crate::Fusion::NotFinite) exists to refuse
901 /// into a plausible-looking number. Every comparison against NaN is false, so a NaN σ
902 /// falls through unchanged and is refused where every other non-finite noise is.
903 fn apply(self, sigma: f32) -> f32 {
904 let floored = if sigma < self.min { self.min } else { sigma };
905 if floored > self.max {
906 self.max
907 } else {
908 floored
909 }
910 }
911}
912
913impl PositionNoise<Ned> {
914 /// From a receiver's horizontal and vertical accuracy, `eph` and `epv` — standard
915 /// deviations in meters, as u-blox (`hAcc`, `vAcc`), MAVLink and PX4 report them.
916 ///
917 /// Also how a two-dimensional fix is expressed: give the vertical axis a σ large
918 /// enough that its Kalman gain is negligible against the height uncertainty the
919 /// filter already holds, and say in the calling code which it is. A kilometer is
920 /// comfortably that for any vehicle this filter runs on, and is a number a reader
921 /// recognizes as deliberate where `1e6` reads as arbitrary.
922 ///
923 /// Unbounded on purpose, which is the whole difference from [`clamped`](Self::clamped):
924 /// a σ standing for an axis the receiver did not measure is a sentinel, not an
925 /// accuracy, and a cap would quietly turn it back into a measurement.
926 pub fn horizontal_vertical(horizontal: f32, vertical: f32) -> Self {
927 Self::from_sigma(horizontal, horizontal, vertical)
928 }
929
930 /// The same, with each σ held within its own [`SigmaBounds`] in meters.
931 ///
932 /// For the axes the receiver actually measured. Both are capped, so this is not the
933 /// way to drop one: a vertical σ of 1000 m under a 100 m cap is a height measurement the
934 /// filter will lean on rather than the one being declined. A two-dimensional fix goes
935 /// through [`horizontal_vertical`](Self::horizontal_vertical) instead, bounding `eph`
936 /// first if it came from the receiver.
937 ///
938 /// A receiver's accuracy estimate is its view of its own geometry and residuals, and
939 /// under multipath it stays small while the fix is meters wrong, so both production
940 /// estimators bound it rather than trusting it, each axis by its own rule:
941 ///
942 /// ```
943 /// # use fusion_nav::prelude::*;
944 /// let (eph, epv) = (0.05, 0.05); // an RTK fix claiming 5 cm
945 ///
946 /// // PX4 floors horizontal at EKF2_GPS_P_NOISE and vertical at 1.5 times it, and caps
947 /// // both at EKF2_NOAID_NOISE while GNSS is the only aiding on that axis.
948 /// let (p, noaid) = (0.5, 10.0);
949 /// let px4 = PositionNoise::clamped(
950 /// eph,
951 /// epv,
952 /// SigmaBounds::new(p, noaid),
953 /// SigmaBounds::new(1.5 * p, noaid),
954 /// );
955 /// assert_eq!(px4, PositionNoise::horizontal_vertical(0.5, 0.75));
956 ///
957 /// // ArduPilot floors horizontal at EK3_POSNE_M_NSE and vertical at 1.5 times it, and
958 /// // caps both at 100 m.
959 /// let posne = 0.5;
960 /// let ardupilot = PositionNoise::clamped(
961 /// eph,
962 /// epv,
963 /// SigmaBounds::new(posne, 100.0),
964 /// SigmaBounds::new(1.5 * posne, 100.0),
965 /// );
966 /// assert_eq!(ardupilot, px4);
967 /// ```
968 ///
969 /// PX4: `EKF/aid_sources/gnss/gps_control.cpp:358-368` for horizontal, whose cap applies
970 /// only while no other source aids horizontally and whose variance is floored again at
971 /// 0.01² m², and `gnss_height_control.cpp:62-69` for vertical, capped only while GNSS is
972 /// *not* the sole height source. The cap is about what the filter can afford to lean on,
973 /// not about the fix. ArduPilot: `constrain_ftype(gpsPosAccuracy, _gpsHorizPosNoise, 100)`
974 /// at `AP_NavEKF3_PosVelFusion.cpp:836`, the ordinary GNSS branch rather than the
975 /// identical line at `:807`, which is the synthetic-zero-velocity case opened at `:789`;
976 /// vertical at `:1402`.
977 ///
978 /// Keeping the bounds here rather than in [`Config`](crate::Config) keeps them traveling
979 /// with the measurement they describe, which is the same reason `R` is a per-call
980 /// argument at all.
981 ///
982 /// Read at PX4-Autopilot `c4e4ef98` (v1.18.0-beta1) and ardupilot `368dc0c4`.
983 pub fn clamped(
984 horizontal: f32,
985 vertical: f32,
986 horizontal_bounds: SigmaBounds,
987 vertical_bounds: SigmaBounds,
988 ) -> Self {
989 let horizontal = horizontal_bounds.apply(horizontal);
990 Self::from_sigma(horizontal, horizontal, vertical_bounds.apply(vertical))
991 }
992}
993
994impl VelocityNoise<Ned> {
995 /// From a receiver's speed accuracy, `sAcc` — one standard deviation in meters per
996 /// second, applied to every axis.
997 ///
998 /// Isotropic, because that is what the receiver said: `sAcc` is a single scalar
999 /// (u-blox `sAcc`, PX4 `s_variance_m_s`, ArduPilot `speed_accuracy`) and it carries no
1000 /// claim about the vertical axis being worse. Both production estimators nonetheless
1001 /// loosen vertical before fusing — PX4 by exactly 1.5,
1002 /// `Vector3f vel_obs_var(vel_var, vel_var, vel_var * sq(1.5f))`
1003 /// (`EKF/aid_sources/gnss/gps_control.cpp:321`), the ratio it names elsewhere as "a
1004 /// typical ratio of vacc/hacc" (`gnss_height_control.cpp:62`); ArduPilot by flooring
1005 /// the axes differently, `_gpsHorizVelNoise` 0.3 m/s against `_gpsVertVelNoise` 0.5
1006 /// on copter (`AP_NavEKF3_PosVelFusion.cpp:821-822`, `AP_NavEKF3.cpp:23-24`).
1007 ///
1008 /// That is a policy about receivers rather than a property of this fix, so it stays
1009 /// with the caller, who writes it as
1010 /// [`horizontal_vertical`](Self::horizontal_vertical)`(sacc, 1.5 * sacc)` for PX4's
1011 /// shape. `R` describing the measurement is what lets a caller with a better number —
1012 /// a receiver reporting `vAcc` separately, a dual-frequency fix — supply it.
1013 ///
1014 /// Read at PX4-Autopilot `c4e4ef98` (v1.18.0-beta1) and ardupilot `368dc0c4`.
1015 pub fn from_speed_accuracy(speed: f32) -> Self {
1016 Self::from_sigma(speed, speed, speed)
1017 }
1018
1019 /// From separate horizontal and vertical speed accuracies, standard deviations in
1020 /// meters per second.
1021 ///
1022 /// The shape both production estimators fuse in; see
1023 /// [`from_speed_accuracy`](Self::from_speed_accuracy) for the ratios they use and why
1024 /// applying one is the caller's call.
1025 ///
1026 /// Also how a solution with no usable vertical velocity is expressed — the case both
1027 /// platforms gate on a flag, PX4 `vel_ned_valid` (`msg/SensorGps.msg:59`) and
1028 /// ArduPilot `have_vertical_velocity` (`AP_GPS.h:214`). Give the down axis a σ large
1029 /// enough that its Kalman gain is negligible against the vertical velocity
1030 /// uncertainty the filter holds; 1000 m/s is unambiguously that and reads as
1031 /// deliberate. Unbounded for the reason
1032 /// [`PositionNoise::horizontal_vertical`] is — a declined axis is a sentinel, and a
1033 /// cap would turn it back into a measurement.
1034 pub fn horizontal_vertical(horizontal: f32, vertical: f32) -> Self {
1035 Self::from_sigma(horizontal, horizontal, vertical)
1036 }
1037
1038 /// From horizontal and vertical speed accuracies, each held within its own
1039 /// [`SigmaBounds`] in meters per second. An isotropic `sacc` bounded alike on every
1040 /// axis is `clamped(sacc, sacc, b, b)`.
1041 ///
1042 /// Bounded for the reason [`PositionNoise::clamped`] is, and per axis because both
1043 /// platforms treat vertical differently, each in its own way:
1044 ///
1045 /// ```
1046 /// # use fusion_nav::prelude::*;
1047 /// let sacc = 0.1; // a receiver claiming 0.1 m/s
1048 ///
1049 /// // PX4 floors sacc at EKF2_GPS_V_NOISE, caps nothing, and widens vertical by 1.5
1050 /// // after the floor. Floor-then-widen is 1.5·max(sacc, v) = max(1.5·sacc, 1.5·v),
1051 /// // so it is a vertical floor of 1.5·v on a vertical σ of 1.5·sacc.
1052 /// let v = 0.3;
1053 /// let px4 = VelocityNoise::clamped(
1054 /// sacc,
1055 /// 1.5 * sacc,
1056 /// SigmaBounds::at_least(v),
1057 /// SigmaBounds::at_least(1.5 * v),
1058 /// );
1059 /// // 0.1 m/s reads 0.3 / 0.45, where one floor applied after widening reads 0.3 / 0.3.
1060 /// assert_eq!(px4, VelocityNoise::horizontal_vertical(v, 1.5 * v));
1061 ///
1062 /// // ArduPilot floors the one sacc at EK3_VELNE_M_NSE horizontally and EK3_VELD_M_NSE
1063 /// // vertically, under one 50 m/s cap.
1064 /// let (velne, veld) = (0.3, 0.5);
1065 /// let ardupilot = VelocityNoise::clamped(
1066 /// sacc,
1067 /// sacc,
1068 /// SigmaBounds::new(velne, 50.0),
1069 /// SigmaBounds::new(veld, 50.0),
1070 /// );
1071 /// assert_eq!(ardupilot, VelocityNoise::horizontal_vertical(0.3, 0.5));
1072 /// ```
1073 ///
1074 /// PX4: `max(sacc, ekf2_gps_v_noise, 0.01)` and the 1.5 at
1075 /// `EKF/aid_sources/gnss/gps_control.cpp:320-321`. `EKF2_GPS_V_NOISE` is 0.3 m/s by
1076 /// default since 11585dfb67 (`src/modules/ekf2/params_gnss.yaml:68-72`), though the
1077 /// struct's own initializer still reads 0.5 (`EKF/common.h:370`); the corpus flies
1078 /// 0.25–0.5. ArduPilot: `AP_NavEKF3_PosVelFusion.cpp:821-822`, copter defaults at
1079 /// `AP_NavEKF3.cpp:23-24`.
1080 ///
1081 /// Bounds are for axes the receiver measured. A solution with no usable vertical
1082 /// velocity goes through [`horizontal_vertical`](Self::horizontal_vertical), for the
1083 /// reason [`PositionNoise::clamped`] gives.
1084 ///
1085 /// Read at PX4-Autopilot `c4e4ef98` (v1.18.0-beta1) and ardupilot `368dc0c4`.
1086 pub fn clamped(
1087 horizontal: f32,
1088 vertical: f32,
1089 horizontal_bounds: SigmaBounds,
1090 vertical_bounds: SigmaBounds,
1091 ) -> Self {
1092 let horizontal = horizontal_bounds.apply(horizontal);
1093 Self::from_sigma(horizontal, horizontal, vertical_bounds.apply(vertical))
1094 }
1095}
1096
1097impl HeadingNoise {
1098 /// From a receiver's heading accuracy, one standard deviation in radians, held within
1099 /// [`SigmaBounds`] in radians.
1100 ///
1101 /// Bounded for the reason [`PositionNoise::clamped`] is: a moving-baseline receiver's
1102 /// accuracy is its view of its own carrier-phase solution, and both production
1103 /// estimators floor it before fusing.
1104 ///
1105 /// ```
1106 /// # use fusion_nav::prelude::*;
1107 /// let accuracy = 0.005; // a receiver claiming 0.3°
1108 ///
1109 /// // PX4 floors at a hard-coded 0.1 rad, and reads an accuracy of zero, or a NaN one,
1110 /// // as the floor. Here a NaN is refused; pass PX4's zero to have it floored.
1111 /// let px4 = HeadingNoise::clamped(accuracy, SigmaBounds::at_least(0.1));
1112 /// assert_eq!(px4, HeadingNoise::from_sigma(0.1));
1113 ///
1114 /// // ArduPilot floors at 5°, "GPS modules are rather too optimistic about their accuracy".
1115 /// let ardupilot = HeadingNoise::clamped(accuracy, SigmaBounds::at_least(5f32.to_radians()));
1116 /// assert_eq!(ardupilot, HeadingNoise::from_sigma(5f32.to_radians()));
1117 /// ```
1118 ///
1119 /// PX4: `R_YAW = sq(fmaxf(yaw_acc, _params.gnss_heading_noise))` at
1120 /// `EKF/aid_sources/gnss/gnss_yaw_control.cpp:138-139`, `gnss_heading_noise{0.1f}` at
1121 /// `EKF/common.h:390`, with a NaN `yaw_acc` read as zero first. ArduPilot:
1122 /// `AP_NavEKF3_Measurements.cpp:749-750`, and a receiver reporting no accuracy is given
1123 /// 10° upstream (`AP_GPS/AP_GPS.cpp:2025-2030`). A NaN stays NaN here and is refused as
1124 /// [`Fusion::NotFinite`](crate::Fusion::NotFinite): substituting a number for a missing
1125 /// one is the caller's decision, and PX4's is `clamped(0.0, ..)`.
1126 ///
1127 /// Read at PX4-Autopilot `c4e4ef98` (v1.18.0-beta1) and ardupilot `368dc0c4`.
1128 pub fn clamped(sigma: f32, bounds: SigmaBounds) -> Self {
1129 Self::from_sigma(bounds.apply(sigma))
1130 }
1131}
1132
1133#[cfg(test)]
1134mod tests {
1135 use nalgebra::{Matrix3, Rotation3};
1136
1137 use super::*;
1138
1139 /// The difference is taken in integers: two hours in, one 400 Hz interval comes back to
1140 /// the microsecond, where the same subtraction in `f32` seconds, spaced 0.49 ms apart
1141 /// there, is off by 59 µs, and can be by up to a fifth of the interval.
1142 #[test]
1143 fn a_short_interval_late_in_a_long_flight_keeps_its_microseconds() {
1144 let late = Timestamp::from_secs_f64(7127.0);
1145 let next = late.after(Seconds::from_secs(0.0025));
1146 assert_eq!(next.as_micros() - late.as_micros(), 2500);
1147 assert_eq!(next.since(late), Seconds::from_secs(0.0025));
1148 assert_eq!(late.since(next), Seconds::from_secs(-0.0025));
1149 assert_eq!(next.before(Seconds::from_secs(0.0025)), late);
1150
1151 let in_f32 = (7127.0f32 + 0.0025) - 7127.0;
1152 assert!((in_f32 - 0.0025).abs() > 5e-5, "{in_f32}");
1153 }
1154
1155 #[test]
1156 fn a_time_before_the_epoch_or_not_a_number_is_the_epoch() {
1157 assert_eq!(Timestamp::from_secs_f64(-1.0), Timestamp::ZERO);
1158 assert_eq!(Timestamp::from_secs_f64(f64::NAN), Timestamp::ZERO);
1159 assert_eq!(Timestamp::from_secs_f64(1.000_000_4).as_micros(), 1_000_000);
1160 assert_eq!(Timestamp::from_secs_f64(1.000_000_6).as_micros(), 1_000_001);
1161 assert_eq!(
1162 Timestamp::ZERO.before(Seconds::from_secs(1.0)),
1163 Timestamp::ZERO
1164 );
1165 let one = Timestamp::from_micros(1);
1166 assert_eq!(one.after(Seconds::from_secs(f32::NAN)), one);
1167 }
1168
1169 /// Two rotations agreeing to within a tolerance, compared as rotations: a quaternion
1170 /// and its negation are the same attitude, and the conversions below produce whichever
1171 /// sign the multiplication lands on.
1172 fn assert_same_rotation(left: Quaternion, right: UnitQuaternion<f32>) {
1173 let left = unit(left);
1174 assert!(
1175 left.angle_to(&right) < 1.0e-6,
1176 "{left} and {right} differ by {} rad",
1177 left.angle_to(&right)
1178 );
1179 }
1180
1181 /// One physical attitude — nose east, rolled 90° right — written out as the body axes
1182 /// each convention would express it in, in the order (NED from FRD, ENU from FLU, NWU
1183 /// from FLU). The columns of a body-to-navigation rotation are the body axes in the
1184 /// navigation frame, so each triad is that matrix.
1185 ///
1186 /// Deliberately neither level nor axis-aligned in yaw: its quaternion is
1187 /// `(½, ½, ½, ½)`, every component nonzero, so a conversion that rotates only the
1188 /// navigation frame lands somewhere else. At level attitude it would not.
1189 fn nose_east_rolled_right() -> [UnitQuaternion<f32>; 3] {
1190 // Nose east, rolled right through 90°: the body down axis points north and the
1191 // body right axis points at the ground.
1192 let ned_from_frd = Matrix3::from_columns(&[
1193 Vector3::new(0.0, 1.0, 0.0), // forward is east
1194 Vector3::new(0.0, 0.0, 1.0), // right is down
1195 Vector3::new(1.0, 0.0, 0.0), // down is north
1196 ]);
1197 let enu_from_flu = Matrix3::from_columns(&[
1198 Vector3::new(1.0, 0.0, 0.0), // forward is east
1199 Vector3::new(0.0, 0.0, 1.0), // left is up
1200 Vector3::new(0.0, -1.0, 0.0), // up is south
1201 ]);
1202 let nwu_from_flu = Matrix3::from_columns(&[
1203 Vector3::new(0.0, -1.0, 0.0), // forward is east, which is -west
1204 Vector3::new(0.0, 0.0, 1.0), // left is up
1205 Vector3::new(-1.0, 0.0, 0.0), // up is south
1206 ]);
1207 [ned_from_frd, enu_from_flu, nwu_from_flu]
1208 .map(|m| UnitQuaternion::from_rotation_matrix(&Rotation3::from_matrix_unchecked(m)))
1209 }
1210
1211 #[test]
1212 fn every_convention_maps_one_attitude_to_the_same_body_to_ned_quaternion() {
1213 let [ned_from_frd, enu_from_flu, nwu_from_flu] = nose_east_rolled_right();
1214
1215 assert_same_rotation(
1216 Attitude::from_body_to_ned(components(ned_from_frd)).body_to_ned(),
1217 ned_from_frd,
1218 );
1219 assert_same_rotation(
1220 Attitude::from_flu_to_enu(components(enu_from_flu)).body_to_ned(),
1221 ned_from_frd,
1222 );
1223 assert_same_rotation(
1224 Attitude::from_flu_to_nwu(components(nwu_from_flu)).body_to_ned(),
1225 ned_from_frd,
1226 );
1227 }
1228
1229 #[test]
1230 fn a_navigation_only_conversion_keeps_the_heading_and_turns_the_vehicle_over() {
1231 let [ned_from_frd, enu_from_flu, _] = nose_east_rolled_right();
1232
1233 // What rotating only the navigation frame computes: an attitude a whole rotation
1234 // away from the truth, which this attitude shows and a level one does not.
1235 let half_applied = ned_from_enu() * enu_from_flu;
1236 assert!(half_applied.angle_to(&ned_from_frd) > 1.0);
1237
1238 // Level, pointing east. The half-applied form differs by a half turn about body
1239 // forward — the vehicle inverted — and reports the same heading, which is the one
1240 // number a bench check reads.
1241 let level = UnitQuaternion::identity();
1242 let (half_roll, _, half_yaw) = (ned_from_enu() * level).euler_angles();
1243 let (roll, _, yaw) = Attitude::from_flu_to_enu(components(level)).euler_angles();
1244 assert!((half_yaw - yaw).abs() < 1.0e-6);
1245 assert!((half_roll.abs() - core::f32::consts::PI).abs() < 1.0e-6);
1246 assert!(roll.abs() < 1.0e-6);
1247 }
1248
1249 #[test]
1250 fn an_identity_in_each_convention_is_the_attitude_that_convention_calls_level() {
1251 let (roll, pitch, yaw) =
1252 Attitude::from_flu_to_enu(components(UnitQuaternion::identity())).euler_angles();
1253 // ENU pairs east with body forward, so its identity is a vehicle pointing east.
1254 assert!(roll.abs() < 1.0e-6 && pitch.abs() < 1.0e-6);
1255 assert!((yaw - core::f32::consts::FRAC_PI_2).abs() < 1.0e-6);
1256
1257 // NWU pairs north with body forward, so its identity is ours.
1258 assert_same_rotation(
1259 Attitude::from_flu_to_nwu(components(UnitQuaternion::identity())).body_to_ned(),
1260 unit(Attitude::level().body_to_ned()),
1261 );
1262
1263 // And NWU's conversion is a conjugation by a half turn about that shared first
1264 // axis, so a vehicle that is only rolled comes through with its own quaternion.
1265 let rolled = UnitQuaternion::from_euler_angles(0.4, 0.0, 0.0);
1266 assert_same_rotation(
1267 Attitude::from_flu_to_nwu(components(rolled)).body_to_ned(),
1268 rolled,
1269 );
1270 }
1271
1272 #[test]
1273 fn every_convention_leaves_as_it_arrived() {
1274 let [ned_from_frd, enu_from_flu, nwu_from_flu] = nose_east_rolled_right();
1275 let attitude = Attitude::from_body_to_ned(components(ned_from_frd));
1276
1277 assert_same_rotation(attitude.flu_to_enu(), enu_from_flu);
1278 assert_same_rotation(attitude.flu_to_nwu(), nwu_from_flu);
1279 assert_same_rotation(attitude.ned_to_body(), ned_from_frd.inverse());
1280 assert_same_rotation(
1281 Attitude::from_flu_to_enu(attitude.flu_to_enu()).body_to_ned(),
1282 ned_from_frd,
1283 );
1284 assert_same_rotation(
1285 Attitude::from_flu_to_nwu(attitude.flu_to_nwu()).body_to_ned(),
1286 ned_from_frd,
1287 );
1288
1289 // The half-applied way out, the navigation frame alone, is a whole rotation off
1290 // at this attitude.
1291 let half_applied = ned_from_enu().inverse() * ned_from_frd;
1292 assert!(half_applied.angle_to(&enu_from_flu) > 1.0);
1293 }
1294
1295 #[test]
1296 fn a_stored_inverse_is_inverted_rather_than_wrapped() {
1297 let [ned_from_frd, ..] = nose_east_rolled_right();
1298 let frd_from_ned = ned_from_frd.inverse();
1299
1300 assert_same_rotation(
1301 Attitude::from_ned_to_body(components(frd_from_ned)).body_to_ned(),
1302 ned_from_frd,
1303 );
1304 // The error the inversion exists to prevent: still a unit quaternion, still
1305 // finite, and a whole rotation away from the truth.
1306 assert!(frd_from_ned.angle_to(&ned_from_frd) > 1.0);
1307 }
1308
1309 #[test]
1310 fn enu_converts_to_ned_by_permutation() {
1311 let ned = Position::enu(1.0, 2.0, 3.0).to_ned();
1312 assert_eq!(ned, Position::ned(2.0, 1.0, -3.0));
1313 let ned = Velocity::enu(-4.0, 5.0, -6.0).to_ned();
1314 assert_eq!(ned, Velocity::ned(5.0, -4.0, 6.0));
1315 }
1316
1317 #[test]
1318 fn ned_converts_back_to_enu_by_the_same_permutation() {
1319 let enu = Position::ned(2.0, 1.0, -3.0).to_enu();
1320 assert_eq!(enu, Position::enu(1.0, 2.0, 3.0));
1321 let velocity = Velocity::enu(-4.0, 5.0, -6.0);
1322 assert_eq!(velocity.to_ned().to_enu(), velocity);
1323 let acceleration = Acceleration::ned(0.1, -0.2, 0.3);
1324 assert_eq!(acceleration.to_enu().to_ned(), acceleration);
1325 }
1326
1327 #[test]
1328 fn frd_converts_back_to_flu() {
1329 assert_eq!(AngularRate::flu(0.1, 0.2, 0.3).to_flu(), [0.1, 0.2, 0.3]);
1330 assert_eq!(Acceleration::body(0.0, 0.0, -9.8).to_flu(), [0.0, 0.0, 9.8]);
1331 assert_eq!(MagField::flu(1.0, -2.0, 3.0).to_flu(), [1.0, -2.0, 3.0]);
1332 }
1333
1334 #[test]
1335 fn flu_converts_to_frd_by_negating_left_and_up() {
1336 assert_eq!(
1337 AngularRate::flu(0.1, 0.2, 0.3),
1338 AngularRate::body(0.1, -0.2, -0.3)
1339 );
1340 // A level FLU accelerometer at rest reads +g up; FRD reads -g down.
1341 assert_eq!(
1342 Acceleration::flu(0.0, 0.0, 9.8),
1343 Acceleration::body(0.0, 0.0, -9.8)
1344 );
1345 }
1346
1347 #[test]
1348 fn degrees_are_converted_where_they_arrive() {
1349 let rate = AngularRate::body_deg_per_s(180.0, 0.0, -90.0);
1350 assert!((rate.x() - core::f32::consts::PI).abs() < 1e-6);
1351 assert!((rate.z() + core::f32::consts::FRAC_PI_2).abs() < 1e-6);
1352 assert!((Radians::from_degrees(-3.5).as_radians() + 0.061_086).abs() < 1e-6);
1353 }
1354
1355 #[test]
1356 fn sigma_and_variance_name_the_same_noise() {
1357 assert_eq!(
1358 PositionNoise::horizontal_vertical(1.5, 3.0),
1359 PositionNoise::<Ned>::from_variance(2.25, 2.25, 9.0)
1360 );
1361 assert_eq!(
1362 VelocityNoise::from_speed_accuracy(0.5).variance(),
1363 [0.25; 3]
1364 );
1365 assert_eq!(
1366 AltitudeNoise::from_sigma(2.0),
1367 AltitudeNoise::from_variance(4.0)
1368 );
1369 }
1370
1371 fn within(min: f32, max: f32) -> SigmaBounds {
1372 SigmaBounds::new(min, max)
1373 }
1374
1375 #[test]
1376 fn clamping_bounds_a_sigma_on_both_sides() {
1377 // Inside the bounds, the receiver's own number survives untouched.
1378 assert_eq!(
1379 PositionNoise::clamped(1.5, 3.0, within(0.5, 100.0), within(0.5, 100.0)),
1380 PositionNoise::horizontal_vertical(1.5, 3.0)
1381 );
1382 // An optimistic fix is floored, an implausible one capped, per axis.
1383 assert_eq!(
1384 PositionNoise::clamped(0.01, 250.0, within(0.5, 100.0), within(0.5, 100.0)),
1385 PositionNoise::horizontal_vertical(0.5, 100.0)
1386 );
1387 assert_eq!(
1388 VelocityNoise::clamped(0.02, 0.02, within(0.5, 50.0), within(0.5, 50.0)).variance(),
1389 [0.25; 3]
1390 );
1391 // Per axis, so PX4's 1.5 vertical ratio survives the bounds it is applied under.
1392 assert_eq!(
1393 VelocityNoise::clamped(0.4, 0.6, within(0.3, 50.0), within(0.3, 50.0)),
1394 VelocityNoise::horizontal_vertical(0.4, 0.6)
1395 );
1396 // And each axis reads its own bounds: the same σ floored differently per axis.
1397 assert_eq!(
1398 PositionNoise::clamped(0.05, 0.05, within(0.5, 10.0), within(0.75, 10.0)),
1399 PositionNoise::horizontal_vertical(0.5, 0.75)
1400 );
1401 assert_eq!(
1402 VelocityNoise::clamped(0.1, 0.15, SigmaBounds::at_least(0.3), within(0.4, 0.42)),
1403 VelocityNoise::horizontal_vertical(0.3, 0.4)
1404 );
1405 assert_eq!(
1406 VelocityNoise::clamped(80.0, 80.0, within(0.3, 50.0), within(0.5, 60.0)),
1407 VelocityNoise::horizontal_vertical(50.0, 60.0)
1408 );
1409 assert_eq!(
1410 HeadingNoise::clamped(0.0, SigmaBounds::at_least(0.1)),
1411 HeadingNoise::from_sigma(0.1)
1412 );
1413 assert_eq!(
1414 HeadingNoise::clamped(2.0, within(0.1, 1.0)),
1415 HeadingNoise::from_sigma(1.0)
1416 );
1417 assert!(!HeadingNoise::clamped(f32::NAN, within(0.1, 1.0)).is_finite());
1418 }
1419
1420 #[test]
1421 fn a_clamped_nan_stays_nan_rather_than_becoming_a_bound() {
1422 // `f32::max` would return the bound here, and the fix would be fused with an
1423 // invented accuracy instead of refused as Fusion::NotFinite.
1424 let noise = PositionNoise::clamped(f32::NAN, 3.0, within(0.5, 100.0), within(0.5, 100.0));
1425 assert!(noise.variance()[0].is_nan());
1426 assert!(!noise.is_finite());
1427 }
1428
1429 #[test]
1430 fn bounds_the_wrong_way_round_saturate_rather_than_panicking() {
1431 assert_eq!(
1432 VelocityNoise::clamped(1.0, 1.0, within(10.0, 5.0), within(10.0, 5.0)).variance(),
1433 [25.0; 3]
1434 );
1435 }
1436
1437 #[test]
1438 fn a_dropped_vertical_axis_is_a_large_sigma_on_that_axis_alone() {
1439 let noise = VelocityNoise::horizontal_vertical(0.3, 1000.0);
1440 assert_eq!(noise.variance()[0], 0.09);
1441 assert_eq!(noise.variance()[2], 1.0e6);
1442 assert!(noise.is_positive());
1443 }
1444
1445 #[test]
1446 fn components_come_out_as_plain_numbers() {
1447 let p = Position::ned(1.0, 2.0, 3.0);
1448 assert_eq!(p.to_array(), [1.0, 2.0, 3.0]);
1449 assert_eq!((p.x(), p.y(), p.z()), (1.0, 2.0, 3.0));
1450 }
1451}