Skip to main content

fusion_nav/
health.rs

1//! Propagation and gate outcomes, per-source health, and the aggregate status.
2//!
3//! See [gate lockout](https://github.com/wboayue/fusion-nav/blob/main/EQUATIONS.md#gate-lockout)
4//! for why a source's health is tracked, and what the filter does when one is locked out.
5
6use nalgebra::{SMatrix, SVector};
7
8use crate::config::Timeouts;
9use crate::display::{Decimals, Fixed};
10use crate::units::{Seconds, Timestamp};
11
12/// How far the estimate can be trusted: whether attitude has converged, and how well
13/// aided it is.
14///
15/// Payload-free, so [`State`](crate::State) stays `Copy` and cheap to read on the hot
16/// path. The detail is in [`Diagnostics`].
17///
18/// Declared in order of increasing severity. When more than one applies the most severe
19/// is reported, so [`Aligning`](Self::Aligning) hides [`Degraded`](Self::Degraded) — an
20/// attitude that has not converged is the larger problem — and
21/// [`DeadReckoning`](Self::DeadReckoning) hides everything, since position is unusable
22/// whatever the attitude is doing. So a vehicle waiting on the ground for its first GNSS fix
23/// reads `DeadReckoning` rather than `Aligning`, although its barometer and magnetometer are
24/// arriving: `Aligning` is reachable only with horizontal aiding.
25#[cfg_attr(feature = "defmt", derive(defmt::Format))]
26#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
27pub enum Status {
28    /// Every source that has ever been fused is still being accepted, and attitude has
29    /// converged. The course constraint is not counted: it reads the filter's own velocity
30    /// rather than a sensor; see [`Eskf::fuse_course`](crate::Eskf::fuse_course).
31    Healthy,
32    /// At least one source has timed out against its own
33    /// [`timeout`](SourceHealth::timeout), and horizontal position is still aided.
34    Degraded,
35    /// The filter is running and aided, but its attitude has not converged: it was
36    /// initialized without a static window, seeded coarsely, or started from a window
37    /// with no magnetometer in it, and is still learning.
38    ///
39    /// Position and velocity are being estimated and are usable to the extent the
40    /// covariance says. Attitude is not yet good enough to fly on.
41    ///
42    /// Two ways out, and neither is a timer or an acknowledgement. Tilt and a widened
43    /// yaw leave on their own, as soon as the covariance falls within
44    /// [`ALIGNED_TILT`](crate::ALIGNED_TILT) and [`ALIGNED_HEADING`](crate::ALIGNED_HEADING)
45    /// — fixed bars, not [`Config::accuracy`](crate::Config::accuracy), which is the
46    /// mission's and moves only [`Validity`](crate::Validity). A heading nothing has observed
47    /// needs a measurement instead: stillness never supplies yaw, so a filter that
48    /// started without a magnetometer stays here however small the covariance is, until a
49    /// heading source — [`Eskf::fuse_mag_heading`](crate::Eskf::fuse_mag_heading),
50    /// [`fuse_gnss_heading`](crate::Eskf::fuse_gnss_heading) or
51    /// [`fuse_course`](crate::Eskf::fuse_course) — accepts one. A vehicle with none of the
52    /// three therefore never leaves, and — since this hides
53    /// [`Degraded`](Self::Degraded) — its source timeouts stop showing in `Status` and
54    /// have to be read from [`Diagnostics`].
55    ///
56    /// Leaving is permanent, and that is a claim about this variant rather than about the
57    /// estimate: it reports a start that has not been resolved yet, not attitude quality
58    /// now. Quality now is [`Validity::tilt`](crate::Validity::tilt), which does fall back,
59    /// and which an unaided filter loses within seconds of propagating. See
60    /// [`Eskf::is_aligned`](crate::Eskf::is_aligned) for what re-entering this state cost on
61    /// the corpus.
62    Aligning,
63    /// Neither GNSS position nor GNSS velocity has been accepted within
64    /// [`Timeouts::dead_reckoning_after`], or ever: horizontal position and velocity error grow
65    /// without bound. A barometer or magnetometer still arriving holds height or heading, and
66    /// does not change this; [`Validity`](crate::Validity) says per quantity what is held.
67    #[default]
68    DeadReckoning,
69}
70
71/// One word or two, as an operator log carries it.
72impl core::fmt::Display for Status {
73    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
74        f.write_str(match self {
75            Self::Healthy => "healthy",
76            Self::Degraded => "degraded",
77            Self::Aligning => "aligning",
78            Self::DeadReckoning => "dead reckoning",
79        })
80    }
81}
82
83/// The outcome of one measurement update.
84///
85/// Carries the dimensionless test ratio `r = ε / γ` of equation (38) in both the accepted
86/// and rejected cases, so a rejection is diagnosable rather than a bare failure. `r > 1`
87/// means rejected, whatever the dimension of the observation.
88///
89/// Not `#[must_use]`, unlike [`Propagation`] and the `reset_*` outcomes, because acceptance
90/// and rejection reach the caller by a second route: [`Diagnostics`] keeps the latest test
91/// ratio, the running counts and the timer for every source, so a loop that reads
92/// [`Eskf::state`](crate::Eskf::state) each cycle loses nothing by discarding this value.
93///
94/// The refusals keep their own counters there rather than a lint here:
95/// [`SourceHealth::refused`] and [`SourceHealth::last_refusal`] say how often a source was
96/// turned away and why, and [`SourceHealth::adopted`] separates a [`Reset`](Self::Reset) from
97/// the ordinary acceptance it otherwise looks like. A refusal still moves no timer — it is not
98/// aiding, and pretending otherwise would let a stream of NaN hold off
99/// [`Status::DeadReckoning`].
100#[cfg_attr(feature = "defmt", derive(defmt::Format))]
101#[derive(Clone, Copy, Debug, PartialEq)]
102pub enum Fusion {
103    /// The measurement passed the gate and was fused.
104    Accepted {
105        /// Test ratio, in `[0, 1]`.
106        test_ratio: f32,
107    },
108    /// The measurement was adopted outright rather than fused, because the filter had no
109    /// estimate of that quantity to fuse it against, or none the gate would let it correct.
110    ///
111    /// The second is recovery from gate lockout: a source whose measurements have been
112    /// rejected for longer than [`Recovery`](crate::Recovery) allows has the next one adopted,
113    /// counted in [`SourceHealth::recovered`]. For the barometer the adoption is of its
114    /// reference `α₀` rather than of any state, so nothing the estimate reports steps.
115    ///
116    /// The first happens once per quantity, and for three of them. Position and velocity are
117    /// adopted on the first GNSS fix after a coarse start: a vehicle that initialized
118    /// while moving knows neither where it is nor how fast it is going, and no gate can
119    /// judge a measurement against nothing. A static start and a seed both have an
120    /// estimate of those two, so their first fix is fused normally, or, given as latitude
121    /// and longitude, places the origin under that estimate; see
122    /// [`Eskf::fuse_gnss_geodetic`](crate::Eskf::fuse_gnss_geodetic).
123    ///
124    /// Heading is the third, and it is not a coarse start's alone: stillness observes
125    /// tilt and never yaw, so a static window carrying no magnetometer leaves yaw a
126    /// prior nothing measured, and the first heading from
127    /// [`fuse_mag_heading`](crate::Eskf::fuse_mag_heading),
128    /// [`fuse_gnss_heading`](crate::Eskf::fuse_gnss_heading) or
129    /// [`fuse_course`](crate::Eskf::fuse_course) is adopted there too. Only a seed escapes,
130    /// having vouched for every quantity.
131    ///
132    /// The state becomes the measurement and its covariance block becomes the
133    /// measurement's, which is what fusing against an infinitely uncertain prior
134    /// converges to — the limit, taken exactly rather than approached with an invented
135    /// variance.
136    ///
137    /// Worth reacting to: position, velocity or **attitude** stepped, which a controller
138    /// consuming the estimate may care about. Heading is the largest of the three — an
139    /// adopted heading can turn the estimate by half a circle, where an adopted position
140    /// moves a quantity no controller was flying on.
141    Reset,
142    /// The measurement failed the gate and was discarded. The state is unchanged.
143    Rejected {
144        /// Test ratio, greater than 1.
145        test_ratio: f32,
146    },
147    /// No initialization has succeeded — [`Eskf::initialize`](crate::Eskf::initialize),
148    /// `initialize_coarse` or `initialize_from` — so there is no state to fuse against. The measurement was discarded.
149    NotInitialized,
150    /// The measurement has nothing to be relative to. The measurement was discarded.
151    ///
152    /// Barometer: no reference altitude `α₀` is held, and position is not yet established to
153    /// read one against — a start in motion before its first fix — or
154    /// [`Config::baro_reference_from_estimate`](crate::Config::baro_reference_from_estimate)
155    /// is off. Fusing it anyway would invent the origin from whichever sample happened to
156    /// arrive first. [`Eskf::set_baro_reference`](crate::Eskf::set_baro_reference) names one.
157    ///
158    /// Geodetic GNSS: no navigation origin is held and this fix cannot place one, because
159    /// its latitude is beyond ±90°. The next usable fix will. See
160    /// [`Eskf::fuse_gnss_geodetic`](crate::Eskf::fuse_gnss_geodetic).
161    ///
162    /// Course: no GNSS velocity has been accepted within its own
163    /// [`timeout`](SourceHealth::timeout), so the velocity the
164    /// course would be taken along is dead-reckoned or was never established. Usually a call
165    /// made before the first [`Eskf::fuse_gnss_velocity`](crate::Eskf::fuse_gnss_velocity), or
166    /// after the receiver stopped; see [`Eskf::fuse_course`](crate::Eskf::fuse_course).
167    NoReference,
168    /// The geometry says nothing about the quantity measured. The measurement was discarded
169    /// and no health timer moved.
170    ///
171    /// A heading from [`Eskf::fuse_gnss_heading`](crate::Eskf::fuse_gnss_heading) or
172    /// [`Eskf::fuse_course`](crate::Eskf::fuse_course), in two ways. The vehicle's forward axis
173    /// is within 30° of vertical, where the heading of that axis is undefined, and a tailsitter
174    /// hovering is the case: PX4 refuses a GNSS yaw reset on the same bar
175    /// (`EKF/aid_sources/gnss/gnss_yaw_control.cpp:221` at `c4e4ef98`). Or, for the course, the
176    /// vehicle is not moving fast enough for the direction of its estimated velocity to mean
177    /// anything: a cross-track uncertainty over 15° of course, which is a hover or a slow taxi.
178    /// Fusing either would hand the gate an angle drawn from noise.
179    ///
180    /// Not the magnetometer's: (34) levels the field rather than reading the heading of body
181    /// x, and (36′) prices the leveling at any tilt, so a magnetic heading is fused through
182    /// 125° of tilt on `285ee2e7`.
183    Unobservable,
184    /// A number in the measurement or its noise is NaN or infinite. The measurement was
185    /// discarded and no health timer moved.
186    ///
187    /// Refused before anything else, including the adoption of a quantity initialization
188    /// left unestablished: one NaN in the state or covariance spreads to every quantity at
189    /// the next update and never leaves.
190    NotFinite,
191    /// A variance in the measurement noise is zero or negative. The measurement was
192    /// discarded and no health timer moved.
193    ///
194    /// `R` has to be a variance some sensor could have. Zero makes the innovation
195    /// covariance `S = H P Hᵀ + R` of equation (24) singular as soon as the state it
196    /// observes is itself certain, and a negative one is worse: it claims a measurement
197    /// better than perfect, and where the measurement is adopted outright it writes that
198    /// negative variance straight into `P`, which
199    /// [`Validity`] then reads as an excellent estimate.
200    ///
201    /// Usually a floor or a unit mistake at the boundary — a receiver reporting `eph = 0`
202    /// while it has no fix, or a variance arrived at by subtracting one σ² from another.
203    /// Refused alongside [`NotFinite`](Self::NotFinite), before any adoption.
204    ///
205    /// One bad component refuses the measurement it belongs to. A GNSS fix is two, so a
206    /// receiver in 2D-fix mode reporting a good `eph` with `epv = 0` keeps its horizontal
207    /// aiding and has its height refused; see [`GnssFusion`]. The filter reports rather than
208    /// repairs: PX4 and ArduPilot clamp such a value into range, and a caller that wants that
209    /// behavior floors `noise` before the call, where the policy is visible. See
210    /// [`Eskf::fuse_gnss_position`](crate::Eskf::fuse_gnss_position).
211    InvalidNoise,
212    /// The filter's own numbers could not support an update, so nothing was committed. The
213    /// state and covariance are unchanged, no health timer moved, and the measurement is not
214    /// at fault.
215    ///
216    /// Two ways to get here, one meaning. The innovation covariance `S = H P Hᵀ + R` of
217    /// equation (24) was not positive-definite, which with a positive `R` means `P` itself had
218    /// stopped being a covariance; or the correction of (26) and (39)–(41) overflowed f32 on
219    /// finite inputs, as (11)–(14) can. Either way a caller learns the same thing: the
220    /// estimate, not the sensor, is what needs attention.
221    ///
222    /// `State`-qualified by the rule [`Propagation::StateNotFinite`] follows, and not merged
223    /// into it: [`NotFinite`](Self::NotFinite) is always the input handed in, and this is what
224    /// the filter produced. Kept out of [`Rejected`](Self::Rejected) because the gate never gave
225    /// a verdict — a rejection says the measurement disagreed with a sound estimate, and
226    /// counting this as one would put a filter fault in the column that times out a sensor.
227    StateInvalid,
228    /// The measurement's time is one the filter cannot place it at: older than
229    /// [`LATENCY_HORIZON`](crate::LATENCY_HORIZON), later than the state by more than
230    /// [`Config::max_predict_dt`](crate::Config::max_predict_dt), or before a start that did not
231    /// show the vehicle at rest, whose motion before it nothing describes. The measurement was
232    /// discarded and no health timer moved.
233    ///
234    /// Inside those bounds the measurement is fused at its own time, equation (23′): against
235    /// the state as it was, or, for one timed between the last IMU sample and the next, as it
236    /// will be, carried forward on the estimated velocity and the last sample's rate.
237    ///
238    /// Fused anyway, an old measurement is a statement about where the vehicle was, applied to
239    /// where it is: at 20 m/s a fix a second old is 20 m of error handed to the gate as truth.
240    /// One later than the state is a clock the IMU and the sensor do not share, or a sample
241    /// the caller has not yet handed to [`predict`](crate::Eskf::predict). Either way it is the
242    /// timestamp that is wrong, which is a refusal rather than a verdict on the value.
243    OutOfHorizon {
244        /// How long before the state the measurement was taken, negative for one after it:
245        /// what tells a latency longer than the horizon from a clock with another epoch.
246        age: Seconds,
247    },
248}
249
250impl Fusion {
251    /// Whether the filter took the measurement, whether by fusing it or by adopting it
252    /// outright. See [`is_reset`](Self::is_reset) to tell those apart.
253    pub const fn is_accepted(self) -> bool {
254        matches!(self, Self::Accepted { .. } | Self::Reset)
255    }
256
257    /// Whether the measurement replaced the estimate rather than correcting it, stepping
258    /// the state.
259    pub const fn is_reset(self) -> bool {
260        matches!(self, Self::Reset)
261    }
262
263    /// Why the measurement was turned away, or `None` if it reached the gate.
264    ///
265    /// [`Reset`](Self::Reset) is not a refusal: the measurement was taken, just adopted rather
266    /// than fused.
267    pub const fn refusal(self) -> Option<Refusal> {
268        match self {
269            Self::NotInitialized => Some(Refusal::NotInitialized),
270            Self::NoReference => Some(Refusal::NoReference),
271            Self::Unobservable => Some(Refusal::Unobservable),
272            Self::NotFinite => Some(Refusal::NotFinite),
273            Self::InvalidNoise => Some(Refusal::InvalidNoise),
274            Self::StateInvalid => Some(Refusal::StateInvalid),
275            Self::OutOfHorizon { .. } => Some(Refusal::OutOfHorizon),
276            Self::Accepted { .. } | Self::Rejected { .. } | Self::Reset => None,
277        }
278    }
279
280    /// The test ratio, where the gate ran at all.
281    pub const fn test_ratio(self) -> Option<f32> {
282        match self {
283            Self::Accepted { test_ratio } | Self::Rejected { test_ratio } => Some(test_ratio),
284            // A reset ran no gate: there was nothing to be inconsistent with.
285            Self::Reset
286            | Self::NotInitialized
287            | Self::NoReference
288            | Self::Unobservable
289            | Self::NotFinite
290            | Self::InvalidNoise
291            | Self::StateInvalid
292            | Self::OutOfHorizon { .. } => None,
293        }
294    }
295}
296
297/// The verdict without the source, which the outcome does not know: `"gnss position {}"`
298/// reads `gnss position rejected, ratio 2.70`. A refusal carries its [`Refusal`]'s words,
299/// and [`Reset`](Self::Reset) reads `adopted`, which is what it did.
300impl core::fmt::Display for Fusion {
301    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
302        match *self {
303            Self::Accepted { test_ratio } => write!(f, "accepted, ratio {}", ratio(test_ratio)),
304            Self::Reset => f.write_str("adopted"),
305            Self::Rejected { test_ratio } => write!(f, "rejected, ratio {}", ratio(test_ratio)),
306            Self::NotInitialized => write!(f, "refused, {}", Refusal::NotInitialized),
307            Self::NoReference => write!(f, "refused, {}", Refusal::NoReference),
308            Self::Unobservable => write!(f, "refused, {}", Refusal::Unobservable),
309            Self::NotFinite => write!(f, "refused, {}", Refusal::NotFinite),
310            Self::InvalidNoise => write!(f, "refused, {}", Refusal::InvalidNoise),
311            Self::StateInvalid => write!(f, "refused, {}", Refusal::StateInvalid),
312            Self::OutOfHorizon { age } => write!(
313                f,
314                "refused, {}, age {} s",
315                Refusal::OutOfHorizon,
316                seconds(age)
317            ),
318        }
319    }
320}
321
322/// A test ratio to two decimals: the gate's bar is 1, so a third digit says nothing an
323/// operator acts on.
324fn ratio(test_ratio: f32) -> Fixed {
325    Fixed::new(test_ratio, Decimals::Two)
326}
327
328/// What one GNSS position fix did: its horizontal and vertical halves, each gated and
329/// reported on its own.
330///
331/// A fix carries two measurements that fail independently. A receiver's height wanders further than
332/// its horizontal position, and it disagrees with a barometer about height without either saying
333/// anything about north or east — so a single verdict over all three axes turns a height
334/// disagreement into lost horizontal aiding, 3945 fixes of 4616 on one corpus log ([evidence]).
335/// Both production estimators split the same way: PX4 runs GNSS position as a two-dimensional
336/// source and GNSS height as a one-dimensional one (`_aid_src_gnss_pos` and `_aid_src_gnss_hgt`,
337/// `src/modules/ekf2/EKF/ekf.h:621-622` at `c4e4ef98e9`), and ArduPilot tests the horizontal pair
338/// and the height separately (`libraries/AP_NavEKF3/AP_NavEKF3_PosVelFusion.cpp:904-906` and
339/// `:1023` at `368dc0c428`).
340///
341/// Each half is a [`Fusion`] with its own gate in [`Gates`](crate::Gates) and its own
342/// [`SourceHealth`] in [`Diagnostics`]: [`gnss_position`](Diagnostics::gnss_position) for
343/// the horizontal pair, [`gnss_height`](Diagnostics::gnss_height) for the vertical. The
344/// halves can disagree: a number unusable in one half refuses that half alone, so a 2D fix
345/// with `epv = 0` reads `horizontal: Accepted` beside `height: InvalidNoise`. What reads the
346/// same in both is what concerns the fix as a whole — an adoption, an origin placed, a filter
347/// not initialized, and any refusal on those paths, which write all three axes at once.
348///
349/// [evidence]: https://github.com/wboayue/fusion-nav/blob/main/DESIGN.md#gates
350#[cfg_attr(feature = "defmt", derive(defmt::Format))]
351#[derive(Clone, Copy, Debug, PartialEq)]
352pub struct GnssFusion {
353    /// North and east, gated at [`Gates::gnss_position`](crate::Gates::gnss_position).
354    pub horizontal: Fusion,
355    /// Down, gated at [`Gates::gnss_height`](crate::Gates::gnss_height).
356    pub height: Fusion,
357}
358
359impl GnssFusion {
360    /// The same outcome for both halves: a refusal, an adoption, an origin placed.
361    pub(crate) const fn both(fusion: Fusion) -> Self {
362        Self {
363            horizontal: fusion,
364            height: fusion,
365        }
366    }
367
368    /// Whether the filter took both halves, by fusing or adopting them. See
369    /// [`Fusion::is_accepted`].
370    pub const fn is_accepted(self) -> bool {
371        self.horizontal.is_accepted() && self.height.is_accepted()
372    }
373
374    /// Whether the fix replaced the position estimate rather than correcting it. Both halves
375    /// or neither: a fix is adopted whole.
376    pub const fn is_reset(self) -> bool {
377        self.horizontal.is_reset()
378    }
379}
380
381/// One verdict when the halves agree, as they do for every outcome that concerns the fix
382/// whole, and both named when they do not.
383impl core::fmt::Display for GnssFusion {
384    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
385        if self.horizontal == self.height {
386            write!(f, "{}", self.horizontal)
387        } else {
388            write!(f, "horizontal {}; height {}", self.horizontal, self.height)
389        }
390    }
391}
392
393/// Why a measurement was turned away before the gate ran.
394///
395/// The cases [`Fusion`] reports that are not a verdict on the measurement's *value*: the
396/// filter could not form an innovation to judge it against, or the numbers offered were not
397/// ones any sensor could produce. Kept on [`SourceHealth::last_refusal`] so that a count of
398/// refusals says which kind, which is the difference between a miswired sensor and a missing
399/// reference.
400#[cfg_attr(feature = "defmt", derive(defmt::Format))]
401#[derive(Clone, Copy, Debug, PartialEq, Eq)]
402pub enum Refusal {
403    /// The filter had not been initialized. See [`Fusion::NotInitialized`].
404    NotInitialized,
405    /// Nothing to measure against: no barometric reference, no origin the fix could place, or
406    /// no held velocity for a course.
407    /// See [`Fusion::NoReference`].
408    NoReference,
409    /// The geometry observed nothing: a forward axis near vertical, or too little speed for a
410    /// course. See [`Fusion::Unobservable`].
411    Unobservable,
412    /// A number in the measurement or its noise was NaN or infinite. See
413    /// [`Fusion::NotFinite`].
414    NotFinite,
415    /// A variance in the measurement noise was zero or negative. See
416    /// [`Fusion::InvalidNoise`].
417    InvalidNoise,
418    /// The filter's own covariance or correction could not support an update. See
419    /// [`Fusion::StateInvalid`].
420    StateInvalid,
421    /// The measurement's time was outside what the filter can place it at. See
422    /// [`Fusion::OutOfHorizon`].
423    OutOfHorizon,
424}
425
426impl core::fmt::Display for Refusal {
427    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
428        f.write_str(match self {
429            Self::NotInitialized => "filter not initialized",
430            Self::NoReference => "no reference to measure against",
431            Self::Unobservable => "geometry observes nothing",
432            Self::NotFinite => "measurement or noise not finite",
433            Self::InvalidNoise => "noise variance not positive",
434            Self::StateInvalid => "filter state cannot support an update",
435            Self::OutOfHorizon => "measurement time outside the horizon",
436        })
437    }
438}
439
440/// The outcome of one propagation step.
441///
442/// Returned by [`Eskf::predict`](crate::Eskf::predict), which refuses a step it cannot
443/// take — a sample that is not a number, or a `dt` longer than
444/// [`Config::max_predict_dt`](crate::Config::max_predict_dt) with [`Config::coast`](crate::Config::coast)
445/// off — rather than attempting it.
446#[must_use = "a refused propagation leaves the state stale unless the outcome is inspected"]
447#[cfg_attr(feature = "defmt", derive(defmt::Format))]
448#[derive(Clone, Copy, Debug, PartialEq)]
449pub enum Propagation {
450    /// The state and covariance advanced across the sample.
451    Propagated,
452    /// `dt` exceeded [`Config::max_predict_dt`](crate::Config::max_predict_dt), so the sample
453    /// was not integrated and the filter coasted across the gap instead. Equation (22′).
454    ///
455    /// The state and covariance advanced, but on an assumption rather than a measurement:
456    /// position moved on the estimated velocity and nothing else did, while the covariance
457    /// grew by what [`Coast`](crate::Coast) allows an unmeasured interval. So
458    /// [`is_propagated`](Self::is_propagated) reads false, and a caller logging gaps sees this
459    /// one. Counted in [`PropagationHealth::coasted`], not as a refusal.
460    Coasted {
461        /// The gap coasted across.
462        dt: Seconds,
463    },
464    /// `dt` exceeded [`Config::max_predict_dt`](crate::Config::max_predict_dt) and
465    /// [`Config::coast`](crate::Config::coast) is off. The state and covariance are unchanged.
466    ///
467    /// The per-source timers still advanced: the time really did pass, so the aiding
468    /// really is that much staler, and [`Status`] must not claim otherwise.
469    StepTooLong {
470        /// The `dt` offered.
471        dt: Seconds,
472        /// The configured limit.
473        limit: Seconds,
474    },
475    /// The sample's [`time`](crate::ImuSample::time) was not after the filter's: `dt` is
476    /// zero or negative. Nothing was propagated, no timer advanced and the filter's clock
477    /// did not move.
478    ///
479    /// Rejected before the bookkeeping rather than after: a negative `dt` would run the
480    /// per-source timers backwards and make stale aiding look fresh. Zero is included
481    /// because two IMU samples sharing a timestamp means duplicated data, which is worth
482    /// knowing about even though propagating over it would be harmless.
483    InvalidStep {
484        /// The time from the filter's clock to the sample's.
485        dt: Seconds,
486    },
487    /// An integration interval in the [`ImuSample`](crate::ImuSample) was under a microsecond,
488    /// or longer than [`Config::max_predict_dt`](crate::Config::max_predict_dt), which one
489    /// sample does not describe any more than a gap that long. The state and covariance are
490    /// unchanged; the timers and the clock advanced, as under [`NotFinite`](Self::NotFinite) and
491    /// for its reason.
492    ///
493    /// Refused because the interval scales what the increment is corrected by and what
494    /// (21) adds: a negative one subtracts process noise and lands a variance below zero,
495    /// which [`Validity`] reads as an estimate better than any the filter could have. An
496    /// interval that is not a number is [`NotFinite`](Self::NotFinite).
497    InvalidInterval {
498        /// The interval offered.
499        interval: Seconds,
500    },
501    /// A number in the [`ImuSample`](crate::ImuSample) is NaN or infinite. The state and
502    /// covariance are unchanged.
503    ///
504    /// The timers advanced, as under [`StepTooLong`](Self::StepTooLong) and for the same
505    /// reason: the `dt` was usable, so the time really did pass and the aiding really is
506    /// that much staler. Only the sample was unusable. Holding them back instead would
507    /// stop every source timing out, and an IMU emitting NaN would leave [`Status`]
508    /// reporting [`Healthy`](Status::Healthy) on aiding that had long since stopped
509    /// arriving.
510    ///
511    /// Refused rather than propagated because a non-finite sample cannot be noticed
512    /// afterwards: it reaches `Exp(φ)` of equation (15), whose small-angle test is false
513    /// for NaN, so the composition yields a `UnitQuaternion` that is not a unit
514    /// quaternion, and it reaches velocity and position through (12)–(14) and `F` through
515    /// (16)–(19). One NaN in `P` never leaves.
516    ///
517    /// ArduPilot refuses non-finite measurements at intake the same way
518    /// (`libraries/AP_NavEKF3/AP_NavEKF3_Measurements.cpp:116-119`, at `368dc0c4`). PX4
519    /// does not: `EstimatorInterface::setIMUData` constrains the integration period and
520    /// never tests `delta_ang` or `delta_vel`
521    /// (`src/modules/ekf2/EKF/estimator_interface.cpp:82-110`, at `c4e4ef98`).
522    ///
523    /// This is the **input**, which is what `NotFinite` means wherever it appears —
524    /// [`Fusion::NotFinite`], [`InitError::NotFinite`](crate::InitError::NotFinite),
525    /// [`Refusal::NotFinite`]. What propagation *produced* is
526    /// [`StateNotFinite`](Self::StateNotFinite).
527    NotFinite,
528    /// The propagated state carried a NaN or an infinity, so it was not committed. The
529    /// state and covariance are unchanged and the timers advanced.
530    ///
531    /// Distinct from [`NotFinite`](Self::NotFinite), which is the sample arriving: this is a
532    /// finite sample overflowing on the way through (11)–(14) or (22). `a_n = R(q̂) a_b + g` and
533    /// `F P Fᵀ` are both products of finite numbers, and f32 has a finite range, so a sensor
534    /// reporting a plausible-looking `1e38` — or a covariance already wide enough that one more
535    /// step leaves the range — is refused here rather than at intake. Either half failing
536    /// discards both, since a state committed beside the covariance of a different step reports
537    /// an uncertainty that describes something else.
538    ///
539    /// Refused rather than committed for the reason a NaN sample is: an infinity in the
540    /// state reaches the quaternion through (15), the covariance through (16)–(22), and
541    /// every estimate after it, with nothing downstream able to tell that it was ever a
542    /// number. The previous state is stale, and a stale state the filter admits to is
543    /// recoverable where a poisoned one it reports as an estimate is not.
544    StateNotFinite,
545    /// No initialization has succeeded; see [`Fusion::NotInitialized`]. Nothing was
546    /// propagated and no timer advanced.
547    NotInitialized,
548}
549
550impl Propagation {
551    /// Whether the sample was integrated. A [`Coasted`](Self::Coasted) step advanced the state
552    /// without it, and reads false.
553    pub const fn is_propagated(self) -> bool {
554        matches!(self, Self::Propagated)
555    }
556}
557
558impl core::fmt::Display for Propagation {
559    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
560        match *self {
561            Self::Propagated => f.write_str("propagated"),
562            Self::Coasted { dt } => write!(f, "gap of {} s coasted", seconds(dt)),
563            Self::StepTooLong { dt, limit } => write!(
564                f,
565                "step of {} s over the {} s limit, not propagated",
566                seconds(dt),
567                seconds(limit)
568            ),
569            Self::InvalidStep { dt } => {
570                write!(f, "step of {} s not usable, not propagated", seconds(dt))
571            }
572            Self::InvalidInterval { interval } => write!(
573                f,
574                "imu interval of {} s not usable, not propagated",
575                seconds(interval)
576            ),
577            Self::NotFinite => f.write_str("imu sample not finite, not propagated"),
578            Self::StateNotFinite => f.write_str("propagated state not finite, not committed"),
579            Self::NotInitialized => f.write_str("filter not initialized, not propagated"),
580        }
581    }
582}
583
584/// A duration to the millisecond, the resolution an IMU interval is read at.
585fn seconds(value: Seconds) -> Fixed {
586    Fixed::new(value.as_secs(), Decimals::Three)
587}
588
589/// Which parts of the estimate are good enough to use.
590///
591/// The single [`Status`] answers *how bad is the worst thing*; this answers *which
592/// outputs can I use*, which is the question a controller actually has. They are
593/// independent axes, and collapsing them loses real information: a filter that started
594/// coarse and has adopted a GNSS fix has position as good as the receiver while its
595/// attitude is still converging, and `Status::Aligning` alone cannot say so.
596///
597/// Every flag is derived from the covariance against
598/// [`Config::accuracy`](crate::Config::accuracy), plus the requirement that the quantity
599/// was ever established at all — a coarse start has no position until a fix arrives, a
600/// window with no magnetometer has no heading until a heading source is fused, and a tight prior on a
601/// number nobody set is not validity. Nor is a covariance the position hold bounded: horizontal
602/// position and velocity stay invalid after one until measured again; see
603/// [`Hold`](crate::Hold).
604///
605/// Horizontal and vertical are separate because sources are: a vehicle with a barometer and no GNSS
606/// has a usable height and no horizontal position at all, as one log in the replay corpus does.
607/// Another carries neither source, so it has no position of either kind.
608#[cfg_attr(feature = "defmt", derive(defmt::Format))]
609#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
610pub struct Validity {
611    /// Tilt: rotation about north and about east, each within
612    /// [`Accuracy::tilt`](crate::Accuracy::tilt). Read on navigation axes rather than body
613    /// ones, which are tilt only while the vehicle is level; see
614    /// [`AttitudeVariance`](crate::AttitudeVariance).
615    pub tilt: bool,
616    /// Heading. False until something observes the rotation about gravity: a
617    /// magnetometer in the initialization window, or an accepted
618    /// [`Eskf::fuse_mag_heading`](crate::Eskf::fuse_mag_heading),
619    /// [`fuse_gnss_heading`](crate::Eskf::fuse_gnss_heading) or
620    /// [`fuse_course`](crate::Eskf::fuse_course). Stillness does not
621    /// observe it, so a perfect static alignment on a vehicle with no magnetometer
622    /// reports `tilt` and not this.
623    pub heading: bool,
624    /// North and east position.
625    pub horizontal_position: bool,
626    /// Down position.
627    pub vertical_position: bool,
628    /// North and east velocity.
629    pub horizontal_velocity: bool,
630    /// Down velocity.
631    pub vertical_velocity: bool,
632}
633
634impl Validity {
635    /// Nothing is valid. What an uninitialized filter reports.
636    pub const NONE: Self = Self {
637        tilt: false,
638        heading: false,
639        horizontal_position: false,
640        vertical_position: false,
641        horizontal_velocity: false,
642        vertical_velocity: false,
643    };
644
645    /// Whether every part of the estimate is usable.
646    pub const fn all(self) -> bool {
647        self.tilt
648            && self.heading
649            && self.horizontal_position
650            && self.vertical_position
651            && self.horizontal_velocity
652            && self.vertical_velocity
653    }
654
655    /// Whether attitude is usable: the part a stabilizing controller needs before
656    /// anything else.
657    pub const fn attitude(self) -> bool {
658        self.tilt && self.heading
659    }
660
661    /// Whether the full navigation solution — position and velocity, both axes — is
662    /// usable.
663    pub const fn navigation(self) -> bool {
664        self.horizontal_position
665            && self.vertical_position
666            && self.horizontal_velocity
667            && self.vertical_velocity
668    }
669}
670
671/// Six flags in the order the struct declares them, a letter where the quantity is usable and
672/// `-` where it is not: `att:T- pos:HV vel:HV` is tilt without heading, position and velocity
673/// on both axes. The shape PX4's `*_valid` flags and ArduPilot's `nav_filter_status` bits
674/// are read in, and short enough for every line of a 1 Hz log.
675impl core::fmt::Display for Validity {
676    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
677        let flag = |valid: bool, letter: &'static str| if valid { letter } else { "-" };
678        write!(
679            f,
680            "att:{}{} pos:{}{} vel:{}{}",
681            flag(self.tilt, "T"),
682            flag(self.heading, "H"),
683            flag(self.horizontal_position, "H"),
684            flag(self.vertical_position, "V"),
685            flag(self.horizontal_velocity, "H"),
686            flag(self.vertical_velocity, "V"),
687        )
688    }
689}
690
691/// The innovation of one update and its variance: `ν` of equation (23) and the diagonal of
692/// `S` of equation (24), one entry per component of the observation.
693///
694/// The diagonal rather than the whole of `S`, which is what PX4 publishes too
695/// (`estimator_innovations` and `estimator_innovation_variances`): it is what a per-axis plot
696/// of `ν` against `±3√S` reads. The joint `ε = νᵀ S⁻¹ ν` the gate tests is
697/// [`test_ratio`](SourceHealth::test_ratio) times the source's threshold in
698/// [`Gates`](crate::Gates), so it is not repeated here.
699///
700/// Room for three components, the largest observation this crate makes; a scalar source fills
701/// one.
702#[derive(Clone, Copy, Debug, Default, PartialEq)]
703pub struct Innovation {
704    nu: [f32; 3],
705    variance: [f32; 3],
706    dimension: usize,
707}
708
709/// The components the observation has, not the three the storage holds.
710#[cfg(feature = "defmt")]
711impl defmt::Format for Innovation {
712    fn format(&self, f: defmt::Formatter<'_>) {
713        defmt::write!(
714            f,
715            "Innovation {{ values: {}, variances: {} }}",
716            self.values(),
717            self.variances()
718        )
719    }
720}
721
722impl Innovation {
723    /// Copy `y` and the diagonal of `S` out of one update.
724    pub(crate) fn new<const M: usize>(y: &SVector<f32, M>, s: &SMatrix<f32, M, M>) -> Self {
725        let mut innovation = Self {
726            dimension: M.min(3),
727            ..Self::default()
728        };
729        for (slot, value) in innovation.nu.iter_mut().zip(y.iter()) {
730            *slot = *value;
731        }
732        for (slot, value) in innovation.variance.iter_mut().zip(s.diagonal().iter()) {
733            *slot = *value;
734        }
735        innovation
736    }
737
738    /// `ν = z − h(x̂)`, one entry per component, in the observation's own frame and units.
739    pub fn values(&self) -> &[f32] {
740        self.nu.get(..self.dimension).unwrap_or_default()
741    }
742
743    /// The diagonal of `S = H P Hᵀ + R`, the variance of each entry of
744    /// [`values`](Self::values).
745    pub fn variances(&self) -> &[f32] {
746        self.variance.get(..self.dimension).unwrap_or_default()
747    }
748}
749
750/// Health of one observation source.
751///
752/// `#[non_exhaustive]`: read the fields, do not construct one. The set grows as the filter
753/// learns to report more, and every addition would otherwise break every caller.
754#[cfg_attr(feature = "defmt", derive(defmt::Format))]
755#[derive(Clone, Copy, Debug, Default, PartialEq)]
756#[non_exhaustive]
757pub struct SourceHealth {
758    /// Test ratio of the most recent measurement **that reached the gate**, or `None` if none
759    /// has.
760    ///
761    /// A refusal leaves it alone: there was no innovation to normalize, so the alternative is
762    /// inventing a ratio or discarding the last real one. It can therefore read healthy while
763    /// [`refused`](Self::refused) climbs — a receiver that starts reporting `eph = 0` freezes
764    /// this at its last good value — so a consumer plotting it reads
765    /// [`last_refusal`](Self::last_refusal) beside it.
766    pub test_ratio: Option<f32>,
767    /// Innovation `ν` and its variance, from the most recent update the gate ran, whether it
768    /// accepted or rejected; `None` if the last measurement that set [`test_ratio`] ran no
769    /// update, and until one has.
770    ///
771    /// Published so that a consistency statistic — NIS against the gate, or a comparison with
772    /// the innovations PX4 logs — reads the filter's own number rather than recomputing it from
773    /// the measurement and the covariance, which would be a second implementation of (23) and
774    /// (24) free to disagree with this one. `None` after an acceptance that ran no update: an
775    /// adoption ([`Fusion::Reset`]) or a geodetic fix spent placing the origin.
776    ///
777    /// [`test_ratio`]: Self::test_ratio
778    pub innovation: Option<Innovation>,
779    /// Time since a measurement from this source was last accepted, or `None` if none
780    /// ever has been. Advances with the `dt` passed to
781    /// [`Eskf::predict`](crate::Eskf::predict).
782    pub time_since_accepted: Option<Seconds>,
783    /// Rejections since the last acceptance.
784    pub consecutive_rejections: u16,
785    /// Measurements accepted over the filter's life.
786    pub accepted: u32,
787    /// Measurements rejected over the filter's life.
788    pub rejected: u32,
789    /// Measurements turned away before the gate ran, over the filter's life.
790    ///
791    /// Distinct from [`rejected`](Self::rejected), which is the gate's verdict on a
792    /// measurement it could judge. A refusal means it could not be judged at all, and a
793    /// source with a rising count here is misconfigured or miswired rather than noisy: a
794    /// source that only ever refuses reads as `never accepted`, exactly like one that was
795    /// never connected.
796    pub refused: u32,
797    /// Why the most recent refusal happened, or `None` if none has.
798    pub last_refusal: Option<Refusal>,
799    /// Measurements adopted outright rather than fused, over the filter's life.
800    ///
801    /// For a quantity initialization left unobserved, once, and for every
802    /// [`recovered`](Self::recovered) lockout; see [`Fusion::Reset`]. Also counted in
803    /// [`accepted`](Self::accepted), because the measurement was taken and the timer restarted
804    /// — this is what tells the two apart, since an adoption steps the state and an ordinary
805    /// acceptance does not.
806    pub adopted: u32,
807    /// Adoptions that recovered from gate lockout, over the filter's life: a measurement the
808    /// gate rejected after [`Recovery`](crate::Recovery)'s timeout, adopted instead. Also
809    /// counted in [`adopted`](Self::adopted).
810    ///
811    /// Counted apart because it means something the first adoption does not: the covariance
812    /// had shrunk around an error it could not see. A source that recovers regularly on good
813    /// data is a finding against the covariance, not a working filter.
814    pub recovered: u32,
815    /// Time since a measurement from this source was last accepted or adopted: the `Δt` of
816    /// equation (24′). (24′) discounts a measurement for the error it shares with those
817    /// already fused, so only a fused one restarts it; a rejected or refused measurement
818    /// changed nothing the next could repeat. Kept per source and restarted at each fused
819    /// measurement, rather than read as a difference of `since_initialized`, because an `f32`
820    /// clock counting hours loses the digits a 0.2 s interval needs.
821    pub(crate) since_measured: Option<Seconds>,
822    /// Time since the gate last judged a measurement from this source, accepted or rejected.
823    /// What the position hold reads: a receiver whose fixes the gate turns down is a matter for
824    /// [`Recovery`](crate::Recovery), not a silence to hold through.
825    pub(crate) since_judged: Option<Seconds>,
826    /// How often this source speaks; see [`period`](Self::period).
827    pub(crate) cadence: Cadence,
828}
829
830impl SourceHealth {
831    /// Whether this source has ever been fused.
832    pub const fn has_been_used(&self) -> bool {
833        self.accepted > 0
834    }
835
836    /// Whether the gate judged a measurement from this source, either way, no more than
837    /// `timeout` ago.
838    pub(crate) fn judged_within(&self, timeout: Seconds) -> bool {
839        self.since_judged.is_some_and(|elapsed| elapsed <= timeout)
840    }
841
842    /// Whether a measurement from this source was accepted no more than `timeout` ago.
843    /// Never, for a source that has not been accepted at all.
844    pub fn accepted_within(&self, timeout: Seconds) -> bool {
845        self.time_since_accepted
846            .is_some_and(|elapsed| elapsed <= timeout)
847    }
848
849    /// This source's update period as measured: the mean interval between its recent
850    /// measurements, or `None` until fifteen have arrived.
851    ///
852    /// Measured rather than configured, because the caller would be asked for a number the
853    /// filter can read off the stream (GOALS.md differentiator 7). Every measurement placed in
854    /// time counts, whether the gate then accepts, rejects or refuses it: this is how often
855    /// the sensor speaks, which [`timeout`](Self::timeout) reads, not how often it is believed.
856    pub fn period(&self) -> Option<Seconds> {
857        self.cadence.period()
858    }
859
860    /// How long this source may go unaccepted before it counts as timed out: two and a half
861    /// of its own [`period`](Self::period)s, or [`Timeouts::dead_reckoning_after`] until a
862    /// period has been measured.
863    ///
864    /// Not capped by `dead_reckoning_after` once one has: that is the mission's limit on
865    /// horizontal dead reckoning, and a mission that sets it to 2 s would otherwise time a
866    /// jittery 1 Hz receiver out below its own 2.5 periods, the regime that flapped
867    /// `2c42096b`. A source slower than that limit allows is reported by `DeadReckoning`,
868    /// which outranks `Degraded`, whenever it matters.
869    ///
870    /// Two and a half, because that is where the corpus stops flapping. `eb799954`'s
871    /// magnetometer, which bursts, changes status 2826 times over two periods and twice over
872    /// two and a half; `2c42096b`'s jittery 1 Hz receiver 330 times against 48. What is left
873    /// is real: on `a299e722`, 10 Hz GNSS velocity rejected for a second at a time, which a
874    /// threshold sized for a 1 Hz receiver never showed.
875    ///
876    /// Per source, because one threshold has to clear the slowest source: sized for a 1 Hz
877    /// GNSS, a 5 Hz barometer that stops is invisible for twelve missed readings. PX4 and
878    /// ArduPilot fix theirs as constants (`no_aid_timeout_max`, 1 s,
879    /// `src/modules/ekf2/EKF/common.h:516` at `c4e4ef98e9`; `hgtRetryTime*_ms`,
880    /// `libraries/AP_NavEKF3/AP_NavEKF3.h:493-497` at `368dc0c428`).
881    pub fn timeout(&self, timeouts: &Timeouts) -> Seconds {
882        self.period()
883            .map_or(timeouts.dead_reckoning_after, |period| {
884                Seconds::from_secs(MISSED_UPDATES * period.as_secs())
885            })
886    }
887
888    /// Whether this source was accepted within its own [`timeout`](Self::timeout): aiding
889    /// that is arriving on its schedule.
890    pub fn is_fresh(&self, timeouts: &Timeouts) -> bool {
891        self.accepted_within(self.timeout(timeouts))
892    }
893
894    /// Note a measurement placed at `time`, for [`period`](Self::period). One not after the
895    /// last is ignored: the same fix offered twice, or two sources' halves of it, is not an
896    /// interval.
897    pub(crate) fn note_arrival(&mut self, time: Timestamp) {
898        self.cadence.note(time);
899    }
900
901    /// Advance the fusion clock. Called from `predict`, since the filter has no clock.
902    pub(crate) fn advance(&mut self, dt: Seconds) {
903        for clock in [
904            &mut self.time_since_accepted,
905            &mut self.since_measured,
906            &mut self.since_judged,
907        ] {
908            if let Some(elapsed) = *clock {
909                *clock = Some(Seconds::from_secs(elapsed.as_secs() + dt.as_secs()));
910            }
911        }
912    }
913
914    /// Record a measurement that passed the gate: its test ratio and the innovation behind
915    /// it, a restarted fusion clock, and the end of any run of rejections.
916    pub(crate) fn record_accepted(&mut self, test_ratio: f32, innovation: Option<Innovation>) {
917        self.test_ratio = Some(test_ratio);
918        self.innovation = innovation;
919        self.time_since_accepted = Some(Seconds::ZERO);
920        self.since_measured = Some(Seconds::ZERO);
921        self.since_judged = Some(Seconds::ZERO);
922        self.consecutive_rejections = 0;
923        self.accepted = self.accepted.saturating_add(1);
924    }
925
926    /// Record a measurement adopted outright. An acceptance for every other purpose, so the
927    /// timer restarts with it; see [`Fusion::Reset`].
928    pub(crate) fn record_adopted(&mut self) {
929        self.record_accepted(0.0, None);
930        self.adopted = self.adopted.saturating_add(1);
931    }
932
933    /// Record a rejected measurement adopted to recover from lockout.
934    pub(crate) fn record_recovered(&mut self) {
935        self.record_adopted();
936        self.recovered = self.recovered.saturating_add(1);
937    }
938
939    /// Whether a measurement the gate has just rejected should be adopted instead: nothing
940    /// accepted for at least `after`, counted from initialization for a source never accepted.
941    /// `None` is recovery turned off.
942    ///
943    /// Counted from the last acceptance rather than the first rejection, as PX4 counts from
944    /// `time_last_fuse`: a fix arriving after an outage longer than `after` and rejected is a
945    /// lockout at once, since nothing constrained the quantity through the outage either.
946    pub(crate) fn locked_out(&self, after: Option<Seconds>, since_initialized: Seconds) -> bool {
947        after.is_some_and(|after| self.time_since_accepted.unwrap_or(since_initialized) >= after)
948    }
949
950    /// Record a measurement the gate refused. The fusion clock keeps running, which is
951    /// what lets a source that is only ever rejected time out.
952    pub(crate) fn record_rejected(&mut self, test_ratio: f32, innovation: Innovation) {
953        self.test_ratio = Some(test_ratio);
954        self.innovation = Some(innovation);
955        self.since_judged = Some(Seconds::ZERO);
956        self.consecutive_rejections = self.consecutive_rejections.saturating_add(1);
957        self.rejected = self.rejected.saturating_add(1);
958    }
959
960    /// Record a measurement turned away before the gate.
961    ///
962    /// No timer moves and no test ratio is recorded: nothing was measured against the state,
963    /// so the source is no fresher than it was. That is what keeps a stream of NaN from
964    /// holding off [`Status::DeadReckoning`].
965    pub(crate) fn record_refused(&mut self, refusal: Refusal) {
966        self.refused = self.refused.saturating_add(1);
967        self.last_refusal = Some(refusal);
968    }
969}
970
971/// Missed updates before a source counts as timed out; see [`SourceHealth::timeout`].
972const MISSED_UPDATES: f32 = 2.5;
973
974/// Intervals a source's period averages over, and how many it waits for before stating one.
975const CADENCE: u16 = 15;
976
977/// A source's mean interval between measurements: a running mean over the first [`CADENCE`],
978/// exponential with weight `1/CADENCE` after.
979///
980/// A mean rather than a median, because a sensor that delivers in bursts is common and its
981/// intervals are bimodal. `eb799954`'s magnetometer arrives 125 ms apart at the median with a
982/// tenth of its intervals under 4 ms, and a median of nine lands on the burst often enough to
983/// read a 3 ms period and time the source out between its own bursts: 14025 status
984/// transitions against 2. A mean reads the rate the source actually delivers, and a gap
985/// raises it by only its share of the window. Fifteen is where the corpus stopped moving:
986/// `2c42096b`'s jittery receiver flaps 76 times averaged over 9, 54 over 15 and 53 over 30.
987///
988/// Held as the mean itself, updated per arrival, since [`Status`] reads the period on every
989/// [`Eskf::state`](crate::Eskf::state).
990///
991/// It adapts, and what that costs is bounded by the weight. A source that slows from 5 Hz to
992/// 1 Hz times out on each of its first few slower intervals, until the mean has moved to
993/// 0.4 s; an outage enters the mean as one long interval, so a 30 s gap on a 1 Hz receiver
994/// raises its timeout to 7.3 s, and ten fixes later the excess has halved. Both err toward
995/// reporting the source, and discarding long intervals instead would leave a source that really slowed timed
996/// out for good.
997#[cfg_attr(feature = "defmt", derive(defmt::Format))]
998#[derive(Clone, Copy, Debug, Default, PartialEq)]
999pub(crate) struct Cadence {
1000    last: Option<Timestamp>,
1001    mean: f32,
1002    count: u16,
1003}
1004
1005impl Cadence {
1006    /// Record a measurement at `time`.
1007    fn note(&mut self, time: Timestamp) {
1008        let interval = match self.last {
1009            Some(last) if time > last => time.since(last).as_secs(),
1010            Some(_) => return,
1011            None => {
1012                self.last = Some(time);
1013                return;
1014            }
1015        };
1016        self.last = Some(time);
1017        self.count = self.count.saturating_add(1);
1018        let weight = 1.0 / f32::from(self.count.min(CADENCE));
1019        self.mean += weight * (interval - self.mean);
1020    }
1021
1022    /// The mean interval, once [`CADENCE`] have arrived. Not before: two intervals of a source
1023    /// that bursts are no rate.
1024    fn period(&self) -> Option<Seconds> {
1025        (self.count >= CADENCE).then(|| Seconds::from_secs(self.mean))
1026    }
1027}
1028
1029/// What propagation refused or coasted across, and the longest gap.
1030///
1031/// Not per source — [`Eskf::predict`](crate::Eskf::predict) is the one path that is not a
1032/// sensor — and the only record that a step was ever turned away. The state stops advancing
1033/// while `Status` goes on reporting on the aiding, so without this a filter running on refused
1034/// steps looks healthy.
1035///
1036/// `#[non_exhaustive]`; see [`SourceHealth`].
1037#[cfg_attr(feature = "defmt", derive(defmt::Format))]
1038#[derive(Clone, Copy, Debug, Default, PartialEq)]
1039#[non_exhaustive]
1040pub struct PropagationHealth {
1041    /// Gaps longer than [`Config::max_predict_dt`](crate::Config::max_predict_dt) coasted
1042    /// across, [`Propagation::Coasted`].
1043    pub coasted: u32,
1044    /// Steps refused as longer than
1045    /// [`Config::max_predict_dt`](crate::Config::max_predict_dt), with
1046    /// [`Config::coast`](crate::Config::coast) off.
1047    pub refused_too_long: u32,
1048    /// Steps refused for their timing: a sample not after the last,
1049    /// [`Propagation::InvalidStep`], or an integration interval under a microsecond or past
1050    /// [`Config::max_predict_dt`](crate::Config::max_predict_dt),
1051    /// [`Propagation::InvalidInterval`].
1052    pub refused_invalid: u32,
1053    /// Steps refused because the [`ImuSample`](crate::ImuSample) carried a NaN or an
1054    /// infinity.
1055    ///
1056    /// Counted apart from [`refused_invalid`](Self::refused_invalid) because the two name
1057    /// different faults: that one is the caller's timing, this one is the sensor. A count
1058    /// climbing here is a miswired or failed IMU, and there is no other record of it —
1059    /// the state simply stops advancing while the aiding timers run on.
1060    pub refused_not_finite: u32,
1061    /// Steps whose propagated state or covariance carried a NaN or an infinity, discarded
1062    /// together.
1063    ///
1064    /// Counted apart from [`refused_not_finite`](Self::refused_not_finite) for the reason
1065    /// that one is counted apart from [`refused_invalid`](Self::refused_invalid): a
1066    /// different fault. That one is a sensor emitting a NaN; this one is arithmetic
1067    /// overflowing on values it accepted, which is a magnitude problem rather than a
1068    /// validity one and is read at a different place in a bring-up.
1069    pub refused_state_not_finite: u32,
1070    /// The longest `dt` past [`Config::max_predict_dt`](crate::Config::max_predict_dt),
1071    /// coasted or refused, or `None` if there has been none.
1072    ///
1073    /// How far past the limit the worst gap ran, which separates a scheduler that overran by a
1074    /// millisecond from a logger that dropped a second of data. The filter reads no clock, so
1075    /// it holds the size of the gap and not when it happened; a caller that needs the moment
1076    /// timestamps it from [`Propagation::Coasted`] or [`Propagation::StepTooLong`].
1077    pub longest_gap: Option<Seconds>,
1078}
1079
1080impl PropagationHealth {
1081    /// Count what one step did, if it was coasted or refused.
1082    pub(crate) fn record(&mut self, outcome: Propagation) {
1083        match outcome {
1084            // The gap's length is noted by `Eskf::predict`, which sees it whatever the outcome.
1085            Propagation::Coasted { .. } => {
1086                self.coasted = self.coasted.saturating_add(1);
1087            }
1088            Propagation::StepTooLong { .. } => {
1089                self.refused_too_long = self.refused_too_long.saturating_add(1);
1090            }
1091            Propagation::InvalidStep { .. } | Propagation::InvalidInterval { .. } => {
1092                self.refused_invalid = self.refused_invalid.saturating_add(1);
1093            }
1094            Propagation::NotFinite => {
1095                self.refused_not_finite = self.refused_not_finite.saturating_add(1);
1096            }
1097            Propagation::StateNotFinite => {
1098                self.refused_state_not_finite = self.refused_state_not_finite.saturating_add(1);
1099            }
1100            Propagation::Propagated | Propagation::NotInitialized => {}
1101        }
1102    }
1103
1104    /// Keep `dt` if it is the longest gap past the limit so far.
1105    pub(crate) fn note_gap(&mut self, dt: Seconds) {
1106        if self.longest_gap.is_none_or(|worst| dt > worst) {
1107            self.longest_gap = Some(dt);
1108        }
1109    }
1110}
1111
1112/// Per-source health and what propagation refused, off the hot path.
1113///
1114/// Returned by [`Eskf::diagnostics`](crate::Eskf::diagnostics).
1115///
1116/// Every count here describes the filter's life since it last initialized, not since it was
1117/// constructed: committing a window resets these. Measurements offered before then are refused
1118/// with [`Fusion::NotInitialized`] and counted, and that count goes with the reset — which is
1119/// what makes a refusal count afterwards a statement about the flight rather than about the
1120/// caller's startup order.
1121///
1122/// `#[non_exhaustive]`; see [`SourceHealth`].
1123#[cfg_attr(feature = "defmt", derive(defmt::Format))]
1124#[derive(Clone, Copy, Debug, Default, PartialEq)]
1125#[non_exhaustive]
1126pub struct Diagnostics {
1127    /// GNSS position updates, horizontal: north and east. See [`GnssFusion`].
1128    pub gnss_position: SourceHealth,
1129    /// GNSS position updates, vertical: the height half of the same fix.
1130    pub gnss_height: SourceHealth,
1131    /// GNSS velocity updates.
1132    pub gnss_velocity: SourceHealth,
1133    /// Barometric altitude updates.
1134    pub baro_altitude: SourceHealth,
1135    /// Magnetic heading updates.
1136    pub mag_heading: SourceHealth,
1137    /// Dual-antenna GNSS heading updates.
1138    pub gnss_heading: SourceHealth,
1139    /// Course constraint updates: heading along the estimated velocity.
1140    pub course: SourceHealth,
1141    /// Zero-velocity updates from a caller's claim that the vehicle is still; see
1142    /// [`Eskf::fuse_stationary`](crate::Eskf::fuse_stationary).
1143    pub stationary: SourceHealth,
1144    /// Position-hold updates the filter fuses itself while unaided; see
1145    /// [`Config::hold`](crate::Config::hold).
1146    pub position_hold: SourceHealth,
1147    /// The yaw estimator of (45)–(52): each GNSS velocity it weighed is an arrival, and each
1148    /// heading the filter took from it an adoption, counted in `adopted` and, as every
1149    /// adoption is, in `accepted`. Never rejected, since its answer is not gated; see
1150    /// [`Config::yaw_estimator`](crate::Config::yaw_estimator).
1151    pub yaw_estimator: SourceHealth,
1152    /// What [`Eskf::predict`](crate::Eskf::predict) refused. Not a source, so not in
1153    /// [`sources`](Self::sources).
1154    pub propagation: PropagationHealth,
1155    /// Variances raised to the floor of equation (42′), counted per entry rather than per
1156    /// covariance.
1157    ///
1158    /// Expected to stay at zero, and it does across the whole corpus — the floor is set well below
1159    /// anything the filter reaches there ([measured]). So a count climbing here says a variance is
1160    /// collapsing for a reason of its own — an `R` far smaller than what the measurement actually
1161    /// observes, or a source fused faster than it carries independent information — and that the
1162    /// floor is masking it rather than protecting against it. It is the one number here whose
1163    /// interesting value is the one it does not have.
1164    ///
1165    /// Not counted per state, which would say *which* variance collapsed: a count that
1166    /// should be zero needs only to be non-zero to be worth reading, and the `sigma_*`
1167    /// columns of `examples/replay/main.rs` name the state as soon as anybody looks.
1168    ///
1169    /// [measured]: https://github.com/wboayue/fusion-nav/blob/main/DESIGN.md#floor
1170    pub floored: u32,
1171    /// Time since initialization, the clock a source never accepted is locked out on.
1172    pub(crate) since_initialized: Seconds,
1173}
1174
1175impl Diagnostics {
1176    /// Every source, for iteration.
1177    ///
1178    /// By reference, since [`Status`] reads this on every
1179    /// [`Eskf::state`](crate::Eskf::state) and a copy of nine [`SourceHealth`]s is most of
1180    /// that call's stack frame.
1181    pub const fn sources(&self) -> [(&'static str, &SourceHealth); 10] {
1182        [
1183            ("gnss_position", &self.gnss_position),
1184            ("gnss_height", &self.gnss_height),
1185            ("gnss_velocity", &self.gnss_velocity),
1186            ("baro_altitude", &self.baro_altitude),
1187            ("mag_heading", &self.mag_heading),
1188            ("gnss_heading", &self.gnss_heading),
1189            ("course", &self.course),
1190            ("stationary", &self.stationary),
1191            ("position_hold", &self.position_hold),
1192            ("yaw_estimator", &self.yaw_estimator),
1193        ]
1194    }
1195
1196    /// The sources [`Status`] counts: every one but those no sensor measures. The course
1197    /// reads the filter's own velocity ([`Eskf::fuse_course`](crate::Eskf::fuse_course)), a
1198    /// stationary claim is the caller's, the position hold is the filter's assumption
1199    /// while nothing aids it, and the yaw estimator reads the GNSS velocity already counted. Counting the hold would turn the dead reckoning it exists for
1200    /// into `Healthy`.
1201    ///
1202    /// Taken from [`sources`](Self::sources) rather than listed again, so a source added there
1203    /// counts toward `Status` unless it is excluded here by name.
1204    pub(crate) fn aiding(&self) -> impl Iterator<Item = &SourceHealth> {
1205        self.sources()
1206            .into_iter()
1207            .filter(|&(name, _)| {
1208                !matches!(
1209                    name,
1210                    "course" | "stationary" | "position_hold" | "yaw_estimator"
1211                )
1212            })
1213            .map(|(_, health)| health)
1214    }
1215
1216    /// The sources that hold horizontal position, which is what
1217    /// [`DeadReckoning`](Status::DeadReckoning) reads: GNSS position and GNSS velocity.
1218    pub(crate) fn horizontal(&self) -> impl Iterator<Item = &SourceHealth> {
1219        [&self.gnss_position, &self.gnss_velocity].into_iter()
1220    }
1221
1222    /// Advance every source's fusion clock. The one list besides [`sources`](Self::sources),
1223    /// which lends no `&mut`; a test holds the two to the same sources.
1224    pub(crate) fn advance(&mut self, dt: Seconds) {
1225        self.since_initialized =
1226            Seconds::from_secs(self.since_initialized.as_secs() + dt.as_secs());
1227        self.gnss_position.advance(dt);
1228        self.gnss_height.advance(dt);
1229        self.gnss_velocity.advance(dt);
1230        self.baro_altitude.advance(dt);
1231        self.mag_heading.advance(dt);
1232        self.gnss_heading.advance(dt);
1233        self.course.advance(dt);
1234        self.stationary.advance(dt);
1235        self.position_hold.advance(dt);
1236        self.yaw_estimator.advance(dt);
1237    }
1238}
1239
1240#[cfg(test)]
1241mod tests {
1242    use std::format;
1243
1244    use super::*;
1245
1246    const fn secs(s: f32) -> Seconds {
1247        Seconds::from_secs(s)
1248    }
1249
1250    fn at(ms: u64) -> Timestamp {
1251        Timestamp::from_micros(ms * 1000)
1252    }
1253
1254    /// A source that has arrived at each of `times`, in milliseconds.
1255    fn arrived(times: &[u64]) -> SourceHealth {
1256        let mut source = SourceHealth::default();
1257        for &t in times {
1258            source.note_arrival(at(t));
1259        }
1260        source
1261    }
1262
1263    #[test]
1264    fn a_period_is_the_mean_interval_once_fifteen_have_arrived() {
1265        // Fourteen intervals are not yet a rate.
1266        let times: [u64; 15] = core::array::from_fn(|i| 200 * i as u64);
1267        assert_eq!(arrived(&times).period(), None);
1268
1269        // A source that bursts: pairs 2 ms apart, a pair every 400 ms, so it delivers every
1270        // 200 ms and the median interval, 2 ms or 398 ms depending on where the window falls,
1271        // is neither. Fifteen intervals are eight short and seven long: 186.7 ms.
1272        let times: [u64; 16] = core::array::from_fn(|i| 400 * (i as u64 / 2) + 2 * (i as u64 % 2));
1273        let period = arrived(&times).period().expect("fifteen intervals");
1274        assert!((period.as_secs() - 0.1867).abs() < 1e-3, "{period:?}");
1275    }
1276
1277    #[test]
1278    fn a_time_not_after_the_last_is_not_an_interval() {
1279        // Every arrival offered twice, as a geodetic fix is on its way through to position:
1280        // zero intervals would halve the period.
1281        let times: [u64; 32] = core::array::from_fn(|i| 1000 * (i as u64 / 2));
1282        let period = arrived(&times).period().expect("fifteen intervals");
1283        assert!((period.as_secs() - 1.0).abs() < 1e-6, "{period:?}");
1284
1285        // And one out of order is dropped without losing the latest time.
1286        let mut times: [u64; 17] = core::array::from_fn(|i| 1000 * i as u64);
1287        times[16] = 500;
1288        let mut source = arrived(&times);
1289        source.note_arrival(at(16_000));
1290        let period = source.period().expect("sixteen intervals");
1291        assert!((period.as_secs() - 1.0).abs() < 1e-6, "{period:?}");
1292    }
1293
1294    #[test]
1295    fn advancing_ages_every_source_there_is() {
1296        let mut diagnostics = Diagnostics::default();
1297        diagnostics.gnss_position.record_accepted(0.0, None);
1298        diagnostics.gnss_height.record_accepted(0.0, None);
1299        diagnostics.gnss_velocity.record_accepted(0.0, None);
1300        diagnostics.baro_altitude.record_accepted(0.0, None);
1301        diagnostics.mag_heading.record_accepted(0.0, None);
1302        diagnostics.gnss_heading.record_accepted(0.0, None);
1303        diagnostics.course.record_accepted(0.0, None);
1304        diagnostics.stationary.record_accepted(0.0, None);
1305        diagnostics.position_hold.record_accepted(0.0, None);
1306        diagnostics.yaw_estimator.record_accepted(0.0, None);
1307        diagnostics.advance(secs(0.5));
1308        for (name, source) in diagnostics.sources() {
1309            assert_eq!(source.time_since_accepted, Some(secs(0.5)), "{name}");
1310        }
1311    }
1312
1313    #[test]
1314    fn a_timeout_is_two_and_a_half_periods_or_dead_reckoning_before_one() {
1315        let timeouts = Timeouts::default();
1316        assert_eq!(
1317            SourceHealth::default().timeout(&timeouts),
1318            timeouts.dead_reckoning_after,
1319            "no period yet"
1320        );
1321
1322        let times: [u64; 16] = core::array::from_fn(|i| 200 * i as u64);
1323        let timeout = arrived(&times).timeout(&timeouts).as_secs();
1324        assert!((timeout - 0.5).abs() < 1e-6, "{timeout}");
1325
1326        // Past `dead_reckoning_after`, and not capped by it.
1327        let times: [u64; 16] = core::array::from_fn(|i| 3000 * i as u64);
1328        let timeout = arrived(&times).timeout(&timeouts).as_secs();
1329        assert!((timeout - 7.5).abs() < 1e-5, "{timeout}");
1330    }
1331
1332    #[test]
1333    fn an_outcome_reads_as_an_operator_log_line() {
1334        assert_eq!(format!("{}", Status::DeadReckoning), "dead reckoning");
1335        assert_eq!(
1336            format!("{}", Fusion::Rejected { test_ratio: 2.7 }),
1337            "rejected, ratio 2.70"
1338        );
1339        assert_eq!(
1340            format!("{}", Fusion::Accepted { test_ratio: 0.314 }),
1341            "accepted, ratio 0.31"
1342        );
1343        assert_eq!(format!("{}", Fusion::Reset), "adopted");
1344        assert_eq!(
1345            format!("{}", Fusion::NoReference),
1346            "refused, no reference to measure against"
1347        );
1348        assert_eq!(
1349            format!("{}", Fusion::Unobservable),
1350            "refused, geometry observes nothing"
1351        );
1352        assert_eq!(
1353            format!(
1354                "{}",
1355                Propagation::StepTooLong {
1356                    dt: secs(0.15),
1357                    limit: secs(0.1)
1358                }
1359            ),
1360            "step of 0.150 s over the 0.100 s limit, not propagated"
1361        );
1362        assert_eq!(
1363            format!("{}", Propagation::Coasted { dt: secs(1.2) }),
1364            "gap of 1.200 s coasted"
1365        );
1366    }
1367
1368    #[test]
1369    fn status_counts_every_source_but_those_no_sensor_measures() {
1370        // `aiding` excludes by name, so a misspelling would count one silently.
1371        let diagnostics = Diagnostics::default();
1372        let excluded = ["course", "stationary", "position_hold", "yaw_estimator"];
1373        assert_eq!(
1374            diagnostics.aiding().count(),
1375            diagnostics.sources().len() - excluded.len()
1376        );
1377        for name in excluded {
1378            assert!(
1379                diagnostics
1380                    .sources()
1381                    .iter()
1382                    .any(|&(source, _)| source == name),
1383                "{name}"
1384            );
1385        }
1386    }
1387
1388    #[test]
1389    fn a_fix_names_its_halves_only_when_they_disagree() {
1390        assert_eq!(format!("{}", GnssFusion::both(Fusion::Reset)), "adopted");
1391        let split = GnssFusion {
1392            horizontal: Fusion::Accepted { test_ratio: 0.5 },
1393            height: Fusion::InvalidNoise,
1394        };
1395        assert_eq!(
1396            format!("{split}"),
1397            "horizontal accepted, ratio 0.50; height refused, noise variance not positive"
1398        );
1399    }
1400
1401    #[test]
1402    fn validity_is_a_letter_per_usable_quantity() {
1403        assert_eq!(format!("{}", Validity::NONE), "att:-- pos:-- vel:--");
1404        let tilt_and_navigation = Validity {
1405            tilt: true,
1406            heading: false,
1407            horizontal_position: true,
1408            vertical_position: true,
1409            horizontal_velocity: true,
1410            vertical_velocity: true,
1411        };
1412        assert_eq!(format!("{tilt_and_navigation}"), "att:T- pos:HV vel:HV");
1413    }
1414
1415    #[test]
1416    fn lockout_counts_from_the_last_acceptance_or_from_initialization() {
1417        let mut source = SourceHealth::default();
1418        // Never accepted: the filter's own clock is what has run.
1419        assert!(!source.locked_out(Some(secs(7.0)), secs(6.9)));
1420        assert!(source.locked_out(Some(secs(7.0)), secs(7.0)));
1421
1422        source.record_accepted(0.5, None);
1423        source.advance(secs(6.9));
1424        assert!(
1425            !source.locked_out(Some(secs(7.0)), secs(100.0)),
1426            "since the acceptance"
1427        );
1428        source.advance(secs(0.1));
1429        assert!(source.locked_out(Some(secs(7.0)), secs(100.0)));
1430        assert!(!source.locked_out(None, secs(100.0)), "off is off");
1431    }
1432}