Skip to main content

fusion_nav/
config.rs

1//! Filter tuning.
2//!
3//! Each default's doc comment says where its number came from: the PX4 or ArduPilot source it
4//! follows, the replay measurement that chose it, or that it is a **placeholder**, as
5//! [`Accuracy`]'s are, being the mission's to supply. The measurements themselves are in
6//! [`DESIGN.md`], or beside the `GOALS.md` decision that owns the default.
7//!
8//! [`DESIGN.md`]: https://github.com/wboayue/fusion-nav/blob/main/DESIGN.md#defaults-and-their-evidence
9
10use crate::display::{Decimals, Fixed};
11use crate::units::{Meters, MetersPerSecond, MetersPerSecond2, Radians, RadiansPerSecond, Seconds};
12
13/// Standard gravity, m s⁻²: [`Config::gravity`]'s default.
14///
15/// The WGS-84 standard value, which is local gravity at about 45° of latitude and sea level.
16/// Elsewhere it is off by up to 0.03 m s⁻² (0.3 %), entering (11) as a systematic vertical
17/// specific-force error that the accelerometer bias state absorbs, so it shows up as a bias
18/// estimate wrong by that much rather than as vertical drift.
19pub const GRAVITY: f32 = 9.806_65;
20
21/// Tilt uncertainty, per axis, at which a start counts as resolved: the bar behind
22/// [`Eskf::is_aligned`](crate::Eskf::is_aligned) and so
23/// [`Status::Aligning`](crate::Status::Aligning).
24///
25/// A constant rather than a field of [`Accuracy`], because it answers a different question.
26/// [`Accuracy`] is what the mission needs from an output; this is whether the start has
27/// produced an attitude the filter can work from, and no mission changes that. Both
28/// production estimators answer it with an internal constant nobody configures: PX4 declares
29/// tilt alignment at 3° (`getTiltVariance() < sq(radians(3.f))`,
30/// `src/modules/ekf2/EKF/control.cpp:73-78` at `c4e4ef98e9`), ArduPilot at 5°
31/// (`tiltErrorVariance < sq(radians(5.0))`,
32/// `libraries/AP_NavEKF3/AP_NavEKF3_Control.cpp:520-525` at `368dc0c428`). This takes the
33/// stricter of the two.
34pub const ALIGNED_TILT: Radians = Radians::from_degrees(3.0);
35
36/// Heading uncertainty at which a start counts as resolved, once a heading has been
37/// established at all: the yaw half of [`ALIGNED_TILT`]'s test.
38///
39/// No published counterpart exists to cite. Both estimators latch yaw alignment on the
40/// magnetometer reset rather than comparing a variance; the only yaw-variance bar either
41/// publishes is 15°, for accepting the GSF yaw estimator (`EKFGSF_yaw_err_max`,
42/// `src/modules/ekf2/EKF/common.h:396` at `c4e4ef98e9`; `GSF_YAW_ACCURACY_THRESHOLD_DEG`,
43/// `libraries/AP_NavEKF3/AP_NavEKF3_core.h:78` at `368dc0c428`), and a static start's own
44/// prior, [`Initialization::sigma_yaw`] at 20°, would fail it.
45///
46/// So 30° is chosen under one hard constraint: it has to clear `sigma_yaw`. A caller who
47/// configures `sigma_yaw` above it gets a static start that never leaves
48/// [`Aligning`](crate::Status::Aligning) until a heading measurement brings yaw down, which
49/// the type system cannot refuse.
50pub const ALIGNED_HEADING: Radians = Radians::from_degrees(30.0);
51
52/// How old a measurement may be and still be fused: the span of the past the filter can place
53/// a measurement in. Older than this, a `fuse_*` refuses it as
54/// [`Fusion::OutOfHorizon`](crate::Fusion::OutOfHorizon).
55///
56/// 0.3 s is what both production estimators hold. PX4 sizes its buffers to one and a half
57/// times `EKF2_DELAY_MAX`, whose default is 200 ms
58/// (`src/modules/ekf2/EKF/estimator_interface.cpp:591-592`, `src/modules/ekf2/module.yaml:26-34`
59/// at `c4e4ef98e9`), and ArduPilot caps a receiver's lag at 250 ms, "the max value the EKF has
60/// been tested for" (`libraries/AP_NavEKF3/AP_NavEKF3_core.cpp:70-71` at `368dc0c428`). The
61/// corpus's receivers are configured at 110 ms, one at 33.
62pub const LATENCY_HORIZON: Seconds = Seconds::from_secs(0.3);
63
64/// IMU noise, as the continuous-time densities of equations (16)–(21).
65#[derive(Clone, Copy, Debug, PartialEq)]
66pub struct ImuNoise {
67    /// Gyroscope white noise, rad s⁻¹ / √Hz.
68    pub gyro_white: f32,
69    /// Accelerometer white noise, m s⁻² / √Hz.
70    pub accel_white: f32,
71    /// Gyroscope bias random walk, rad s⁻² / √Hz.
72    pub gyro_bias_walk: f32,
73    /// Accelerometer bias random walk, m s⁻³ / √Hz.
74    pub accel_bias_walk: f32,
75}
76
77impl Default for ImuNoise {
78    /// White noise at ten times PX4 EKF2's density, measured to be what this filter needs;
79    /// bias walks at ArduPilot EK3's density.
80    ///
81    /// These are deliberately far above what an IMU datasheet or an Allan variance plot
82    /// gives for the sensor alone, because the process noise of a real airframe absorbs
83    /// what the model leaves out: vibration, scale-factor and cross-axis error, timing
84    /// jitter, and the coning and sculling a first-order propagation does not capture.
85    /// Datasheet-grade figures would make the covariance claim a precision the estimate
86    /// does not have, and an overconfident covariance gates out measurements that were fine.
87    ///
88    /// Both estimators add a bias walk as `(σ Δt)²` per prediction step, so their σ is
89    /// per-step at their own rate and the density it implies is `σ √Δt`: PX4 at
90    /// `src/modules/ekf2/EKF/covariance.cpp:152-153,166-167` with a 10 ms step
91    /// (`module.yaml:22`, `EKF2_PREDICT_US`), at `c4e4ef98e9`; ArduPilot at
92    /// `libraries/AP_NavEKF3/AP_NavEKF3_core.cpp:1065,1071` with a 12 ms step
93    /// (`AP_NavEKF3_core.cpp:36`, `EKF_TARGET_DT`), at `368dc0c428`, its bias states being
94    /// `b Δt`. PX4's 1.0e-3 rad s⁻² and 3.0e-3 m s⁻³ (`params_gyro_bias.yaml:19`,
95    /// `params_accel_bias.yaml:19`) come to 1.0e-4 and 3.0e-4; ArduPilot's copter 1.0e-3 and
96    /// 2.0e-2 (`AP_NavEKF3.cpp:30-31`) to 1.1e-4 and 2.2e-3. These are ArduPilot's, the
97    /// larger pair.
98    ///
99    /// The conversion is worth most where only `tilt · g + b_a` is observed: read unconverted, the
100    /// walk drags a grounded vehicle's tilt to a 5.2° peak against EKF2's 1.08° (`2c42096b`), and
101    /// converted it peaks at 2.5°.
102    ///
103    /// The white noise is PX4's per-step σ taken as a density, which is ten times the density PX4's
104    /// 1.5e-2 rad s⁻¹ and 0.35 m s⁻² stand for at its 10 ms step (`module.yaml:118-131`). The
105    /// factor stands for two things this filter does not model: the floors both estimators put
106    /// under a receiver's reported accuracy, and vibration, which PX4 meets only by
107    /// inflating accelerometer noise on clipping (`covariance.cpp:125-133`). Replay measured both
108    /// spending it: at 0.3× `accel_white`, `2c42096b` vibrating on the ground peaks at 4.6° of tilt
109    /// against 2.5°, and `a299e722`'s raw `R` rejects 493 velocity solutions against 283. A change
110    /// that models either is the moment to measure this again.
111    ///
112    /// The floor the sensors themselves measure,
113    /// [`StaticWindow::noise`](crate::StaticWindow::noise), sits 9 to 180 times below the
114    /// gyroscope's and 6 to 230 times below the accelerometer's on the corpus, so the factor is
115    /// measured from below too
116    /// ([evidence](https://github.com/wboayue/fusion-nav/blob/main/DESIGN.md#imunoise)).
117    fn default() -> Self {
118        Self {
119            gyro_white: 1.5e-2,
120            accel_white: 3.5e-1,
121            gyro_bias_walk: 1.1e-4,
122            accel_bias_walk: 2.2e-3,
123        }
124    }
125}
126
127/// How often a gate rejects a measurement that was fine: the percentile of the chi-square
128/// distribution its threshold sits at.
129///
130/// Named rather than continuous, because each name is an exact tabulated quantile and a
131/// continuous `p` would need an inverse normal CDF and the Wilson–Hilferty approximation at
132/// three degrees of freedom — code a reader has to check, for percentiles nobody asks for.
133/// `#[non_exhaustive]` because it is an input, constructed and rarely matched, so a new
134/// percentile costs callers nothing.
135#[derive(Clone, Copy, Debug, PartialEq, Eq)]
136#[non_exhaustive]
137pub enum Percentile {
138    /// 95 %: one good measurement in twenty rejected.
139    P95,
140    /// 99 %: one in a hundred.
141    P99,
142    /// 99.9 %: one in a thousand.
143    P999,
144}
145
146/// Innovation gate threshold `γ` of equation (37), for an observation of `DOF` dimensions.
147///
148/// The degrees of freedom are a property of the observation rather than a choice, so they
149/// are part of the type: a threshold for a one-dimensional observation cannot be written
150/// into a three-dimensional source's field, and [`Gate::at`] exists only for the dimensions
151/// this crate observes. The only free parameter left is the [`Percentile`].
152#[derive(Clone, Copy, Debug, PartialEq)]
153pub struct Gate<const DOF: usize>(f32);
154
155impl<const DOF: usize> Gate<DOF> {
156    /// A threshold given as a number, for a caller with a reason to leave the [`Percentile`]
157    /// table; `None` unless it is finite and strictly positive.
158    ///
159    /// Each refusal is a gate that fails silently. NaN compares false against every `ε` and
160    /// accepts everything; `+∞` does the same while reporting a test ratio of zero; zero or
161    /// less rejects everything, and zero makes the ratio of (38) a division by zero.
162    pub const fn new(threshold: f32) -> Option<Self> {
163        if threshold.is_finite() && threshold > 0.0 {
164            Some(Self(threshold))
165        } else {
166            None
167        }
168    }
169
170    /// The threshold `γ`, which a test ratio of 1 corresponds to.
171    pub const fn threshold(self) -> f32 {
172        self.0
173    }
174}
175
176impl Gate<1> {
177    /// The chi-square quantile at `percentile` for one degree of freedom: barometric
178    /// altitude, GNSS height, magnetic heading.
179    ///
180    /// Checked in this module's tests against the closed-form CDF, not against another table.
181    pub const fn at(percentile: Percentile) -> Self {
182        Self(match percentile {
183            Percentile::P95 => 3.841_459,
184            Percentile::P99 => 6.634_897,
185            Percentile::P999 => 10.827_566,
186        })
187    }
188}
189
190impl Gate<2> {
191    /// The chi-square quantile at `percentile` for two degrees of freedom: the horizontal half
192    /// of a GNSS position fix.
193    ///
194    /// `−2 ln(1 − p)` exactly, since two degrees of freedom is the one case with a closed-form
195    /// CDF, `F(x) = 1 − e^(−x/2)`; checked against it in this module's tests.
196    pub const fn at(percentile: Percentile) -> Self {
197        Self(match percentile {
198            Percentile::P95 => 5.991_465,
199            Percentile::P99 => 9.210_34,
200            Percentile::P999 => 13.815_511,
201        })
202    }
203}
204
205impl Gate<3> {
206    /// The chi-square quantile at `percentile` for three degrees of freedom: GNSS velocity.
207    ///
208    /// Checked in this module's tests against the closed-form CDF, not against another table.
209    pub const fn at(percentile: Percentile) -> Self {
210        Self(match percentile {
211            Percentile::P95 => 7.814_728,
212            Percentile::P99 => 11.344_867,
213            Percentile::P999 => 16.266_236,
214        })
215    }
216}
217
218/// Innovation gate thresholds `γ`, one per observation, from equation (37).
219///
220/// The filter reports `r = ε / γ`, so these set what `r = 1` means. Built from a percentile,
221/// then adjusted per source where a caller has a reason:
222///
223/// ```
224/// use fusion_nav::{Gate, Gates, Percentile};
225///
226/// let mut gates = Gates::at(Percentile::P99);
227/// gates.baro_altitude = Gate::<1>::at(Percentile::P999); // a barometer that sees prop wash
228/// ```
229///
230/// A threshold of the wrong dimension does not compile:
231///
232/// ```compile_fail
233/// # use fusion_nav::{Gate, Gates, Percentile};
234/// let mut gates = Gates::default();
235/// gates.baro_altitude = Gate::<3>::at(Percentile::P99); // expected `Gate<1>`, found `Gate<3>`
236/// ```
237///
238/// Every threshold is compared against the joint `ε = yᵀ S⁻¹ y` over all of the
239/// observation's components, not one component at a time. Both production estimators test
240/// per component against the diagonal of `S` only: PX4 each axis at 5σ
241/// (`src/modules/ekf2/EKF/ekf.h:1142` at `c4e4ef98e9`), which fits its fusing the axes one by
242/// one (`EKF/position_fusion.cpp:62-68`); ArduPilot the horizontal pair as one sum against the
243/// summed variances and the vertical alone
244/// (`libraries/AP_NavEKF3/AP_NavEKF3_PosVelFusion.cpp:904-906` and `:1023` at `368dc0c428`).
245/// The joint test is what makes a [`Percentile`] mean what it names: only `ε` is chi-square
246/// with `dim(z)` degrees of freedom, so only it rejects exactly `1 − p` of good measurements,
247/// where a per-component test's rate depends on correlations nothing states. And it keeps
248/// those correlations — a yaw error couples north and east, so the region `S` describes is an
249/// ellipsoid rather than a box aligned with the navigation axes.
250///
251/// A GNSS position fix is the exception, split into a horizontal `Gate<2>` and a vertical `Gate<1>`
252/// as ArduPilot splits it, because the joint test's cost is all or nothing: a height the estimate
253/// disagrees with rejects a good horizontal fix, 3945 of 4616 on a vehicle whose barometer and
254/// receiver drift apart ([evidence]). The split keeps the joint test within each half, so north and
255/// east are still tested as the ellipse `S` describes; what it gives up is the north–down and
256/// east–down correlation, which a position fix barely carries. See
257/// [`GnssFusion`](crate::GnssFusion).
258///
259/// [evidence]: https://github.com/wboayue/fusion-nav/blob/main/DESIGN.md#gates
260#[derive(Clone, Copy, Debug, PartialEq)]
261pub struct Gates {
262    /// GNSS position, horizontal: north and east.
263    pub gnss_position: Gate<2>,
264    /// GNSS position, vertical: the height half of the same fix.
265    pub gnss_height: Gate<1>,
266    /// GNSS velocity.
267    pub gnss_velocity: Gate<3>,
268    /// Barometric altitude.
269    pub baro_altitude: Gate<1>,
270    /// Magnetic heading.
271    pub mag_heading: Gate<1>,
272    /// Dual-antenna GNSS heading.
273    pub gnss_heading: Gate<1>,
274    /// Course constraint.
275    pub course: Gate<1>,
276    /// A caller's claim that the vehicle is still: zero velocity, all three axes.
277    pub stationary: Gate<3>,
278    /// The position hold an unaided filter fuses: north and east.
279    pub position_hold: Gate<2>,
280}
281
282impl Gates {
283    /// Every source gated at the same percentile, each at its own degrees of freedom.
284    pub const fn at(percentile: Percentile) -> Self {
285        Self {
286            gnss_position: Gate::<2>::at(percentile),
287            gnss_height: Gate::<1>::at(percentile),
288            gnss_velocity: Gate::<3>::at(percentile),
289            baro_altitude: Gate::<1>::at(percentile),
290            mag_heading: Gate::<1>::at(percentile),
291            gnss_heading: Gate::<1>::at(percentile),
292            course: Gate::<1>::at(percentile),
293            stationary: Gate::<3>::at(percentile),
294            position_hold: Gate::<2>::at(percentile),
295        }
296    }
297}
298
299impl Default for Gates {
300    /// The 99.9th percentile: the tightest gate that costs nothing measurable on good data.
301    ///
302    /// Replayed at 95 %, 99 %, 99.9 % and 5σ, the corpus could not tell them apart and the
303    /// simulator prices the tight end: on `mission` 95 % turns away one good fix in thirty for
304    /// worse accuracy, and 99.9 % and 5σ give what 99 % does ([evidence]). 99.9 % is still tighter
305    /// than both production estimators, which test each axis at 5σ (PX4
306    /// `src/modules/ekf2/EKF/common.h:348-374` at `c4e4ef98e9`; ArduPilot `*_I_GATE_DEFAULT 500`,
307    /// in hundredths of σ, `libraries/AP_NavEKF3/AP_NavEKF3.cpp:34-36` at `368dc0c428`): a one-axis
308    /// outlier fails `γ` = 16.27 at 4.0σ.
309    ///
310    /// [evidence]: https://github.com/wboayue/fusion-nav/blob/main/DESIGN.md#gates
311    fn default() -> Self {
312        Self::at(Percentile::P999)
313    }
314}
315
316/// How long the filter may go without horizontal aiding before the status reports it
317/// dead-reckoning.
318///
319/// What the status *says*; what the filter *does* about a source it keeps rejecting is
320/// [`Recovery`]'s. When a single source counts as timed out is not configured: each source's
321/// own measured period sets it, [`SourceHealth::timeout`](crate::SourceHealth::timeout).
322#[derive(Clone, Copy, Debug, PartialEq)]
323pub struct Timeouts {
324    /// Beyond this with neither GNSS position nor GNSS velocity accepted, the status becomes
325    /// [`DeadReckoning`](crate::Status::DeadReckoning), whatever else is still arriving. Also
326    /// a source's [`timeout`](crate::SourceHealth::timeout) until its period is measured.
327    ///
328    /// Horizontal, because that is the error nothing else bounds: a barometer holds height and
329    /// a magnetometer heading, and position still drifts. PX4's `inertial_dead_reckoning` is
330    /// the same test (`src/modules/ekf2/EKF/ekf_helper.cpp:804-903` at `c4e4ef98e9`), cleared
331    /// only by horizontal position or velocity aiding, and ArduPilot's `dead_reckoning` flag
332    /// reads horizontal sources alone (`libraries/AP_NavEKF3/AP_NavEKF3_Control.cpp:811` at
333    /// `368dc0c428`). A mission question rather than a measured one: how long a vehicle may
334    /// navigate on its inertial solution is what it is flying for.
335    pub dead_reckoning_after: Seconds,
336}
337
338impl Default for Timeouts {
339    /// PX4's `EKF2_NOAID_TOUT`, the time it allows inertial dead reckoning before reporting
340    /// the horizontal solution invalid (`src/modules/ekf2/EKF/common.h:519` at `c4e4ef98e9`).
341    fn default() -> Self {
342        Self {
343            dead_reckoning_after: Seconds::from_secs(5.0),
344        }
345    }
346}
347
348/// How long each source's measurement error persists: the time constant `τ` of equation
349/// (24′), per source. `None` fuses that source as white, which is (24) as written.
350///
351/// Equation (24) treats each measurement's error as independent of the last, and almost no source
352/// in the corpus is: fused as white, the lag-1 autocorrelation of its innovations (the `acf1_` keys
353/// of the replay `summary` line) is positive on nearly every real log. A filter that fuses such
354/// measurements as white averages down an error it cannot observe, and its covariance claims the
355/// averaging worked. (24′) fuses each at the variance that makes a run of them carry what they
356/// actually carry, which is less the shorter the interval is against `τ`.
357///
358/// Inflating `R` rather than flooring `P`, and in the gain rather than the gate, is measured; the
359/// [decision] records what each alternative read.
360///
361/// Per source, like [`Recovery`] and [`Gates`], because a source is what the gate judges and
362/// what (24′) times. Configured rather than derived, as [`ImuNoise`] is: it is a property of
363/// the sensor, and the filter cannot measure it in flight without retuning itself. The replay
364/// harness's `--derive` reads it offline, `τ = −T / ln ρ` at a log's sample interval `T` and its
365/// lag-one innovation autocorrelation `ρ`, and that reading is a lower bound: an innovation is
366/// whiter than the error behind it, because the filter follows part of that error. It falls
367/// well short of a simulated sensor's own `τ`, so `--derive` prints it only above the default
368/// ([measured]); an estimator the filter does not bias is #195.
369///
370/// [measured]: https://github.com/wboayue/fusion-nav/blob/main/DESIGN.md#correlation
371///
372/// [decision]: https://github.com/wboayue/fusion-nav/blob/main/GOALS.md#correlated-measurement-error-as-equivalent-white-noise
373#[derive(Clone, Copy, Debug, PartialEq)]
374pub struct Correlation {
375    /// GNSS horizontal position.
376    pub gnss_position: Option<Seconds>,
377    /// GNSS height.
378    pub gnss_height: Option<Seconds>,
379    /// GNSS velocity, all three axes.
380    pub gnss_velocity: Option<Seconds>,
381    /// Barometric altitude. The reference's drift is (30′)'s, not this: this is the noise
382    /// about it.
383    pub baro_altitude: Option<Seconds>,
384    /// Magnetic heading, with the leveling variance of (36′) it carries.
385    pub mag_heading: Option<Seconds>,
386    /// Dual-antenna GNSS heading.
387    pub gnss_heading: Option<Seconds>,
388    /// Course constraint: the sideslip the constraint allows, which persists as long as the
389    /// wind and the vehicle's trim do.
390    pub course: Option<Seconds>,
391}
392
393impl Correlation {
394    /// Every measurement independent of the last: equation (24) with nothing added.
395    pub const WHITE: Self = Self {
396        gnss_position: None,
397        gnss_height: None,
398        gnss_velocity: None,
399        baro_altitude: None,
400        mag_heading: None,
401        gnss_heading: None,
402        course: None,
403    };
404}
405
406impl Default for Correlation {
407    /// The corpus's: `τ = −T / ln ρ` from each real log's `acf1_` for the source, read with every
408    /// measurement fused as white, at that log's own sample interval, and the median over the real
409    /// logs whose autocorrelation is positive. GNSS heading and the course constraint rest on one
410    /// log each. The table, and the logs left out, are the [decision's].
411    ///
412    /// [decision's]: https://github.com/wboayue/fusion-nav/blob/main/GOALS.md#correlated-measurement-error-as-equivalent-white-noise
413    fn default() -> Self {
414        Self {
415            gnss_position: Some(Seconds::from_secs(8.5)),
416            gnss_height: Some(Seconds::from_secs(37.0)),
417            gnss_velocity: Some(Seconds::from_secs(0.5)),
418            baro_altitude: Some(Seconds::from_secs(0.26)),
419            mag_heading: Some(Seconds::from_secs(1.3)),
420            gnss_heading: Some(Seconds::from_secs(0.25)),
421            course: Some(Seconds::from_secs(1.4)),
422        }
423    }
424}
425
426/// How long each source may be rejected before the filter adopts it again: automatic recovery from
427/// [gate lockout](https://github.com/wboayue/fusion-nav/blob/main/EQUATIONS.md#gate-lockout), one
428/// switch per source. `None` turns that source's recovery off.
429///
430/// A source recovers when the gate rejects one of its measurements and none has been accepted
431/// for at least this long — since initialization, for a source never accepted. That
432/// measurement is then adopted rather than discarded: [`Fusion::Reset`](crate::Fusion::Reset),
433/// counted in [`SourceHealth::recovered`](crate::SourceHealth::recovered). The state becomes
434/// the measurement and its covariance block the measurement's, which is what undoes the
435/// overconfidence that locked the gate rather than only moving the estimate. Only a rejection
436/// triggers it: a source that is silent or refused has nothing to adopt, and a stream of NaN
437/// must not step the state.
438///
439/// On by default, because a filter that reports lockout and stops hands every integrator the
440/// same timeout-and-reset loop to write. An application that owns its failsafe policy — a
441/// controller that cannot take a step, an operator alert instead of a reset — turns off the
442/// source it owns, or all of them with [`Recovery::OFF`], and drives
443/// [`Eskf::reset_position_to`](crate::Eskf::reset_position_to) itself. The decision is [rejection
444/// handling](https://github.com/wboayue/fusion-nav/blob/main/GOALS.md#rejection-handling-recover-by-default-opt-out-per-source).
445///
446/// Per source, not per quantity, because a source is what the gate rejects: the fields mirror
447/// [`Gates`] and [`Diagnostics`](crate::Diagnostics).
448///
449/// After a position hold the timeout is waived once: the first GNSS position or velocity the
450/// gate turns down is adopted at once, unless its field here is `None`; see [`Hold`].
451#[derive(Clone, Copy, Debug, PartialEq)]
452pub struct Recovery {
453    /// GNSS horizontal position: north and east are adopted, height is left alone.
454    pub gnss_position: Option<Seconds>,
455    /// GNSS height: the down axis is adopted, and the barometric reference is dropped so that
456    /// the next altitude reads it again against the adopted height — unless
457    /// [`Config::baro_reference_from_estimate`] is off, which leaves the reference the
458    /// caller's and keeps it.
459    pub gnss_height: Option<Seconds>,
460    /// GNSS velocity: all three axes are adopted.
461    pub gnss_velocity: Option<Seconds>,
462    /// Barometric altitude: the reference `α₀` is read again from the estimate, as the first
463    /// one is when a start leaves none, and the state does not move. Needs an established
464    /// position to read it against, and [`Config::baro_reference_from_estimate`] on: with it
465    /// off the reference is the caller's, and a barometer that disagrees stays rejected.
466    pub baro_altitude: Option<Seconds>,
467    /// Magnetic heading: adopted as the first heading is, with the `R` of (36′) — but only
468    /// while neither GNSS position nor velocity is fresh
469    /// ([`SourceHealth::is_fresh`](crate::SourceHealth::is_fresh)), since with those arriving
470    /// a magnetometer that disagrees for this long is more likely disturbed than right. Nor while a GNSS heading is
471    /// accepted, for the same reason.
472    pub mag_heading: Option<Seconds>,
473    /// Dual-antenna GNSS heading: adopted as the first heading is, with the caller's `R`. No
474    /// guard: it is the absolute heading reference when the vehicle carries one.
475    pub gnss_heading: Option<Seconds>,
476    /// Course constraint: adopted as the first heading is — but only while neither magnetic
477    /// nor GNSS heading is fresh, since with either arriving a course that disagrees this long
478    /// is sideslip the caller did not allow for,
479    /// a crosswind or a multirotor crabbing, rather than a wrong heading.
480    pub course: Option<Seconds>,
481    /// The yaw estimator's heading, adopted in place of one GNSS contradicts: once no GNSS
482    /// velocity has been accepted for this long and a second in a row is rejected, while the
483    /// estimator of [`Config::yaw_estimator`] has converged on a yaw more than 25° from the
484    /// filter's. A magnetometer is then not adopted back until one of its headings passes
485    /// the gate.
486    /// That velocity, and the first GNSS position the gate turns down after it, are then
487    /// adopted at once rather than after their own timeouts, since what they were judged
488    /// against was the wrong heading. Not while a dual-antenna
489    /// GNSS heading is fresh ([`SourceHealth::is_fresh`](crate::SourceHealth::is_fresh)): it
490    /// measures what the estimator infers. Unlike the fields above it times another source's
491    /// rejections: the estimator's answer is never gated.
492    pub yaw_estimator: Option<Seconds>,
493}
494
495impl Recovery {
496    /// No automatic recovery at all: the filter reports lockout and the application decides.
497    pub const OFF: Self = Self {
498        gnss_position: None,
499        gnss_height: None,
500        gnss_velocity: None,
501        baro_altitude: None,
502        mag_heading: None,
503        gnss_heading: None,
504        course: None,
505        yaw_estimator: None,
506    };
507}
508
509impl Default for Recovery {
510    /// PX4's timeouts, at `c4e4ef98e9`: `reset_timeout_max`, 7 s, for horizontal position,
511    /// velocity and heading, and `hgt_fusion_timeout_max`, 5 s, for height
512    /// (`src/modules/ekf2/EKF/common.h:515-517`). Each is applied the way PX4 applies it —
513    /// position at `aid_sources/gnss/gps_control.cpp:204-213`, velocity at `:146-156`, heading
514    /// at `aid_sources/magnetometer/mag_control.cpp:276-289`, whose guard against fresh
515    /// horizontal aiding [`mag_heading`](Self::mag_heading) keeps — with one departure.
516    ///
517    /// PX4 resets height only when every height source is failing (`ekf_helper.cpp:48-57`),
518    /// and otherwise stops fusing the one that disagrees, whichever `EKF2_HGT_REF` names the
519    /// reference (GNSS by default, `module.yaml:87-106`). Here GNSS height is always the
520    /// absolute and the barometer's offset is estimated,
521    /// equation (30′), so a GNSS height rejected for 5 s is adopted even while the barometer
522    /// is accepted, and a barometer rejected for 5 s has its reference read again: the
523    /// absolute wins either way.
524    ///
525    /// The two heading sources PX4 either lacks or does not recover take the same 7 s, by the
526    /// rule recovery follows everywhere here rather than by a measurement. PX4 stops fusing a
527    /// GNSS yaw that has failed for `reset_timeout_max` and resets nothing
528    /// (`aid_sources/gnss/gnss_yaw_control.cpp:78-81`); ArduPilot realigns it on the ground
529    /// after 10 s (`AP_NavEKF3_MagFusion.cpp:433-435` at `368dc0c4`). PX4 has no course
530    /// constraint at all; ArduPilot's in-flight course realignment is itself a reset
531    /// (`realignYawGPS`, `AP_NavEKF3_MagFusion.cpp:145-218`).
532    ///
533    /// The yaw estimator's is PX4's `EKFGSF_reset_delay`, 1 s (`common.h:395`), applied as PX4
534    /// applies it to a rejected velocity (`gps_control.cpp:113-125`, `isYawFailure` at
535    /// `:546-562`) less its in-air condition, which this filter is not told.
536    fn default() -> Self {
537        Self {
538            gnss_position: Some(Seconds::from_secs(7.0)),
539            gnss_height: Some(Seconds::from_secs(5.0)),
540            gnss_velocity: Some(Seconds::from_secs(7.0)),
541            baro_altitude: Some(Seconds::from_secs(5.0)),
542            mag_heading: Some(Seconds::from_secs(7.0)),
543            gnss_heading: Some(Seconds::from_secs(7.0)),
544            course: Some(Seconds::from_secs(7.0)),
545            yaw_estimator: Some(Seconds::from_secs(1.0)),
546        }
547    }
548}
549
550/// What the filter assumes across an IMU gap it coasts over. Equation (22′).
551///
552/// A gap longer than [`Config::max_predict_dt`] has no measurement describing it, so the
553/// filter coasts: position advances on the estimated velocity, nothing else moves, and the
554/// covariance grows by (22) plus an acceleration nobody measured. Refusing the step instead
555/// leaves a moving vehicle's state stale by `v Δt` under a covariance that did not grow, and
556/// every fix after the gap is then tens of σ away and turned down until
557/// [`Config::recovery`] adopts one — the lockout coasting exists to prevent.
558///
559/// On by default, per [rejection
560/// handling](https://github.com/wboayue/fusion-nav/blob/main/GOALS.md#rejection-handling-recover-by-default-opt-out-per-source):
561/// a correction the filter can make honestly is made, and `Config::coast = None` is the
562/// opt-out for an application that owns the gap itself.
563#[derive(Clone, Copy, Debug, PartialEq)]
564pub struct Coast {
565    /// Density of the acceleration the vehicle may have pulled during the gap, m s⁻² / √Hz,
566    /// added to the velocity block of (21) for the gap's duration: `σ_v = a √Δt`, 3.5 m/s over
567    /// a 3.1 s gap at the default.
568    pub acceleration: f32,
569    /// Density of the rotation the vehicle may have made during the gap, rad s⁻¹ / √Hz,
570    /// added to the attitude block of (21) for the gap's duration: 10° over a 3.1 s gap at
571    /// the default. All three axes, because a vehicle that turns also banks.
572    pub rotation: f32,
573}
574
575impl Default for Coast {
576    /// Measured on the two sources with gaps at speed, and set at twice the smallest value
577    /// either needed. Both are properties of an airframe's maneuvers, so a vehicle more
578    /// agile than these is the reason to raise them.
579    ///
580    /// `rotation` is the corpus's finding rather than the simulator's: `4b473e91`'s course turns
581    /// 44° across a 3.1 s gap, and coasted without it the stale heading steers velocity off until
582    /// the gate turns it down, 7 recoveries at half the default `acceleration` ([evidence]).
583    ///
584    /// [evidence]: https://github.com/wboayue/fusion-nav/blob/main/DESIGN.md#coast
585    fn default() -> Self {
586        Self {
587            acceleration: 2.0,
588            rotation: 0.1,
589        }
590    }
591}
592
593/// The position hold an unaided filter fuses to keep its tilt. Equation (28″).
594///
595/// Without horizontal aiding nothing observes tilt, so a vehicle that loses GNSS in flight loses
596/// its attitude on the schedule [`ImuNoise`] sets while the true error may still be small. With
597/// no horizontal measurement judged within [`Timeouts::dead_reckoning_after`], which a start that
598/// has heard none meets from its first step, no
599/// [`Eskf::fuse_stationary`](crate::Eskf::fuse_stationary) accepted, and the tilt σ past 3°, the
600/// filter fuses its own position estimate from the step the hold engaged, every 0.2 s at `sigma`.
601/// Bounding position bounds velocity, so a velocity error can no longer hide a tilt error, and the
602/// accelerometer levels the filter. PX4's fake position (`fake_pos_control.cpp:47-82` at
603/// `c4e4ef98`) and ArduPilot's `AID_NONE` (`AP_NavEKF3_Control.cpp:416-420` at `368dc0c4`) do the
604/// same.
605///
606/// It assumes the vehicle stays near where the hold engaged. A hover meets that; a sustained
607/// acceleration reads as tilt error, and `sigma` is the trade between the two, which is the
608/// mission's: how far a vehicle flies on without aiding is what it is flying for. A vehicle that
609/// keeps moving without GNSS, a car or a fixed-wing, should turn it off: on UrbanNav's car the
610/// hold took the F9P's position NEES from 1.02 to 32 ([decision]).
611///
612/// The hold is an assumption, not a sensor: [`Status`](crate::Status) stays `DeadReckoning`, and
613/// horizontal position and velocity each stay invalid in [`Validity`](crate::Validity), however
614/// tight the covariance the hold leaves, until a measurement of that quantity is accepted or
615/// adopted. A stationary claim, a fix the gate turns down or a velocity does not validate position;
616/// a stationary claim does bound velocity, and so does the second position fix the gate passes, which
617/// has checked the velocity that carried the estimate from the first. The
618/// first GNSS fix or velocity after it that the gate turns down is adopted at once rather than
619/// after [`Config::recovery`]'s timeout, since what the gate would be judging it against is the
620/// hold. A source whose recovery is off is never adopted. A caller's `reset_position_to` or
621/// `reset_velocity_to` ends it for its quantity, until the hold next fuses. On by default, as
622/// [`Coast`] is; `Config::hold = None` is the opt-out.
623///
624/// [decision]: https://github.com/wboayue/fusion-nav/blob/main/GOALS.md#holding-tilt-without-aiding
625#[derive(Clone, Copy, Debug, PartialEq)]
626pub struct Hold {
627    /// The σ of the held position on each horizontal axis.
628    pub sigma: Meters,
629}
630
631impl Default for Hold {
632    /// PX4's `EKF2_NOAID_NOISE` (`src/modules/ekf2/module.yaml:67-75` at `c4e4ef98`) and
633    /// ArduPilot's `EK3_NOAID_M_NSE` (`AP_NavEKF3.cpp:447-453` at `368dc0c4`) both default to
634    /// 10 m.
635    fn default() -> Self {
636        Self {
637            sigma: Meters::from_meters(10.0),
638        }
639    }
640}
641
642/// Quasi-static initialization, equations (5)–(8).
643#[derive(Clone, Copy, Debug, PartialEq)]
644pub struct Initialization {
645    /// How long the vehicle must have been still.
646    ///
647    /// A duration rather than a sample count, because the same count means very different things
648    /// across IMU rates: 100 samples is 2 s at 50 Hz and 0.25 s at 400 Hz, and a quarter second is
649    /// too short to average sensor noise down or to tell stillness from a slow drift. The logs in
650    /// `data/manifest.txt` alone span 50 Hz to 250 Hz.
651    pub min_duration: Seconds,
652    /// Largest angular rate magnitude still considered stationary.
653    ///
654    /// Compared against the **peak** over the window, not a filtered value, so at the
655    /// same number this is the stricter test: one vibration spike is enough to fail it.
656    pub max_gyro_rate: RadiansPerSecond,
657    /// Largest departure of the specific-force magnitude from gravity still considered
658    /// stationary. Peak over the window, as with
659    /// [`max_gyro_rate`](Self::max_gyro_rate).
660    pub max_accel_deviation: MetersPerSecond2,
661    /// Initial position standard deviation.
662    pub sigma_position: Meters,
663    /// Initial velocity standard deviation.
664    pub sigma_velocity: MetersPerSecond,
665    /// Initial roll and pitch standard deviation. Gravity determines these well.
666    ///
667    /// The whole tilt uncertainty of a leveled start, including the share an accelerometer
668    /// bias explains: that share, `σ_βa / γ`, is carried as the tilt's correlation with the
669    /// bias rather than added to it, and where it exceeds this figure it is the prior, with
670    /// the window's own scatter across gravity added as the share the bias does not explain
671    /// (equation (8)). At the defaults it does, 0.0204 rad against 0.02.
672    pub sigma_tilt: Radians,
673    /// Initial yaw standard deviation. Much larger than
674    /// [`sigma_tilt`](Self::sigma_tilt): yaw inherits the magnetometer's calibration error.
675    pub sigma_yaw: Radians,
676    /// Initial accelerometer bias standard deviation.
677    ///
678    /// 0.2 m/s², the switch-on bias both production estimators assume: PX4's
679    /// `EKF2_ABIAS_INIT` (`src/modules/ekf2/EKF/common.h:340`, `c4e4ef98`), and ArduPilot's
680    /// `0.2 · EK3_ACC_BIAS_LIM` at its default of 1.0
681    /// (`libraries/AP_NavEKF3/AP_NavEKF3_core.cpp:616`, `AP_NavEKF3_core.h:52`,
682    /// `AP_NavEKF3.cpp:567`, `368dc0c4`). A static window cannot measure it, since (5) reads a
683    /// horizontal bias as tilt, which is why the tilt carries its correlation with it.
684    ///
685    /// At 0.1, `harsh_imu`'s 0.186 m/s² bias sat at 1.86σ and its attitude was overconfident on 50
686    /// seeds ([evidence]).
687    ///
688    /// [evidence]: https://github.com/wboayue/fusion-nav/blob/main/DESIGN.md#initialization
689    pub sigma_accel_bias: MetersPerSecond2,
690    /// Initial gyroscope bias standard deviation, before the window's mean rate is weighed.
691    ///
692    /// A window at rest weighs its own mean rate against this, (7), and a still window measures
693    /// the bias far better ([evidence]); so this is the prior of a start in motion, where the
694    /// bias starts at zero, and no start at rest ends wider than it but for the Earth's
695    /// rotation, which (8) adds. PX4 starts every bias at zero under 0.1 rad/s
696    /// (`EKF2_GBIAS_INIT`, `src/modules/ekf2/params_gyro_bias.yaml:5-14`, `c4e4ef98`), and
697    /// ArduPilot's fallback is 2.5°/s (`libraries/AP_InertialSensor/AP_InertialSensor.cpp:1536-1542`,
698    /// `368dc0c4`).
699    ///
700    /// 0.01 rad/s against both, because the corpus's biases are smaller than either assumes
701    /// (EKF2's per-log medians 1.2 × 10⁻³ rad/s RMS, none past 4.5 × 10⁻³), and a wider prior
702    /// lets a coarse start explain its attitude error as bias: at PX4's 0.1, `7ce66f0d`, the hand
703    /// launch, rejects 58157 measurements against 4877 ([evidence]).
704    ///
705    /// [evidence]: https://github.com/wboayue/fusion-nav/blob/main/DESIGN.md#initialization
706    pub sigma_gyro_bias: RadiansPerSecond,
707}
708
709impl Default for Initialization {
710    /// The stationarity tolerances are PX4 EKF2's — 15°/s and 20% of gravity — chosen against the
711    /// replay corpus: tighter ones called four of its first five logs moving while they sat on the
712    /// ground, on idle vibration and prop wash
713    /// ([evidence](https://github.com/wboayue/fusion-nav/blob/main/DESIGN.md#initialization)).
714    fn default() -> Self {
715        Self {
716            min_duration: Seconds::from_secs(2.0),
717            max_gyro_rate: RadiansPerSecond::from_rad_per_s(0.262),
718            max_accel_deviation: MetersPerSecond2::from_m_per_s2(1.961),
719            sigma_position: Meters::from_meters(1.0),
720            sigma_velocity: MetersPerSecond::from_m_per_s(0.1),
721            sigma_tilt: Radians::from_radians(0.02),
722            sigma_yaw: Radians::from_radians(0.35),
723            sigma_accel_bias: MetersPerSecond2::from_m_per_s2(0.2),
724            sigma_gyro_bias: RadiansPerSecond::from_rad_per_s(0.01),
725        }
726    }
727}
728
729/// How good an estimate has to be before the filter calls it valid.
730///
731/// The one group of numbers here that is **meant** to be supplied rather than derived: a
732/// survey platform and a racing quadrotor disagree about what "good enough" means, and no
733/// amount of flight data settles it. Everything else in [`Config`] is a property of the
734/// hardware or the mathematics; this is a property of the mission.
735///
736/// Each is a standard deviation, compared against the covariance the filter carries. For
737/// limits that differ between axes, read [`Eskf::covariance`](crate::Eskf::covariance)
738/// directly — these are the coarse per-quantity bar.
739///
740/// Nothing here moves [`Status`](crate::Status). Whether the start has been resolved is
741/// [`ALIGNED_TILT`] and [`ALIGNED_HEADING`]'s question, so a survey platform can ask for 1° of
742/// roll without also waiting for 1° before the filter stops reporting
743/// [`Aligning`](crate::Status::Aligning).
744///
745/// A bar tighter than the prior [`Initialization`] starts from is accepted and never met: that
746/// quantity is invalid from the first epoch, while the filter aligns and reports
747/// [`Healthy`](crate::Status::Healthy) as usual. On a replay it reads `attitude_lost=0.00` —
748/// out of service the moment the start resolved — beside an `aligned_at=` that has not moved.
749#[derive(Clone, Copy, Debug, PartialEq)]
750pub struct Accuracy {
751    /// Tilt, per axis: the attitude's σ about north and about east, each held to this. See
752    /// [`AttitudeVariance`](crate::AttitudeVariance).
753    pub tilt: Radians,
754    /// Heading.
755    pub heading: Radians,
756    /// Position, horizontally and vertically.
757    pub position: Meters,
758    /// Velocity, horizontally and vertically.
759    pub velocity: MetersPerSecond,
760    /// How far ahead [`Eskf::predicted_validity`](crate::Eskf::predicted_validity) asks:
761    /// how long after arming the vehicle needs the estimate to still be good.
762    ///
763    /// The one number in this crate that no data could settle. Every other default here was
764    /// measured or could be, by replaying what real vehicles do; this one is a statement
765    /// about the mission ahead rather than about any sensor, and a multirotor that will be
766    /// under a GNSS fix within a second disagrees completely with a fixed-wing hand-launched
767    /// into a minute of dead reckoning.
768    ///
769    /// Read as a duration of *unaided* flight: the covariance is propagated this far with
770    /// nothing fusing and each quantity tested at the far end. A value that is not a positive
771    /// duration projects nothing, which leaves `predicted_validity` its other clause — the
772    /// current answer widened by whatever source is being accepted — rather than reducing it
773    /// to [`validity`](crate::Eskf::validity).
774    ///
775    /// The projection is (22′), exact at any horizon under its assumption of a vehicle that
776    /// stays put, and one step: a horizon of minutes costs what one of a second does.
777    pub horizon: Seconds,
778}
779
780impl Default for Accuracy {
781    /// Attitude clears the prior a static alignment starts from, by the margin an unaided
782    /// filter takes to drift through. All four are **placeholders** for a mission nobody has
783    /// named: tilt and heading are [`ALIGNED_TILT`] and [`ALIGNED_HEADING`] because a bar
784    /// some estimator uses is a better starting point than none, and position and velocity
785    /// are loose enough to admit a 1 Hz GNSS solution.
786    ///
787    /// Exactly those, not rounded near them. A mission bar a hair under the alignment bar
788    /// opens a sliver where a start aligns and is out of service at the same epoch, for a
789    /// reason nobody chose: 0.052 rad against 3°'s 0.0524 did exactly that.
790    ///
791    /// A bar equal to the prior it is compared against is no bar at all: with
792    /// [`tilt`](Accuracy::tilt) at [`Initialization::sigma_tilt`](Initialization::sigma_tilt)
793    /// exactly and [`heading`](Accuracy::heading) at
794    /// [`Initialization::sigma_yaw`](Initialization::sigma_yaw) exactly, compared with `<=`, a
795    /// static start passes by zero margin and the first `Q` of equation (21) takes it away
796    /// again. On every static log in `data/manifest.txt` that read as a valid attitude for
797    /// exactly one sample.
798    ///
799    /// What these buy, measured on a static start at [`ImuNoise`]'s defaults: 10.3 s of unaided
800    /// propagation before tilt leaves the bar and 274 s before heading does, from a window that
801    /// measured its gyroscope, and 4.84 s and 51.4 s from one whose gyroscope never scattered
802    /// (`an_unaided_start_holds_its_attitude_for_the_margin_the_defaults_buy`). The window sets
803    /// them: the gyroscope-bias prior enters attitude through equation (20)'s `−I Δt` and grows
804    /// as `σ_βg² t²`, and only a window that measured the bias makes that prior small (see
805    /// [`Initialization::sigma_gyro_bias`]). They are the schedule with [`Config::hold`] off. The
806    /// position hold engages at the same 3° and pulls tilt back toward the bar each fusion, so
807    /// with it on tilt stays valid until one fusion every 0.2 s is no longer enough: 16.28 s on
808    /// `f16771dd` rather than 9.83, which is not a better attitude.
809    ///
810    /// What those two times move is [`Validity`](crate::Validity), and nothing else.
811    /// [`Status`](crate::Status) is already answering on the aiding timers by then — an unaided
812    /// filter reports [`DeadReckoning`](crate::Status::DeadReckoning) from
813    /// [`Timeouts::dead_reckoning_after`] without horizontal aiding, or from its first step if
814    /// none was ever accepted — and [`Aligning`](crate::Status::Aligning) reads the alignment
815    /// bars rather than these.
816    /// So these are a claim about which outputs a controller may still use, which is the
817    /// question [`Accuracy`] exists to answer. Supply your own numbers.
818    ///
819    /// [`horizon`](Accuracy::horizon) is 1 s, and it is a placeholder in a stronger sense
820    /// than the other four: they are bars some estimator uses, while no estimator publishes
821    /// this one at all. A second is the shortest horizon that is not simply
822    /// [`validity`](crate::Eskf::validity) asked twice — long enough that (20) has started to
823    /// tell on tilt, short enough that a vehicle expecting a
824    /// GNSS fix on takeoff is not failed for the gap before it.
825    fn default() -> Self {
826        Self {
827            tilt: ALIGNED_TILT,
828            heading: ALIGNED_HEADING,
829            position: Meters::from_meters(5.0),
830            velocity: MetersPerSecond::from_m_per_s(1.0),
831            horizon: Seconds::from_secs(1.0),
832        }
833    }
834}
835
836/// Everything the filter is tuned by.
837///
838/// Construct by updating the default:
839///
840/// ```
841/// use fusion_nav::{Config, Seconds, Timeouts};
842///
843/// let config = Config {
844///     timeouts: Timeouts {
845///         dead_reckoning_after: Seconds::from_secs(2.0),  // a multirotor in close quarters
846///     },
847///     ..Config::default()
848/// };
849/// ```
850#[derive(Clone, Copy, Debug, PartialEq)]
851pub struct Config {
852    /// IMU noise densities.
853    pub imu: ImuNoise,
854    /// Innovation gate thresholds.
855    pub gates: Gates,
856    /// Fusion timeouts feeding [`Status`](crate::Status).
857    pub timeouts: Timeouts,
858    /// Automatic recovery from gate lockout, per source.
859    pub recovery: Recovery,
860    /// How long each source's measurement error persists, equation (24′).
861    pub correlation: Correlation,
862    /// Static initialization.
863    pub init: Initialization,
864    /// How good an estimate must be to count as valid.
865    pub accuracy: Accuracy,
866    /// Local gravity `γ`, m s⁻²: the `g` of equations (5)–(8), (11) and (22′).
867    ///
868    /// Configured rather than read from the origin the filter places, because the origin
869    /// arrives with the first GNSS fix, which can come after propagation has begun, and a
870    /// propagation constant that changes mid-flight is the retuning differentiator 7 rules out
871    /// ([decision]). The value for a site is [`Geodetic::normal_gravity`](crate::Geodetic::normal_gravity)
872    /// at it, and the replay harness's `--derive` prints it from a log's origin. Bounded to
873    /// Earth's normal gravity, so a value in ft s⁻² or in units of `g` is refused.
874    ///
875    /// [decision]: https://github.com/wboayue/fusion-nav/blob/main/GOALS.md#local-gravity-configured-derived-offline
876    pub gravity: f32,
877    /// Largest `dt` [`Eskf::predict`](crate::Eskf::predict) will integrate one IMU sample
878    /// over.
879    ///
880    /// Beyond this the sample is not integrated, because the discretization of equations
881    /// (9)–(22) is a first-order approximation over a short interval and one sample cannot
882    /// describe a long one. The filter coasts across the gap instead, by [`Config::coast`], or
883    /// with that off refuses the step and leaves the state where it was.
884    ///
885    /// Gaps come from logging dropouts, a scheduler overrun, or a sensor that genuinely
886    /// stopped, and the filter cannot tell which. The default passes the longest ordinary
887    /// interval on every corpus log, 90.5 ms, with 10 ms to spare
888    /// ([evidence](https://github.com/wboayue/fusion-nav/blob/main/DESIGN.md#max_predict_dt));
889    /// the replay harness's `--derive` reads a log's own, and prints it where it exceeds this.
890    ///
891    /// The same bound holds a measurement timed after the state, which is carried forward to
892    /// its time on the estimated velocity and the last sample's rate: the longest the filter
893    /// extrapolates on one sample either way. Later than this, a `fuse_*` refuses it as
894    /// [`Fusion::OutOfHorizon`](crate::Fusion::OutOfHorizon).
895    pub max_predict_dt: Seconds,
896    /// Coasting across a step longer than [`max_predict_dt`](Self::max_predict_dt), equation
897    /// (22′). `None` refuses the step instead, as
898    /// [`Propagation::StepTooLong`](crate::Propagation::StepTooLong).
899    pub coast: Option<Coast>,
900    /// The position hold an unaided filter fuses to keep its tilt, equation (28″). `None`
901    /// fuses nothing while unaided, and tilt is lost on the schedule [`ImuNoise`] sets.
902    pub hold: Option<Hold>,
903    /// Random walk of the barometric offset, m s⁻¹ / √Hz: the `q_b` of equation (30′).
904    ///
905    /// `α₀`, the reference a barometer's altitude is measured against, is estimated rather
906    /// than held constant, and this is how fast the filter lets it move. A barometer's
907    /// reference drifts with the weather and the sensor, which no window can measure, so it
908    /// is configured as [`ImuNoise`] is.
909    ///
910    /// The default is PX4's `baro_bias_nsd`, `src/modules/ekf2/EKF/common.h:347` at
911    /// `c4e4ef98e9`, and the corpus's one long drifting barometer agrees with it within what a
912    /// log can say: the replay harness's `--derive` reads `2c42096b`'s walk as 0.21, an upper
913    /// bound good to about half, and the log rejects no GNSS height at either value, where a
914    /// constant reference, zero, rejects 14
915    /// ([measured](https://github.com/wboayue/fusion-nav/blob/main/DESIGN.md#baro_offset_walk)).
916    ///
917    /// What it costs is height where the barometer does not drift. The simulator's never
918    /// does, and there `mission` scores 0.170 m of vertical RMSE here against 0.085 at zero:
919    /// the offset walks away from what the barometer knew, and GNSS height takes over the low
920    /// frequencies. A barometer characterized on the bench as more stable than this is the
921    /// reason to lower it.
922    pub baro_offset_walk: f32,
923    /// Whether a filter holding no barometric reference takes one from the estimate, at the
924    /// first altitude once position is established. Equations (30) and (30′).
925    ///
926    /// On by default, because every start that leaves no reference — one in motion, a window
927    /// with no barometer, a seed — otherwise discards the barometer for the whole flight:
928    /// `cd7e0001`, a coarse start, refuses all 3530 of its altitudes, as `2c42096b` refused
929    /// 35575 while it started coarse. PX4 does the same at
930    /// `baro_height_control.cpp:79` at `c4e4ef98`. See
931    /// [`Eskf::fuse_baro_altitude`](crate::Eskf::fuse_baro_altitude) for how the reference is
932    /// seeded.
933    ///
934    /// Off also keeps [`Recovery`] from reading a new one, for a barometer or a GNSS height
935    /// locked out: the caller's reference outlives both.
936    ///
937    /// Off is for an application that names its own reference with
938    /// [`Eskf::set_baro_reference`](crate::Eskf::set_baro_reference) — a surveyed pad, say —
939    /// and would rather altitudes were refused with
940    /// [`Fusion::NoReference`](crate::Fusion::NoReference) until it does than referred to the
941    /// estimate's height at whatever moment the first reading arrived.
942    pub baro_reference_from_estimate: bool,
943    /// Whether the filter runs the yaw estimator of equations (45)–(52): a bank of yaw
944    /// hypotheses weighed by each GNSS velocity, whose answer is adopted as the first heading
945    /// where no heading source has established one.
946    ///
947    /// It is how a multirotor with no magnetometer and no second antenna gets a heading, which
948    /// [`Eskf::fuse_course`](crate::Eskf::fuse_course) cannot give a vehicle that flies in any
949    /// direction facing any other. PX4 and ArduPilot run the same estimator (`EKFGSF_yaw`).
950    /// It needs GNSS velocity and a horizontal acceleration: a hover separates no hypotheses.
951    ///
952    /// On by default, per [rejection
953    /// handling](https://github.com/wboayue/fusion-nav/blob/main/GOALS.md#rejection-handling-recover-by-default-opt-out-per-source).
954    /// Off, it is never stepped and costs no cycles, for a vehicle that always has a heading
955    /// source or a processor that cannot afford five small filters on every IMU sample
956    /// ([cost](https://github.com/wboayue/fusion-nav/blob/main/validation/cost.md)).
957    pub yaw_estimator: bool,
958}
959
960impl Default for Config {
961    // Written out rather than derived: `Seconds::default()` is zero, which would make
962    // `max_predict_dt` refuse every step.
963    fn default() -> Self {
964        Self {
965            imu: ImuNoise::default(),
966            gates: Gates::default(),
967            timeouts: Timeouts::default(),
968            recovery: Recovery::default(),
969            correlation: Correlation::default(),
970            init: Initialization::default(),
971            accuracy: Accuracy::default(),
972            gravity: GRAVITY,
973            max_predict_dt: Seconds::from_secs(0.1),
974            coast: Some(Coast::default()),
975            hold: Some(Hold::default()),
976            baro_offset_walk: 0.13,
977            baro_reference_from_estimate: true,
978            yaw_estimator: true,
979        }
980    }
981}
982
983impl Config {
984    /// Check every value the filter would otherwise take on trust; [`Eskf::new`] calls it.
985    ///
986    /// `Config` is plain data, built as `Config { field, ..Config::default() }`, so the bound
987    /// on each value is checked once where it enters the filter rather than by a type per
988    /// field. A value outside its bound is not a tuning the filter can honour: each reads as
989    /// something else downstream, silently. A NaN [`Recovery`] timeout never compares true,
990    /// so that source's recovery is off; a zero one adopts on the first rejection, so its gate
991    /// is off. A NaN density in [`ImuNoise`], [`Coast`] or
992    /// [`baro_offset_walk`](Self::baro_offset_walk) makes every step's covariance non-finite,
993    /// so [`predict`](crate::Eskf::predict) refuses each one and the filter stops. A NaN
994    /// [`Accuracy`] bound makes every quantity invalid for good. A negative density is squared
995    /// into the same `Q` as its magnitude and is refused as a claim nothing means, the sign of
996    /// a unit or a field confused. `None` is how an optional value says off, so an infinite one
997    /// is refused rather than taken as a second spelling of it.
998    ///
999    /// [`Eskf::new`]: crate::Eskf::new
1000    pub fn validate(&self) -> Result<(), ConfigError> {
1001        use ConfigBound::{NonNegative, Positive};
1002        // Destructured without `..`: a field added to `Config`, or to any part of it checked
1003        // here, does not compile until it is placed below.
1004        let Config {
1005            imu:
1006                ImuNoise {
1007                    gyro_white,
1008                    accel_white,
1009                    gyro_bias_walk,
1010                    accel_bias_walk,
1011                },
1012            // A `Gate` is valid by construction.
1013            gates: _,
1014            timeouts: Timeouts {
1015                dead_reckoning_after,
1016            },
1017            recovery,
1018            correlation,
1019            init:
1020                Initialization {
1021                    min_duration,
1022                    max_gyro_rate,
1023                    max_accel_deviation,
1024                    sigma_position,
1025                    sigma_velocity,
1026                    sigma_tilt,
1027                    sigma_yaw,
1028                    sigma_accel_bias,
1029                    sigma_gyro_bias,
1030                },
1031            accuracy:
1032                Accuracy {
1033                    tilt,
1034                    heading,
1035                    position,
1036                    velocity,
1037                    horizon,
1038                },
1039            gravity,
1040            max_predict_dt,
1041            coast,
1042            hold,
1043            baro_offset_walk,
1044            baro_reference_from_estimate: _,
1045            yaw_estimator: _,
1046        } = *self;
1047        let check = |field, value: f32, bound: ConfigBound| {
1048            if bound.holds(value) {
1049                Ok(())
1050            } else {
1051                Err(ConfigError {
1052                    field,
1053                    value,
1054                    bound,
1055                })
1056            }
1057        };
1058        check("imu.gyro_white", gyro_white, NonNegative)?;
1059        check("imu.accel_white", accel_white, NonNegative)?;
1060        check("imu.gyro_bias_walk", gyro_bias_walk, NonNegative)?;
1061        check("imu.accel_bias_walk", accel_bias_walk, NonNegative)?;
1062        let dead_reckoning_after = dead_reckoning_after.as_secs();
1063        check(
1064            "timeouts.dead_reckoning_after",
1065            dead_reckoning_after,
1066            Positive,
1067        )?;
1068        for (field, after) in recovery.named().into_iter().chain(correlation.named()) {
1069            if let Some(after) = after {
1070                check(field, after.as_secs(), Positive)?;
1071            }
1072        }
1073        check("init.min_duration", min_duration.as_secs(), NonNegative)?;
1074        check(
1075            "init.max_gyro_rate",
1076            max_gyro_rate.as_rad_per_s(),
1077            NonNegative,
1078        )?;
1079        let deviation = max_accel_deviation.as_m_per_s2();
1080        check("init.max_accel_deviation", deviation, NonNegative)?;
1081        check("init.sigma_position", sigma_position.as_meters(), Positive)?;
1082        check("init.sigma_velocity", sigma_velocity.as_m_per_s(), Positive)?;
1083        check("init.sigma_tilt", sigma_tilt.as_radians(), Positive)?;
1084        check("init.sigma_yaw", sigma_yaw.as_radians(), Positive)?;
1085        check(
1086            "init.sigma_accel_bias",
1087            sigma_accel_bias.as_m_per_s2(),
1088            Positive,
1089        )?;
1090        check(
1091            "init.sigma_gyro_bias",
1092            sigma_gyro_bias.as_rad_per_s(),
1093            Positive,
1094        )?;
1095        check("accuracy.tilt", tilt.as_radians(), Positive)?;
1096        check("accuracy.heading", heading.as_radians(), Positive)?;
1097        check("accuracy.position", position.as_meters(), Positive)?;
1098        check("accuracy.velocity", velocity.as_m_per_s(), Positive)?;
1099        check("accuracy.horizon", horizon.as_secs(), NonNegative)?;
1100        check("gravity", gravity, ConfigBound::Gravity)?;
1101        check("max_predict_dt", max_predict_dt.as_secs(), Positive)?;
1102        if let Some(Coast {
1103            acceleration,
1104            rotation,
1105        }) = coast
1106        {
1107            check("coast.acceleration", acceleration, NonNegative)?;
1108            check("coast.rotation", rotation, NonNegative)?;
1109        }
1110        if let Some(Hold { sigma }) = hold {
1111            check("hold.sigma", sigma.as_meters(), Positive)?;
1112        }
1113        check("baro_offset_walk", baro_offset_walk, NonNegative)
1114    }
1115}
1116
1117impl Recovery {
1118    /// Each source's timeout, named by its path from [`Config`]. Destructured without `..`,
1119    /// so a source added here is a compile error until [`Config::validate`] sees it.
1120    fn named(self) -> [(&'static str, Option<Seconds>); 8] {
1121        let Self {
1122            gnss_position,
1123            gnss_height,
1124            gnss_velocity,
1125            baro_altitude,
1126            mag_heading,
1127            gnss_heading,
1128            course,
1129            yaw_estimator,
1130        } = self;
1131        [
1132            ("recovery.gnss_position", gnss_position),
1133            ("recovery.gnss_height", gnss_height),
1134            ("recovery.gnss_velocity", gnss_velocity),
1135            ("recovery.baro_altitude", baro_altitude),
1136            ("recovery.mag_heading", mag_heading),
1137            ("recovery.gnss_heading", gnss_heading),
1138            ("recovery.course", course),
1139            ("recovery.yaw_estimator", yaw_estimator),
1140        ]
1141    }
1142}
1143
1144impl Correlation {
1145    /// Each source's `τ`, named by its path from [`Config`]; as [`Recovery::named`].
1146    fn named(self) -> [(&'static str, Option<Seconds>); 7] {
1147        let Self {
1148            gnss_position,
1149            gnss_height,
1150            gnss_velocity,
1151            baro_altitude,
1152            mag_heading,
1153            gnss_heading,
1154            course,
1155        } = self;
1156        [
1157            ("correlation.gnss_position", gnss_position),
1158            ("correlation.gnss_height", gnss_height),
1159            ("correlation.gnss_velocity", gnss_velocity),
1160            ("correlation.baro_altitude", baro_altitude),
1161            ("correlation.mag_heading", mag_heading),
1162            ("correlation.gnss_heading", gnss_heading),
1163            ("correlation.course", course),
1164        ]
1165    }
1166}
1167
1168/// Why [`Config::validate`] refused a configuration: the first field outside its bound.
1169#[cfg_attr(feature = "defmt", derive(defmt::Format))]
1170#[derive(Clone, Copy, Debug, PartialEq)]
1171pub struct ConfigError {
1172    /// The field's path from [`Config`], as it is written in a struct literal:
1173    /// `"recovery.gnss_height"`.
1174    pub field: &'static str,
1175    /// The value it held, in the field's own unit: seconds for a duration, the density's
1176    /// unit for a density.
1177    pub value: f32,
1178    /// The bound it failed.
1179    pub bound: ConfigBound,
1180}
1181
1182/// The bound a [`Config`] value must meet. Each refuses NaN and ±∞.
1183#[cfg_attr(feature = "defmt", derive(defmt::Format))]
1184#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1185pub enum ConfigBound {
1186    /// Greater than zero: a duration the filter waits for or divides by, a standard
1187    /// deviation, or an accuracy bar.
1188    Positive,
1189    /// Zero or more, where zero is a claim rather than a fault: a noise density (no noise of
1190    /// that kind), a window duration or stationarity tolerance, or a projection horizon.
1191    NonNegative,
1192    /// Within Earth's normal gravity, 9.7 to 9.9 m s⁻²: the equator at about 26 km to the poles
1193    /// at sea level, with margin above. Anything outside is a unit, not a site.
1194    Gravity,
1195}
1196
1197impl ConfigBound {
1198    fn holds(self, value: f32) -> bool {
1199        value.is_finite()
1200            && match self {
1201                Self::Positive => value > 0.0,
1202                Self::NonNegative => value >= 0.0,
1203                Self::Gravity => (9.7..=9.9).contains(&value),
1204            }
1205    }
1206}
1207
1208impl core::fmt::Display for ConfigError {
1209    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1210        let bound = match self.bound {
1211            ConfigBound::Positive => "finite and positive",
1212            ConfigBound::NonNegative => "finite and not negative",
1213            ConfigBound::Gravity => "Earth's normal gravity, 9.7 to 9.9",
1214        };
1215        let value = Fixed::new(self.value, Decimals::Three);
1216        write!(f, "config {} is {value}, and must be {bound}", self.field)
1217    }
1218}
1219
1220impl core::error::Error for ConfigError {}
1221
1222#[cfg(test)]
1223mod tests {
1224    use super::*;
1225    use nalgebra::ComplexField;
1226
1227    /// The chi-square CDF at `x` for one or three degrees of freedom, by Simpson's rule.
1228    ///
1229    /// Substituting `x = t²` removes the one-dof density's singularity at zero and leaves the
1230    /// same constant for both: `F(x) = √(2/π) ∫₀^√x t^(k−1) e^(−t²/2) dt`. Computed rather than
1231    /// looked up, so the table is checked against the distribution and not against a copy.
1232    fn chi_square_cdf(dof: i32, x: f64) -> f64 {
1233        const STEPS: usize = 10_000;
1234        let upper = ComplexField::sqrt(x);
1235        let h = upper / STEPS as f64;
1236        let density = |t: f64| t.powi(dof - 1) * ComplexField::exp(-t * t / 2.0);
1237        let interior: f64 = (1..STEPS)
1238            .map(|i| density(i as f64 * h) * if i % 2 == 1 { 4.0 } else { 2.0 })
1239            .sum();
1240        let integral = h / 3.0 * (density(0.0) + interior + density(upper));
1241        ComplexField::sqrt(2.0 / core::f64::consts::PI) * integral
1242    }
1243
1244    const PERCENTILES: [(Percentile, f64); 3] = [
1245        (Percentile::P95, 0.95),
1246        (Percentile::P99, 0.99),
1247        (Percentile::P999, 0.999),
1248    ];
1249
1250    #[test]
1251    fn each_threshold_is_the_quantile_it_names() {
1252        // Three-significant-figure values (7.81, 3.84) miss by 1e-4,
1253        // so the bound separates a quantile from a rounded one.
1254        for (percentile, p) in PERCENTILES {
1255            let one = Gate::<1>::at(percentile).threshold();
1256            let three = Gate::<3>::at(percentile).threshold();
1257            let two = Gate::<2>::at(percentile).threshold();
1258            let f1 = chi_square_cdf(1, f64::from(one));
1259            let f2 = 1.0 - ComplexField::exp(-f64::from(two) / 2.0);
1260            let f3 = chi_square_cdf(3, f64::from(three));
1261            assert!((f1 - p).abs() < 1e-6, "1 dof at {p}: F({one}) = {f1}");
1262            assert!((f2 - p).abs() < 1e-6, "2 dof at {p}: F({two}) = {f2}");
1263            assert!((f3 - p).abs() < 1e-6, "3 dof at {p}: F({three}) = {f3}");
1264        }
1265    }
1266
1267    #[test]
1268    fn a_threshold_that_would_fail_silently_is_refused() {
1269        for bad in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, 0.0, -0.0, -1.0] {
1270            assert_eq!(Gate::<1>::new(bad), None, "{bad}");
1271        }
1272        assert_eq!(Gate::<3>::new(11.34).map(Gate::threshold), Some(11.34));
1273        assert_eq!(
1274            Gate::<1>::new(f32::MIN_POSITIVE).map(Gate::threshold),
1275            Some(f32::MIN_POSITIVE)
1276        );
1277    }
1278
1279    #[test]
1280    fn the_default_is_the_999th_percentile() {
1281        assert_eq!(Gates::default(), Gates::at(Percentile::P999));
1282        assert_eq!(
1283            Gates::default().gnss_position,
1284            Gate::<2>::at(Percentile::P999)
1285        );
1286        assert_eq!(
1287            Gates::default().baro_altitude,
1288            Gate::<1>::at(Percentile::P999)
1289        );
1290    }
1291
1292    /// Every field [`Config::validate`] checks, with a way to write a value into it and the
1293    /// bound it must meet. Written out as the struct literal names them, so a path that
1294    /// `validate` misspells is a failure here rather than a message nobody can act on.
1295    #[allow(clippy::type_complexity)]
1296    const FIELDS: [(&str, fn(&mut Config, f32), ConfigBound); 40] = {
1297        use ConfigBound::{NonNegative, Positive};
1298        [
1299            ("imu.gyro_white", |c, v| c.imu.gyro_white = v, NonNegative),
1300            ("imu.accel_white", |c, v| c.imu.accel_white = v, NonNegative),
1301            (
1302                "imu.gyro_bias_walk",
1303                |c, v| c.imu.gyro_bias_walk = v,
1304                NonNegative,
1305            ),
1306            (
1307                "imu.accel_bias_walk",
1308                |c, v| c.imu.accel_bias_walk = v,
1309                NonNegative,
1310            ),
1311            (
1312                "timeouts.dead_reckoning_after",
1313                |c, v| c.timeouts.dead_reckoning_after = Seconds::from_secs(v),
1314                Positive,
1315            ),
1316            (
1317                "recovery.gnss_position",
1318                |c, v| c.recovery.gnss_position = Some(Seconds::from_secs(v)),
1319                Positive,
1320            ),
1321            (
1322                "recovery.gnss_height",
1323                |c, v| c.recovery.gnss_height = Some(Seconds::from_secs(v)),
1324                Positive,
1325            ),
1326            (
1327                "recovery.gnss_velocity",
1328                |c, v| c.recovery.gnss_velocity = Some(Seconds::from_secs(v)),
1329                Positive,
1330            ),
1331            (
1332                "recovery.baro_altitude",
1333                |c, v| c.recovery.baro_altitude = Some(Seconds::from_secs(v)),
1334                Positive,
1335            ),
1336            (
1337                "recovery.mag_heading",
1338                |c, v| c.recovery.mag_heading = Some(Seconds::from_secs(v)),
1339                Positive,
1340            ),
1341            (
1342                "recovery.gnss_heading",
1343                |c, v| c.recovery.gnss_heading = Some(Seconds::from_secs(v)),
1344                Positive,
1345            ),
1346            (
1347                "recovery.course",
1348                |c, v| c.recovery.course = Some(Seconds::from_secs(v)),
1349                Positive,
1350            ),
1351            (
1352                "recovery.yaw_estimator",
1353                |c, v| c.recovery.yaw_estimator = Some(Seconds::from_secs(v)),
1354                Positive,
1355            ),
1356            (
1357                "correlation.gnss_position",
1358                |c, v| c.correlation.gnss_position = Some(Seconds::from_secs(v)),
1359                Positive,
1360            ),
1361            (
1362                "correlation.gnss_height",
1363                |c, v| c.correlation.gnss_height = Some(Seconds::from_secs(v)),
1364                Positive,
1365            ),
1366            (
1367                "correlation.gnss_velocity",
1368                |c, v| c.correlation.gnss_velocity = Some(Seconds::from_secs(v)),
1369                Positive,
1370            ),
1371            (
1372                "correlation.baro_altitude",
1373                |c, v| c.correlation.baro_altitude = Some(Seconds::from_secs(v)),
1374                Positive,
1375            ),
1376            (
1377                "correlation.mag_heading",
1378                |c, v| c.correlation.mag_heading = Some(Seconds::from_secs(v)),
1379                Positive,
1380            ),
1381            (
1382                "correlation.gnss_heading",
1383                |c, v| c.correlation.gnss_heading = Some(Seconds::from_secs(v)),
1384                Positive,
1385            ),
1386            (
1387                "correlation.course",
1388                |c, v| c.correlation.course = Some(Seconds::from_secs(v)),
1389                Positive,
1390            ),
1391            (
1392                "init.min_duration",
1393                |c, v| c.init.min_duration = Seconds::from_secs(v),
1394                NonNegative,
1395            ),
1396            (
1397                "init.max_gyro_rate",
1398                |c, v| c.init.max_gyro_rate = RadiansPerSecond::from_rad_per_s(v),
1399                NonNegative,
1400            ),
1401            (
1402                "init.max_accel_deviation",
1403                |c, v| c.init.max_accel_deviation = MetersPerSecond2::from_m_per_s2(v),
1404                NonNegative,
1405            ),
1406            (
1407                "init.sigma_position",
1408                |c, v| c.init.sigma_position = Meters::from_meters(v),
1409                Positive,
1410            ),
1411            (
1412                "init.sigma_velocity",
1413                |c, v| c.init.sigma_velocity = MetersPerSecond::from_m_per_s(v),
1414                Positive,
1415            ),
1416            (
1417                "init.sigma_tilt",
1418                |c, v| c.init.sigma_tilt = Radians::from_radians(v),
1419                Positive,
1420            ),
1421            (
1422                "init.sigma_yaw",
1423                |c, v| c.init.sigma_yaw = Radians::from_radians(v),
1424                Positive,
1425            ),
1426            (
1427                "init.sigma_accel_bias",
1428                |c, v| c.init.sigma_accel_bias = MetersPerSecond2::from_m_per_s2(v),
1429                Positive,
1430            ),
1431            (
1432                "init.sigma_gyro_bias",
1433                |c, v| c.init.sigma_gyro_bias = RadiansPerSecond::from_rad_per_s(v),
1434                Positive,
1435            ),
1436            (
1437                "accuracy.tilt",
1438                |c, v| c.accuracy.tilt = Radians::from_radians(v),
1439                Positive,
1440            ),
1441            (
1442                "accuracy.heading",
1443                |c, v| c.accuracy.heading = Radians::from_radians(v),
1444                Positive,
1445            ),
1446            (
1447                "accuracy.position",
1448                |c, v| c.accuracy.position = Meters::from_meters(v),
1449                Positive,
1450            ),
1451            (
1452                "accuracy.velocity",
1453                |c, v| c.accuracy.velocity = MetersPerSecond::from_m_per_s(v),
1454                Positive,
1455            ),
1456            (
1457                "accuracy.horizon",
1458                |c, v| c.accuracy.horizon = Seconds::from_secs(v),
1459                NonNegative,
1460            ),
1461            ("gravity", |c, v| c.gravity = v, ConfigBound::Gravity),
1462            (
1463                "max_predict_dt",
1464                |c, v| c.max_predict_dt = Seconds::from_secs(v),
1465                Positive,
1466            ),
1467            (
1468                "coast.acceleration",
1469                |c, v| c.coast.get_or_insert_default().acceleration = v,
1470                NonNegative,
1471            ),
1472            (
1473                "coast.rotation",
1474                |c, v| c.coast.get_or_insert_default().rotation = v,
1475                NonNegative,
1476            ),
1477            (
1478                "hold.sigma",
1479                |c, v| c.hold.get_or_insert_default().sigma = Meters::from_meters(v),
1480                Positive,
1481            ),
1482            (
1483                "baro_offset_walk",
1484                |c, v| c.baro_offset_walk = v,
1485                NonNegative,
1486            ),
1487        ]
1488    };
1489
1490    #[test]
1491    fn the_defaults_and_every_switch_off_validate() {
1492        assert_eq!(Config::default().validate(), Ok(()));
1493        let off = Config {
1494            recovery: Recovery::OFF,
1495            correlation: Correlation::WHITE,
1496            coast: None,
1497            hold: None,
1498            ..Config::default()
1499        };
1500        assert_eq!(off.validate(), Ok(()));
1501    }
1502
1503    #[test]
1504    fn each_field_outside_its_bound_is_refused_by_name() {
1505        for (field, set, bound) in FIELDS {
1506            let bad: &[f32] = match bound {
1507                // Standard gravity in ft s⁻², and in units of `g`, either side of the band.
1508                ConfigBound::Gravity => &[f32::NAN, f32::INFINITY, 0.0, 1.0, 9.69, 9.91, 32.174],
1509                _ => &[
1510                    f32::NAN,
1511                    f32::INFINITY,
1512                    f32::NEG_INFINITY,
1513                    -1.0,
1514                    -f32::MIN_POSITIVE,
1515                    0.0,
1516                    -0.0,
1517                ],
1518            };
1519            // Zero is a claim for a density, not a fault.
1520            for &value in bad
1521                .iter()
1522                .filter(|v| **v != 0.0 || bound != ConfigBound::NonNegative)
1523            {
1524                let mut config = Config::default();
1525                set(&mut config, value);
1526                let refused = config.validate().err();
1527                assert_eq!(
1528                    refused.map(|e| (e.field, e.bound)),
1529                    Some((field, bound)),
1530                    "{field} = {value}"
1531                );
1532            }
1533        }
1534    }
1535
1536    #[test]
1537    fn each_field_inside_its_bound_is_accepted() {
1538        // The smallest value each bound admits, and a large one: the bound is the check, not
1539        // a range someone thought plausible.
1540        for (field, set, bound) in FIELDS {
1541            let (least, most) = match bound {
1542                ConfigBound::Positive => (f32::MIN_POSITIVE, 1.0e6),
1543                ConfigBound::NonNegative => (0.0, 1.0e6),
1544                ConfigBound::Gravity => (9.7, 9.9),
1545            };
1546            for value in [least, most] {
1547                let mut config = Config::default();
1548                set(&mut config, value);
1549                assert_eq!(config.validate(), Ok(()), "{field} = {value}");
1550            }
1551        }
1552    }
1553}