Skip to main content

fusion_nav/eskf/
site.rs

1//! Where the filter is: the origin of (43)–(44), the declination `D_m` of (6) and (35), and the
2//! barometric reference `α₀` of (30) with its offset, (30′).
3//!
4//! Entry points: [`Eskf::set_origin`], [`Eskf::origin`], [`Eskf::geodetic_position`],
5//! [`Eskf::set_magnetic_declination`], [`Eskf::magnetic_declination`],
6//! [`Eskf::set_baro_reference`] and [`Eskf::baro_reference`].
7
8use crate::frames::Body;
9use crate::geodetic::{Geodetic, LocalOrigin};
10use crate::math::{exp_quat, wrap_pi};
11use crate::state::{Offset, State};
12use crate::units::{Altitude, AltitudeNoise, Attitude, Position, Radians, Timestamp};
13use nalgebra::Vector3;
14
15use super::Eskf;
16
17impl Eskf {
18    /// Put the navigation origin at a known point, such as a surveyed home or a landing
19    /// pad. Equation (43).
20    ///
21    /// Without this, the first [`fuse_gnss_geodetic`](Self::fuse_gnss_geodetic) places
22    /// the origin, which is right for most flights. Setting it is for an application whose
23    /// positions mean something relative to a fixed point.
24    ///
25    /// The vehicle does not move. With an origin already held, the position estimate is
26    /// re-expressed about the new one, so the geodetic position is unchanged and only the
27    /// numbers describing it are; its covariance is untouched, because a change of origin
28    /// adds no uncertainty. With none held, the current position is taken to be relative
29    /// to this one, which is the caller's claim to make — true after a static start on
30    /// the point being named, false anywhere else.
31    ///
32    /// Call it after initializing: a static start clears the origin, since it declares
33    /// position zero to be wherever the vehicle is. Called before, it still names the site,
34    /// which is where the magnetic model is read (see
35    /// [`set_magnetic_declination`](Self::set_magnetic_declination)): the window then levels
36    /// its heading at the site's declination, and a heading only the magnetometer set is
37    /// turned whenever a later origin changes it.
38    ///
39    /// Returns `false`, changing nothing, for an origin with a coordinate that is not a
40    /// number or a latitude beyond ±90°, or one so far from the held position that it cannot
41    /// be written in `f32` meters from the new origin.
42    #[must_use = "a refused origin leaves the filter to place its own on the first fix"]
43    pub fn set_origin(&mut self, origin: Geodetic) -> bool {
44        let Some(new) = LocalOrigin::new(origin) else {
45            return false;
46        };
47        if let Some(old) = self.origin {
48            let position = new.to_ned(old.to_geodetic(self.estimate.state().position));
49            // An origin further from the vehicle than `f32` reaches, such as a height of
50            // 1e38 m, which `LocalOrigin::new` has no reason to refuse on its own.
51            if !position.is_finite() {
52                return false;
53            }
54            self.estimate.commit(State {
55                position,
56                ..*self.estimate.state()
57            });
58        }
59        self.place_origin(new);
60        true
61    }
62
63    /// The navigation origin: the point [`State::position`](crate::State::position) is
64    /// relative to, and the tangent plane the filter converts geodetic fixes in. `None`
65    /// until the first [`fuse_gnss_geodetic`](Self::fuse_gnss_geodetic) or
66    /// [`set_origin`](Self::set_origin).
67    ///
68    /// Also the conversion an application wants for anything else it holds in latitude
69    /// and longitude — a waypoint, a geofence — so that it lands in the same frame as the
70    /// estimate.
71    pub const fn origin(&self) -> Option<LocalOrigin> {
72        self.origin
73    }
74
75    /// The position estimate as latitude, longitude and height, once the filter is
76    /// initialized and has an [`origin`](Self::origin) to place it with.
77    pub fn geodetic_position(&self) -> Option<Geodetic> {
78        if !self.initialized {
79            return None;
80        }
81        self.origin
82            .map(|origin| origin.to_geodetic(self.estimate.state().position))
83    }
84
85    /// Hold `origin` as the navigation origin, and read the site's declination from the
86    /// magnetic model there unless the caller has set one. Every placement reads the model:
87    /// [`set_origin`](Self::set_origin) and the coarse start's through here, and (44) through
88    /// [`place_origin_under_estimate`](Self::place_origin_under_estimate), at the fix.
89    ///
90    /// GOALS.md differentiator 7: the site is the one thing the model needs, and the origin is
91    /// the moment the filter learns it, as PX4 learns it from its first valid fix
92    /// (`updateWorldMagneticModel`, `EKF/aid_sources/magnetometer/mag_control.cpp:642-644` at
93    /// `c4e4ef98`).
94    ///
95    /// A heading referred to north through the declination alone is turned by the change,
96    /// about navigation down, since the heading (6) leveled or a magnetometer set was the
97    /// magnetic heading plus the old value and is that plus the new one. A static start is
98    /// the usual case: it levels before any fix names the site, and the first fix arrives a
99    /// declination later. Left alone, that is a standing innovation of the whole change on
100    /// every heading (14.0° at 56° N, 44° E), which the gate of (37) reads as a disturbed
101    /// magnetometer and turns away until [`Config::recovery`](crate::Config::recovery)
102    /// adopts one. The covariance is kept as it was: the turn composes on the left, so the
103    /// body-frame error `δθ` of (2) is the same error before and after it, and the tilt a
104    /// window leveled against its accelerometer bias by (8) keeps that correlation in the
105    /// body axes it was built in. A heading any true source has vouched for is not turned,
106    /// and the change arrives as an innovation, as a caller's does. GNSS position and velocity
107    /// do not count as one, though they correct heading through the correlations while the
108    /// vehicle accelerates: a first origin arrives before that matters, and a caller moving
109    /// the origin mid-flight to a distant site sets the declination itself.
110    pub(super) fn place_origin(&mut self, origin: LocalOrigin) {
111        if let Some(learned) = self.declination_at(origin.geodetic()) {
112            self.learn_declination(learned);
113        }
114        self.origin = Some(origin);
115    }
116
117    /// Place the origin by equation (44) under the estimate of the antenna when `fix` was
118    /// taken, so that the fix's error becomes the position's. Returns `false`, committing
119    /// nothing, where no origin puts `fix` at the estimate (see [`LocalOrigin::placing`]).
120    ///
121    /// The estimate of the antenna's position is what the fix measures, (28′), with the
122    /// heading the site's declination turns it to first: read before, a 1 m arm under a 14.0°
123    /// turn misplaces the origin by 0.24 m. The turn is committed only once the origin is
124    /// found, so a fix refused here changes nothing. [`place_origin`](Self::place_origin)
125    /// takes the other order, for an origin already named.
126    pub(super) fn place_origin_under_estimate(
127        &mut self,
128        fix: Geodetic,
129        antenna: Position<Body>,
130        time: Timestamp,
131    ) -> bool {
132        let learned = self.declination_at(fix);
133        let turn = learned.map_or(0.0, |(_, turn)| turn);
134        let (past, _) = self.past(time);
135        let attitude_then = exp_quat(Vector3::z() * turn) * past.attitude.quaternion();
136        let antenna_then = past.position.vector() + attitude_then * antenna.vector();
137        let Some(origin) = LocalOrigin::placing(fix, Position::from_vector(antenna_then)) else {
138            return false;
139        };
140        if let Some(learned) = learned {
141            self.learn_declination(learned);
142        }
143        self.origin = Some(origin);
144        true
145    }
146
147    /// Set the magnetic declination at the operating site: `D_m` of equations (6) and (35),
148    /// east-positive, the angle that turns a magnetic heading into a true one.
149    ///
150    /// Optional where the `magnetic-model` feature is on, as it is by default: the filter
151    /// reads the declination from a World Magnetic Model table wherever it places its
152    /// [`origin`](Self::origin), and turns a heading only the magnetometer has referred to
153    /// north along with it (see [`Geodetic::magnetic_declination`]). A value set here is the
154    /// caller's and the model never overrides it; that is the call for a site the table
155    /// describes badly or a date far from its epoch. Zero until one or the other.
156    ///
157    /// Held by the filter rather than [`Config`](crate::Config) because it is a property of where
158    /// the vehicle is, like the [`origin`](Self::origin) and `α₀`, and a vehicle that powers on
159    /// before a GNSS fix learns its site only once a fix arrives. PX4 tracks it at runtime from the
160    /// last valid GNSS position (`EKF/estimator_interface.h:485` at `c4e4ef98`).
161    ///
162    /// Read wherever a magnetic heading becomes a true one: the heading a static window
163    /// commits by (6), every [`fuse_mag_heading`](Self::fuse_mag_heading) by (35), and the
164    /// heading adoption and recovery. So set it before [`initialize`](Self::initialize) when
165    /// the site is known. It moves no state. A change once heading is established arrives as
166    /// an innovation of exactly the change on the next heading; a large one against the
167    /// heading's σ is [`Fusion::Rejected`](crate::Fusion::Rejected) until
168    /// [`Config::recovery`](crate::Config::recovery) adopts a heading at the new value.
169    ///
170    /// Returns `false`, changing nothing, for a value that is not a finite number: it enters
171    /// every heading innovation for the rest of the flight, so a NaN here ends magnetic
172    /// aiding rather than spoiling one update.
173    #[must_use = "a refused declination leaves the previous one in every heading"]
174    pub fn set_magnetic_declination(&mut self, declination: Radians) -> bool {
175        if !declination.as_radians().is_finite() {
176            return false;
177        }
178        self.declination = declination;
179        self.declination_set = true;
180        true
181    }
182
183    /// The magnetic declination in use; see
184    /// [`set_magnetic_declination`](Self::set_magnetic_declination).
185    pub const fn magnetic_declination(&self) -> Radians {
186        self.declination
187    }
188
189    /// What the magnetic model says at `site`, committing nothing: its declination there, and
190    /// the turn about navigation down that gives a heading referred to north through the
191    /// declination alone (zero for any other). `None` where there is nothing to learn: a
192    /// declination the caller set, or no model.
193    fn declination_at(&self, site: Geodetic) -> Option<(Radians, f32)> {
194        if self.declination_set {
195            return None;
196        }
197        let declination = model_declination(site)?;
198        let change = wrap_pi(declination.as_radians() - self.declination.as_radians());
199        let turns = self.initialized && self.magnetic_north;
200        Some((declination, if turns { change } else { 0.0 }))
201    }
202
203    /// Commit what [`declination_at`](Self::declination_at) read: the declination half of
204    /// [`place_origin`](Self::place_origin), apart for (44), which places the origin with the
205    /// turned heading before it commits the turn.
206    fn learn_declination(&mut self, (declination, turn): (Radians, f32)) {
207        self.declination = declination;
208        if turn != 0.0 {
209            let mut turned =
210                exp_quat(Vector3::z() * turn) * self.estimate.state().attitude.quaternion();
211            turned.renormalize();
212            self.estimate.commit(State {
213                attitude: Attitude::from_quaternion(turned),
214                ..*self.estimate.state()
215            });
216        }
217    }
218
219    /// Set the barometric reference `α₀` directly, with the σ it is known to. Equations (30)
220    /// and (30′).
221    ///
222    /// For a reference known better than the estimate: a surveyed pad, or the ground
223    /// re-established before takeoff. A filter with no reference needs none of this, since
224    /// [`fuse_baro_altitude`](Self::fuse_baro_altitude) takes one from the estimate unless
225    /// [`Config::baro_reference_from_estimate`](crate::Config::baro_reference_from_estimate)
226    /// says otherwise. The filter estimates `α₀` from then on, starting from this σ and
227    /// uncorrelated with the state, so `noise` is the claim that decides how far the first
228    /// disagreement with GNSS height moves it.
229    ///
230    /// Returns `false`, changing nothing, for a reference that is not a number or a σ that is
231    /// not positive: `α₀` appears in every barometric measurement for the rest of the flight,
232    /// so a NaN here is not one bad update but the end of barometric aiding, and a σ of zero
233    /// is a reference no disagreement could ever move.
234    #[must_use = "a refused reference leaves barometric fusion returning NoReference"]
235    pub fn set_baro_reference(&mut self, reference: Altitude, noise: AltitudeNoise) -> bool {
236        if !reference.as_meters().is_finite() || !noise.is_finite() || !noise.is_positive() {
237            return false;
238        }
239        self.establish_reference(Some((reference, Offset::independent(noise.variance()))));
240        true
241    }
242
243    /// The barometric reference `α₀` as currently estimated, equations (30) and (30′):
244    /// measured by a window taken at rest, named by
245    /// [`set_baro_reference`](Self::set_baro_reference), or read against the estimate by the
246    /// first altitude once position is established. `None` until one of those has happened —
247    /// a start in motion keeps whatever reference the flight already had rather than calling
248    /// its own altitude the ground.
249    ///
250    /// Exposed because it is what the filter's zero altitude means: the barometer reading
251    /// the filter would call the origin's height. It moves as barometer and GNSS height
252    /// disagree, at the rate [`Config::baro_offset_walk`](crate::Config::baro_offset_walk)
253    /// allows.
254    pub const fn baro_reference(&self) -> Option<Altitude> {
255        self.baro_reference
256    }
257
258    /// Set `α₀` together with the covariance of its error, or clear both. Equation (30′).
259    ///
260    /// One place, because the two describe one thing: a reference with no offset row claims
261    /// to be exact, and an offset row with no reference correlates the estimate with nothing.
262    pub(super) fn establish_reference(&mut self, reference: Option<(Altitude, Offset)>) {
263        match reference {
264            Some((reference, offset)) => {
265                self.baro_reference = Some(reference);
266                self.commit_offset(offset);
267            }
268            None => {
269                self.baro_reference = None;
270                self.offset = Offset::default();
271            }
272        }
273    }
274
275    /// The offset a fresh covariance keeps: the reference's own variance, and no correlation
276    /// with an error state that has just been replaced. A start that keeps the reference the
277    /// flight had keeps its error too, since nothing about the reference changed.
278    pub(super) fn surviving_offset(&self) -> Offset {
279        Offset::independent(self.offset.variance)
280    }
281}
282
283/// The declination the magnetic model gives at `site`, or `None` without the
284/// `magnetic-model` feature, which links no table.
285fn model_declination(site: Geodetic) -> Option<Radians> {
286    #[cfg(feature = "magnetic-model")]
287    return site.magnetic_declination();
288    #[cfg(not(feature = "magnetic-model"))]
289    {
290        let _ = site;
291        None
292    }
293}
294
295#[cfg(test)]
296mod tests {
297
298    use crate::config::{Config, Correlation};
299    use crate::eskf::Eskf;
300    use crate::eskf::fixtures::*;
301
302    use crate::geodetic::{Geodetic, LocalOrigin};
303    use crate::health::{Fusion, GnssFusion, Propagation};
304    use crate::init::Alignment;
305    use crate::init::tests::{still, turning};
306
307    use crate::observation::mag::tests::measured;
308
309    use crate::state::{Covariance, ErrorState};
310    use crate::units::{
311        Altitude, AltitudeNoise, AngularRate, HeadingNoise, MagField, Position, PositionNoise,
312        Radians, Seconds,
313    };
314
315    #[test]
316    fn before_position_is_established_an_altitude_is_refused_not_referred_to_nothing() {
317        let mut filter = coarse();
318        assert_eq!(filter.baro_reference(), None);
319        assert_eq!(
320            filter.fuse_baro_altitude(
321                filter.now(),
322                Altitude::from_meters(60.0),
323                AltitudeNoise::from_sigma(2.0)
324            ),
325            Fusion::NoReference
326        );
327        assert_eq!(filter.baro_reference(), None);
328        assert_eq!(
329            filter.diagnostics().baro_altitude.time_since_accepted,
330            None,
331            "a refused measurement is not aiding"
332        );
333    }
334
335    #[test]
336    fn after_a_coarse_start_the_first_altitude_past_the_first_fix_sets_the_reference() {
337        let mut filter = coarse();
338        // Every reading here arrives with no step between it and the last, which (24′) takes
339        // for the same error twice; this is (30′)'s arithmetic, on white sources.
340        filter.config.correlation = Correlation::WHITE;
341        assert_eq!(
342            filter.fuse_gnss_position(
343                filter.now(),
344                Position::ned(10.0, -4.0, -30.0),
345                PositionNoise::from_sigma(1.0, 1.0, 2.8),
346                Position::zero()
347            ),
348            GnssFusion::both(Fusion::Reset)
349        );
350
351        // 30 m up by the fix, 130 m by the barometer: α₀ = α + p̂_D = 100 m, and the
352        // altitude lands on the estimate rather than moving it.
353        let before = filter.state().position;
354        assert_eq!(
355            filter.fuse_baro_altitude(
356                filter.now(),
357                Altitude::from_meters(130.0),
358                AltitudeNoise::from_sigma(2.0)
359            ),
360            Fusion::Accepted { test_ratio: 0.0 }
361        );
362        assert_eq!(filter.baro_reference(), Some(Altitude::from_meters(100.0)));
363        assert_eq!(filter.state().position, before, "spent on α₀, not fused");
364        assert_eq!(filter.diagnostics().baro_altitude.accepted, 1);
365
366        // Seeded correlated with the height it was read against, (30′): P_bb = P_DD + R_m,
367        // P_xb = −P[:, D]. An independent σ here is the constant offset that took
368        // `moving_start`'s `nees_pos` to 112.59.
369        let down = ErrorState::PositionDown;
370        let p_dd = filter.covariance().variance(down);
371        assert!((filter.offset.variance - (p_dd + 4.0)).abs() < 1e-4);
372        assert_eq!(
373            filter.offset.cross,
374            -filter.covariance().as_matrix().column(down.index())
375        );
376
377        // So the barometer alone says nothing about absolute height: h = p_D + b has no
378        // covariance with p_D, and a second reading two meters higher moves α₀, not the
379        // estimate. Only GNSS height, disagreeing with both, can.
380        assert!(
381            filter
382                .fuse_baro_altitude(
383                    filter.now(),
384                    Altitude::from_meters(132.0),
385                    AltitudeNoise::from_sigma(2.0)
386                )
387                .is_accepted()
388        );
389        assert!((filter.state().position.vector()[2] + 30.0).abs() < 1e-3);
390        let reference = filter.baro_reference().expect("held").as_meters();
391        assert!(reference > 100.5, "{reference}");
392    }
393
394    #[test]
395    fn with_the_seed_turned_off_an_altitude_waits_for_a_named_reference() {
396        let mut filter = Eskf::new(Config {
397            baro_reference_from_estimate: false,
398            ..Config::default()
399        })
400        .unwrap();
401        let _ = filter
402            .initialize_over(&[still(); 8], Seconds::from_secs(0.25))
403            .expect("a 2 s window of stillness");
404        assert_eq!(
405            filter.fuse_baro_altitude(
406                filter.now(),
407                Altitude::from_meters(60.0),
408                AltitudeNoise::from_sigma(2.0)
409            ),
410            Fusion::NoReference
411        );
412        assert_eq!(filter.baro_reference(), None);
413        assert!(
414            filter.set_baro_reference(Altitude::from_meters(60.0), AltitudeNoise::from_sigma(0.5))
415        );
416        assert!(
417            filter
418                .fuse_baro_altitude(
419                    filter.now(),
420                    Altitude::from_meters(60.0),
421                    AltitudeNoise::from_sigma(2.0)
422                )
423                .is_accepted()
424        );
425    }
426
427    #[test]
428    fn a_static_window_with_no_barometer_takes_the_reference_from_the_first_altitude() {
429        // A barometer that came online after the window closed. The static start put the
430        // vehicle at the origin, so the first reading is the ground.
431        let mut filter = initialized();
432        assert_eq!(filter.baro_reference(), None);
433        assert_eq!(
434            filter.fuse_baro_altitude(
435                filter.now(),
436                Altitude::from_meters(60.0),
437                AltitudeNoise::from_sigma(2.0)
438            ),
439            Fusion::Accepted { test_ratio: 0.0 }
440        );
441        assert_eq!(filter.baro_reference(), Some(Altitude::from_meters(60.0)));
442    }
443
444    #[test]
445    fn a_static_restart_replaces_the_reference() {
446        let mut filter = aided();
447        assert_eq!(filter.baro_reference(), Some(Altitude::from_meters(100.0)));
448        let alignment = filter
449            .initialize_over(&window_at(250.0), Seconds::from_secs(0.25))
450            .expect("a 2 s window");
451        assert_eq!(alignment, Alignment::Static);
452        assert_eq!(
453            filter.baro_reference(),
454            Some(Altitude::from_meters(250.0)),
455            "a static start declares this altitude to be zero"
456        );
457    }
458
459    #[test]
460    fn a_restart_in_motion_keeps_the_reference_the_flight_began_with() {
461        let mut filter = aided();
462
463        // Taking 250 m as the reference would call the current altitude zero: the
464        // barometer would then say z ~ 0 while GNSS about the flight's origin says
465        // z ~ -150.
466        let alignment = filter
467            .initialize_over(&moving_window_at(250.0), Seconds::from_secs(0.25))
468            .expect("moving, not unusable");
469        assert!(matches!(alignment, Alignment::Coarse(..)));
470        assert_eq!(filter.baro_reference(), Some(Altitude::from_meters(100.0)));
471
472        // Short as well as moving, which is what a restart in flight usually offers. The
473        // window is too short to measure motion for `classify`, but `α₀` measures it
474        // anyway rather than reading window length as stillness.
475        let _ = filter
476            .initialize_over(&moving_window_at(250.0), Seconds::from_secs(0.1))
477            .expect("short and moving");
478        assert_eq!(filter.baro_reference(), Some(Altitude::from_meters(100.0)));
479
480        // And a moving window with no barometer in it does not wipe the reference either.
481        let mut window = [still(); 8];
482        window[3].imu = window[3].imu.with_gyro(AngularRate::body(0.0, 0.4, 0.0));
483        let _ = filter
484            .initialize_over(&window, Seconds::from_secs(0.25))
485            .expect("moving, not unusable");
486        assert_eq!(filter.baro_reference(), Some(Altitude::from_meters(100.0)));
487    }
488
489    #[test]
490    fn a_window_too_short_to_align_from_still_names_the_ground_it_sat_on() {
491        // `classify` reports a short window as coarse before it ever measures motion, so
492        // alignment cannot answer this: a vehicle sitting on the ground with 0.8 s of
493        // samples has an honest reference, and reading "coarse" as "moving" would have
494        // cost it barometric aiding for the whole flight with nothing saying so.
495        let mut filter = Eskf::default();
496        let alignment = filter
497            .initialize_over(&window_at(112.0), Seconds::from_secs(0.1))
498            .expect("short, so coarse");
499        assert!(matches!(alignment, Alignment::Coarse(..)));
500        assert_eq!(filter.baro_reference(), Some(Altitude::from_meters(112.0)));
501        assert!(
502            filter
503                .fuse_baro_altitude(
504                    filter.now(),
505                    Altitude::from_meters(112.0),
506                    AltitudeNoise::from_sigma(2.0)
507                )
508                .is_accepted()
509        );
510    }
511
512    #[test]
513    fn a_seed_takes_its_baro_reference_from_the_first_altitude() {
514        let mut filter = Eskf::default();
515        let (state, covariance) = seed();
516        let _ = filter.seed(state, covariance).expect("a sane seed");
517        assert_eq!(filter.baro_reference(), None);
518
519        // The seed holds p_D = 0, so the reading is its zero.
520        assert_eq!(
521            filter.fuse_baro_altitude(
522                filter.now(),
523                Altitude::from_meters(60.0),
524                AltitudeNoise::from_sigma(2.0)
525            ),
526            Fusion::Accepted { test_ratio: 0.0 }
527        );
528        assert_eq!(filter.baro_reference(), Some(Altitude::from_meters(60.0)));
529        assert!(
530            filter
531                .fuse_baro_altitude(
532                    filter.now(),
533                    Altitude::from_meters(60.5),
534                    AltitudeNoise::from_sigma(2.0)
535                )
536                .is_accepted()
537        );
538    }
539
540    #[test]
541    fn reinitializing_in_flight_keeps_the_reference_the_flight_began_with() {
542        let mut filter = aided();
543        let (state, covariance) = seed();
544        let _ = filter.seed(state, covariance).expect("a sane seed");
545        assert_eq!(
546            filter.baro_reference(),
547            Some(Altitude::from_meters(100.0)),
548            "the barometer did not change when the filter restarted"
549        );
550    }
551
552    #[test]
553    fn a_window_at_rest_measures_its_own_reference_variance() {
554        let mut filter = Eskf::default();
555        let window = window_at(100.0);
556        let _ = filter
557            .initialize_over(&window, Seconds::from_secs(0.25))
558            .expect("a 2 s window of stillness");
559        assert_eq!(filter.offset.cross.norm(), 0.0);
560        // Squared deviations of 0.65625 m² over seven degrees of freedom, then over eight
561        // readings: the standard error of their mean.
562        let want = 0.65625 / 7.0 / 8.0;
563        assert!(
564            (filter.offset.variance - want).abs() < 2e-3 * want,
565            "{}",
566            filter.offset.variance
567        );
568    }
569
570    #[test]
571    fn a_barometer_the_receiver_disagrees_with_moves_its_reference() {
572        // The barometer reads 3 m above where the receiver puts the vehicle, every time. A
573        // constant `α₀` would split the difference in the height for ever; an estimated one
574        // takes the disagreement into the reference, and the height follows the receiver.
575        // The receiver is white, as a constant fix is, and says so: at (24′)'s default a
576        // 10 Hz fix is worth 1/280 of one, and 30 s moves the reference 0.8 m of the 3.
577        let mut filter = aided();
578        filter.config.correlation = Correlation::WHITE;
579        for step in 0..3000 {
580            assert_eq!(filter.step(still().imu, DT), Propagation::Propagated);
581            if step % 10 == 0 {
582                let _ = filter.fuse_gnss_position(
583                    filter.now(),
584                    Position::ned(0.0, 0.0, 0.0),
585                    PositionNoise::from_sigma(1.0, 1.0, 1.0),
586                    Position::zero(),
587                );
588                let _ = filter.fuse_baro_altitude(
589                    filter.now(),
590                    Altitude::from_meters(103.0),
591                    AltitudeNoise::from_sigma(0.5),
592                );
593            }
594        }
595        let reference = filter
596            .baro_reference()
597            .expect("the window set one")
598            .as_meters();
599        assert!(reference > 102.0, "α₀ = {reference}");
600        assert!(filter.state().position.vector()[2].abs() < 0.5);
601    }
602
603    #[test]
604    fn a_reference_with_no_uncertainty_is_refused() {
605        let mut filter = initialized();
606        assert!(
607            !filter.set_baro_reference(Altitude::from_meters(52.0), AltitudeNoise::from_sigma(0.0))
608        );
609        assert_eq!(filter.baro_reference(), None);
610    }
611
612    #[test]
613    fn a_declination_set_before_initializing_reaches_the_heading_it_commits() {
614        // The wiring no test in `init` can see. A level vehicle reading a field with no
615        // east component is pointing at magnetic north, so its true heading is the
616        // declination and nothing else.
617        let mut filter = Eskf::default();
618        assert!(filter.set_magnetic_declination(Radians::from_radians(-0.06)));
619        let _ = filter
620            .initialize_over(&window_with_mag(), Seconds::from_secs(0.25))
621            .expect("a 2 s window of stillness");
622
623        let (_, _, yaw) = filter.state().attitude.euler_angles();
624        assert!((yaw + 0.06).abs() < 1e-6, "heading committed as {yaw}");
625    }
626
627    #[test]
628    fn a_declination_that_is_not_a_number_is_refused_and_changes_nothing() {
629        let mut filter = initialized();
630        assert!(filter.set_magnetic_declination(Radians::from_radians(0.1)));
631        assert!(!filter.set_magnetic_declination(Radians::from_radians(f32::NAN)));
632        assert!(!filter.set_magnetic_declination(Radians::from_radians(f32::INFINITY)));
633        assert_eq!(filter.magnetic_declination(), Radians::from_radians(0.1));
634    }
635
636    #[test]
637    fn a_declination_change_moves_the_next_heading_innovation_by_exactly_itself() {
638        // Established heading, so both headings fuse rather than adopt, from the same state:
639        // the declination is the only difference between the two filters.
640        let mut charted = Eskf::default();
641        let _ = charted
642            .initialize_over(&window_with_mag(), Seconds::from_secs(0.25))
643            .expect("a 2 s window of stillness");
644        let mut shifted = charted.clone();
645        assert!(shifted.set_magnetic_declination(Radians::from_radians(0.02)));
646
647        let field = MagField::body(0.22, 0.01, 0.44);
648        let noise = HeadingNoise::from_sigma(0.05);
649        let nu = |filter: &mut Eskf| {
650            assert!(
651                filter
652                    .fuse_mag_heading(filter.now(), field, noise)
653                    .is_accepted()
654            );
655            let innovation = filter.diagnostics().mag_heading.innovation;
656            innovation
657                .expect("an accepted heading publishes its ν")
658                .values()[0]
659        };
660        let moved = nu(&mut shifted) - nu(&mut charted);
661        assert!((moved - 0.02).abs() < 1e-6, "ν moved by {moved}");
662    }
663
664    #[test]
665    fn a_heading_adopted_after_a_declination_change_is_true_heading_at_the_new_value() {
666        // No magnetometer in the window, so the first heading is adopted. A field reading
667        // magnetic north on the heading the filter holds is, at declination d, true heading
668        // d: the adoption steps yaw onto exactly that.
669        let mut filter = initialized();
670        assert!(filter.set_magnetic_declination(Radians::from_radians(0.3)));
671        let field = measured(filter.state().attitude, 0.0);
672        assert!(
673            filter
674                .fuse_mag_heading(filter.now(), field, HeadingNoise::from_sigma(0.05))
675                .is_reset()
676        );
677        let (_, _, yaw) = filter.state().attitude.euler_angles();
678        assert!((yaw - 0.3).abs() < 1e-5, "adopted yaw {yaw}");
679    }
680
681    #[test]
682    fn the_first_geodetic_fix_places_the_origin_under_the_estimate() {
683        let mut filter = initialized();
684        assert_eq!(filter.origin(), None);
685        let outcome = filter.fuse_gnss_geodetic(
686            filter.now(),
687            zurich(),
688            PositionNoise::horizontal_vertical(1.5, 1.5),
689            Position::zero(),
690        );
691        assert!(outcome.is_accepted() && !outcome.is_reset(), "{outcome:?}");
692
693        let origin = filter.origin().expect("placed by the first fix");
694        assert!(
695            near(origin.to_ned(zurich()), filter.state().position),
696            "the fix lands on the estimate, so nothing steps"
697        );
698    }
699
700    /// A site whose declination is large, about 14° east, so a heading that
701    /// missed it is far outside any gate.
702    fn east_of_moscow() -> Geodetic {
703        Geodetic::from_degrees(56.41, 43.76, 150.0)
704    }
705
706    fn yaw_of(filter: &Eskf) -> f32 {
707        filter.state().attitude.euler_angles().2
708    }
709
710    #[cfg(feature = "magnetic-model")]
711    #[test]
712    fn a_static_start_learns_its_declination_at_the_first_fix_and_turns_its_heading() {
713        let mut filter = Eskf::default();
714        let _ = filter
715            .initialize_over(&window_with_mag(), Seconds::from_secs(0.25))
716            .expect("a 2 s window of stillness");
717        assert_eq!(yaw_of(&filter), 0.0, "leveled at declination zero");
718        let covariance = *filter.covariance();
719        let model = east_of_moscow()
720            .magnetic_declination()
721            .expect("a finite site");
722
723        let noise = PositionNoise::horizontal_vertical(1.5, 1.5);
724        let _ = filter.fuse_gnss_geodetic(filter.now(), east_of_moscow(), noise, Position::zero());
725        assert_eq!(filter.magnetic_declination(), model);
726        assert!((yaw_of(&filter) - model.as_radians()).abs() < 1e-6);
727        let attitude = |p: &Covariance| p.as_matrix().fixed_view::<3, 3>(6, 6).into_owned();
728        assert_eq!(
729            attitude(filter.covariance()),
730            attitude(&covariance),
731            "the body-frame error is the same error after the turn"
732        );
733
734        // The next heading from the same field agrees with the turned estimate. Unturned, it
735        // would carry the whole declination as its innovation.
736        let field = MagField::body(0.22, 0.0, 0.44);
737        let fusion = filter.fuse_mag_heading(filter.now(), field, HeadingNoise::from_sigma(0.05));
738        assert!(fusion.is_accepted(), "{fusion:?}");
739        let nu = filter
740            .diagnostics()
741            .mag_heading
742            .innovation
743            .expect("fused")
744            .values()[0];
745        assert!(nu.abs() < 1e-4, "ν {nu}");
746    }
747
748    #[cfg(feature = "magnetic-model")]
749    #[test]
750    fn a_declination_the_caller_set_is_never_replaced_by_the_model() {
751        let mut filter = Eskf::default();
752        assert!(filter.set_magnetic_declination(Radians::from_radians(0.1)));
753        let _ = filter
754            .initialize_over(&window_with_mag(), Seconds::from_secs(0.25))
755            .expect("a 2 s window of stillness");
756        let yaw = yaw_of(&filter);
757        let noise = PositionNoise::horizontal_vertical(1.5, 1.5);
758        let _ = filter.fuse_gnss_geodetic(filter.now(), east_of_moscow(), noise, Position::zero());
759        assert_eq!(filter.magnetic_declination(), Radians::from_radians(0.1));
760        assert_eq!(yaw_of(&filter), yaw);
761    }
762
763    #[cfg(feature = "magnetic-model")]
764    #[test]
765    fn a_true_heading_is_not_turned_by_a_declination_learned_after_it() {
766        // No magnetometer in the window; a dual-antenna heading establishes yaw, and it is
767        // true heading, which no declination touches.
768        let mut filter = initialized();
769        let noise = HeadingNoise::from_sigma(0.02);
770        let heading = filter.fuse_gnss_heading(filter.now(), Radians::from_radians(0.5), noise);
771        assert!(heading.is_reset(), "{heading:?}");
772        let yaw = yaw_of(&filter);
773
774        let noise = PositionNoise::horizontal_vertical(1.5, 1.5);
775        let _ = filter.fuse_gnss_geodetic(filter.now(), east_of_moscow(), noise, Position::zero());
776        assert!(
777            filter.magnetic_declination().as_radians() > 0.2,
778            "the model was read"
779        );
780        assert_eq!(yaw_of(&filter), yaw);
781    }
782
783    #[cfg(feature = "magnetic-model")]
784    #[test]
785    fn a_true_heading_fused_over_a_magnetic_one_stops_the_turn() {
786        // Heading leveled from the window's magnetometer, then a dual-antenna heading
787        // accepted over it: the estimate is no longer the magnetometer's alone.
788        let mut filter = Eskf::default();
789        let _ = filter
790            .initialize_over(&window_with_mag(), Seconds::from_secs(0.25))
791            .expect("a 2 s window of stillness");
792        let noise = HeadingNoise::from_sigma(0.02);
793        let heading = filter.fuse_gnss_heading(filter.now(), Radians::from_radians(0.0), noise);
794        assert!(heading.is_accepted(), "{heading:?}");
795        let yaw = yaw_of(&filter);
796
797        let noise = PositionNoise::horizontal_vertical(1.5, 1.5);
798        let _ = filter.fuse_gnss_geodetic(filter.now(), east_of_moscow(), noise, Position::zero());
799        assert!(
800            filter.magnetic_declination().as_radians() > 0.2,
801            "the model was read"
802        );
803        assert_eq!(yaw_of(&filter), yaw);
804    }
805
806    #[cfg(feature = "magnetic-model")]
807    #[test]
808    fn a_seed_vouches_for_its_heading_and_a_new_origin_rereads_the_site() {
809        let (state, covariance) = seed();
810        let mut filter = Eskf::default();
811        let _ = filter.seed(state, covariance).expect("a sane seed");
812        let yaw = yaw_of(&filter);
813        assert!(filter.set_origin(east_of_moscow()));
814        assert_eq!(
815            yaw_of(&filter),
816            yaw,
817            "a seed's heading is the caller's claim"
818        );
819        let there = filter.magnetic_declination();
820
821        assert!(filter.set_origin(zurich()));
822        assert_ne!(
823            filter.magnetic_declination(),
824            there,
825            "a new site, a new value"
826        );
827    }
828
829    #[cfg(not(feature = "magnetic-model"))]
830    #[test]
831    fn without_the_model_declination_is_the_callers_alone() {
832        let mut filter = Eskf::default();
833        let _ = filter
834            .initialize_over(&window_with_mag(), Seconds::from_secs(0.25))
835            .expect("a 2 s window of stillness");
836        let noise = PositionNoise::horizontal_vertical(1.5, 1.5);
837        let _ = filter.fuse_gnss_geodetic(filter.now(), east_of_moscow(), noise, Position::zero());
838        assert_eq!(filter.magnetic_declination(), Radians::ZERO);
839        assert_eq!(yaw_of(&filter), 0.0);
840    }
841
842    #[cfg(feature = "magnetic-model")]
843    #[test]
844    fn the_origin_goes_under_the_antenna_the_turned_heading_puts() {
845        // A magnetometer-leveled heading turned 14.0° by the first fix: the arm has to be
846        // read after the turn, or the origin sits 0.24 m from where the estimate puts the
847        // antenna.
848        let mut filter = Eskf::default();
849        let _ = filter
850            .initialize_over(&window_with_mag(), Seconds::from_secs(0.25))
851            .expect("a 2 s window of stillness");
852        let noise = PositionNoise::horizontal_vertical(1.5, 1.5);
853        let _ = filter.fuse_gnss_geodetic(filter.now(), east_of_moscow(), noise, mast());
854        let rotation = filter.state().attitude.quaternion();
855        let antenna = Position::from_vector(rotation * mast().vector());
856        let origin = filter.origin().expect("placed by the first fix");
857        assert!(near(origin.to_ned(east_of_moscow()), antenna));
858    }
859
860    #[test]
861    fn the_origin_goes_under_the_antenna_the_first_fix_measured() {
862        // Level and nose north after a static start: the antenna is 1 m north of the IMU,
863        // so the fix places the origin 1 m south of it, under the IMU.
864        let mut filter = initialized();
865        let noise = PositionNoise::horizontal_vertical(1.5, 1.5);
866        let _ = filter.fuse_gnss_geodetic(filter.now(), zurich(), noise, mast());
867        let origin = filter.origin().expect("placed by the first fix");
868        assert!(near(origin.to_ned(zurich()), Position::ned(1.0, 0.0, -0.5)));
869        assert!(near(filter.state().position, Position::zero()));
870    }
871
872    #[test]
873    fn placing_the_origin_makes_the_fix_error_the_position_error() {
874        let mut filter = initialized();
875        let mut matrix = *filter.covariance().as_matrix();
876        let (pn, vn) = (
877            ErrorState::PositionNorth as usize,
878            ErrorState::VelocityNorth as usize,
879        );
880        matrix[(pn, vn)] = 0.01;
881        matrix[(vn, pn)] = 0.01;
882        filter.covariance = Covariance::from_matrix(matrix);
883
884        let _ = filter.fuse_gnss_geodetic(
885            filter.now(),
886            zurich(),
887            PositionNoise::horizontal_vertical(1.5, 3.0),
888            Position::zero(),
889        );
890
891        let p = filter.covariance();
892        assert_eq!(p.variance(ErrorState::PositionNorth), 2.25);
893        assert_eq!(p.variance(ErrorState::PositionDown), 9.0);
894        assert_eq!(
895            p.as_matrix()[(pn, vn)],
896            0.0,
897            "the fix's error owes nothing to velocity"
898        );
899    }
900
901    #[test]
902    fn an_origin_placed_after_moving_accounts_for_the_move() {
903        let (mut state, covariance) = seed();
904        state.position = Position::ned(40.0, -15.0, -3.0);
905        let mut filter = Eskf::default();
906        let _ = filter.seed(state, covariance).expect("a sane seed");
907        let _ = filter.fuse_gnss_geodetic(
908            filter.now(),
909            zurich(),
910            PositionNoise::horizontal_vertical(1.5, 1.5),
911            Position::zero(),
912        );
913
914        let origin = filter.origin().expect("placed");
915        assert!(near(origin.to_ned(zurich()), state.position));
916        assert_ne!(
917            origin.geodetic(),
918            zurich(),
919            "the origin is where it started"
920        );
921    }
922
923    #[test]
924    fn after_a_coarse_start_the_origin_is_the_first_fix_and_it_is_adopted() {
925        let mut filter = coarse();
926        let outcome = filter.fuse_gnss_geodetic(
927            filter.now(),
928            zurich(),
929            PositionNoise::horizontal_vertical(1.5, 1.5),
930            Position::zero(),
931        );
932        assert_eq!(outcome, GnssFusion::both(Fusion::Reset));
933        assert_eq!(filter.origin().map(|o| o.geodetic()), Some(zurich()));
934        assert_eq!(filter.state().position, Position::zero());
935    }
936
937    #[test]
938    fn later_fixes_are_converted_about_the_same_origin() {
939        let mut filter = coarse();
940        let _ = filter.fuse_gnss_geodetic(
941            filter.now(),
942            zurich(),
943            PositionNoise::horizontal_vertical(1.5, 1.5),
944            Position::zero(),
945        );
946        // Position is established now, so this one is fused, not adopted.
947        let north = Geodetic::from_degrees(47.3987, 8.5456, 488.0);
948        assert!(
949            !filter
950                .fuse_gnss_geodetic(
951                    filter.now(),
952                    north,
953                    PositionNoise::horizontal_vertical(1.5, 1.5),
954                    Position::zero()
955                )
956                .is_reset()
957        );
958        let p = filter.origin().expect("held").to_ned(north).vector();
959        assert!((p.x - 111.2).abs() < 0.1 && p.y.abs() < 1e-3, "{p:?}");
960    }
961
962    #[test]
963    fn a_fix_that_cannot_place_an_origin_is_refused_and_the_next_one_places_it() {
964        let mut filter = initialized();
965        let off_the_earth = Geodetic::from_degrees(91.0, 0.0, 0.0);
966        assert_eq!(
967            filter.fuse_gnss_geodetic(
968                filter.now(),
969                off_the_earth,
970                PositionNoise::horizontal_vertical(1.5, 1.5),
971                Position::zero()
972            ),
973            GnssFusion::both(Fusion::NoReference)
974        );
975        assert_eq!(filter.origin(), None);
976        assert!(
977            filter
978                .fuse_gnss_geodetic(
979                    filter.now(),
980                    zurich(),
981                    PositionNoise::horizontal_vertical(1.5, 1.5),
982                    Position::zero()
983                )
984                .is_accepted()
985        );
986        assert!(filter.origin().is_some());
987    }
988
989    #[test]
990    fn moving_the_origin_does_not_move_the_vehicle() {
991        let mut filter = initialized();
992        let _ = filter.fuse_gnss_geodetic(
993            filter.now(),
994            zurich(),
995            PositionNoise::horizontal_vertical(1.5, 1.5),
996            Position::zero(),
997        );
998        let before = filter.geodetic_position().expect("an origin is held");
999        let variance = filter.covariance().variance(ErrorState::PositionNorth);
1000
1001        let home = Geodetic::from_degrees(47.3967, 8.5446, 480.0);
1002        assert!(filter.set_origin(home));
1003        let after = filter.geodetic_position().expect("still held");
1004        let moved = LocalOrigin::new(before)
1005            .expect("usable")
1006            .to_ned(after)
1007            .vector()
1008            .norm();
1009        assert!(moved < 1e-2, "the vehicle moved {moved} m");
1010        assert_ne!(filter.state().position, Position::zero(), "the numbers did");
1011        assert_eq!(
1012            filter.covariance().variance(ErrorState::PositionNorth),
1013            variance
1014        );
1015    }
1016
1017    #[test]
1018    fn the_origin_follows_stillness_and_not_the_alignment() {
1019        // Two halves of one claim, which have to move together: a window that held still
1020        // says zero is here, and that is the whole of what lets it report an established
1021        // position. A short one says it as honestly -- `classify` calls it coarse before
1022        // it measures motion -- so keying the origin on the verdict would leave such a
1023        // start reporting an established (0,0,0) about an origin nothing put under it.
1024        let mut filter = initialized();
1025        assert!(filter.set_origin(zurich()));
1026        let _ = filter
1027            .initialize_over(&[still(); 8], Seconds::from_secs(0.1))
1028            .expect("short, so coarse");
1029        assert_eq!(filter.origin(), None, "still, so zero is here now");
1030        assert!(filter.validity().horizontal_position);
1031
1032        assert!(filter.set_origin(zurich()));
1033        let _ = filter
1034            .initialize_over(&turning(0.0, 0.4, 0.0), Seconds::from_secs(0.25))
1035            .expect("moving, not unusable");
1036        assert!(
1037            filter.origin().is_some(),
1038            "a restart in motion keeps the flight's origin"
1039        );
1040        assert!(
1041            !filter.validity().horizontal_position,
1042            "and says nothing about where it is until a fix is adopted about that origin"
1043        );
1044    }
1045
1046    #[test]
1047    fn an_origin_off_the_earth_is_refused() {
1048        let mut filter = initialized();
1049        assert!(!filter.set_origin(Geodetic::from_degrees(91.0, 0.0, 0.0)));
1050        assert_eq!(filter.origin(), None);
1051    }
1052}