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(×).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(×).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(×).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(×);
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(×).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(×).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}