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}