Skip to main content

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}