1use 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 #[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 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 pub const fn origin(&self) -> Option<LocalOrigin> {
72 self.origin
73 }
74
75 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 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 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 #[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 pub const fn magnetic_declination(&self) -> Radians {
186 self.declination
187 }
188
189 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 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 #[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 pub const fn baro_reference(&self) -> Option<Altitude> {
255 self.baro_reference
256 }
257
258 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 pub(super) fn surviving_offset(&self) -> Offset {
279 Offset::independent(self.offset.variance)
280 }
281}
282
283fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}