1use crate::config::LATENCY_HORIZON;
11use crate::frames::{Body, Ned};
12use crate::geodetic::{Geodetic, LocalOrigin};
13use crate::health::{Diagnostics, Fusion, GnssFusion, SourceHealth};
14use crate::math::correlation_inflation;
15use crate::observation::{baro, gnss};
16use crate::propagate;
17use crate::state::State;
18use crate::units::{
19 Altitude, AltitudeNoise, AngularRate, Position, PositionNoise, Seconds, Timestamp, Velocity,
20 VelocityNoise,
21};
22use crate::update::{Observation, Update, update};
23
24use super::Eskf;
25use super::adopt::{HORIZONTAL, POSITION};
26use super::hold::recovery_after;
27
28impl Eskf {
29 fn apply(
40 &mut self,
41 outcome: Update,
42 source: fn(&mut Diagnostics) -> &mut SourceHealth,
43 ) -> Fusion {
44 match outcome {
45 Update::Accepted {
46 state,
47 covariance,
48 offset,
49 offset_correction,
50 ratio,
51 innovation,
52 } => {
53 self.estimate.commit(state);
54 self.commit_covariance(covariance, offset);
55 if let Some(reference) = self.baro_reference {
57 self.baro_reference = Some(Altitude::from_meters(
58 reference.as_meters() - offset_correction,
59 ));
60 }
61 source(&mut self.diagnostics).record_accepted(ratio, Some(innovation));
62 self.note_alignment();
63 Fusion::Accepted { test_ratio: ratio }
64 }
65 Update::Rejected { ratio, innovation } => {
66 source(&mut self.diagnostics).record_rejected(ratio, innovation);
67 Fusion::Rejected { test_ratio: ratio }
68 }
69 Update::Invalid => refuse(source(&mut self.diagnostics), Fusion::StateInvalid),
70 }
71 }
72
73 #[inline(never)]
91 pub(super) fn apply_or_recover(
92 &mut self,
93 outcome: Update,
94 source: fn(&mut Diagnostics) -> &mut SourceHealth,
95 after: Option<Seconds>,
96 adopt: impl FnOnce(&mut Self) -> bool,
97 ) -> Fusion {
98 let since_initialized = self.diagnostics.since_initialized;
99 let locked_out = source(&mut self.diagnostics).locked_out(after, since_initialized);
100 if !(matches!(outcome, Update::Rejected { .. }) && locked_out) {
101 return self.apply(outcome, source);
102 }
103 if !adopt(self) {
104 return refuse(source(&mut self.diagnostics), Fusion::NotFinite);
105 }
106 source(&mut self.diagnostics).record_recovered();
107 self.note_alignment();
108 Fusion::Reset
109 }
110
111 pub fn fuse_gnss_position(
174 &mut self,
175 time: Timestamp,
176 position: Position<Ned>,
177 noise: PositionNoise<Ned>,
178 antenna: Position<Body>,
179 ) -> GnssFusion {
180 if let Err(refusal) = self.admit(time) {
181 return self.refuse_gnss(refusal);
182 }
183 self.diagnostics.gnss_position.note_arrival(time);
184 self.diagnostics.gnss_height.note_arrival(time);
185 if !antenna.is_finite() {
188 return self.refuse_gnss(Fusion::NotFinite);
189 }
190 if self.unestablished.position {
191 if !position.is_finite() || !noise.is_finite() {
192 return self.refuse_gnss(Fusion::NotFinite);
193 }
194 if !noise.is_positive() {
195 return self.refuse_gnss(Fusion::InvalidNoise);
196 }
197 let Some(adopted) = self.carried_position(position, antenna, time) else {
198 return self.refuse_gnss(Fusion::NotFinite);
199 };
200 self.adopt_position(adopted, noise, POSITION);
201 self.unestablished.position = false;
202 self.diagnostics.gnss_position.record_adopted();
203 self.diagnostics.gnss_height.record_adopted();
204 return GnssFusion::both(Fusion::Reset);
205 }
206
207 let (z, r) = (position.vector(), noise.variance());
208 let horizontal = match screen(&[z[0], z[1]], &[r[0], r[1]]) {
209 Some(refusal) => refuse(&mut self.diagnostics.gnss_position, refusal),
210 None => {
211 let observation = self
212 .observe(time, |past, _| {
213 gnss::horizontal_observation(past, position, noise, antenna)
214 })
215 .correlated(correlation_inflation(
216 self.diagnostics.gnss_position.since_measured,
217 self.config.correlation.gnss_position,
218 ));
219 let outcome = update(
220 self.estimate.state(),
221 &self.covariance,
222 &self.offset,
223 &observation,
224 self.config.gates.gnss_position,
225 );
226 self.apply_or_recover(
227 outcome,
228 |diagnostics| &mut diagnostics.gnss_position,
229 recovery_after(
230 self.hold.holds_position() || self.yaw_replaced.owes_position(),
231 self.config.recovery.gnss_position,
232 ),
233 |filter| {
234 let adopted = filter.carried_position(position, antenna, time);
235 adopted
236 .map(|adopted| filter.adopt_position(adopted, noise, HORIZONTAL))
237 .is_some()
238 },
239 )
240 }
241 };
242 if matches!(horizontal, Fusion::Accepted { .. }) {
246 self.yaw_replaced.settle_position();
247 }
248 if matches!(horizontal, Fusion::Accepted { .. }) {
249 if !self.hold.holds_position() {
250 self.hold.end_velocity();
251 }
252 self.hold.end_position();
253 }
254 let height = match screen(&[z[2]], &[r[2]]) {
255 Some(refusal) => refuse(&mut self.diagnostics.gnss_height, refusal),
256 None => {
257 let observation = self
258 .observe(time, |past, _| {
259 gnss::height_observation(past, position, noise, antenna)
260 })
261 .correlated(correlation_inflation(
262 self.diagnostics.gnss_height.since_measured,
263 self.config.correlation.gnss_height,
264 ));
265 let outcome = update(
266 self.estimate.state(),
267 &self.covariance,
268 &self.offset,
269 &observation,
270 self.config.gates.gnss_height,
271 );
272 self.apply_or_recover(
273 outcome,
274 |diagnostics| &mut diagnostics.gnss_height,
275 self.config.recovery.gnss_height,
276 |filter| {
277 let adopted = filter.carried_position(position, antenna, time);
278 adopted
279 .map(|adopted| filter.adopt_height(adopted, noise))
280 .is_some()
281 },
282 )
283 }
284 };
285 GnssFusion { horizontal, height }
286 }
287
288 pub(super) fn admit(&self, time: Timestamp) -> Result<(), Fusion> {
305 if !self.initialized {
306 return Err(Fusion::NotInitialized);
307 }
308 let age = self.time.since(time);
309 if age > LATENCY_HORIZON
310 || -age.as_secs() > self.config.max_predict_dt.as_secs()
311 || time < self.earliest
312 {
313 return Err(Fusion::OutOfHorizon { age });
314 }
315 Ok(())
316 }
317
318 pub(super) fn past(&self, time: Timestamp) -> (State, Seconds) {
323 let omega = self
324 .angular_rate
325 .unwrap_or_else(|| AngularRate::body(0.0, 0.0, 0.0));
326 let (past, placed) = self.estimate.at(time, self.time, omega);
327 (past, self.time.since(placed))
328 }
329
330 #[inline(never)]
346 pub(super) fn observe<const M: usize>(
347 &self,
348 time: Timestamp,
349 build: impl FnOnce(&State, AngularRate<Body>) -> Observation<M>,
350 ) -> Observation<M> {
351 if time == self.time {
352 return build(
353 self.estimate.state(),
354 self.mean_rate(self.estimate.state(), 0.0),
355 );
356 }
357 let (past, age) = self.past(time);
358 let tau = age.as_secs();
359 let omega = self.mean_rate(&past, tau);
360 if tau == 0.0 {
361 return build(&past, omega);
362 }
363 let now = self.estimate.state().attitude.quaternion();
364 let a_n = (self.estimate.state().velocity.vector() - past.velocity.vector()) / tau;
367 let a_b = now.inverse() * (a_n - propagate::gravity_vector(self.config.gravity));
368 let a = propagate::error_dynamics(self.estimate.state(), omega.vector(), a_b);
369 build(&past, omega).delayed(age, &a)
370 }
371
372 fn mean_rate(&self, past: &State, tau: f32) -> AngularRate<Body> {
379 if tau == 0.0 {
380 return self.angular_rate.unwrap_or_default();
381 }
382 let (now, then) = (
383 self.estimate.state().attitude.quaternion(),
384 past.attitude.quaternion(),
385 );
386 AngularRate::from_vector((then.inverse() * now).scaled_axis() / tau)
387 }
388
389 fn carried_position(
398 &self,
399 taken: Position<Ned>,
400 antenna: Position<Body>,
401 time: Timestamp,
402 ) -> Option<Position<Ned>> {
403 let (past, _) = self.past(time);
404 let arm = past.attitude.quaternion() * antenna.vector();
405 let moved = self.estimate.state().position.vector() - past.position.vector();
406 let carried = Position::from_vector(taken.vector() - arm + moved);
407 carried.is_finite().then_some(carried)
408 }
409
410 pub(super) fn carried_velocity(
414 &self,
415 taken: Velocity<Ned>,
416 antenna: Position<Body>,
417 time: Timestamp,
418 ) -> Option<Velocity<Ned>> {
419 let (past, age) = self.past(time);
420 let omega = self.mean_rate(&past, age.as_secs());
421 let turning = past.attitude.quaternion() * omega.vector().cross(&antenna.vector());
422 let moved = self.estimate.state().velocity.vector() - past.velocity.vector();
423 let carried = Velocity::from_vector(taken.vector() - turning + moved);
424 carried.is_finite().then_some(carried)
425 }
426
427 fn refuse_gnss(&mut self, outcome: Fusion) -> GnssFusion {
429 refuse(&mut self.diagnostics.gnss_position, outcome);
430 GnssFusion::both(refuse(&mut self.diagnostics.gnss_height, outcome))
431 }
432
433 pub fn fuse_gnss_geodetic(
469 &mut self,
470 time: Timestamp,
471 fix: Geodetic,
472 noise: PositionNoise<Ned>,
473 antenna: Position<Body>,
474 ) -> GnssFusion {
475 if let Err(refusal) = self.admit(time) {
476 return self.refuse_gnss(refusal);
477 }
478 self.diagnostics.gnss_position.note_arrival(time);
480 self.diagnostics.gnss_height.note_arrival(time);
481 if !fix.is_finite() || !antenna.is_finite() {
482 return self.refuse_gnss(Fusion::NotFinite);
483 }
484 if let Some(origin) = self.origin {
485 return self.fuse_gnss_position(time, origin.to_ned(fix), noise, antenna);
486 }
487 if !noise.is_finite() {
488 return self.refuse_gnss(Fusion::NotFinite);
489 }
490 if !noise.is_positive() {
491 return self.refuse_gnss(Fusion::InvalidNoise);
492 }
493
494 if self.unestablished.position {
495 let Some(origin) = LocalOrigin::new(fix) else {
496 return self.refuse_gnss(Fusion::NoReference);
497 };
498 if self
501 .carried_position(Position::zero(), antenna, time)
502 .is_none()
503 {
504 return self.refuse_gnss(Fusion::NotFinite);
505 }
506 self.place_origin(origin);
507 return self.fuse_gnss_position(time, Position::zero(), noise, antenna);
508 }
509
510 if !self.place_origin_under_estimate(fix, antenna, time) {
513 return self.refuse_gnss(Fusion::NoReference);
514 }
515 let placed = self.reset_position_to(self.estimate.state().position, noise);
516 debug_assert!(
517 placed,
518 "the estimate and the fix's noise are both already checked"
519 );
520 self.diagnostics.gnss_position.record_accepted(0.0, None);
521 self.diagnostics.gnss_height.record_accepted(0.0, None);
522 GnssFusion::both(Fusion::Accepted { test_ratio: 0.0 })
523 }
524
525 pub fn fuse_gnss_velocity(
554 &mut self,
555 time: Timestamp,
556 velocity: Velocity<Ned>,
557 noise: VelocityNoise<Ned>,
558 antenna: Position<Body>,
559 ) -> Fusion {
560 if let Err(refusal) = self.admit(time) {
561 return refuse(&mut self.diagnostics.gnss_velocity, refusal);
562 }
563 self.diagnostics.gnss_velocity.note_arrival(time);
564 if !velocity.is_finite() || !noise.is_finite() || !antenna.is_finite() {
565 return refuse(&mut self.diagnostics.gnss_velocity, Fusion::NotFinite);
566 }
567 if !noise.is_positive() {
568 return refuse(&mut self.diagnostics.gnss_velocity, Fusion::InvalidNoise);
569 }
570 self.weigh_yaw(time, velocity, noise, antenna);
571 self.adopt_first_yaw();
572 if self.unestablished.velocity {
573 let Some(adopted) = self.carried_velocity(velocity, antenna, time) else {
574 return refuse(&mut self.diagnostics.gnss_velocity, Fusion::NotFinite);
575 };
576 self.adopt_velocity(adopted, noise);
577 self.unestablished.velocity = false;
578 self.diagnostics.gnss_velocity.record_adopted();
579 return Fusion::Reset;
580 }
581 let observation = self
582 .observe(time, |past, omega| {
583 gnss::velocity_observation(past, velocity, noise, antenna, omega)
584 })
585 .correlated(correlation_inflation(
586 self.diagnostics.gnss_velocity.since_measured,
587 self.config.correlation.gnss_velocity,
588 ));
589 let outcome = update(
590 self.estimate.state(),
591 &self.covariance,
592 &self.offset,
593 &observation,
594 self.config.gates.gnss_velocity,
595 );
596 if matches!(outcome, Update::Rejected { .. }) {
597 self.replace_failed_yaw();
598 }
599 let fusion = self.apply_or_recover(
600 outcome,
601 |diagnostics| &mut diagnostics.gnss_velocity,
602 recovery_after(
603 self.hold.holds_velocity() || self.yaw_replaced.owes_velocity(),
604 self.config.recovery.gnss_velocity,
605 ),
606 |filter| {
607 let adopted = filter.carried_velocity(velocity, antenna, time);
608 adopted
609 .map(|adopted| filter.adopt_velocity(adopted, noise))
610 .is_some()
611 },
612 );
613 if matches!(fusion, Fusion::Accepted { .. }) {
615 self.hold.end_velocity();
616 }
617 fusion
618 }
619
620 pub fn fuse_baro_altitude(
671 &mut self,
672 time: Timestamp,
673 altitude: Altitude,
674 noise: AltitudeNoise,
675 ) -> Fusion {
676 if let Err(refusal) = self.admit(time) {
677 return refuse(&mut self.diagnostics.baro_altitude, refusal);
678 }
679 self.diagnostics.baro_altitude.note_arrival(time);
680 if !altitude.as_meters().is_finite() || !noise.is_finite() {
681 return refuse(&mut self.diagnostics.baro_altitude, Fusion::NotFinite);
682 }
683 if !noise.is_positive() {
684 return refuse(&mut self.diagnostics.baro_altitude, Fusion::InvalidNoise);
685 }
686 let Some(reference) = self.baro_reference else {
687 if self.unestablished.position || !self.config.baro_reference_from_estimate {
688 return refuse(&mut self.diagnostics.baro_altitude, Fusion::NoReference);
689 }
690 self.reference_from_estimate(altitude, noise, time);
691 self.diagnostics.baro_altitude.record_accepted(0.0, None);
692 return Fusion::Accepted { test_ratio: 0.0 };
693 };
694 let observation = self
695 .observe(time, |past, _| {
696 baro::altitude_observation(past, altitude, reference, noise)
697 })
698 .correlated(correlation_inflation(
699 self.diagnostics.baro_altitude.since_measured,
700 self.config.correlation.baro_altitude,
701 ));
702 let outcome = update(
703 self.estimate.state(),
704 &self.covariance,
705 &self.offset,
706 &observation,
707 self.config.gates.baro_altitude,
708 );
709 let after = if self.unestablished.position || !self.config.baro_reference_from_estimate {
712 None
713 } else {
714 self.config.recovery.baro_altitude
715 };
716 self.apply_or_recover(
717 outcome,
718 |diagnostics| &mut diagnostics.baro_altitude,
719 after,
720 |filter| {
721 filter.reference_from_estimate(altitude, noise, time);
722 true
723 },
724 )
725 }
726}
727
728fn screen(values: &[f32], variances: &[f32]) -> Option<Fusion> {
731 if !values.iter().chain(variances).all(|v| v.is_finite()) {
732 return Some(Fusion::NotFinite);
733 }
734 if !variances.iter().all(|v| *v > 0.0) {
735 return Some(Fusion::InvalidNoise);
736 }
737 None
738}
739
740pub(super) fn refuse(source: &mut SourceHealth, outcome: Fusion) -> Fusion {
747 if let Some(refusal) = outcome.refusal() {
748 source.record_refused(refusal);
749 }
750 outcome
751}
752
753#[cfg(test)]
754mod tests {
755
756 use crate::config::{Correlation, GRAVITY, LATENCY_HORIZON};
757 use crate::eskf::Eskf;
758 use crate::eskf::fixtures::*;
759 use crate::frames::Ned;
760 use crate::geodetic::Geodetic;
761 use crate::health::{Fusion, GnssFusion, Propagation, Refusal};
762 use crate::init::tests::still;
763
764 use crate::observation::mag::tests::{attitude_of, measured};
765
766 use crate::propagate::ImuSample;
767 use crate::state::{Covariance, ErrorState, STATES, State};
768 use crate::units::{
769 Acceleration, Altitude, AltitudeNoise, AngularRate, HeadingNoise, MagField, Position,
770 PositionNoise, Radians, Seconds, Timestamp, Velocity, VelocityNoise,
771 };
772
773 #[test]
777 fn a_measurement_the_filter_cannot_place_in_time_is_refused() {
778 let mut filter = aided();
779 let now = filter.now();
780 let altitude = Altitude::from_meters(100.0);
781 let noise = AltitudeNoise::from_sigma(2.0);
782 let before = filter.diagnostics().baro_altitude;
783
784 let past = Seconds::from_secs(LATENCY_HORIZON.as_secs() + 0.001);
785 let ahead = Seconds::from_secs(filter.config().max_predict_dt.as_secs() + 0.001);
786 for (time, age) in [
787 (now.before(past), past.as_secs()),
788 (now.after(ahead), -ahead.as_secs()),
789 ] {
790 let Fusion::OutOfHorizon { age: refused } =
791 filter.fuse_baro_altitude(time, altitude, noise)
792 else {
793 panic!("not refused");
794 };
795 assert!(
796 (refused.as_secs() - age).abs() < 1e-6,
797 "{refused:?} against {age}"
798 );
799 }
800 let health = filter.diagnostics().baro_altitude;
801 assert_eq!(health.refused, before.refused + 2);
802 assert_eq!(health.last_refusal, Some(Refusal::OutOfHorizon));
803 assert_eq!(health.time_since_accepted, before.time_since_accepted);
804
805 let inside = Seconds::from_secs(LATENCY_HORIZON.as_secs() - 0.001);
806 assert!(
807 filter
808 .fuse_baro_altitude(now.before(inside), altitude, noise)
809 .is_accepted()
810 );
811 assert!(
812 filter
813 .fuse_baro_altitude(now.after(DT), altitude, noise)
814 .is_accepted()
815 );
816 }
817
818 #[test]
819 fn a_refused_measurement_is_visible_in_diagnostics_not_only_in_the_return_value() {
820 let mut filter = initialized();
823 let nan = Velocity::ned(f32::NAN, 0.0, 0.0);
824 let noise = VelocityNoise::from_speed_accuracy(0.3);
825
826 assert_eq!(
827 filter.fuse_gnss_velocity(filter.now(), nan, noise, Position::zero()),
828 Fusion::NotFinite
829 );
830 assert_eq!(
831 filter.fuse_gnss_velocity(
832 filter.now(),
833 Velocity::ned(1.0, 0.0, 0.0),
834 VelocityNoise::from_variance(0.0, 1.0, 1.0),
835 Position::zero()
836 ),
837 Fusion::InvalidNoise
838 );
839
840 let health = filter.diagnostics().gnss_velocity;
841 assert_eq!(health.refused, 2);
842 assert_eq!(health.last_refusal, Some(Refusal::InvalidNoise));
843 assert_eq!(health.accepted, 0);
844 assert_eq!(health.rejected, 0, "neither reached the gate");
845 assert_eq!(
846 health.time_since_accepted, None,
847 "a refusal is not aiding, so no timer starts"
848 );
849 assert!(!health.has_been_used());
850 }
851
852 #[test]
853 fn a_refusal_names_which_kind_it_was() {
854 let mut filter = coarse();
855 assert_eq!(
856 filter.fuse_baro_altitude(
857 filter.now(),
858 Altitude::from_meters(60.0),
859 AltitudeNoise::from_sigma(2.0)
860 ),
861 Fusion::NoReference
862 );
863 assert_eq!(
864 filter.diagnostics().baro_altitude.last_refusal,
865 Some(Refusal::NoReference),
866 "a missing reference is a different problem from a bad number"
867 );
868
869 let mut fresh = Eskf::default();
870 assert_eq!(
871 fresh.fuse_baro_altitude(
872 fresh.now(),
873 Altitude::from_meters(60.0),
874 AltitudeNoise::from_sigma(2.0)
875 ),
876 Fusion::NotInitialized
877 );
878 assert_eq!(
879 fresh.diagnostics().baro_altitude.last_refusal,
880 Some(Refusal::NotInitialized)
881 );
882 }
883
884 #[test]
885 fn an_altitude_above_the_reference_pulls_the_estimate_up_and_moves_nothing_sideways() {
886 let mut filter = Eskf::default();
887 let _ = filter
888 .initialize_over(&window_at(100.0), Seconds::from_secs(0.25))
889 .expect("a 2 s window of stillness");
890
891 assert!(
893 filter
894 .fuse_baro_altitude(
895 filter.now(),
896 Altitude::from_meters(102.0),
897 AltitudeNoise::from_sigma(0.5)
898 )
899 .is_accepted()
900 );
901
902 let position = filter.state().position.vector();
903 assert!(
904 position[2] < -1.0,
905 "up is negative down: {} should be near -2 m",
906 position[2]
907 );
908 assert_eq!(
909 (position[0], position[1]),
910 (0.0, 0.0),
911 "(30) observes p_D alone, and a still start correlates it with nothing"
912 );
913 }
914
915 #[test]
916 fn an_altitude_the_gate_turns_down_leaves_the_estimate_where_it_was() {
917 let mut filter = Eskf::default();
918 let _ = filter
919 .initialize_over(&window_at(100.0), Seconds::from_secs(0.25))
920 .expect("a 2 s window of stillness");
921 let before = filter.state().position;
922
923 let outcome = filter.fuse_baro_altitude(
926 filter.now(),
927 Altitude::from_meters(200.0),
928 AltitudeNoise::from_sigma(0.5),
929 );
930 assert!(matches!(outcome, Fusion::Rejected { .. }), "{outcome:?}");
931 assert_eq!(filter.state().position, before);
932 assert_eq!(filter.diagnostics().baro_altitude.rejected, 1);
933 }
934
935 #[test]
939 fn an_old_fix_is_judged_against_where_the_vehicle_was() {
940 let flying = || {
941 let mut filter = Eskf::new(crate::Config {
943 hold: None,
944 ..crate::Config::default()
945 })
946 .unwrap();
947 let state = State {
948 velocity: Velocity::ned(20.0, 0.0, 0.0),
949 ..State::default()
950 };
951 let _ = filter
952 .seed(state, Covariance::from_sigmas([0.5; STATES]))
953 .expect("a sane seed");
954 for _ in 0..50 {
955 assert!(filter.step(still().imu, DT).is_propagated());
956 }
957 filter
958 };
959 let age = Seconds::from_secs(0.25);
960 let noise = PositionNoise::horizontal_vertical(0.5, 0.5);
961
962 let mut filter = flying();
963 let there = Position::ned(filter.state().position.x() - 5.0, 0.0, 0.0);
964 let taken = filter.now().before(age);
965 let aged = filter
966 .fuse_gnss_position(taken, there, noise, Position::zero())
967 .horizontal;
968 assert!(
969 aged.test_ratio().is_some_and(|ratio| ratio < 1.0e-3),
970 "{aged:?}"
971 );
972
973 let mut filter = flying();
974 let now = filter.now();
975 let current = filter
976 .fuse_gnss_position(now, there, noise, Position::zero())
977 .horizontal;
978 assert!(matches!(current, Fusion::Rejected { .. }), "{current:?}");
979 }
980
981 #[test]
985 fn a_fix_from_before_the_start_is_placed_only_after_a_start_at_rest() {
986 let noise = PositionNoise::horizontal_vertical(0.5, 0.5);
987 let before = Seconds::from_secs(0.05);
988
989 let mut seeded = Eskf::default();
990 let (state, covariance) = seed();
991 let start = Timestamp::from_micros(1_000_000);
992 let _ = seeded
993 .initialize_from(state, covariance, start)
994 .expect("a sane seed");
995 let taken = start.before(before);
996 let refused =
997 seeded.fuse_gnss_position(taken, Position::ned(0.0, 0.0, 0.0), noise, Position::zero());
998 assert_eq!(
999 refused,
1000 GnssFusion::both(Fusion::OutOfHorizon { age: before })
1001 );
1002
1003 let mut still = initialized();
1004 let taken = still.now().before(before);
1005 let fused =
1006 still.fuse_gnss_position(taken, Position::ned(0.0, 0.0, 0.0), noise, Position::zero());
1007 assert!(fused.is_accepted(), "{fused:?}");
1008 }
1009
1010 #[test]
1015 fn a_correction_reaches_the_history_the_next_old_fix_is_judged_against() {
1016 let mut filter = Eskf::default();
1017 let _ = filter
1018 .seed(State::default(), Covariance::from_sigmas([2.0; STATES]))
1019 .expect("a sane seed");
1020 for _ in 0..50 {
1021 assert!(filter.step(still().imu, DT).is_propagated());
1022 }
1023 let taken = filter.now().before(Seconds::from_secs(0.2));
1024 let off = Position::ned(3.0, 0.0, 0.0);
1025 let noise = PositionNoise::horizontal_vertical(0.5, 0.5);
1026 let first = filter
1027 .fuse_gnss_position(taken, off, noise, Position::zero())
1028 .horizontal;
1029 let second = filter
1030 .fuse_gnss_position(taken, off, noise, Position::zero())
1031 .horizontal;
1032 let (Some(first), Some(second)) = (first.test_ratio(), second.test_ratio()) else {
1033 panic!("both fixes reach the gate: {first:?}, {second:?}");
1034 };
1035 assert!(second < first, "{second} against {first}");
1036 }
1037
1038 #[test]
1041 fn a_fix_ahead_of_the_state_is_judged_against_where_the_vehicle_will_be() {
1042 let mut filter = Eskf::default();
1043 let state = State {
1044 velocity: Velocity::ned(20.0, 0.0, 0.0),
1045 ..State::default()
1046 };
1047 let _ = filter
1048 .seed(state, Covariance::from_sigmas([0.5; STATES]))
1049 .expect("a sane seed");
1050 for _ in 0..50 {
1051 assert!(filter.step(still().imu, DT).is_propagated());
1052 }
1053 let lead = Seconds::from_secs(0.05);
1054 let there = Position::ned(filter.state().position.x() + 1.0, 0.0, 0.0);
1055 let noise = PositionNoise::horizontal_vertical(0.5, 0.5);
1056 let taken = filter.now().after(lead);
1057 let outcome = filter
1058 .fuse_gnss_position(taken, there, noise, Position::zero())
1059 .horizontal;
1060 assert!(
1061 outcome.test_ratio().is_some_and(|ratio| ratio < 1.0e-3),
1062 "{outcome:?}"
1063 );
1064 }
1065
1066 #[test]
1070 fn a_heading_ahead_of_the_state_is_judged_against_the_turn() {
1071 let rate = core::f32::consts::FRAC_PI_2;
1072 let mut sigmas = [0.5; STATES];
1073 for axis in [
1075 ErrorState::AttitudeX,
1076 ErrorState::AttitudeY,
1077 ErrorState::AttitudeZ,
1078 ] {
1079 sigmas[axis.index()] = 0.005;
1080 }
1081 for axis in [
1082 ErrorState::GyroBiasX,
1083 ErrorState::GyroBiasY,
1084 ErrorState::GyroBiasZ,
1085 ] {
1086 sigmas[axis.index()] = 0.001;
1087 }
1088 let mut filter = Eskf::default();
1089 let _ = filter
1090 .seed(State::default(), Covariance::from_sigmas(sigmas))
1091 .expect("a sane seed");
1092 let turning = still().imu.with_gyro(AngularRate::body(0.0, 0.0, rate));
1093 for _ in 0..20 {
1094 assert!(filter.step(turning, DT).is_propagated());
1095 }
1096 let lead = Seconds::from_secs(0.09);
1097 let (_, _, yaw) = filter.state().attitude.euler_angles();
1098 let field_at = |yaw: f32| MagField::body(0.22 * yaw.cos(), -0.22 * yaw.sin(), 0.44);
1099 let noise = HeadingNoise::from_sigma(0.02);
1100
1101 let mut judged = filter.clone();
1102 let ahead = field_at(yaw + rate * lead.as_secs());
1103 let outcome = judged.fuse_mag_heading(filter.now().after(lead), ahead, noise);
1104 assert!(
1105 outcome.test_ratio().is_some_and(|ratio| ratio < 0.05),
1106 "{outcome:?}"
1107 );
1108 let current = filter.fuse_mag_heading(filter.now(), ahead, noise);
1109 assert!(matches!(current, Fusion::Rejected { .. }), "{current:?}");
1110 }
1111
1112 #[test]
1113 fn a_run_of_correlated_fixes_leaves_more_uncertainty_than_a_run_of_white_ones() {
1114 let mut white = aided();
1120 white.config.correlation = Correlation::WHITE;
1121 let (mut correlated, mut unaided) = (aided(), aided());
1122 for step in 0..500 {
1123 for filter in [&mut white, &mut correlated, &mut unaided] {
1124 assert_eq!(filter.step(still().imu, DT), Propagation::Propagated);
1125 }
1126 if step % 20 == 0 {
1127 for filter in [&mut white, &mut correlated] {
1128 let _ = filter.fuse_gnss_position(
1129 filter.now(),
1130 Position::ned(0.0, 0.0, 0.0),
1131 PositionNoise::from_sigma(1.0, 1.0, 1.0),
1132 Position::zero(),
1133 );
1134 }
1135 }
1136 }
1137 for axis in [ErrorState::PositionNorth, ErrorState::PositionDown] {
1138 let variance = |filter: &Eskf| filter.covariance().variance(axis);
1139 let (w, c, u) = (variance(&white), variance(&correlated), variance(&unaided));
1140 assert!(c > 3.0 * w, "{axis:?}: {c} against {w} white");
1141 assert!(c < 0.95 * u, "{axis:?}: {c} against {u} with no fix at all");
1142 }
1143 }
1144
1145 #[test]
1146 fn every_source_is_fused_at_its_own_correlation() {
1147 let mut white = initialized();
1151 white.config.correlation = Correlation::WHITE;
1152 let mut correlated = initialized();
1153 for step in 0..500 {
1154 for filter in [&mut white, &mut correlated] {
1155 assert_eq!(filter.step(still().imu, DT), Propagation::Propagated);
1156 if step % 20 == 0 {
1157 let _ = filter.fuse_gnss_velocity(
1158 filter.now(),
1159 Velocity::ned(0.0, 0.0, 0.0),
1160 VelocityNoise::from_speed_accuracy(0.3),
1161 Position::zero(),
1162 );
1163 }
1164 if step % 5 == 0 {
1165 let _ = filter.fuse_mag_heading(
1166 filter.now(),
1167 measured(attitude_of(0.0, 0.0, 0.0), 0.0),
1168 HeadingNoise::from_sigma(0.05),
1169 );
1170 }
1171 }
1172 }
1173 for axis in [ErrorState::VelocityNorth, ErrorState::AttitudeZ] {
1174 let (w, c) = (
1175 white.covariance().variance(axis),
1176 correlated.covariance().variance(axis),
1177 );
1178 assert!(c > 2.0 * w, "{axis:?}: {c} against {w} white");
1179 }
1180 }
1181
1182 #[test]
1183 fn a_refused_fix_does_not_restart_the_interval_of_24_prime() {
1184 let good = |filter: &mut Eskf| {
1187 let _ = filter.fuse_gnss_position(
1188 filter.now(),
1189 Position::ned(0.0, 0.0, 0.0),
1190 PositionNoise::from_sigma(1.0, 1.0, 1.0),
1191 Position::zero(),
1192 );
1193 };
1194 let (mut interleaved, mut clean) = (aided(), aided());
1195 for filter in [&mut interleaved, &mut clean] {
1196 good(filter);
1197 for _ in 0..10 {
1198 assert_eq!(filter.step(still().imu, DT), Propagation::Propagated);
1199 }
1200 }
1201 let refused = interleaved.fuse_gnss_position(
1202 interleaved.now(),
1203 Position::ned(f32::NAN, f32::NAN, f32::NAN),
1204 PositionNoise::from_sigma(1.0, 1.0, 1.0),
1205 Position::zero(),
1206 );
1207 assert_eq!(refused, GnssFusion::both(Fusion::NotFinite));
1208 for filter in [&mut interleaved, &mut clean] {
1209 for _ in 0..10 {
1210 assert_eq!(filter.step(still().imu, DT), Propagation::Propagated);
1211 }
1212 good(filter);
1213 }
1214 let north = ErrorState::PositionNorth;
1215 assert_eq!(
1216 interleaved.covariance().variance(north),
1217 clean.covariance().variance(north)
1218 );
1219 }
1220
1221 #[test]
1222 fn a_rejected_fix_or_a_refused_half_leaves_the_interval_alone() {
1223 let fix = |filter: &mut Eskf, position: Position<Ned>, noise: PositionNoise<Ned>| {
1228 let _ = filter.fuse_gnss_position(filter.now(), position, noise, Position::zero());
1229 };
1230 let one = PositionNoise::from_sigma(1.0, 1.0, 1.0);
1231 let steps = |filter: &mut Eskf, n: usize| {
1232 for _ in 0..n {
1233 assert_eq!(filter.step(still().imu, DT), Propagation::Propagated);
1234 }
1235 };
1236 let (mut rejected, mut half, mut clean) = (aided(), aided(), aided());
1237 for filter in [&mut rejected, &mut half, &mut clean] {
1238 fix(filter, Position::ned(0.0, 0.0, 0.0), one);
1239 steps(filter, 10);
1240 }
1241 let outcome = rejected.fuse_gnss_position(
1242 rejected.now(),
1243 Position::ned(1000.0, 0.0, 1000.0),
1244 one,
1245 Position::zero(),
1246 );
1247 assert!(
1248 matches!(outcome.horizontal, Fusion::Rejected { .. }),
1249 "{outcome:?}"
1250 );
1251 let outcome = half.fuse_gnss_position(
1252 half.now(),
1253 Position::ned(0.0, 0.0, 0.0),
1254 PositionNoise::horizontal_vertical(1.0, f32::NAN),
1255 Position::zero(),
1256 );
1257 assert_eq!(outcome.height, Fusion::NotFinite);
1258 for filter in [&mut rejected, &mut half, &mut clean] {
1259 steps(filter, 1);
1260 fix(filter, Position::ned(0.0, 0.0, 0.0), one);
1261 }
1262 let (north, down) = (ErrorState::PositionNorth, ErrorState::PositionDown);
1263 let variance = |filter: &Eskf, axis| filter.covariance().variance(axis);
1264 assert_eq!(variance(&rejected, north), variance(&clean, north));
1265 assert_eq!(variance(&half, down), variance(&clean, down));
1266 }
1267
1268 #[test]
1269 fn the_interval_of_24_prime_restarts_with_the_filter() {
1270 let fix = |filter: &mut Eskf| {
1274 let _ = filter.fuse_gnss_position(
1275 filter.now(),
1276 Position::ned(0.0, 0.0, 0.0),
1277 PositionNoise::from_sigma(1.0, 1.0, 1.0),
1278 Position::zero(),
1279 );
1280 };
1281 let mut restarted = aided();
1282 fix(&mut restarted);
1283 let _ = restarted
1284 .initialize_over(&window_at(100.0), Seconds::from_secs(0.25))
1285 .expect("a 2 s window of stillness");
1286 let mut fresh = aided();
1287 for filter in [&mut restarted, &mut fresh] {
1288 for _ in 0..20 {
1289 assert_eq!(filter.step(still().imu, DT), Propagation::Propagated);
1290 }
1291 fix(filter);
1292 }
1293 let north = ErrorState::PositionNorth;
1294 assert_eq!(
1295 restarted.covariance().variance(north),
1296 fresh.covariance().variance(north)
1297 );
1298 }
1299
1300 #[test]
1301 fn a_fix_that_agrees_with_the_estimate_narrows_its_uncertainty() {
1302 let mut filter = initialized();
1303 let before = filter.covariance().variance(ErrorState::PositionNorth);
1304 let outcome = filter.fuse_gnss_position(
1305 filter.now(),
1306 Position::ned(0.5, -0.5, 0.2),
1307 PositionNoise::horizontal_vertical(1.0, 1.0),
1308 Position::zero(),
1309 );
1310
1311 let Fusion::Accepted { test_ratio } = outcome.horizontal else {
1312 panic!("expected an acceptance, got {outcome:?}");
1313 };
1314 assert!(test_ratio > 0.0 && test_ratio <= 1.0);
1315 assert!(filter.covariance().variance(ErrorState::PositionNorth) < before);
1316 assert!(filter.state().position.x() > 0.0, "moved toward the fix");
1317 let health = filter.diagnostics().gnss_position;
1318 assert_eq!(health.test_ratio, Some(test_ratio));
1319 assert!(health.innovation.is_some());
1320 }
1321
1322 #[test]
1323 fn a_rejected_fix_changes_nothing_but_the_sources_record_of_it() {
1324 let mut filter = initialized();
1325 let noise = PositionNoise::horizontal_vertical(1.0, 1.0);
1326 assert!(
1327 filter
1328 .fuse_gnss_position(
1329 filter.now(),
1330 Position::ned(0.1, 0.0, 0.0),
1331 noise,
1332 Position::zero()
1333 )
1334 .is_accepted()
1335 );
1336 assert!(filter.step(still().imu, DT).is_propagated());
1337 let (state, covariance) = (filter.state(), *filter.covariance());
1338 let timer = filter.diagnostics().gnss_position.time_since_accepted;
1339
1340 let both = filter.fuse_gnss_position(
1342 filter.now(),
1343 Position::ned(1000.0, 0.0, 1000.0),
1344 noise,
1345 Position::zero(),
1346 );
1347 assert!(matches!(both.height, Fusion::Rejected { .. }), "{both:?}");
1348 let outcome = both.horizontal;
1349 assert!(matches!(outcome, Fusion::Rejected { test_ratio } if test_ratio > 1.0));
1350 assert_eq!(filter.state(), state);
1351 assert_eq!(filter.covariance(), &covariance);
1352 let health = filter.diagnostics().gnss_position;
1353 assert_eq!(health.time_since_accepted, timer);
1354 assert_eq!((health.rejected, health.consecutive_rejections), (1, 1));
1355 assert_eq!(health.test_ratio, outcome.test_ratio());
1356 }
1357
1358 #[test]
1359 fn a_covariance_that_is_no_longer_one_refuses_the_update() {
1360 let mut filter = initialized();
1361 let mut p = *filter.covariance().as_matrix();
1362 p[(0, 0)] = -10.0;
1363 filter.covariance = Covariance::from_matrix(p);
1364 let state = filter.state();
1365
1366 let outcome = filter.fuse_gnss_position(
1367 filter.now(),
1368 Position::ned(0.1, 0.0, 0.0),
1369 PositionNoise::horizontal_vertical(1.0, 1.0),
1370 Position::zero(),
1371 );
1372 assert_eq!(outcome.horizontal, Fusion::StateInvalid);
1373 let health = filter.diagnostics().gnss_position;
1374 assert_eq!(health.last_refusal, Some(Refusal::StateInvalid));
1375 assert_eq!((health.accepted, health.rejected), (0, 0));
1376 assert!(outcome.height.is_accepted(), "{outcome:?}");
1379 assert_eq!(
1380 filter.state().position.vector()[0],
1381 state.position.vector()[0]
1382 );
1383 }
1384
1385 #[test]
1386 fn a_height_the_estimate_disagrees_with_costs_no_horizontal_aiding() {
1387 let mut filter = initialized();
1390 let outcome = filter.fuse_gnss_position(
1391 filter.now(),
1392 Position::ned(0.2, -0.1, -50.0),
1393 PositionNoise::horizontal_vertical(1.0, 1.0),
1394 Position::zero(),
1395 );
1396 assert!(
1397 matches!(outcome.horizontal, Fusion::Accepted { .. }),
1398 "{outcome:?}"
1399 );
1400 assert!(
1401 matches!(outcome.height, Fusion::Rejected { .. }),
1402 "{outcome:?}"
1403 );
1404 let d = filter.diagnostics();
1405 assert_eq!((d.gnss_position.accepted, d.gnss_position.rejected), (1, 0));
1406 assert_eq!((d.gnss_height.accepted, d.gnss_height.rejected), (0, 1));
1407 assert!(
1408 filter.state().position.vector()[2].abs() < 1e-3,
1409 "height untouched"
1410 );
1411 }
1412
1413 #[test]
1414 fn a_2d_fix_with_no_usable_height_still_fuses_its_horizontal_position() {
1415 let mut filter = initialized();
1416 let outcome = filter.fuse_gnss_position(
1417 filter.now(),
1418 Position::ned(0.2, -0.1, 0.0),
1419 PositionNoise::<Ned>::from_variance(1.0, 1.0, 0.0),
1420 Position::zero(),
1421 );
1422 assert!(
1423 matches!(outcome.horizontal, Fusion::Accepted { .. }),
1424 "{outcome:?}"
1425 );
1426 assert_eq!(outcome.height, Fusion::InvalidNoise);
1427 assert_eq!(
1428 filter.diagnostics().gnss_height.last_refusal,
1429 Some(Refusal::InvalidNoise)
1430 );
1431 }
1432
1433 fn nose_east() -> Eskf {
1436 seeded(
1437 attitude_of(0.0, 0.0, core::f32::consts::FRAC_PI_2),
1438 AngularRate::zero(),
1439 )
1440 }
1441
1442 #[test]
1443 fn a_fix_of_the_antenna_where_the_estimate_puts_it_moves_nothing() {
1444 let mut filter = nose_east();
1445 let before = filter.state().position;
1446 let at_antenna = Position::ned(0.0, 1.0, -0.5);
1447 let noise = PositionNoise::horizontal_vertical(0.5, 0.5);
1448 let fused = filter.fuse_gnss_position(filter.now(), at_antenna, noise, mast());
1449 assert!(
1450 fused
1451 .horizontal
1452 .test_ratio()
1453 .is_some_and(|ratio| ratio < 1e-9),
1454 "{fused:?}"
1455 );
1456 assert!(fused.height.test_ratio().is_some_and(|ratio| ratio < 1e-9));
1457 assert!(near(filter.state().position, before));
1458
1459 let mut unarmed = nose_east();
1461 let fused = unarmed.fuse_gnss_position(unarmed.now(), at_antenna, noise, Position::zero());
1462 assert!(
1463 fused
1464 .horizontal
1465 .test_ratio()
1466 .is_some_and(|ratio| ratio > 0.1)
1467 );
1468 }
1469
1470 #[test]
1471 fn a_velocity_of_the_antenna_includes_its_swing_about_the_imu() {
1472 let mut filter = nose_east();
1475 let imu = ImuSample::reading(
1476 AngularRate::body(0.0, 0.0, 0.5),
1477 Acceleration::body(0.0, 0.0, -GRAVITY),
1478 );
1479 assert!(filter.step(imu, DT).is_propagated());
1480 let v = filter.state().velocity.vector();
1481 let swung = Velocity::ned(v.x - 0.5, v.y, v.z);
1482 let noise = VelocityNoise::from_speed_accuracy(0.1);
1483 let fused = filter.fuse_gnss_velocity(filter.now(), swung, noise, mast());
1484 let ratio = fused.test_ratio().expect("fused");
1485 assert!(ratio < 1e-3, "ratio {ratio}");
1486 }
1487
1488 #[test]
1489 fn an_antenna_that_is_not_a_number_is_refused_before_it_reaches_the_state() {
1490 let broken = Position::body(f32::NAN, 0.0, 0.0);
1491 let mut filter = coarse();
1492 let before = filter.state();
1493 let noise = PositionNoise::horizontal_vertical(0.5, 0.5);
1494 let fused = filter.fuse_gnss_position(filter.now(), Position::zero(), noise, broken);
1495 assert_eq!(fused, GnssFusion::both(Fusion::NotFinite));
1496 let noise = VelocityNoise::from_speed_accuracy(0.3);
1497 let fused = filter.fuse_gnss_velocity(filter.now(), Velocity::zero(), noise, broken);
1498 assert_eq!(fused, Fusion::NotFinite);
1499 let noise = PositionNoise::horizontal_vertical(0.5, 0.5);
1500 let fused = filter.fuse_gnss_geodetic(filter.now(), zurich(), noise, broken);
1501 assert_eq!(fused, GnssFusion::both(Fusion::NotFinite));
1502 assert_eq!(filter.state().position, before.position);
1503 assert_eq!(filter.state().velocity, before.velocity);
1504 assert_eq!(filter.origin(), None);
1505 }
1506
1507 #[test]
1508 fn a_fix_that_is_not_a_number_is_refused_even_with_an_origin_held() {
1509 let mut filter = initialized();
1512 assert!(filter.set_origin(zurich()));
1513 let mut window = [still(); 8];
1514 window[3].imu = window[3].imu.with_gyro(AngularRate::body(0.0, 0.4, 0.0));
1515 let _ = filter
1516 .initialize_over(&window, Seconds::from_secs(0.25))
1517 .expect("moving, so position is unestablished");
1518 let noise = PositionNoise::horizontal_vertical(1.5, 1.5);
1519
1520 let nonsense = Geodetic::from_degrees(f64::NAN, 8.5, 488.0);
1521 assert_eq!(
1522 filter.fuse_gnss_geodetic(filter.now(), nonsense, noise, Position::zero()),
1523 GnssFusion::both(Fusion::NotFinite)
1524 );
1525 assert_eq!(
1526 filter.fuse_gnss_geodetic(
1527 filter.now(),
1528 zurich(),
1529 PositionNoise::from_sigma(f32::NAN, 1.5, 1.5),
1530 Position::zero()
1531 ),
1532 GnssFusion::both(Fusion::NotFinite)
1533 );
1534 assert!(filter.state().position.is_finite());
1535 assert!(
1536 filter
1537 .fuse_gnss_geodetic(filter.now(), zurich(), noise, Position::zero())
1538 .is_reset()
1539 );
1540 }
1541
1542 #[test]
1543 fn every_source_refuses_a_measurement_that_is_not_a_number() {
1544 let mut filter = initialized();
1545 assert!(filter.set_baro_reference(
1546 Altitude::from_meters(52.0),
1547 AltitudeNoise::from_sigma(0.001)
1548 ));
1549 let nan = f32::NAN;
1550 assert_eq!(
1551 filter
1552 .fuse_gnss_position(
1553 filter.now(),
1554 Position::ned(nan, 0.0, 0.0),
1555 PositionNoise::horizontal_vertical(1.5, 1.5),
1556 Position::zero()
1557 )
1558 .horizontal,
1559 Fusion::NotFinite
1560 );
1561 assert_eq!(
1562 filter.fuse_gnss_velocity(
1563 filter.now(),
1564 Velocity::ned(0.0, 0.0, 0.0),
1565 VelocityNoise::from_speed_accuracy(nan),
1566 Position::zero()
1567 ),
1568 Fusion::NotFinite
1569 );
1570 assert_eq!(
1571 filter.fuse_baro_altitude(
1572 filter.now(),
1573 Altitude::from_meters(nan),
1574 AltitudeNoise::from_sigma(2.0)
1575 ),
1576 Fusion::NotFinite
1577 );
1578 assert_eq!(
1579 filter.fuse_mag_heading(
1580 filter.now(),
1581 MagField::body(0.2, 0.0, 0.4),
1582 HeadingNoise::from_variance(f32::INFINITY)
1583 ),
1584 Fusion::NotFinite
1585 );
1586 assert_eq!(
1587 filter.fuse_stationary(filter.now(), VelocityNoise::from_speed_accuracy(nan)),
1588 Fusion::NotFinite
1589 );
1590 assert_eq!(filter.diagnostics().gnss_position.accepted, 0);
1591 }
1592
1593 #[test]
1594 fn every_source_measures_its_period_from_what_it_offers() {
1595 let mut filter = initialized();
1598 let nan = f32::NAN;
1599 hold(&mut filter, 4.0, 20, |filter| {
1600 let now = filter.now();
1601 let noise = PositionNoise::horizontal_vertical(1.5, 1.5);
1602 let _ = filter.fuse_gnss_position(
1603 now,
1604 Position::ned(nan, nan, nan),
1605 noise,
1606 Position::zero(),
1607 );
1608 let _ = filter.fuse_gnss_velocity(
1609 now,
1610 Velocity::ned(nan, 0.0, 0.0),
1611 VelocityNoise::from_speed_accuracy(0.3),
1612 Position::zero(),
1613 );
1614 let _ = filter.fuse_baro_altitude(
1615 now,
1616 Altitude::from_meters(nan),
1617 AltitudeNoise::from_sigma(2.0),
1618 );
1619 let heading = HeadingNoise::from_variance(nan);
1620 let _ = filter.fuse_mag_heading(now, MagField::body(0.2, 0.0, 0.4), heading);
1621 let _ = filter.fuse_gnss_heading(now, Radians::from_radians(0.0), heading);
1622 let _ = filter.fuse_course(now, heading);
1623 let _ = filter.fuse_stationary(now, VelocityNoise::from_speed_accuracy(nan));
1624 });
1625 let offered = filter.diagnostics().sources();
1628 let offered = offered
1629 .iter()
1630 .filter(|&&(name, _)| !matches!(name, "position_hold" | "yaw_estimator"));
1631 for &(name, source) in offered {
1632 assert_eq!(source.accepted, 0, "{name}");
1633 let period = source.period().map(Seconds::as_secs);
1634 assert!(
1635 period.is_some_and(|period| (period - 0.2).abs() < 1e-4),
1636 "{name}: {period:?}"
1637 );
1638 }
1639 }
1640
1641 #[test]
1642 fn every_source_refuses_a_variance_no_sensor_could_have() {
1643 let mut filter = initialized();
1644 assert!(filter.set_baro_reference(
1645 Altitude::from_meters(52.0),
1646 AltitudeNoise::from_sigma(0.001)
1647 ));
1648 assert_eq!(
1649 filter.fuse_gnss_position(
1650 filter.now(),
1651 Position::ned(1.0, 2.0, 3.0),
1652 PositionNoise::<Ned>::from_variance(0.0, 1.0, -1.0),
1653 Position::zero(),
1654 ),
1655 GnssFusion::both(Fusion::InvalidNoise),
1656 "zero variance claims a perfect measurement and makes S singular"
1657 );
1658 assert_eq!(
1659 filter.fuse_gnss_velocity(
1660 filter.now(),
1661 Velocity::ned(0.0, 0.0, 0.0),
1662 VelocityNoise::<Ned>::from_variance(1.0, -4.0, 1.0),
1663 Position::zero(),
1664 ),
1665 Fusion::InvalidNoise
1666 );
1667 assert_eq!(
1668 filter.fuse_baro_altitude(
1669 filter.now(),
1670 Altitude::from_meters(60.0),
1671 AltitudeNoise::from_variance(0.0)
1672 ),
1673 Fusion::InvalidNoise
1674 );
1675 assert_eq!(
1676 filter.fuse_mag_heading(
1677 filter.now(),
1678 MagField::body(0.2, 0.0, 0.4),
1679 HeadingNoise::from_variance(-1.0)
1680 ),
1681 Fusion::InvalidNoise
1682 );
1683 assert_eq!(
1684 filter.fuse_stationary(
1685 filter.now(),
1686 VelocityNoise::<Ned>::from_variance(1.0, 1.0, 0.0)
1687 ),
1688 Fusion::InvalidNoise
1689 );
1690 assert_eq!(
1691 filter.fuse_gnss_geodetic(
1692 filter.now(),
1693 zurich(),
1694 PositionNoise::<Ned>::from_variance(1.0, 1.0, 0.0),
1695 Position::zero()
1696 ),
1697 GnssFusion::both(Fusion::InvalidNoise)
1698 );
1699 assert_eq!(filter.origin(), None, "and no origin was placed on the way");
1700
1701 for (name, source) in filter.diagnostics().sources() {
1702 assert_eq!(source.accepted, 0, "{name} counted a refused measurement");
1703 assert_eq!(source.time_since_accepted, None, "{name} started aiding");
1704 }
1705 }
1706}